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

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

opened this

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: violationsByDirective in src/http/csp_report.ts now takes a window in hours rather than UTC days and returns newest too. The security page and the terminal's controls screen call it with 7 * 24; MCP calls it with 24.
  • 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 a seen timestamp in ms. recordViolation sets 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.ts posts reports to /csp-report and reads them back through MCP. It checks the counts and newest, that a 25-hour-old row is left out, that the answer matches violationsByDirective, and that a non-admin is refused. I also changed one assertion in test/csp-report.test.ts: it now expects newest, 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.md has the regenerated tool table (system lists the csp kind; the drift test passes) and a paragraph under "Room for a new tool". test/mcp-size.test.ts passes 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/mcp answered not_found from 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

Closes #10906

commented

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: violationsByDirective in src/http/csp_report.ts now takes a window in hours rather than UTC days and returns newest too. The security page and the terminal's controls screen call it with 7 * 24; MCP calls it with 24.
  • 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 a seen timestamp in ms. recordViolation sets 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.ts posts reports to /csp-report and reads them back through MCP. It checks the counts and newest, that a 25-hour-old row is left out, that the answer matches violationsByDirective, and that a non-admin is refused. I also changed one assertion in test/csp-report.test.ts: it now expects newest, 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.md has the regenerated tool table (system lists the csp kind; the drift test passes) and a paragraph under "Room for a new tool". test/mcp-size.test.ts passes 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/mcp answered not_found from 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

commented

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: violationsByDirective in src/http/csp_report.ts now takes a window in hours rather than UTC days and returns newest too. The security page and the terminal's controls screen call it with 7 * 24; MCP calls it with 24.
  • 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 a seen timestamp in ms. recordViolation sets 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: main added stalls to the system tool (#10911), which conflicted with this branch's description. The merged tool keeps both: stalls on the machine answer and the csp kind.
  • Tests: test/mcp-csp-violations.test.ts posts reports to /csp-report and reads them back through MCP. It checks the counts and newest, that a 25-hour-old row is left out, that the answer matches violationsByDirective, and that a non-admin is refused. I also changed one assertion in test/csp-report.test.ts: it now expects newest, 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.md has the regenerated tool table (system lists the csp kind; the drift test passes) and a paragraph under "Room for a new tool". test/mcp-size.test.ts passes 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/mcp answered not_found from 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

Changes requested — before the latest push

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.

commented

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: recordViolation now counts per UTC hour. The day column keeps its name and holds YYYY-MM-DDTHH. The limit of 500 rows per UTC day and the 30 days kept are unchanged.
  • The window: violationsByDirective counts only hours that start inside the window, from a new windowStart helper. n never includes a report older than hours. It can leave out reports from the first part-hour of the window, so it may slightly undercount but never overcounts.
  • since in the answer: the MCP answer now gives since, 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.ts records one directive 25 h ago and again now and expects n 1 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. In test/csp-report.test.ts I 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

Approved

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.

Sign in to join this conversation.

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs