Public: anyone can read this; the crew can change it.

An agent watching security reads the forge's CSP violations by directive, as the security page counts them #10914

Merged opened this

Check · Review · Land · Live done — Landed and live in production.

Merged

scout/forge:scout/10906-an-agent-watching-security-reads-the-for → main 4 commits fork 1 thread · 1 unresolved

  • Merged. scout/10906-an-agent-watching-security-reads-the-for was merged into main as 4f90246.

Review

Sign in to review this change.
10 files changed +179 −28
docs/MCP.md +10 −1
@@ -208,7 +208,7 @@ bytes in `tools/list`. After changing a tool run `bun scripts/mcp-table.ts`;
208208 | `subscription_wake` | — | Admin: wake a resting subscription | 168 |
209209 | `subscriptions` | — | Claude accounts: scopes, budgets | 158 |
210210 | `sync` | — | Sync a mirror now; `originId` reconnects it | 197 |
211 | `system` | — | The forge machine now: CPU, memory, disk, network | 193 |
211 | `system` | `csp` | The forge machine now: CPU, memory, disk, network | 277 |
212212 | `take_back` | — | Open the pull that undoes a shipped pull | 219 |
213213 | `take_updates` | — | Take the parent forge's updates into a fork | 176 |
214214 | `teams` | — | Crew teams for person admins: charter, dial (0–100) and member agents | 338 |
@@ -274,6 +274,15 @@ each bug `{ repo, number, priority, dueAt }`, soonest due first, at most 100,
274274 from `src/flow/bugs.ts`. An admin sees every repository; anyone else only
275275 public ones, as the terminal's bugs screen does.
276276
277 `system` with `{ kind: "csp" }` is another (#10906): the last 24 hours of
278 Content-Security-Policy violations browsers reported to `/csp-report`,
279 `{ hours: 24, since, directives: [{ directive, n, newest }] }`, most first,
280 counted per UTC hour so `n` holds only reports from `since` (the first whole
281 hour inside the 24) on, `newest`
282 the blocked host counted last, read by `violationsByDirective` in
283 `src/http/csp_report.ts` as the security page reads its week. A forge admin
284 only; anyone else is `forbidden`.
285
277286 `flow` with `{ q, action }` is the fleet's write door (#450): the plan
278287 `POST /api/v1/fleet/ops` answers for a dry run, `{ dryRun: true, query, kind,
279288 verb, value, reversible, rows, counts }`, with nothing changed and only a read
docs/features/csp-reports.md +1 −1
@@ -2,6 +2,6 @@
22
33 Every page the forge renders carries `report-uri /csp-report` and `report-to csp` in its policy, with a `Reporting-Endpoints: csp="/csp-report"` header. The bare floor policy that `securityFloor` puts on non-document answers does not name it: the floor also covers an app's proxied responses, and a relative address there would resolve to the app's host, where no report could be counted. When a browser blocks something it posts a report there, so a broken or hostile page is seen, not guessed.
44
5 `POST /csp-report` (`src/http/csp_report.ts`) takes `application/csp-report`, `application/reports+json` or `application/json`, at most 8 KB, in either the `report-uri` or the Reporting API shape. Anything else gets a 4xx and nothing is stored. It keeps only the directive, the blocked host (no path, no query string) and the page's path, as one count per UTC day in `csp_violation`. That is at most 500 rows a day (the rest fold into one row) and 30 days kept. Anonymous callers are limited to 30 a minute each and 600 a minute per forge. It answers 204.
5 `POST /csp-report` (`src/http/csp_report.ts`) takes `application/csp-report`, `application/reports+json` or `application/json`, at most 8 KB, in either the `report-uri` or the Reporting API shape. Anything else gets a 4xx and nothing is stored. It keeps only the directive, the blocked host (no path, no query string) and the page's path, as one count per UTC hour in `csp_violation` (its `day` column holds `YYYY-MM-DDTHH`), so a window's count holds only reports from the window's whole hours, never a report older than it. That is at most 500 rows a UTC day (the rest fold into one row) and 30 days kept. Anonymous callers are limited to 30 a minute each and 600 a minute per forge. It answers 204.
66
77 `/settings/security` shows one line, "Blocked by the page policy, 7 days", listing counts by directive. When nothing was blocked, the line is not shown. The terminal's Security controls screen (admin only) lists the same weekly counts, one row per directive below the controls, and none when nothing was blocked.
src/core/migration-pins/20261110160000-csp-violation-seen.pin added +1
@@ -0,0 +1,1 @@
1 d768f5b2170a5ef1a1638a3693511744564006bcb0e8bd5040ef64840c7ab719
src/core/migrations/20261110160000-csp-violation-seen.ts added +7
@@ -0,0 +1,7 @@
1 // When a CSP violation row was last counted (#10906), in epoch ms, so an
2 // agent reads the newest blocked host per directive and a window of hours,
3 // not only of UTC days. Rows already kept are dated the end of their day. Additive.
4 export default `
5 ALTER TABLE csp_violation ADD COLUMN seen INTEGER NOT NULL DEFAULT 0;
6 UPDATE csp_violation SET seen = (unixepoch(day) + 86399) * 1000;
7 `;
src/http/csp_report.ts +46 −17
@@ -8,7 +8,8 @@
88 * At most 8 KB, a known content type, a known shape; anything else is a 4xx
99 * and stores nothing. Only three things are kept: the directive, the blocked
1010 * host (never a path or query string) and the page's path, as one count per
11 * UTC day. Rows a day are bounded (`DAY_ROWS`, the rest fold into one), and
11 * UTC hour (`day` holds `YYYY-MM-DDTHH`), so a window's sum holds only its own
12 * hours. Rows a day are bounded (`DAY_ROWS`, the rest fold into one), and
1213 * days past `KEEP_DAYS` are dropped as new ones arrive. Anonymous callers are
1314 * limited per client and per forge like the page-view beacon. Always 204.
1415 */
@@ -121,33 +122,61 @@ export function parseCspReport(body: string): Violation[] | "malformed" {
121122 return got === undefined ? "malformed" : [got];
122123 }
123124
124 /** Count one violation on its UTC day; past `DAY_ROWS` distinct rows a day, the rest share one. */
125 /** The UTC hour bucket a time falls in: `2026-11-10T14`. */
126 const hourOf = (ms: number): string => new Date(ms).toISOString().slice(0, 13);
127
128 /** Count one violation in its UTC hour; past `DAY_ROWS` distinct rows a UTC day, the rest share one. */
125129 export function recordViolation(db: Database, v: Violation, now: Date = new Date()): void {
126 const day = now.toISOString().slice(0, 10);
127 const bump = db.query<unknown, [string, string, string, string]>(
128 "UPDATE csp_violation SET n = n + 1 WHERE day = ?1 AND directive = ?2 AND blocked = ?3 AND path = ?4",
130 const seen = now.getTime();
131 const hour = hourOf(seen);
132 const day = hour.slice(0, 10);
133 const bump = db.query<unknown, [string, string, string, string, number]>(
134 "UPDATE csp_violation SET n = n + 1, seen = ?5 WHERE day = ?1 AND directive = ?2 AND blocked = ?3 AND path = ?4",
129135 );
130 if (bump.run(day, v.directive, v.blocked, v.path).changes > 0) return;
131 const rows = db.query<{ n: number }, [string]>("SELECT COUNT(*) AS n FROM csp_violation WHERE day = ?1").get(day)?.n ?? 0;
136 if (bump.run(hour, v.directive, v.blocked, v.path, seen).changes > 0) return;
137 const rows =
138 db.query<{ n: number }, [string, string]>("SELECT COUNT(*) AS n FROM csp_violation WHERE day >= ?1 AND day <= ?2").get(`${day}T00`, `${day}T23`)?.n ?? 0;
132139 const key = rows < DAY_ROWS ? v : { directive: v.directive, blocked: OVERFLOW, path: "" };
133140 db.query(
134 `INSERT INTO csp_violation (day, directive, blocked, path, n) VALUES (?1, ?2, ?3, ?4, 1)
135 ON CONFLICT (day, directive, blocked, path) DO UPDATE SET n = n + 1`,
136 ).run(day, key.directive, key.blocked, key.path);
141 `INSERT INTO csp_violation (day, directive, blocked, path, n, seen) VALUES (?1, ?2, ?3, ?4, 1, ?5)
142 ON CONFLICT (day, directive, blocked, path) DO UPDATE SET n = n + 1, seen = ?5`,
143 ).run(hour, key.directive, key.blocked, key.path, seen);
137144 if (rows === 0) {
138 const cutoff = new Date(now.getTime() - KEEP_DAYS * 86_400_000).toISOString().slice(0, 10);
145 const cutoff = new Date(seen - KEEP_DAYS * 86_400_000).toISOString().slice(0, 10);
139146 db.query("DELETE FROM csp_violation WHERE day < ?1").run(cutoff);
140147 }
141148 }
142149
143 /** Violations over the last `days` UTC days (today included), per directive, most first. */
144 export function violationsByDirective(db: Database, days = 7, now: Date = new Date()): { directive: string; n: number }[] {
145 const since = new Date(now.getTime() - (days - 1) * 86_400_000).toISOString().slice(0, 10);
150 export interface DirectiveCount {
151 readonly directive: string;
152 readonly n: number;
153 /** The blocked host (or keyword) counted most recently under this directive. */
154 readonly newest: string;
155 }
156
157 /** The start of the first whole UTC hour inside the last `hours`: what a window's counts reach back to. */
158 export function windowStart(hours: number, now: Date = new Date()): Date {
159 return new Date(Math.ceil((now.getTime() - hours * 3_600_000) / 3_600_000) * 3_600_000);
160 }
161
162 /**
163 * Violations counted in the last `hours`, per directive, most first, with the
outdated

SUM(n) sums whole-day rows chosen by seen, so a count can include reports older than hours. Bucket the counts by hour, or stop calling the result a 24 h count.

164 * newest blocked host. Only hour buckets that start inside the window count
165 * (from `windowStart`), so `n` never includes a report older than `hours`; it
166 * may leave out up to the window's first partial hour. Rows from before hourly
167 * buckets (`day` a bare date) are not read. The security page reads a week, the
168 * terminal too, and MCP `system` `{ kind: "csp" }` a day.
169 */
170 export function violationsByDirective(db: Database, hours = 7 * 24, now: Date = new Date()): DirectiveCount[] {
171 const from = hourOf(windowStart(hours, now).getTime());
172 // SQLite takes a bare column from the row MAX() chose: `blocked` is the newest's.
146173 return db
147 .query<{ directive: string; n: number }, [string]>(
148 "SELECT directive, SUM(n) AS n FROM csp_violation WHERE day >= ?1 GROUP BY directive ORDER BY n DESC, directive",
174 .query<{ directive: string; n: number; newest: string }, [string]>(
175 `SELECT directive, SUM(n) AS n, blocked AS newest, MAX(seen) AS last FROM csp_violation
176 WHERE day >= ?1 AND length(day) = 13 GROUP BY directive ORDER BY n DESC, directive`,
149177 )
150 .all(since);
178 .all(from)
179 .map((r) => ({ directive: r.directive, n: r.n, newest: r.newest }));
151180 }
152181
153182 export async function cspReportSubmit(ctx: Ctx): Promise<Response> {
src/http/mcp/tools/system.ts +11 −4
@@ -1,5 +1,6 @@
11 import { noteRead, telemetryReader } from "../../../actions/telemetry.ts";
22 import { systemWithReaders } from "../../../system/telemetry.ts";
3 import { violationsByDirective, windowStart } from "../../csp_report.ts";
34 import { countRead } from "../../../system/telemetry_reads.ts";
45 import { stallHour, TOP_ROUTES } from "../../../metrics/stalls.ts";
56 import { loopWatch } from "../../../perf/live.ts";
@@ -7,12 +8,18 @@ import { good, bad, type Tool } from "../shared.ts";
78
89 export const tool: Tool = {
910 name: "system",
10 doc: "the machine the forge runs on, now: CPU, load, memory, disk, network, processes, `stalls`: the last hour's main-thread stalls by route or `job:<name>` as {cause, ms, count}, and `readers`: who polls the telemetry and how fast; a forge admin, or an agent granted `telemetry:read`",
11 doc: "the machine the forge runs on, now: CPU, load, memory, disk, network, processes, `stalls`: the last hour's main-thread stalls by route or `job:<name>` as {cause, ms, count}, and `readers`: who polls the telemetry and how fast; a forge admin, or an agent granted `telemetry:read`. `{ kind: \"csp\" }`: the last 24 h of Content-Security-Policy violations browsers reported, `{ directive, n, newest }` per directive (`newest` the blocked host last counted), most first, counted per UTC hour from `since`, as the security page reads them; a forge admin only",
1112 description:
12 "The forge machine now: CPU, memory, disk, network. Admin or telemetry:read.",
13 inputSchema: { type: "object", properties: {} },
13 "The forge machine now: CPU, memory, disk, network. Admin or telemetry:read; `kind: csp`: 24 h of CSP violations (admin).",
14 inputSchema: { type: "object", properties: { kind: { type: "string", enum: ["csp"] } } },
1415 writes: false,
15 run(ctx, user) {
16 run(ctx, user, args) {
17 if (args["kind"] !== undefined) {
18 if (args["kind"] !== "csp") return bad("invalid", "kind is csp, or absent for the machine");
19 if (!user.isAdmin) return bad("forbidden", "only an admin of this forge reads its CSP violations");
20 const now = new Date();
21 return good({ hours: 24, since: windowStart(24, now).toISOString(), directives: violationsByDirective(ctx.app.db, 24, now) });
22 }
1623 const reader = telemetryReader(ctx.app, user);
1724 if (!reader)
1825 return bad("forbidden", "only an admin of this forge, or an agent granted telemetry:read, reads its machine");
src/tui/screens/controls.ts +1 −1
@@ -30,7 +30,7 @@ export function controlRows(dataDir: string, nowMs: number): ListRow[] {
3030
3131 /** One row per directive the Content-Security-Policy blocked in the last 7 days; none when nothing was. */
3232 export function cspRows(db: Database, nowMs: number): ListRow[] {
33 return violationsByDirective(db, 7, new Date(nowMs)).map((w) => ({
33 return violationsByDirective(db, 7 * 24, new Date(nowMs)).map((w) => ({
3434 title: `Blocked by the page policy: ${w.directive}`,
3535 secondary: "",
3636 meta: `${w.n} in 7 days`,
src/web/pages/security.ts +1 −1
@@ -240,7 +240,7 @@ function reportRow(dataDir: string, nowMs: number): Html {
240240
241241 /** What browsers said the Content-Security-Policy blocked this week, by directive; nothing when nothing was. */
242242 function cspRow(db: Database, nowMs: number): Html | null {
243 const week = violationsByDirective(db, 7, new Date(nowMs));
243 const week = violationsByDirective(db, 7 * 24, new Date(nowMs));
244244 if (week.length === 0) return null;
245245 return statusRow(
246246 "Blocked by the page policy, 7 days",
test/csp-report.test.ts +3 −3
@@ -47,7 +47,7 @@ describe("POST /csp-report", () => {
4747 expect((await send(body)).status).toBe(204);
4848 expect((await send(body)).status).toBe(204);
4949 expect(rows()).toEqual([
50 { day: new Date().toISOString().slice(0, 10), directive: "script-src-elem", blocked: "evil.example:8443", path: "/alice/secret/issues", n: 2 },
50 { day: new Date().toISOString().slice(0, 13), directive: "script-src-elem", blocked: "evil.example:8443", path: "/alice/secret/issues", n: 2 },
5151 ]);
5252 });
5353
@@ -105,9 +105,9 @@ describe("the security page", () => {
105105 expect(await page()).not.toContain('id="csp"');
106106 const now = new Date();
107107 recordViolation(app.db, { directive: "img-src", blocked: "x.example", path: "/" }, now);
108 recordViolation(app.db, { directive: "img-src", blocked: "y.example", path: "/a" }, now);
108 recordViolation(app.db, { directive: "img-src", blocked: "y.example", path: "/a" }, new Date(now.getTime() + 1));
109109 recordViolation(app.db, { directive: "frame-src", blocked: "z.example", path: "/" }, new Date(now.getTime() - 10 * 86_400_000));
110 expect(violationsByDirective(app.db, 7, now)).toEqual([{ directive: "img-src", n: 2 }]);
110 expect(violationsByDirective(app.db, 7 * 24, new Date(now.getTime() + 1))).toEqual([{ directive: "img-src", n: 2, newest: "y.example" }]);
111111 const html = await page();
112112 expect(html).toContain('id="csp"');
113113 expect(html).toMatch(/<span class="mono">img-src<\/span> 2/);
test/mcp-csp-violations.test.ts added +98
@@ -0,0 +1,98 @@
1 // An agent watching security reads CSP violations through MCP `system` `{ kind: "csp" }` (#10906).
2 import { afterAll, beforeAll, expect, test } from "bun:test";
3 import { mkdtemp, rm } from "node:fs/promises";
4 import { tmpdir } from "node:os";
5 import { join } from "node:path";
6 import { createApp, type App } from "../src/app.ts";
7 import { createToken } from "../src/core/tokens.ts";
8 import { createUser } from "../src/core/users.ts";
9 import { recordViolation, violationsByDirective } from "../src/http/csp_report.ts";
10 import { serve, type Servers } from "../src/http/server.ts";
11
12 let dir = "";
13 let app: App;
14 let servers: Servers;
15 let admin = "";
16 let member = "";
17
18 beforeAll(async () => {
19 dir = await mkdtemp(join(tmpdir(), "mcp-csp-"));
20 app = createApp({ dataDir: dir, port: 0 });
21 servers = await serve(app);
22 const mk = async (username: string, extra: Record<string, unknown> = {}) => {
23 const u = await createUser(app.db, { origin: "seed", trusted: true, username, email: `${username}@forge.local`, password: "a-good-passphrase", ...extra });
24 if (!u.ok) throw new Error(u.error);
25 return u.value;
26 };
27 admin = createToken(app.db, (await mk("alice", { adminIfFirst: true })).id, "a", "ro").token;
28 member = createToken(app.db, (await mk("bob")).id, "b", "admin").token;
29 }, 30_000);
30 afterAll(async () => {
31 await servers.stop();
32 app.close();
33 await rm(dir, { recursive: true, force: true });
34 });
35
36 async function csp(token: string, kind: unknown = "csp"): Promise<{ isError?: boolean; body: any; text: string }> {
37 const res = await fetch(`${servers.url}/mcp`, {
38 method: "POST",
39 headers: { "content-type": "application/json", accept: "application/json, text/event-stream", authorization: `Bearer ${token}` },
40 body: JSON.stringify({ jsonrpc: "2.0", id: 1, method: "tools/call", params: { name: "system", arguments: { kind } } }),
41 });
42 const result = ((await res.json()) as any).result;
43 const text: string = result.content[0].text;
44 return { isError: result.isError, text, body: result.isError ? undefined : JSON.parse(text) };
45 }
46
47 const report = (directive: string, blocked: string) =>
48 fetch(`${servers.url}/csp-report`, {
49 method: "POST",
50 headers: { "content-type": "application/csp-report", "x-forwarded-for": "10.7.7.7" },
51 body: JSON.stringify({ "csp-report": { "document-uri": `${servers.url}/x`, "effective-directive": directive, "blocked-uri": blocked } }),
52 });
53
54 test("a posted report is read back by directive, with its count and newest blocked host", async () => {
55 // A day-old count is outside the 24 hours.
56 recordViolation(app.db, { directive: "frame-src", blocked: "old.example", path: "/" }, new Date(Date.now() - 25 * 3_600_000));
57 expect((await report("script-src-elem", "https://a.example/x.js")).status).toBe(204);
58 expect((await report("script-src-elem", "https://a.example/x.js")).status).toBe(204);
59 await Bun.sleep(5);
60 expect((await report("script-src-elem", "https://b.example/y.js?secret=1")).status).toBe(204);
61 expect((await report("img-src", "data:image/png;base64,AAAA")).status).toBe(204);
62 const got = await csp(admin);
63 expect(got.isError).toBeFalsy();
64 expect(got.body).toEqual({
65 hours: 24,
66 since: expect.stringMatching(/^\d{4}-\d\d-\d\dT\d\d:00:00\.000Z$/),
67 directives: [
68 { directive: "script-src-elem", n: 3, newest: "b.example" },
69 { directive: "img-src", n: 1, newest: "data" },
70 ],
71 });
72 // The same function the security page reads.
73 expect(got.body.directives).toEqual(violationsByDirective(app.db, 24));
74 });
75
76 test("one directive reported 25 h ago and again now counts 1 in the 24 h, not the whole UTC day", () => {
77 app.db.run("DELETE FROM csp_violation");
78 const now = new Date("2026-11-10T12:30:00Z");
79 const v = { directive: "connect-src", blocked: "c.example", path: "/" };
80 recordViolation(app.db, v, new Date(now.getTime() - 25 * 3_600_000));
81 recordViolation(app.db, v, now);
82 expect(violationsByDirective(app.db, 24, now)).toEqual([{ directive: "connect-src", n: 1, newest: "c.example" }]);
83 // Both reports fall in the week.
84 expect(violationsByDirective(app.db, 7 * 24, now)).toEqual([{ directive: "connect-src", n: 2, newest: "c.example" }]);
85 // Same UTC day, 11 h apart: a 6 h window still counts only the late one.
86 recordViolation(app.db, v, new Date("2026-11-10T01:00:00Z"));
87 expect(violationsByDirective(app.db, 6, now)).toEqual([{ directive: "connect-src", n: 1, newest: "c.example" }]);
88 });
89
90 test("a non-admin is refused, and an unknown kind is invalid", async () => {
91 const refused = await csp(member);
92 expect(refused.isError).toBe(true);
93 expect(refused.text).toContain("forbidden");
94 expect(refused.text).not.toContain("a.example");
95 const odd = await csp(admin, "secrets");
96 expect(odd.isError).toBe(true);
97 expect(odd.text).toContain("invalid");
98 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs