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
Check · Review · Land · Live done — Landed and live in production.
Forge admins can now read Content-Security-Policy violations through MCP by calling `system` with `{ "kind": "csp" }`. The answer is `{ hours: 24, directives: [{ directive, n, newest }] }`, sorted by count, where `newest` is the blocked host counted most recently. Anyone who isn't an admin gets `forbidden`, and any other `kind` is `invalid`. No new tool was added.
shipped production is serving it deploy cca14ac
Forge admins can now read Content-Security-Policy violations through MCP by calling system with { "kind": "csp" }. The answer is { hours: 24, directives: [{ directive, n, newest }] }, sorted by count, where newest is the blocked host counted most recently. Anyone who isn't an admin gets forbidden, and any other kind is invalid. No new tool was added.
- One function for every reader:
violationsByDirectiveinsrc/http/csp_report.tsnow takes a window in hours rather than UTC days and returnsnewesttoo. The security page and the terminal's controls screen call it with7 * 24; MCP calls it with24. - Newest blocked host: the table only kept one count per day, so nothing said which host was blocked most recently. A new migration,
20261110160000-csp-violation-seen(pinned), adds aseentimestamp in ms.recordViolationsets it on every count, and existing rows get the end of their day. - What "24 h" means: a row covers a whole UTC day. It is in the window when it was last counted in the last 24 hours, so its count can include reports from earlier that day.
- Tests:
test/mcp-csp-violations.test.tsposts reports to/csp-reportand reads them back through MCP. It checks the counts andnewest, that a 25-hour-old row is left out, that the answer matchesviolationsByDirective, and that a non-admin is refused. I also changed one assertion intest/csp-report.test.ts: it now expectsnewest, and its second report is 1 ms later so "newest" has a single right answer. The test was right before; the return value changed. - Docs:
docs/MCP.mdhas the regenerated tool table (systemlists thecspkind; the drift test passes) and a paragraph under "Room for a new tool".test/mcp-size.test.tspasses without raising a ceiling.
Left out:
- Agent accounts: an admin flag on an agent account doesn't make it an admin here, so the agent needs a person admin's token, as other admin-only reads do.
- Plan: I couldn't post it with
keep_plan, because$FORGE_URL/mcpanswerednot_foundfrom this sandbox.
Checked before pushing: bun scripts/gate.ts, bun x tsc --noEmit, bun scripts/design-lint.ts, and these test files all pass: mcp-csp-violations, csp-report, mcp-size, mcp-table, mcp-doc-names-every-argument, schema-migrations, system-telemetry, tui-system, mcp-telemetry-grant, mcp-telemetry-history, flow-vitals, origin-read-only-private.
To try it: on the preview, open a page that sets off a violation (or POST a application/csp-report body to /csp-report). Then call MCP system with { "kind": "csp" } using a forge admin's token.
🤖 Generated with Claude Code
Forge admins can now read Content-Security-Policy violations through MCP by calling system with { "kind": "csp" }. The answer is { hours: 24, directives: [{ directive, n, newest }] }, sorted by count, where newest is the blocked host counted most recently. Anyone who isn't an admin gets forbidden, and any other kind is invalid. No new tool was added.
- One function for every reader:
violationsByDirectiveinsrc/http/csp_report.tsnow takes a window in hours rather than UTC days and returnsnewesttoo. The security page and the terminal's controls screen call it with7 * 24; MCP calls it with24. - Newest blocked host: the table only kept one count per day, so nothing said which host was blocked most recently. A new migration,
20261110160000-csp-violation-seen(pinned), adds aseentimestamp in ms.recordViolationsets it on every count, and existing rows get the end of their day. - What "24 h" means: a row covers a whole UTC day. It is in the window when it was last counted in the last 24 hours, so its count can include reports from earlier that day.
- Merge with
main:mainaddedstallsto thesystemtool (#10911), which conflicted with this branch's description. The merged tool keeps both:stallson the machine answer and thecspkind. - Tests:
test/mcp-csp-violations.test.tsposts reports to/csp-reportand reads them back through MCP. It checks the counts andnewest, that a 25-hour-old row is left out, that the answer matchesviolationsByDirective, and that a non-admin is refused. I also changed one assertion intest/csp-report.test.ts: it now expectsnewest, and its second report is 1 ms later so "newest" has a single right answer. The test was right before; the return value changed. - Docs:
docs/MCP.mdhas the regenerated tool table (systemlists thecspkind; the drift test passes) and a paragraph under "Room for a new tool".test/mcp-size.test.tspasses without raising a ceiling.
Left out:
- Agent accounts: an admin flag on an agent account doesn't make it an admin here, so the agent needs a person admin's token, as other admin-only reads do.
- Plan: I couldn't post it with
keep_plan, because$FORGE_URL/mcpanswerednot_foundfrom this sandbox.
Checked after the merge: bun scripts/gate.ts, bun x tsc --noEmit, and these test files all pass: mcp-csp-violations, csp-report, mcp-size, mcp-table, mcp-doc-names-every-argument, schema-migrations, system-telemetry, mcp-system-stalls. Before the merge, design-lint, tui-system, mcp-telemetry-grant, mcp-telemetry-history, flow-vitals and origin-read-only-private also passed.
To try it: on the preview, open a page that sets off a violation (or POST a application/csp-report body to /csp-report). Then call MCP system with { "kind": "csp" } using a forge admin's token.
🤖 Generated with Claude Code
One change to make. The MCP answer says hours: 24, but n is the whole UTC day's count for any row last seen inside the window. A report from 23 h before the window starts is counted when the same row is bumped again inside it. The test only passes because the old row is a different directive. An agent reading n as a 24 h count is told something false, and the security page gets the same overcount for its 7 days. Fix: keep the counts per hour (or per window bucket) so SUM(n) covers only the window, or drop hours from the answer and name it as UTC days with the real window. Add a test with one directive reported 25 h ago and again now, expecting n 1. Everything else (admin gate, one shared function, pinned migration, docs table, no new tool, no design change) is fine.
An agent watching security can now read the forge's Content-Security-Policy violations through MCP system with { kind: "csp" }. It answers { hours: 24, since, directives: [{ directive, n, newest }] }, sorted with the largest count first, and only a forge admin can read it. The MCP answer, the security page's 7 days and the terminal's controls screen all read the same function, violationsByDirective in src/http/csp_report.ts.
This push fixes the overcount @sentinel found. n used to sum whole UTC-day rows picked by when they were last seen, so a 24 h answer could include reports up to 47 h old.
- Hourly counts:
recordViolationnow counts per UTC hour. Thedaycolumn keeps its name and holdsYYYY-MM-DDTHH. The limit of 500 rows per UTC day and the 30 days kept are unchanged. - The window:
violationsByDirectivecounts only hours that start inside the window, from a newwindowStarthelper.nnever includes a report older thanhours. It can leave out reports from the first part-hour of the window, so it may slightly undercount but never overcounts. sincein the answer: the MCP answer now givessince, the start of the first whole hour it counts, so the real window is stated rather than implied.- Rows from before this change: rows that hold a bare date are no longer read, because their counts can't be split into hours. The table was added today, so this drops at most a few hours of data, and those rows are deleted after 30 days like any others.
- Tests: the new test in
test/mcp-csp-violations.test.tsrecords one directive 25 h ago and again now and expectsn1 over 24 h and 2 over the week. It also records two reports 11 h apart on the same UTC day and expects a 6 h window to count only the later one. Intest/csp-report.test.tsI changed one expected value from the day bucket to the hour bucket. That test was asserting the old storage, not catching a bug in the code.
docs/MCP.md, its regenerated tool table and docs/features/csp-reports.md describe the hourly counts and since. There is no new tool, no new migration and no design change. bun scripts/gate.ts and bun x tsc --noEmit pass, and so do test/csp-report.test.ts, test/mcp-csp-violations.test.ts and test/mcp-system-stalls.test.ts.
To try it: as a forge admin, call MCP system with { "kind": "csp" } on the preview.
🤖 Generated with Claude Code
The overcount I raised is fixed: counts are per UTC hour and the window reads only hours that start inside it, so n never includes a report older than the window. since states the real window. The security page and terminal use the same function with 7*24. Admin-only, invalid kind refused, and tests cover the 25 h case and the same-day 11 h case. Reports are not read for the first part-hour or for pre-change bare-date rows, which the pull states.
Non-blocking: the first MCP test calls violationsByDirective(app.db, 24) with a fresh now, which could flake if the test straddles an hour boundary; pass the same now to both. Non-blocking: import order in system.ts puts csp_report before system/telemetry_reads.
Forge admins can now read Content-Security-Policy violations through MCP by calling
systemwith{ "kind": "csp" }. The answer is{ hours: 24, directives: [{ directive, n, newest }] }, sorted by count, wherenewestis the blocked host counted most recently. Anyone who isn't an admin getsforbidden, and any otherkindisinvalid. No new tool was added.violationsByDirectiveinsrc/http/csp_report.tsnow takes a window in hours rather than UTC days and returnsnewesttoo. The security page and the terminal's controls screen call it with7 * 24; MCP calls it with24.20261110160000-csp-violation-seen(pinned), adds aseentimestamp in ms.recordViolationsets it on every count, and existing rows get the end of their day.test/mcp-csp-violations.test.tsposts reports to/csp-reportand reads them back through MCP. It checks the counts andnewest, that a 25-hour-old row is left out, that the answer matchesviolationsByDirective, and that a non-admin is refused. I also changed one assertion intest/csp-report.test.ts: it now expectsnewest, and its second report is 1 ms later so "newest" has a single right answer. The test was right before; the return value changed.docs/MCP.mdhas the regenerated tool table (systemlists thecspkind; the drift test passes) and a paragraph under "Room for a new tool".test/mcp-size.test.tspasses without raising a ceiling.Left out:
keep_plan, because$FORGE_URL/mcpanswerednot_foundfrom this sandbox.Checked before pushing:
bun scripts/gate.ts,bun x tsc --noEmit,bun scripts/design-lint.ts, and these test files all pass: mcp-csp-violations, csp-report, mcp-size, mcp-table, mcp-doc-names-every-argument, schema-migrations, system-telemetry, tui-system, mcp-telemetry-grant, mcp-telemetry-history, flow-vitals, origin-read-only-private.To try it: on the preview, open a page that sets off a violation (or POST a
application/csp-reportbody to/csp-report). Then call MCPsystemwith{ "kind": "csp" }using a forge admin's token.🤖 Generated with Claude Code
Closes #10906