From 57ce852ca8bc35bd9285eb5d86e0fba764115347 Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Thu, 3 Sep 2026 18:01:00 +0000 Subject: [PATCH 01/12] feat(notices): redaction contract and retention policy close out #99 acceptance item 7 - sensitivity: 'public' | 'internal' | 'secret' on publish; per-route host ceilings (noticeDelivery.*.sensitivity with dated evidence) honoured by the ledger, inbox resource, event admission, and resources/updated signaller; withholdings recorded on the notice; internal content secret-pattern redacted on egress - notices.retention config (AB4829) -> ledger retain()/inspect(); terminal notices pruned on admitted events; journal compaction via AgentStateStore.compact()/inspect() (compact baseline record, kernel format 2, pruned-key bookkeeping) on both drivers - adapter capability rows + adapterRevision bumps; inspect --state and Workbench State panel show the resolved retention policy - mcp-server-runtime.d.ts spells GeneratedNoticeDeliveryBinding locally; packed assertion that no aliased/public declaration resolves through the notices subpath --- .changeset/99-notice-redaction-retention.md | 6 + docs/diagnostics.md | 3 +- docs/entry-conventions.md | 32 ++ docs/framework-mode.md | 38 ++ .../src/mcp/harness/tools/publish-notice.tsx | 20 +- .../adapters/capabilities/claude-2.1.250.json | 8 + .../adapters/capabilities/codex-0.147.0.json | 8 + .../capabilities/cursor-2026-08-28.json | 8 + .../adapters/capabilities/portable-1.0.0.json | 4 + .../src/adapters/capability-state.ts | 64 ++- packages/agent-bundle/src/adapters/claude.ts | 2 +- packages/agent-bundle/src/adapters/cursor.ts | 2 +- .../src/adapters/notice-delivery.ts | 22 +- packages/agent-bundle/src/adapters/plugin.ts | 2 +- .../agent-bundle/src/adapters/portable.ts | 2 +- .../agent-bundle/src/adapters/registry.ts | 7 +- packages/agent-bundle/src/api.ts | 6 +- packages/agent-bundle/src/build/build.ts | 7 + packages/agent-bundle/src/build/cli-bins.ts | 2 + packages/agent-bundle/src/build/entries.ts | 13 +- .../agent-bundle/src/build/entry-shell.ts | 62 ++- .../agent-bundle/src/build/inspect-bundler.ts | 2 + .../agent-bundle/src/build/package-build.ts | 2 + packages/agent-bundle/src/config/normalize.ts | 3 + .../src/config/notice-retention.ts | 153 ++++++++ packages/agent-bundle/src/config/validate.ts | 7 + packages/agent-bundle/src/core/credentials.ts | 56 ++- .../agent-bundle/src/core/state-inspection.ts | 26 +- packages/agent-bundle/src/core/types.ts | 44 +++ .../src/dev/playground/mcp-probe-service.ts | 30 +- .../src/dev/routes/route-manifest.ts | 5 +- .../agent-bundle/src/dev/workbench-server.ts | 1 + packages/agent-bundle/src/index.ts | 2 + .../agent-bundle/src/mcp-server-runtime.ts | 39 +- packages/agent-bundle/src/test/mcp.ts | 20 +- packages/agent-bundle/src/test/workbench.ts | 5 +- .../tests/adapter-capability-states.test.ts | 93 ++++- .../tests/adapter-metadata.test.ts | 8 +- .../agent-bundle/tests/entry-shell.test.ts | 19 +- .../agent-bundle/tests/inspect-state.test.ts | 45 ++- .../tests/mcp-server-runtime.test.ts | 25 ++ .../tests/notice-redaction-parity.test.ts | 50 +++ .../tests/notice-retention-config.test.ts | 83 ++++ .../tests/projection/mcp-in-memory.test.ts | 72 ++++ .../tests/public-api-packed.test.ts | 11 + .../tests/route-manifest-routes.test.ts | 9 +- .../tests/route-unit/render-route.test.ts | 29 ++ packages/rsc-runtime/README.md | 102 +++++ packages/rsc-runtime/src/mount/index.ts | 40 +- packages/rsc-runtime/src/notices/contract.ts | 109 ++++++ .../rsc-runtime/src/notices/inbox-route.ts | 39 +- packages/rsc-runtime/src/notices/index.ts | 39 ++ packages/rsc-runtime/src/notices/ledger.ts | 224 ++++++++++- packages/rsc-runtime/src/notices/redaction.ts | 240 ++++++++++++ .../src/notices/resource-updated.ts | 21 +- packages/rsc-runtime/src/notices/retention.ts | 59 +++ packages/rsc-runtime/src/notices/router.ts | 82 +++- packages/rsc-runtime/src/notices/state.ts | 139 ++++++- packages/rsc-runtime/src/state/conformance.ts | 98 +++++ packages/rsc-runtime/src/state/contract.ts | 52 ++- packages/rsc-runtime/src/state/index.ts | 9 + packages/rsc-runtime/src/state/journal.ts | 105 ++++- .../rsc-runtime/src/state/memory-driver.ts | 127 +++++- packages/rsc-runtime/src/state/sqlite.ts | 177 ++++++++- .../tests/fixtures/notices-sqlite-process.mjs | 13 +- .../tests/notices-redaction.test.ts | 370 ++++++++++++++++++ .../tests/notices-retention.test.ts | 317 +++++++++++++++ .../notices-sqlite-cross-process.test.ts | 28 +- packages/workbench/src/routes/routes-page.tsx | 16 + .../workbench/tests/examples-real.e2e.test.ts | 6 + packages/workbench/tests/routes-page.test.ts | 19 + website/docs/en/reference/configuration.mdx | 23 ++ website/docs/zh/reference/configuration.mdx | 20 + website/plugins/generated-reference.ts | 51 +++ 74 files changed, 3531 insertions(+), 151 deletions(-) create mode 100644 .changeset/99-notice-redaction-retention.md create mode 100644 packages/agent-bundle/src/config/notice-retention.ts create mode 100644 packages/agent-bundle/tests/notice-redaction-parity.test.ts create mode 100644 packages/agent-bundle/tests/notice-retention-config.test.ts create mode 100644 packages/rsc-runtime/src/notices/redaction.ts create mode 100644 packages/rsc-runtime/src/notices/retention.ts create mode 100644 packages/rsc-runtime/tests/notices-redaction.test.ts create mode 100644 packages/rsc-runtime/tests/notices-retention.test.ts diff --git a/.changeset/99-notice-redaction-retention.md b/.changeset/99-notice-redaction-retention.md new file mode 100644 index 000000000..543f12cc3 --- /dev/null +++ b/.changeset/99-notice-redaction-retention.md @@ -0,0 +1,6 @@ +--- +"@agent-bundle/runtime": minor +"agent-bundle": minor +--- + +Close out #99 acceptance item 7 with a notice redaction contract and a retention policy. `notices.publish()` accepts `sensitivity: 'public' | 'internal' | 'secret'` (default `internal`); each host's `noticeDelivery` row may name a dated `sensitivity` ceiling, and the ledger, inbox resource (`agent-bundle://notices/inbox`, which now reports `sensitivity` and `disclosure`), event admission, and `resources/updated` signaller withhold a notice above the route's ceiling, recording the refusal as `withheld[route]` on the notice; `internal` content is secret-pattern redacted on every route (`redactSecretText`, `redactNoticeDocument`, `resolveNoticeDisclosure`, `AGENT_NOTICE_ROUTE_SHAPES` from `@agent-bundle/runtime/notices`). `notices.retention: { terminalTtl, maxTerminal, maxJournalBytes }` in `agent-bundle.config.ts` (validated as `AB4829`, shown by `inspect --state` and the Workbench State panel) prunes settled terminal notices on admitted events and compacts the ledger journal past its byte bound through the new `AgentNoticeLedger.retain()` / `inspect()` and the state kernel's `AgentStateStore.compact()` / `inspect()` (a `compact` journal record and `AgentStateChange` kind; a compacted SQLite store moves to kernel format 2). Built-in hosts admit `secret` on `current-response` / `next-event` and `internal` on `mcp-inbox` / `mcp-resource-updated` (adapter revisions bumped). Breaking: `AgentStateStore` implementations must add `compact()` and `inspect()`, `AgentNoticeLedger` gains `retain()` / `inspect()`, `AgentNoticeDelivery` gains `disclosure`, and `AgentStateChange` / `AgentStateJournalRecord` gain the `compact` kind. The aliased `mcp-server-runtime.d.ts` no longer imports from `@agent-bundle/runtime/notices` (`GeneratedNoticeDeliveryBinding` is spelled locally). (#TBD) diff --git a/docs/diagnostics.md b/docs/diagnostics.md index 36320acaa..5188e739f 100644 --- a/docs/diagnostics.md +++ b/docs/diagnostics.md @@ -471,7 +471,7 @@ above, never per feature. Skills keep their own closed per-host schemas | `AB4927` | error | A command explicitly targets a host that supports commands but whose `commands.` row for a frontmatter field the command uses is `degraded`, `unavailable`, or `prohibited` (the message carries the host's reason). Cursor's pinned commands surface is frontmatter-free Markdown, so every field row is unavailable there. | Remove the field or drop that host from the command's `targets`. | | `AB4928` | warning | An implicitly selected host supports commands but cannot express a frontmatter field the command uses; the command ships there without it (Cursor receives the prompt body only). | Accept the omission, restrict the command's `targets` to hosts that support the field, or remove the field. | -## Route graph, state, layout, and provider conventions (`AB4800`–`AB4832`, `AB4940`–`AB4942`) +## Route graph, state, layout, and provider conventions (`AB4800`–`AB4833`, `AB4940`–`AB4942`) The route-graph compiler discovers conventional route modules (`src/mcp//{tools,resources,prompts,apps}/*`, `src/events/*/*`, @@ -666,6 +666,7 @@ schema constants), unions, nested objects, transforms, coercions — raises | `AB4830` | error | A conventional layout module (`src/layout.*`, `src/mcp//layout.*`) does not satisfy the layout contract: its default export is not a function component, it exports the route-only `config`/`inputSchema`/`resultSchema`, or it exports `execute`/`render`. Default-export one component receiving `{ children, route, signal }` that renders `Agent.Result` around `children`. | | `AB4831` | error | Two layout modules declare one layout scope (for example `src/layout.ts` beside `src/layout.tsx`). Keep exactly one module per scope. | | `AB4832` | error | A server layout (`src/mcp//layout.*`) names an MCP server that declares no tool, resource, or prompt route modules — the server directory is missing or holds only `apps/` routes, which never take a layout. Add routes under that server directory, move the layout, or rename it `_layout.*` to opt out. A server pinned to `custom`, `command`, or `remote` via `routes.servers.` is skipped entirely: its layout is neither validated (`AB4830`) nor retained, because no generated worker composes it. | +| `AB4833` | error | `notices.retention` is malformed: `notices` or `retention` is not an object, carries an unknown key, `terminalTtl` is not a positive integer of milliseconds or a duration such as `"7d"`, `"12h"`, `"30m"`, or `"90s"`, `maxTerminal` / `maxJournalBytes` is not a positive integer — or the policy is declared by a project without a conventional `src/state.ts`, which has no co-mounted notice ledger to retain. Omit a field to keep the runtime default (`7d`, `500`, `16777216`). | | `AB4940` | error | A conventional provider module has no default export or its default export is not a function. Default-export a factory receiving `{ invocation, signal }`. | | `AB4941` | error | Two provider filenames derive the same camel-cased provider key. Rename one file so every provider key is unique. | | `AB4942` | error | A provider filename derives the reserved `processLifetime` key. Rename the file so its camel-cased key does not collide with the framework-owned provider. | diff --git a/docs/entry-conventions.md b/docs/entry-conventions.md index 6929368a4..e2ba58bc0 100644 --- a/docs/entry-conventions.md +++ b/docs/entry-conventions.md @@ -182,6 +182,38 @@ whose table marks the route unavailable has no consumer for the signal, so those servers register no subscription handlers and advertise no subscribe capability. +#### Notice redaction and retention + +The generated ledger honours two policies the artifact carries as literals +(#99 acceptance item 7). The host's `noticeDelivery` advertisement is +declared once per generated module (`noticeDeliveryAdvertisement`) and passed +to both the worker's `createGeneratedRuntimeState` and the server's +`createGeneratedNoticeRuntime` / `createNoticeInboxSignaller`: each supported +row may name a `sensitivity` ceiling (`public | internal | secret`, absent +means `internal`) with dated `sensitivityEvidence`, and the ledger withholds a +notice whose author-declared `sensitivity` exceeds the ceiling of the route +about to carry it — the inbox omits it, event admission neither authorizes nor +attempts it, the signaller never announces it — recording the refusal on the +notice (`withheld[route]`) instead of moving its state. `internal` content +(the default) is passed through the runtime's secret-pattern redaction on +every route before it leaves the store; `public` travels as authored; +`secret` travels as authored only where the row admits it. The built-in hosts +admit `secret` on `current-response` and `next-event` and `internal` on +`mcp-inbox` and `mcp-resource-updated`; the pinned tables carry the dated +evidence and the generated notice reference page renders it. + +`notices.retention` in `agent-bundle.config.ts` (`terminalTtl`, `maxTerminal`, +`maxJournalBytes`; `AB4829` when malformed or declared without `src/state.ts`) +resolves over the runtime defaults (`7d`, `500`, `16777216`) and is emitted as +`noticeRetentionPolicy` into every generated module that mounts the ledger, so +the MCP worker, the server process, the routed CLI bin, and rendered scripts +prune the same way: settled terminal notices past the TTL (or beyond the cap) +leave the ledger state on the next admitted event, and the store's journal is +compacted onto its head once it exceeds the byte bound. `inspect --state` +reports the resolved policy and whether it was declared or defaulted (the +Workbench State panel shows the same); live counts and the last compaction are +facts of one installed store, read through `AgentNoticeLedger.inspect()`. + #### State mutation budgets `defineState({ ... })` accepts an optional `budgets` runtime policy. Omitted diff --git a/docs/framework-mode.md b/docs/framework-mode.md index 85e2ad00e..9d387253b 100644 --- a/docs/framework-mode.md +++ b/docs/framework-mode.md @@ -301,6 +301,44 @@ release-identity config rejects absolute paths. The per-invocation CLI subject to the same project-root containment check; absolute and external output roots are unsupported. +### `notices` + +`notices.retention` is the retention policy of the notice ledger a stateful +project co-mounts beside `src/state.ts` (#99): + +```ts +export default defineConfig({ + plugin: { ... }, + notices: { + retention: { + terminalTtl: '7d', // ms, or 'ms' | 's' | 'm' | 'h' | 'd' + maxTerminal: 500, + maxJournalBytes: 16_777_216, + }, + }, +}); +``` + +Terminal notices — `expired`, `unavailable`, `withdrawn`, `acknowledged`, and +`attempted` with an exhausted retry budget — leave the ledger once they have +been settled for `terminalTtl`, or earliest-settled first once more than +`maxTerminal` remain; the store's journal is compacted onto its head once it +exceeds `maxJournalBytes`. Every field is optional and defaults to the values +shown; pruning runs only on admitted events and explicit `retain()` calls, so +no timer is implied. A malformed policy — an unknown key, a non-positive or +fractional value, a duration outside that grammar, or a policy declared by a +project without a state module — is `AB4829`. `inspect --state` and the +Workbench State panel show the resolved policy and whether it was declared or +defaulted. + +Redaction is not configured here: it follows the notice's author-declared +`sensitivity` (`public | internal | secret`, default `internal`, passed to +`notices.publish()`) and each host's dated per-route ceiling in its pinned +`noticeDelivery` table. `internal` content is secret-pattern redacted on every +route, `public` travels as authored, and `secret` travels only over a route +whose ceiling admits it — otherwise it stays in the store and the route +records the refusal on the notice. + ## Live development into hosts `agent-bundle dev` is the webpack-HMR analog for plugins that are installed diff --git a/packages/agent-bundle/fixtures/route-harness/src/mcp/harness/tools/publish-notice.tsx b/packages/agent-bundle/fixtures/route-harness/src/mcp/harness/tools/publish-notice.tsx index 72bfa81c8..95c366c57 100644 --- a/packages/agent-bundle/fixtures/route-harness/src/mcp/harness/tools/publish-notice.tsx +++ b/packages/agent-bundle/fixtures/route-harness/src/mcp/harness/tools/publish-notice.tsx @@ -1,4 +1,5 @@ import { Agent, agent } from '@agent-bundle/runtime'; +import type { AgentNoticePublishInput } from '@agent-bundle/runtime/notices'; import { z } from 'zod'; export const config = { @@ -9,17 +10,22 @@ export const config = { export const inputSchema = z.object({ message: z.string(), recipientSession: z.string(), + /** Author-declared disclosure class; the runtime defaults to `internal`. */ + sensitivity: z.enum(['public', 'internal', 'secret']).optional(), }).strict(); export const resultSchema = z.object({ noticeId: z.string(), + sensitivity: z.enum(['public', 'internal', 'secret']), state: z.literal('pending'), }).strict(); export default async function PublishNotice({ input }: { readonly input: z.infer }) { const context = await agent(); if (context.notices === undefined) throw new TypeError('Notice publishing is unavailable.'); - const published = await context.notices.publish({ + // `satisfies` pins the publish API surface: a vocabulary change on + // `sensitivity` (or a renamed field) fails this route's type check. + const publishInput = { content: { root: { kind: 'text', text: input.message }, status: 'success', @@ -29,13 +35,19 @@ export default async function PublishNotice({ input }: { readonly input: z.infer recipient: { session: { sessionId: input.recipientSession }, }, - }, { + ...(input.sensitivity === undefined ? {} : { sensitivity: input.sensitivity }), + } satisfies AgentNoticePublishInput; + const published = await context.notices.publish(publishInput, { idempotencyKey: `notice:${input.recipientSession}:${input.message}`, }); - const result = { noticeId: published.notice.id, state: published.notice.state }; + const result = { + noticeId: published.notice.id, + sensitivity: published.notice.sensitivity ?? 'internal', + state: published.notice.state, + }; return ( - {`notice ${result.noticeId}: ${result.state}`} + {`notice ${result.noticeId}: ${result.state} (${result.sensitivity})`} ); } diff --git a/packages/agent-bundle/src/adapters/capabilities/claude-2.1.250.json b/packages/agent-bundle/src/adapters/capabilities/claude-2.1.250.json index bb673ee4c..509708747 100644 --- a/packages/agent-bundle/src/adapters/capabilities/claude-2.1.250.json +++ b/packages/agent-bundle/src/adapters/capabilities/claude-2.1.250.json @@ -136,6 +136,8 @@ }, "noticeDelivery": { "current-response": { + "sensitivity": "secret", + "sensitivityEvidence": "2026-09-03 (#99 close-out): the hook response is returned to the same Claude Code process that ran the recipient's hook (https://code.claude.com/docs/en/hooks, retrieved 2026-09-02), the trust boundary the recipient already holds; no third party observes it, so a secret notice may travel in full.", "state": "supported" }, "directed-push": { @@ -147,12 +149,18 @@ "state": "unavailable" }, "mcp-inbox": { + "sensitivity": "internal", + "sensitivityEvidence": "2026-09-03 (#99 close-out): inbox identity is derived from the MCP transport only (authInfo clientId / transport sessionId), which this pinned host does not authenticate to the plugin, and a bare stdio inbox is honestly empty; secret content is withheld and internal content is secret-passed before the resource is served.", "state": "supported" }, "mcp-resource-updated": { + "sensitivity": "internal", + "sensitivityEvidence": "2026-09-03 (#99 close-out): notifications/resources/updated carries only the inbox URI (MCP 2025-11-25 server/resources, retrieved 2026-09-02) and is sent only for notices the inbox route may itself disclose, so it can never leak content above the inbox ceiling.", "state": "supported" }, "next-event": { + "sensitivity": "secret", + "sensitivityEvidence": "2026-09-03 (#99 close-out): the hook response is returned to the same Claude Code process that ran the recipient's hook (https://code.claude.com/docs/en/hooks, retrieved 2026-09-02), the trust boundary the recipient already holds; no third party observes it, so a secret notice may travel in full.", "state": "supported" } }, diff --git a/packages/agent-bundle/src/adapters/capabilities/codex-0.147.0.json b/packages/agent-bundle/src/adapters/capabilities/codex-0.147.0.json index 6d3595780..43092f95a 100644 --- a/packages/agent-bundle/src/adapters/capabilities/codex-0.147.0.json +++ b/packages/agent-bundle/src/adapters/capabilities/codex-0.147.0.json @@ -409,6 +409,8 @@ }, "noticeDelivery": { "current-response": { + "sensitivity": "secret", + "sensitivityEvidence": "2026-09-03 (#99 close-out): the hook response is returned to the same Codex process that ran the recipient's hook (rust-v0.147.0 generated hook schemas), the trust boundary the recipient already holds; no third party observes it, so a secret notice may travel in full.", "state": "supported" }, "directed-push": { @@ -420,12 +422,18 @@ "state": "unavailable" }, "mcp-inbox": { + "sensitivity": "internal", + "sensitivityEvidence": "2026-09-03 (#99 close-out): inbox identity is derived from the MCP transport only (authInfo clientId / transport sessionId), which this pinned host does not authenticate to the plugin, and a bare stdio inbox is honestly empty; secret content is withheld and internal content is secret-passed before the resource is served.", "state": "supported" }, "mcp-resource-updated": { + "sensitivity": "internal", + "sensitivityEvidence": "2026-09-03 (#99 close-out): notifications/resources/updated carries only the inbox URI (MCP 2025-11-25 server/resources, retrieved 2026-09-02) and is sent only for notices the inbox route may itself disclose, so it can never leak content above the inbox ceiling.", "state": "supported" }, "next-event": { + "sensitivity": "secret", + "sensitivityEvidence": "2026-09-03 (#99 close-out): the hook response is returned to the same Codex process that ran the recipient's hook (rust-v0.147.0 generated hook schemas), the trust boundary the recipient already holds; no third party observes it, so a secret notice may travel in full.", "state": "supported" } }, diff --git a/packages/agent-bundle/src/adapters/capabilities/cursor-2026-08-28.json b/packages/agent-bundle/src/adapters/capabilities/cursor-2026-08-28.json index 47be8c194..c90000592 100644 --- a/packages/agent-bundle/src/adapters/capabilities/cursor-2026-08-28.json +++ b/packages/agent-bundle/src/adapters/capabilities/cursor-2026-08-28.json @@ -295,6 +295,8 @@ }, "noticeDelivery": { "current-response": { + "sensitivity": "secret", + "sensitivityEvidence": "2026-09-03 (#99 close-out): the hook response is returned to the same Cursor process that ran the recipient's hook (hooks reference retrieved 2026-08-28), the trust boundary the recipient already holds; no third party observes it, so a secret notice may travel in full.", "state": "supported" }, "directed-push": { @@ -306,12 +308,18 @@ "state": "unavailable" }, "mcp-inbox": { + "sensitivity": "internal", + "sensitivityEvidence": "2026-09-03 (#99 close-out): inbox identity is derived from the MCP transport only (authInfo clientId / transport sessionId), which this pinned host does not authenticate to the plugin, and a bare stdio inbox is honestly empty; secret content is withheld and internal content is secret-passed before the resource is served.", "state": "supported" }, "mcp-resource-updated": { + "sensitivity": "internal", + "sensitivityEvidence": "2026-09-03 (#99 close-out): notifications/resources/updated carries only the inbox URI (MCP 2025-11-25 server/resources, retrieved 2026-09-02) and is sent only for notices the inbox route may itself disclose, so it can never leak content above the inbox ceiling.", "state": "supported" }, "next-event": { + "sensitivity": "secret", + "sensitivityEvidence": "2026-09-03 (#99 close-out): the hook response is returned to the same Cursor process that ran the recipient's hook (hooks reference retrieved 2026-08-28), the trust boundary the recipient already holds; no third party observes it, so a secret notice may travel in full.", "state": "supported" } }, diff --git a/packages/agent-bundle/src/adapters/capabilities/portable-1.0.0.json b/packages/agent-bundle/src/adapters/capabilities/portable-1.0.0.json index c471c38e1..3f3a03860 100644 --- a/packages/agent-bundle/src/adapters/capabilities/portable-1.0.0.json +++ b/packages/agent-bundle/src/adapters/capabilities/portable-1.0.0.json @@ -113,9 +113,13 @@ "state": "unavailable" }, "mcp-inbox": { + "sensitivity": "internal", + "sensitivityEvidence": "2026-09-03 (#99 close-out): inbox identity is derived from the MCP transport only (authInfo clientId / transport sessionId), which this pinned host does not authenticate to the plugin, and a bare stdio inbox is honestly empty; secret content is withheld and internal content is secret-passed before the resource is served.", "state": "supported" }, "mcp-resource-updated": { + "sensitivity": "internal", + "sensitivityEvidence": "2026-09-03 (#99 close-out): notifications/resources/updated carries only the inbox URI (MCP 2025-11-25 server/resources, retrieved 2026-09-02) and is sent only for notices the inbox route may itself disclose, so it can never leak content above the inbox ceiling.", "state": "supported" }, "next-event": { diff --git a/packages/agent-bundle/src/adapters/capability-state.ts b/packages/agent-bundle/src/adapters/capability-state.ts index 5aec5aba8..9e33622e3 100644 --- a/packages/agent-bundle/src/adapters/capability-state.ts +++ b/packages/agent-bundle/src/adapters/capability-state.ts @@ -4,9 +4,11 @@ import type { CapabilityEvidence, CapabilityState } from '../core/capabilities.t import { featureCapabilityName } from '../core/components.ts'; import { NOTICE_DELIVERY_ROUTES, + NOTICE_SENSITIVITIES, type NoticeDeliveryAdvertisement, type NoticeDeliveryRoute, type NoticeDeliveryRouteState, + type NoticeSensitivity, } from './notice-delivery.ts'; import type { TargetAdapterMetadata } from './types.ts'; @@ -88,10 +90,23 @@ export interface CapabilityTableRow { export interface NoticeDeliveryCapabilityTableEntry { readonly reason?: string; + /** The most sensitive notice class the route carries in full; JSON widens the literal. */ + readonly sensitivity?: string; + /** Dated evidence for `sensitivity`; required whenever a ceiling is named. */ + readonly sensitivityEvidence?: string; /** JSON imports widen literals; unknown table states fail closed below. */ readonly state: string; } +const sensitivityRank: Readonly> = Object.freeze({ internal: 1, public: 0, secret: 2 }); + +/** The ceiling a supported row admits; absent means `internal` (the pre-sensitivity contract). */ +const routeCeiling = (entry: NoticeDeliveryRouteState): NoticeSensitivity | undefined => + entry.state === 'supported' ? entry.sensitivity ?? 'internal' : undefined; + +const isNoticeSensitivity = (value: unknown): value is NoticeSensitivity => + typeof value === 'string' && (NOTICE_SENSITIVITIES as readonly string[]).includes(value); + /** * An `unavailable` notice route must say when the host was surveyed: the * reason carries an ISO calendar date (`YYYY-MM-DD`), as every pinned table @@ -170,8 +185,24 @@ export const noticeDeliveryAdvertisementFrom = ( throw new CapabilityStateError(`The pinned ${target} table advertises no notice delivery route ${route}.`); } switch (row.state) { - case 'supported': - return [route, Object.freeze({ state: 'supported' })]; + case 'supported': { + if (row.sensitivity === undefined) return [route, Object.freeze({ state: 'supported' })]; + if (!isNoticeSensitivity(row.sensitivity)) { + throw new CapabilityStateError( + `Unsupported notice sensitivity ${JSON.stringify(row.sensitivity)} for ${route} in the pinned ${target} table.`, + ); + } + if (typeof row.sensitivityEvidence !== 'string' || !DATED_REASON.test(row.sensitivityEvidence)) { + throw new CapabilityStateError( + `The pinned ${target} table names a ${row.sensitivity} sensitivity ceiling for notice delivery route ${route} without dated evidence (an ISO date such as 2026-09-03 naming when the host was surveyed).`, + ); + } + return [route, Object.freeze({ + sensitivity: row.sensitivity, + sensitivityEvidence: row.sensitivityEvidence, + state: 'supported', + })]; + } case 'unavailable': if (typeof row.reason !== 'string' || !DATED_REASON.test(row.reason)) { throw new CapabilityStateError( @@ -192,20 +223,39 @@ export const noticeDeliveryAdvertisementFrom = ( * Intersects host advertisements for a composite adapter: a route is * supported only where every host supports it, and the dated reasons of the * hosts that do not are kept so the composite stays as honest as its parts. + * A supported route's sensitivity ceiling is the lower of the two, with the + * evidence of the host that set it; two hosts at the same ceiling keep both + * pieces of evidence. */ export const intersectNoticeDeliveryAdvertisements = ( left: NoticeDeliveryAdvertisement, right: NoticeDeliveryAdvertisement, ): NoticeDeliveryAdvertisement => Object.freeze(Object.fromEntries( NOTICE_DELIVERY_ROUTES.map((route): [NoticeDeliveryRoute, NoticeDeliveryRouteState] => { - const reasons = [left[route], right[route]] - .flatMap((entry) => (entry.state === 'unavailable' ? [entry.reason] : [])); - return reasons.length === 0 - ? [route, Object.freeze({ state: 'supported' })] - : [route, Object.freeze({ + const entries = [left[route], right[route]]; + const reasons = entries.flatMap((entry) => (entry.state === 'unavailable' ? [entry.reason] : [])); + if (reasons.length > 0) { + return [route, Object.freeze({ reason: [...new Set(reasons)].sort((first, second) => first.localeCompare(second)).join('; '), state: 'unavailable', })]; + } + const ceilings = entries.map((entry) => routeCeiling(entry) ?? 'internal'); + const lowest = ceilings.reduce((low, ceiling) => (sensitivityRank[ceiling] < sensitivityRank[low] ? ceiling : low)); + const evidence = [...new Set(entries.flatMap((entry) => + entry.state === 'supported' && (entry.sensitivity ?? 'internal') === lowest && entry.sensitivityEvidence !== undefined + ? [entry.sensitivityEvidence] + : []))].sort((first, second) => first.localeCompare(second)); + // An `internal` ceiling nobody evidenced is the bare pre-sensitivity row; + // a named ceiling always travels with the evidence of the host that set it. + if (evidence.length === 0) { + return [route, Object.freeze({ state: 'supported' })]; + } + return [route, Object.freeze({ + sensitivity: lowest, + sensitivityEvidence: evidence.join('; '), + state: 'supported', + })]; }), )) as NoticeDeliveryAdvertisement; diff --git a/packages/agent-bundle/src/adapters/claude.ts b/packages/agent-bundle/src/adapters/claude.ts index 4a2365a17..584ffcce2 100644 --- a/packages/agent-bundle/src/adapters/claude.ts +++ b/packages/agent-bundle/src/adapters/claude.ts @@ -431,7 +431,7 @@ const hookContract = Object.freeze({ wrapperSource: (entry) => nativeHookWrapperSource(entry, 'Claude'), } satisfies TargetHookContract); const metadata = Object.freeze({ - adapterRevision: '1.24.0', + adapterRevision: '1.25.0', observedVersion: capabilityTable.observedCliVersion, schemas: schemaDescriptorsFrom(schemaProvenance, schemaProvenance.observedCliVersion), }); diff --git a/packages/agent-bundle/src/adapters/cursor.ts b/packages/agent-bundle/src/adapters/cursor.ts index e48007a1c..abf3a62c4 100644 --- a/packages/agent-bundle/src/adapters/cursor.ts +++ b/packages/agent-bundle/src/adapters/cursor.ts @@ -465,7 +465,7 @@ export const cursorManifest = ( }); const metadata = Object.freeze({ - adapterRevision: '1.11.0', + adapterRevision: '1.12.0', observedVersion: capabilityTable.observedCliVersion, schemas: schemaDescriptorsFrom(schemaProvenance, schemaProvenance.observedCliVersion), }); diff --git a/packages/agent-bundle/src/adapters/notice-delivery.ts b/packages/agent-bundle/src/adapters/notice-delivery.ts index 4ea9852d1..1f5aa3504 100644 --- a/packages/agent-bundle/src/adapters/notice-delivery.ts +++ b/packages/agent-bundle/src/adapters/notice-delivery.ts @@ -18,8 +18,28 @@ export const NOTICE_DELIVERY_ROUTES = Object.freeze([ export type NoticeDeliveryRoute = (typeof NOTICE_DELIVERY_ROUTES)[number]; +/** + * Author-declared disclosure classes of a notice, mirroring the runtime's + * `AgentNoticeSensitivity`: `public` is delivered as authored, `internal` + * (the default) after the secret-pattern pass, `secret` only over a route + * whose row admits it. + */ +export const NOTICE_SENSITIVITIES = Object.freeze(['public', 'internal', 'secret'] as const); + +export type NoticeSensitivity = (typeof NOTICE_SENSITIVITIES)[number]; + +/** + * A supported route may name the most sensitive notice it carries in full + * (`sensitivity`) together with the dated evidence for that ceiling. Absent + * means `internal`: the pre-sensitivity contract, under which default + * notices flowed and `secret` never leaves the store through that route. + */ export type NoticeDeliveryRouteState = - | { readonly state: 'supported' } + | { + readonly sensitivity?: NoticeSensitivity; + readonly sensitivityEvidence?: string; + readonly state: 'supported'; + } | { readonly reason: string; readonly state: 'unavailable' }; /** A host's honest, dated advertisement of which notice delivery routes it can carry. */ diff --git a/packages/agent-bundle/src/adapters/plugin.ts b/packages/agent-bundle/src/adapters/plugin.ts index d91a94792..5a91dc938 100644 --- a/packages/agent-bundle/src/adapters/plugin.ts +++ b/packages/agent-bundle/src/adapters/plugin.ts @@ -240,7 +240,7 @@ const artifactValidation = deepFreeze({ }); const metadata = Object.freeze({ - adapterRevision: '1.27.0', + adapterRevision: '1.28.0', observedVersion: `${claudeAdapter.metadata.observedVersion}+${codexAdapter.metadata.observedVersion}+${cursorAdapter.metadata.observedVersion}`, // Metadata schemas must exactly match the validation contract: each host's // documents, with one shared Claude-format hook schema (the pinned Codex diff --git a/packages/agent-bundle/src/adapters/portable.ts b/packages/agent-bundle/src/adapters/portable.ts index a712fb2e0..0dc42a85c 100644 --- a/packages/agent-bundle/src/adapters/portable.ts +++ b/packages/agent-bundle/src/adapters/portable.ts @@ -94,7 +94,7 @@ const schemaValidator = createAdapterValidator(); const validatePlugin = schemaValidator.compile(pluginSchema); const validateMcp = schemaValidator.compile(mcpSchema); const metadata = Object.freeze({ - adapterRevision: '1.8.0', + adapterRevision: '1.9.0', observedVersion: capabilityTable.observedSpecificationVersion, schemas: schemaDescriptorsFrom(schemaProvenance, schemaProvenance.version), }); diff --git a/packages/agent-bundle/src/adapters/registry.ts b/packages/agent-bundle/src/adapters/registry.ts index c4000cd2e..aba695de6 100644 --- a/packages/agent-bundle/src/adapters/registry.ts +++ b/packages/agent-bundle/src/adapters/registry.ts @@ -472,7 +472,12 @@ const snapshotNoticeDelivery = (adapter: TargetAdapter): NoticeDeliveryAdvertise `Target adapter "${adapter.name}" notice delivery route "${route}" must declare a state.`, ); } - return [route, { ...(typeof row.reason === 'string' ? { reason: row.reason } : {}), state: row.state }]; + return [route, { + ...(typeof row.reason === 'string' ? { reason: row.reason } : {}), + ...(typeof row.sensitivity === 'string' ? { sensitivity: row.sensitivity } : {}), + ...(typeof row.sensitivityEvidence === 'string' ? { sensitivityEvidence: row.sensitivityEvidence } : {}), + state: row.state, + }]; })); return noticeDeliveryAdvertisementFrom(adapter.name, entries); }; diff --git a/packages/agent-bundle/src/api.ts b/packages/agent-bundle/src/api.ts index 7d6f4c04f..a4e7630da 100644 --- a/packages/agent-bundle/src/api.ts +++ b/packages/agent-bundle/src/api.ts @@ -24,6 +24,7 @@ import { import { isInsideOrEqual } from './core/paths.ts'; import { stateDefinitionProjection, + type StateNoticeRetentionProjection, type StateProjectionBudgets, type StateProjectionDriver, } from './core/state-inspection.ts'; @@ -372,6 +373,8 @@ export type StateInspectionDriver = StateProjectionDriver; export type StateInspectionBudgets = StateProjectionBudgets; +export type { StateNoticeRetentionProjection }; + export type StateInspection = | { readonly declared: false; @@ -390,6 +393,7 @@ export type StateInspection = readonly durableLocation?: string; readonly id: string; readonly lifetime: NonNullable['lifetime']; + readonly noticeRetention?: StateNoticeRetentionProjection; readonly notices: readonly string[]; readonly provenance: NonNullable['provenance']; readonly source: string; @@ -885,7 +889,7 @@ const accountComponentsFor = ( const inspectState = (model: NormalizedPlugin): StateInspection => { const definition = model.state; if (definition === undefined) return Object.freeze({ declared: false }); - const projection = stateDefinitionProjection(definition); + const projection = stateDefinitionProjection(definition, definition.source, model.notices); return deepFreeze({ declared: true, ...projection, diff --git a/packages/agent-bundle/src/build/build.ts b/packages/agent-bundle/src/build/build.ts index 490f71e23..f81672c77 100644 --- a/packages/agent-bundle/src/build/build.ts +++ b/packages/agent-bundle/src/build/build.ts @@ -381,6 +381,10 @@ export const build = async (options: BuildOptions): Promise => { const compiledMcpApps: CompiledMcpApp[] = []; const compiledMcpEntries: CompiledMcpEntry[] = []; const tools = options.tools === undefined ? {} : { tools: options.tools }; + // The resolved `notices.retention`; generated ledgers fall back to the runtime defaults without it. + const noticePolicy = options.model.notices === undefined + ? {} + : { noticeRetention: options.model.notices.retention.resolved }; // One identity feeds every compiled surface, exactly the identity the // manifest, `inspect`, and dev status report (issue #237). const meta = projectMeta(options.model.metadata); @@ -413,6 +417,7 @@ export const build = async (options: BuildOptions): Promise => { layouts: options.model.layouts ?? [], meta, outDir: target.root, + ...noticePolicy, providers: options.model.providers ?? [], ...(options.model.state === undefined ? {} : { state: options.model.state }), ...tools, @@ -425,6 +430,7 @@ export const build = async (options: BuildOptions): Promise => { cwd: options.projectRoot, meta, ...(noticeDelivery === undefined ? {} : { noticeDelivery }), + ...noticePolicy, outDir: target.root, plugin: { name: options.model.metadata.name, version: options.model.metadata.version }, providers: options.model.providers ?? [], @@ -441,6 +447,7 @@ export const build = async (options: BuildOptions): Promise => { layouts: options.model.layouts ?? [], meta, ...(noticeDelivery === undefined ? {} : { noticeDelivery }), + ...noticePolicy, outDir: target.root, plugin: { name: options.model.metadata.name, version: options.model.metadata.version }, providers: options.model.providers ?? [], diff --git a/packages/agent-bundle/src/build/cli-bins.ts b/packages/agent-bundle/src/build/cli-bins.ts index af32fe626..3979390f0 100644 --- a/packages/agent-bundle/src/build/cli-bins.ts +++ b/packages/agent-bundle/src/build/cli-bins.ts @@ -125,6 +125,7 @@ export const cliBinRslibEntries = ( name: model.metadata.name, version: model.metadata.version, }, + ...(model.notices === undefined ? {} : { noticeRetention: model.notices.retention.resolved }), providers: model.providers ?? [], routes: cli.routes, ...(model.state === undefined ? {} : { state: model.state }), @@ -147,6 +148,7 @@ export const cliBinRslibEntries = ( sourceInputs: entry.sourceInputs, virtualSource: generatedRenderedRouteWorkerSource({ layouts: model.layouts ?? [], + ...(model.notices === undefined ? {} : { noticeRetention: model.notices.retention.resolved }), providers: model.providers ?? [], routes: renderedRoutes, ...(model.state === undefined ? {} : { state: model.state }), diff --git a/packages/agent-bundle/src/build/entries.ts b/packages/agent-bundle/src/build/entries.ts index 94442f57f..814cf467d 100644 --- a/packages/agent-bundle/src/build/entries.ts +++ b/packages/agent-bundle/src/build/entries.ts @@ -15,6 +15,7 @@ import type { AgentBundleToolsConfig, NormalizedHook, NormalizedMcpServer, + NormalizedNoticeRetentionPolicy, NormalizedScript, NormalizedStateDefinition, } from '../core/types.ts'; @@ -163,6 +164,7 @@ export const compileEntries = async ( readonly meta: AgentBundleMeta; readonly outDir: string; readonly providers?: readonly CompiledProvider[]; + readonly noticeRetention?: NormalizedNoticeRetentionPolicy; readonly state?: NormalizedStateDefinition; readonly tools?: AgentBundleToolsConfig; }, @@ -196,7 +198,8 @@ export const compileEntries = async ( virtualSource: generatedRenderedScriptEntrySource({ name, routeId: rendered.routeId, - ...(options.state === undefined ? {} : { state: options.state }), + ...(options.noticeRetention === undefined ? {} : { noticeRetention: options.noticeRetention }), + ...(options.state === undefined ? {} : { state: options.state }), workerFile: rendered.workerFile, }), }), @@ -217,7 +220,8 @@ export const compileEntries = async ( provenance: { kind: 'conventional', relativePath: `scripts/${name}` }, source, }], - ...(options.state === undefined ? {} : { state: options.state }), + ...(options.noticeRetention === undefined ? {} : { noticeRetention: options.noticeRetention }), + ...(options.state === undefined ? {} : { state: options.state }), }), }), ]; @@ -328,6 +332,7 @@ export const compileMcpEntries = async ( readonly outDir: string; readonly plugin: { readonly name: string; readonly version: string }; readonly providers?: readonly CompiledProvider[]; + readonly noticeRetention?: NormalizedNoticeRetentionPolicy; readonly state?: NormalizedStateDefinition; readonly target: string; readonly tools?: AgentBundleToolsConfig; @@ -365,6 +370,7 @@ export const compileMcpEntries = async ( plugin: options.plugin, routes: server.generatedRoutes, serverName: server.name, + ...(options.noticeRetention === undefined ? {} : { noticeRetention: options.noticeRetention }), ...(options.state === undefined ? {} : { state: options.state }), target: options.target, workerFile: `${entry.name}-flight.mjs`, @@ -382,6 +388,7 @@ export const compileMcpEntries = async ( providers: options.providers ?? [], routes: server.generatedRoutes, serverName: server.name, + ...(options.noticeRetention === undefined ? {} : { noticeRetention: options.noticeRetention }), ...(options.state === undefined ? {} : { state: options.state }), }); }); @@ -525,6 +532,7 @@ export const compileHooks = async ( readonly outDir: string; readonly plugin: { readonly name: string; readonly version: string }; readonly providers?: readonly CompiledProvider[]; + readonly noticeRetention?: NormalizedNoticeRetentionPolicy; readonly state?: NormalizedStateDefinition; readonly tools?: AgentBundleToolsConfig; }, @@ -560,6 +568,7 @@ export const compileHooks = async ( providers: options.providers ?? [], routes: [], serverName: 'hooks', + ...(options.noticeRetention === undefined ? {} : { noticeRetention: options.noticeRetention }), ...(options.state === undefined ? {} : { state: options.state }), }), }; diff --git a/packages/agent-bundle/src/build/entry-shell.ts b/packages/agent-bundle/src/build/entry-shell.ts index cf4ecae30..16b82a3a8 100644 --- a/packages/agent-bundle/src/build/entry-shell.ts +++ b/packages/agent-bundle/src/build/entry-shell.ts @@ -4,7 +4,7 @@ import { fileURLToPath } from 'node:url'; import { eventIpcRuntimeSpecifier, eventProjectRuntimeSpecifier } from '../adapters/hook-contract.ts'; import type { NoticeDeliveryAdvertisement } from '../adapters/notice-delivery.ts'; import { stableJson } from '../core/digest.ts'; -import type { NormalizedHook, NormalizedStateDefinition } from '../core/types.ts'; +import type { NormalizedHook, NormalizedNoticeRetentionPolicy, NormalizedStateDefinition } from '../core/types.ts'; import { orderedProviders } from '../routes/provider-execution.ts'; import { layoutChainFor, layoutRouteName } from '../routes/layouts.ts'; import { providerKeyFromName } from '../routes/providers.ts'; @@ -145,6 +145,8 @@ export interface GeneratedCliBinEntryOptions { /** Conventional request context providers, mounted for plain commands in this process (#313). */ readonly providers?: readonly CompiledProvider[]; readonly routes: readonly CompiledAgentRoute[]; + /** The project's resolved `notices.retention`; the runtime defaults apply when absent. */ + readonly noticeRetention?: NormalizedNoticeRetentionPolicy; readonly state?: NormalizedStateDefinition; /** Durable-state anchor fallback; defaults to `cwd` (the npm package bin). */ readonly stateFallback?: GeneratedStateFallback; @@ -170,14 +172,42 @@ const generatedStateImports = ( ]; }; +/** + * Notice ledger policy the generated runtime mounts (#99 acceptance item 7): + * the host's delivery advertisement, whose per-route sensitivity ceilings the + * ledger honours, and the project's resolved retention policy. Emitted as + * literals so the artifact carries the exact policy it was built with. + */ +export interface GeneratedNoticePolicy { + readonly noticeDelivery?: NoticeDeliveryAdvertisement; + readonly noticeRetention?: NormalizedNoticeRetentionPolicy; +} + +/** The policy literals, declared once per generated module and referenced by name. */ +const noticePolicyDeclarations = (policy: GeneratedNoticePolicy): readonly string[] => [ + ...(policy.noticeDelivery === undefined + ? [] + : [`const noticeDeliveryAdvertisement = Object.freeze(${stableJson(policy.noticeDelivery)});`]), + ...(policy.noticeRetention === undefined + ? [] + : [`const noticeRetentionPolicy = Object.freeze(${stableJson(policy.noticeRetention)});`]), +]; + +const noticePolicyFields = (policy: GeneratedNoticePolicy): string => [ + ...(policy.noticeDelivery === undefined ? [] : [', noticeDelivery: noticeDeliveryAdvertisement']), + ...(policy.noticeRetention === undefined ? [] : [', noticeRetention: noticeRetentionPolicy']), +].join(''); + const generatedStateOwner = ( state: NormalizedStateDefinition | undefined, fallback: GeneratedStateFallback, + policy: GeneratedNoticePolicy, ): readonly string[] => { if (state === undefined) return []; if (state.lifetime !== 'workspace-durable') { return [ - `const runtimeState = createGeneratedRuntimeState({ definition: stateDefinition, driver: createMemoryStateDriver({ lifetime: ${JSON.stringify(state.lifetime)} }) });`, + ...noticePolicyDeclarations(policy), + `const runtimeState = createGeneratedRuntimeState({ definition: stateDefinition, driver: createMemoryStateDriver({ lifetime: ${JSON.stringify(state.lifetime)} })${noticePolicyFields(policy)} });`, '', ]; } @@ -185,8 +215,9 @@ const generatedStateOwner = ( ? "fileURLToPath(new URL('..', import.meta.url))" : "join(process.cwd(), '.agent-bundle')"; return [ + ...noticePolicyDeclarations(policy), `const durableAnchor = process.env.AGENT_BUNDLE_PLUGIN_ROOT ?? ${fallbackExpression};`, - "const runtimeState = createGeneratedRuntimeState({ definition: stateDefinition, driver: createSqliteStateDriver({ root: join(durableAnchor, 'state') }) });", + `const runtimeState = createGeneratedRuntimeState({ definition: stateDefinition, driver: createSqliteStateDriver({ root: join(durableAnchor, 'state') })${noticePolicyFields(policy)} });`, '', ]; }; @@ -279,7 +310,7 @@ export const generatedCliBinEntrySource = (options: GeneratedCliBinEntryOptions) ...routeImports(commandRoutes), ...providerImports(providers), '', - ...generatedStateOwner(options.state, stateFallback), + ...generatedStateOwner(options.state, stateFallback, options), 'const processLifetime = { hits: 0, instanceId: crypto.randomUUID(), pid: process.pid };', ...providerRegistrySource(providers), 'const routes = Object.freeze({', @@ -385,6 +416,8 @@ export interface GeneratedRenderedRouteWorkerOptions { readonly layouts?: readonly CompiledLayout[]; readonly providers?: readonly CompiledProvider[]; readonly routes: readonly CompiledAgentRoute[]; + /** The project's resolved `notices.retention`; the runtime defaults apply when absent. */ + readonly noticeRetention?: NormalizedNoticeRetentionPolicy; readonly state?: NormalizedStateDefinition; /** Durable-state anchor fallback; defaults to `cwd` and must match the owning executable. */ readonly stateFallback?: GeneratedStateFallback; @@ -482,7 +515,7 @@ export const generatedRenderedRouteWorkerSource = ( ...providerImports(providers), ...layoutImports(layouts), '', - ...generatedStateOwner(options.state, stateFallback), + ...generatedStateOwner(options.state, stateFallback, options), '// Generated routes contain only intrinsic Agent protocol elements, so no client references exist.', 'globalThis.__rspack_rsc_manifest__ ??= Object.freeze({ clientManifest: Object.freeze({}) });', "if (parentPort === null) throw new Error('Generated render worker requires a parent port.');", @@ -558,6 +591,8 @@ export const generatedRenderedRouteWorkerSource = ( export interface GeneratedRenderedScriptEntryOptions { readonly name: string; readonly routeId: string; + /** The project's resolved `notices.retention`; the runtime defaults apply when absent. */ + readonly noticeRetention?: NormalizedNoticeRetentionPolicy; readonly state?: NormalizedStateDefinition; readonly workerFile: string; } @@ -605,6 +640,8 @@ export interface GeneratedRouteMcpEntryOptions { readonly plugin: { readonly name: string; readonly version: string }; readonly routes: readonly CompiledAgentRoute[]; readonly serverName: string; + /** The project's resolved `notices.retention`; the runtime defaults apply when absent. */ + readonly noticeRetention?: NormalizedNoticeRetentionPolicy; readonly state?: NormalizedStateDefinition; readonly target?: string; readonly workerFile: string; @@ -619,6 +656,8 @@ export interface GeneratedRouteFlightWorkerOptions { readonly providers?: readonly CompiledProvider[]; readonly routes: readonly CompiledAgentRoute[]; readonly serverName: string; + /** The project's resolved `notices.retention`; the runtime defaults apply when absent. */ + readonly noticeRetention?: NormalizedNoticeRetentionPolicy; readonly state?: NormalizedStateDefinition; } @@ -663,6 +702,8 @@ const noticeInboxRecord = (wired: boolean): readonly string[] => interface NoticeRouteSelection { readonly noticeDelivery?: NoticeDeliveryAdvertisement; + /** The project's resolved `notices.retention`; the runtime defaults apply when absent. */ + readonly noticeRetention?: NormalizedNoticeRetentionPolicy; readonly state?: NormalizedStateDefinition; } @@ -706,11 +747,14 @@ const noticeDeliveryImports = (wired: boolean): readonly string[] => ] : []; -const noticeDeliveryOwner = (wired: boolean): readonly string[] => +const noticeDeliveryOwner = (wired: boolean, policy: GeneratedNoticePolicy): readonly string[] => wired ? [ + ...noticePolicyDeclarations(policy), "const durableAnchor = process.env.AGENT_BUNDLE_PLUGIN_ROOT ?? fileURLToPath(new URL('..', import.meta.url));", - "const noticeDelivery = createNoticeInboxSignaller({ store: createGeneratedNoticeRuntime({ driver: createSqliteStateDriver({ root: join(durableAnchor, 'state') }), lifetime: 'workspace-durable' }) });", + `const noticeDelivery = createNoticeInboxSignaller({ ${ + policy.noticeDelivery === undefined ? '' : 'delivery: noticeDeliveryAdvertisement, ' + }store: createGeneratedNoticeRuntime({ driver: createSqliteStateDriver({ root: join(durableAnchor, 'state') }), lifetime: 'workspace-durable'${noticePolicyFields(policy)} }) });`, '', ] : []; @@ -821,7 +865,7 @@ export const generatedRouteFlightWorkerSource = (options: GeneratedRouteFlightWo 'process.stdout.write = process.stderr.write.bind(process.stderr);', `const ARTIFACT_EPOCH = ${JSON.stringify(options.artifactEpoch)};`, 'const processLifetime = { hits: 0, instanceId: crypto.randomUUID(), pid: process.pid };', - ...generatedStateOwner(options.state, 'artifact'), + ...generatedStateOwner(options.state, 'artifact', options), ...providerRegistrySource(providers), ...composeLayoutsSource(layouts), 'const routes = Object.freeze({', @@ -1005,7 +1049,7 @@ export const generatedRouteMcpEntrySource = (options: GeneratedRouteMcpEntryOpti ...noticeInboxRecord(wiresInbox), '});', '', - ...noticeDeliveryOwner(wiresResourceUpdated), + ...noticeDeliveryOwner(wiresResourceUpdated, options), ...(hasEvents ? [ // The endpoint identity is artifact-location dependent, so it stays diff --git a/packages/agent-bundle/src/build/inspect-bundler.ts b/packages/agent-bundle/src/build/inspect-bundler.ts index 8c5126261..0840b2538 100644 --- a/packages/agent-bundle/src/build/inspect-bundler.ts +++ b/packages/agent-bundle/src/build/inspect-bundler.ts @@ -201,6 +201,7 @@ const mcpEntryEntries = async ( ? undefined : generatedRouteMcpEntrySource({ ...(noticeDelivery === undefined ? {} : { noticeDelivery }), + ...(model.notices === undefined ? {} : { noticeRetention: model.notices.retention.resolved }), plugin: { name: model.metadata.name, version: model.metadata.version }, routes: generatedRoutes, serverName, @@ -256,6 +257,7 @@ const mcpEntryEntries = async ( artifactEpoch: generatedRouteArtifactEpoch({ name: model.metadata.name, version: model.metadata.version }), layouts: model.layouts ?? [], ...(noticeDelivery === undefined ? {} : { noticeDelivery }), + ...(model.notices === undefined ? {} : { noticeRetention: model.notices.retention.resolved }), providers: model.providers ?? [], routes: generatedRoutes, serverName, diff --git a/packages/agent-bundle/src/build/package-build.ts b/packages/agent-bundle/src/build/package-build.ts index 7cf516ad5..61b2e19e4 100644 --- a/packages/agent-bundle/src/build/package-build.ts +++ b/packages/agent-bundle/src/build/package-build.ts @@ -141,6 +141,7 @@ export const planPackageEntries = async ( name: model.metadata.name, version: model.metadata.version, }, + ...(model.notices === undefined ? {} : { noticeRetention: model.notices.retention.resolved }), providers: model.providers ?? [], routes: bin.generatedCli.routes, ...(model.state === undefined ? {} : { state: model.state }), @@ -160,6 +161,7 @@ export const planPackageEntries = async ( sourceInputs, virtualSource: generatedRenderedRouteWorkerSource({ layouts: model.layouts ?? [], + ...(model.notices === undefined ? {} : { noticeRetention: model.notices.retention.resolved }), providers: model.providers ?? [], routes: renderedRoutes, ...(model.state === undefined ? {} : { state: model.state }), diff --git a/packages/agent-bundle/src/config/normalize.ts b/packages/agent-bundle/src/config/normalize.ts index a0a113a3e..2218b4c02 100644 --- a/packages/agent-bundle/src/config/normalize.ts +++ b/packages/agent-bundle/src/config/normalize.ts @@ -72,6 +72,7 @@ import type { SkillIr } from '../skills/ir.ts'; import { decideSkillTreeLayout, lowerSkillIr, lowerSkillIrForHosts } from '../skills/lower.ts'; import { parseSkillIr } from '../skills/parse-ir.ts'; import type { SkillHost } from '../skills/tokens.ts'; +import { normalizeNoticeRetention } from './notice-retention.ts'; import { configuredScriptNames, judgeScriptRoute, scriptRouteName } from './script-routes.ts'; const isSkillHost = (name: string): name is SkillHost => @@ -1299,6 +1300,7 @@ export const normalizeProject = async ( provenance: { kind: 'conventional', sourcePath: discovered.state.source }, source: discovered.state.source, }; + const notices = normalizeNoticeRetention(loaded.config, loaded.configPath, state !== undefined).retention; const packageBuild = normalizePackageBuild( loaded.config, loaded.context.projectRoot, @@ -1331,6 +1333,7 @@ export const normalizeProject = async ( mcpServers, hooks: normalizeHooks(loaded, discovered, targetNames, registry, payloads), ...(nativeHooks.length === 0 ? {} : { nativeHooks }), + ...(notices === undefined ? {} : { notices: { retention: notices } }), ...(packageBuild === undefined ? {} : { packageBuild }), ...(payloads.length === 0 ? {} : { payloads }), ...(providers.length === 0 ? {} : { providers }), diff --git a/packages/agent-bundle/src/config/notice-retention.ts b/packages/agent-bundle/src/config/notice-retention.ts new file mode 100644 index 000000000..354b8e864 --- /dev/null +++ b/packages/agent-bundle/src/config/notice-retention.ts @@ -0,0 +1,153 @@ +import type { Diagnostic } from '../core/diagnostics.ts'; +import { deepFreeze } from '../core/freeze.ts'; +import type { + AgentBundleConfig, + NormalizedNoticeRetention, + NormalizedNoticeRetentionPolicy, + SourceProvenance, +} from '../core/types.ts'; + +/** + * `notices.retention` (#99 acceptance item 7): the retention policy of the + * notice ledger a stateful project co-mounts beside `src/state.ts`. Validated + * here as `AB4829`; the runtime re-validates the resolved policy when the + * generated runtime mounts it. + */ + +// Kept independent of the optional runtime peer, like the state budgets in +// `core/state-inspection.ts`; `notice-retention-parity.test.ts` compares these +// with `AGENT_NOTICE_DEFAULT_RETENTION` so the two boundaries cannot drift. +export const noticeRetentionDefaults: NormalizedNoticeRetentionPolicy = Object.freeze({ + maxJournalBytes: 16 * 1024 * 1024, + maxTerminal: 500, + terminalTtlMs: 7 * 24 * 60 * 60 * 1000, +}); + +const durationUnits: Readonly> = Object.freeze({ + d: 24 * 60 * 60 * 1000, + h: 60 * 60 * 1000, + m: 60 * 1000, + ms: 1, + s: 1000, +}); + +/** + * Parses a terminal TTL: a positive integer of milliseconds or a duration + * literal `` such as `'7d'` or `'90s'`. Returns + * `undefined` for anything else; the caller reports it. + */ +export const parseNoticeRetentionDuration = (value: unknown): number | undefined => { + if (typeof value === 'number') return Number.isSafeInteger(value) && value >= 1 ? value : undefined; + if (typeof value !== 'string') return undefined; + const match = /^(\d+)(ms|s|m|h|d)$/u.exec(value.trim()); + if (match === null) return undefined; + const amount = Number(match[1]); + const unit = durationUnits[match[2] as string]; + if (unit === undefined || !Number.isSafeInteger(amount) || amount < 1) return undefined; + const milliseconds = amount * unit; + return Number.isSafeInteger(milliseconds) ? milliseconds : undefined; +}; + +const isPlainRecord = (value: unknown): value is Readonly> => + typeof value === 'object' + && value !== null + && !Array.isArray(value) + && (Object.getPrototypeOf(value) === Object.prototype || Object.getPrototypeOf(value) === null); + +const retentionKeys = new Set(['maxJournalBytes', 'maxTerminal', 'terminalTtl']); + +const diagnostic = (message: string, sourcePath: string, hasState: boolean): Diagnostic => ({ + code: 'AB4829', + message, + recovery: hasState + ? 'Declare `notices.retention` as an object whose `terminalTtl` is a positive integer of milliseconds or a duration such as "7d", "12h", or "30m", and whose `maxTerminal` and `maxJournalBytes` are positive integers; omit a field to keep its default.' + : 'Add a conventional `src/state.ts` (the notice ledger is co-mounted beside it) or remove `notices` from the config.', + severity: 'error', + sourcePath, +}); + +export interface NormalizedNoticesResult { + readonly diagnostics: readonly Diagnostic[]; + readonly retention?: NormalizedNoticeRetention; +} + +/** + * Validates and resolves `notices.retention`. A project without a state + * module has no notice ledger, so declaring a policy there is an error + * rather than a silent no-op. + */ +export const normalizeNoticeRetention = ( + config: AgentBundleConfig, + configPath: string, + hasState: boolean, +): NormalizedNoticesResult => { + const notices = config.notices; + if (notices === undefined) return { diagnostics: [] }; + if (!isPlainRecord(notices)) { + return { diagnostics: [diagnostic('`notices` configuration must be an object.', configPath, hasState)] }; + } + const unknownKeys = Object.keys(notices).filter((key) => key !== 'retention'); + if (unknownKeys.length > 0) { + return { + diagnostics: [diagnostic( + `\`notices\` configuration has unknown ${unknownKeys.length === 1 ? 'key' : 'keys'} ${unknownKeys.map((key) => JSON.stringify(key)).join(', ')}; only \`retention\` is supported.`, + configPath, + hasState, + )], + }; + } + const retention = notices.retention; + if (retention === undefined) return { diagnostics: [] }; + if (!hasState) { + return { + diagnostics: [diagnostic( + '`notices.retention` configures the notice ledger, which is co-mounted only beside a conventional `src/state.ts`; this project declares no state module.', + configPath, + hasState, + )], + }; + } + if (!isPlainRecord(retention)) { + return { diagnostics: [diagnostic('`notices.retention` must be an object.', configPath, hasState)] }; + } + const diagnostics: Diagnostic[] = []; + const declared: { maxJournalBytes?: number; maxTerminal?: number; terminalTtlMs?: number } = {}; + for (const [key, value] of Object.entries(retention)) { + if (!retentionKeys.has(key)) { + diagnostics.push(diagnostic( + `\`notices.retention\` has unknown key ${JSON.stringify(key)}; supported keys are terminalTtl, maxTerminal, and maxJournalBytes.`, + configPath, + hasState, + )); + continue; + } + if (key === 'terminalTtl') { + const milliseconds = parseNoticeRetentionDuration(value); + if (milliseconds === undefined) { + diagnostics.push(diagnostic( + '`notices.retention.terminalTtl` must be a positive integer of milliseconds or a duration such as "7d", "12h", "30m", or "90s".', + configPath, + hasState, + )); + } else { + declared.terminalTtlMs = milliseconds; + } + continue; + } + if (typeof value !== 'number' || !Number.isSafeInteger(value) || value < 1) { + diagnostics.push(diagnostic(`\`notices.retention.${key}\` must be a positive integer.`, configPath, hasState)); + continue; + } + declared[key as 'maxJournalBytes' | 'maxTerminal'] = value; + } + if (diagnostics.length > 0) return { diagnostics }; + const provenance: SourceProvenance = { kind: 'config', sourcePath: configPath }; + return { + diagnostics: [], + retention: deepFreeze({ + declared, + provenance, + resolved: { ...noticeRetentionDefaults, ...declared }, + }), + }; +}; diff --git a/packages/agent-bundle/src/config/validate.ts b/packages/agent-bundle/src/config/validate.ts index 76a46bd76..8e346a7bd 100644 --- a/packages/agent-bundle/src/config/validate.ts +++ b/packages/agent-bundle/src/config/validate.ts @@ -46,6 +46,7 @@ import { } from './normalize.ts'; import { type DiscoveredProject, payloadDeclarationEntry, payloadDeclarationSource } from './discover.ts'; import type { LoadedConfig } from './load.ts'; +import { normalizeNoticeRetention } from './notice-retention.ts'; import { configuredScriptNames, judgeScriptRoute, scriptRouteName } from './script-routes.ts'; import type { SkillDocument } from './skill.ts'; import { referencedResources } from './skill-references.ts'; @@ -2200,6 +2201,12 @@ export const validateSource = ( loaded.configPath, )); } + // `notices.retention` (AB4829) needs the co-mounted ledger a state module brings. + diagnostics.push(...normalizeNoticeRetention( + loaded.config, + loaded.configPath, + discovered.state?.definition !== undefined, + ).diagnostics); diagnostics.push(...packageConventionShadowNudges(loaded)); diagnostics.push(...skillConventionShadowNudges(loaded, discovered)); diagnostics.push(...legacyConventionalDocumentErrors(loaded, discovered)); diff --git a/packages/agent-bundle/src/core/credentials.ts b/packages/agent-bundle/src/core/credentials.ts index 653f9e19a..f87566cba 100644 --- a/packages/agent-bundle/src/core/credentials.ts +++ b/packages/agent-bundle/src/core/credentials.ts @@ -52,11 +52,35 @@ export const isCredentialKey = (key: string): boolean => { export const isProviderEndpointKey = (key: string): boolean => /^(?:CODEX|OPENAI)_(?:API_BASE|BASE_URL|URL)$/iu.test(key); -const providerCredentialPatterns = Object.freeze([ - /\bsk-(?:proj-|ant-|live-)?[a-z0-9_-]{16,}\b/iu, - /\b(?:gh[pousr]_[a-z0-9]{20,}|github_pat_[a-z0-9_]{20,}|xox[baprs]-[a-z0-9-]{16,}|akia[a-z0-9]{16})\b/iu, - /\bbearer[ \t]+[a-z0-9._~+/=-]{20,}\b/iu, -]); +/** + * Pattern sources of the free-text secret pass. The notice ledger in + * `@agent-bundle/runtime` (`notices/redaction.ts`, `NOTICE_SECRET_PATTERN_SOURCES`) + * carries the same three sources: the runtime is an optional peer of this + * package, so neither side can import the other's module, and + * `notice-redaction-parity.test.ts` pins them byte-identical instead. Edit + * both together. + */ +export const CREDENTIAL_TEXT_PATTERN_SOURCES = Object.freeze({ + assignment: String.raw`((?:["']?)(?:api[-_ ]?key|api[-_ ]?token|access[-_ ]?token|authorization|credential|password|secret|token)(?:["']?)\s*[:=]\s*)("[^"\r\n]*"|'[^'\r\n]*'|[^\s,;\r\n]+)`, + provider: Object.freeze([ + String.raw`\bsk-(?:proj-|ant-|live-)?[a-z0-9_-]{16,}\b`, + String.raw`\b(?:gh[pousr]_[a-z0-9]{20,}|github_pat_[a-z0-9_]{20,}|xox[baprs]-[a-z0-9-]{16,}|akia[a-z0-9]{16})\b`, + String.raw`\bbearer[ \t]+[a-z0-9._~+/=-]{20,}\b`, + ]), + /** + * URL userinfo (`scheme://user:secret@host`): the authority runs until one + * of the terminators every WHATWG scheme shares (`/`, `?`, `#`) and the + * match is greedy through the final `@`, so a raw `@`, quote, backslash, or + * whitespace inside a password cannot leave part of it behind. The scheme is + * anchored to the start of its own character run, so a URL glued to an + * identifier (`_https://user:secret@…`) is masked too. + */ + urlUserinfo: String.raw`(? new RegExp(source, 'iu')), +); // `String.prototype.replace` resets `lastIndex` on global regexes, so sharing these is safe. const globalProviderCredentialPatterns = Object.freeze( @@ -67,16 +91,24 @@ const globalProviderCredentialPatterns = Object.freeze( export const containsProviderCredential = (value: string): boolean => providerCredentialPatterns.some((pattern) => pattern.test(value)); -const credentialAssignmentPattern = /((?:["']?)(?:api[-_ ]?key|api[-_ ]?token|access[-_ ]?token|authorization|credential|password|secret|token)(?:["']?)\s*[:=]\s*)("[^"\r\n]*"|'[^'\r\n]*'|[^\s,;\r\n]+)/giu; +const credentialAssignmentPattern = new RegExp(CREDENTIAL_TEXT_PATTERN_SOURCES.assignment, 'giu'); + +/** Masks `scheme://user:secret@host` credentials; shared by the probe and Workbench log surfaces. */ +export const urlUserinfoPattern = new RegExp(CREDENTIAL_TEXT_PATTERN_SOURCES.urlUserinfo, 'giu'); -/** Raw process output remains useful evidence after known credential material is irreversibly removed. */ +/** + * Raw process output remains useful evidence after known credential material + * is irreversibly removed. Provider forms go first: an unquoted + * `authorization: Bearer ` would otherwise lose only the word `Bearer` + * to the assignment pass and keep the token. + */ export const redactCredentialText = (value: string): string => { - let redacted = value.replace(credentialAssignmentPattern, (_match, prefix: string, assigned: string) => { - const quote = assigned[0] === '"' || assigned[0] === "'" ? assigned[0] : ''; - return `${prefix}${quote}[REDACTED]${quote}`; - }); + let redacted = value; for (const pattern of globalProviderCredentialPatterns) { redacted = redacted.replace(pattern, '[REDACTED]'); } - return redacted; + return redacted.replace(credentialAssignmentPattern, (_match, prefix: string, assigned: string) => { + const quote = assigned[0] === '"' || assigned[0] === "'" ? assigned[0] : ''; + return `${prefix}${quote}[REDACTED]${quote}`; + }); }; diff --git a/packages/agent-bundle/src/core/state-inspection.ts b/packages/agent-bundle/src/core/state-inspection.ts index 99d9387f4..e315f0d99 100644 --- a/packages/agent-bundle/src/core/state-inspection.ts +++ b/packages/agent-bundle/src/core/state-inspection.ts @@ -1,5 +1,6 @@ +import { noticeRetentionDefaults } from '../config/notice-retention.ts'; import { deepFreeze } from './freeze.ts'; -import type { NormalizedStateDefinition } from './types.ts'; +import type { NormalizedNoticeRetentionPolicy, NormalizedNotices, NormalizedStateDefinition } from './types.ts'; export type StateProjectionDriver = 'memory' | 'sqlite'; @@ -20,6 +21,17 @@ export const agentStateDefaultBudgets: StateProjectionBudgets = Object.freeze({ maxStateBytes: 1_048_576, }); +/** + * The notice ledger's retention policy as the generated runtime will mount it: + * the runtime defaults unless `notices.retention` declared otherwise. Live + * counts and the last compaction are runtime facts of one installed store + * (`AgentNoticeLedger.inspect()`); static inspection shows the policy only. + */ +export interface StateNoticeRetentionProjection { + readonly resolved: NormalizedNoticeRetentionPolicy; + readonly source: 'declared' | 'defaults'; +} + export interface StateDefinitionProjection { readonly budgets: | { @@ -33,6 +45,8 @@ export interface StateDefinitionProjection { readonly durableLocation?: string; readonly id: string; readonly lifetime: NormalizedStateDefinition['lifetime']; + /** Retention policy of the co-mounted notice ledger; absent on projections built before it was inspected. */ + readonly noticeRetention?: StateNoticeRetentionProjection; readonly notices: readonly string[]; readonly source: string; } @@ -43,6 +57,9 @@ const durableStateLocation = const noticeLedgerInspection = 'Generated runtimes co-mount the notice ledger store at the same lifetime under reserved id @agent-bundle/runtime/agent-notice-ledger/v1.'; +const noticeRetentionInspection = + 'Notice retention prunes settled notices on admitted events and compacts the ledger journal past its byte bound; live counts and the last compaction belong to each installed store (AgentNoticeLedger.inspect()).'; + const stateDriver = ( lifetime: NormalizedStateDefinition['lifetime'], ): StateProjectionDriver => { @@ -63,7 +80,11 @@ const stateDriver = ( export const stateDefinitionProjection = ( definition: NormalizedStateDefinition, source = definition.source, + notices?: NormalizedNotices, ): StateDefinitionProjection => { + const noticeRetention: StateNoticeRetentionProjection = notices === undefined + ? Object.freeze({ resolved: noticeRetentionDefaults, source: 'defaults' }) + : Object.freeze({ resolved: notices.retention.resolved, source: 'declared' }); const budgets: StateDefinitionProjection['budgets'] = definition.budgets === 'dynamic' ? Object.freeze({ source: 'dynamic' }) : Object.freeze({ @@ -79,7 +100,8 @@ export const stateDefinitionProjection = ( ...(definition.lifetime === 'workspace-durable' ? { durableLocation: durableStateLocation } : {}), id: definition.id, lifetime: definition.lifetime, - notices: [noticeLedgerInspection], + noticeRetention, + notices: [noticeLedgerInspection, noticeRetentionInspection], source, }); }; diff --git a/packages/agent-bundle/src/core/types.ts b/packages/agent-bundle/src/core/types.ts index add0b254e..e7b7fe700 100644 --- a/packages/agent-bundle/src/core/types.ts +++ b/packages/agent-bundle/src/core/types.ts @@ -269,6 +269,24 @@ export type AgentBundleHookInput = /** False disables the conventional src/state.ts module. */ export type AgentBundleStateConfig = false; +/** + * Retention policy of the notice ledger a stateful project co-mounts beside + * `src/state.ts` (#99). Omitted fields keep the runtime defaults: seven days, + * 500 terminal notices, a 16 MiB journal. + */ +export interface AgentBundleNoticeRetentionConfig { + /** Retained journal bytes above which the ledger compacts its store onto the head. */ + readonly maxJournalBytes?: number; + /** Most terminal notices kept regardless of age. */ + readonly maxTerminal?: number; + /** How long a terminal notice is kept after it settled: milliseconds, or a duration such as `'7d'`, `'12h'`, `'30m'`, `'90s'`. */ + readonly terminalTtl?: number | string; +} + +export interface AgentBundleNoticesConfig { + readonly retention?: AgentBundleNoticeRetentionConfig; +} + export interface AgentBundleDevRuntimeConfig { readonly provider: string; } @@ -295,6 +313,7 @@ export interface AgentBundleConfig extends AgentBundleConfigExtensions { lib?: AgentBundleLibConfig; marketplace?: boolean; mcp?: AgentBundleMcpConfig; + notices?: AgentBundleNoticesConfig; output?: AgentBundleOutputConfig; payload?: AgentBundlePayloadConfig; plugin: AgentBundlePluginConfig; @@ -626,6 +645,29 @@ export interface NormalizedStateDefinition { readonly source: string; } +/** Resolved notice retention policy in the runtime's units (milliseconds, counts, bytes). */ +export interface NormalizedNoticeRetentionPolicy { + readonly maxJournalBytes: number; + readonly maxTerminal: number; + readonly terminalTtlMs: number; +} + +/** + * The project's notice retention policy: the fields `notices.retention` + * declared (already converted to runtime units) and the fully resolved + * policy the generated runtime mounts. Present only when the config declares + * `notices.retention`; the runtime defaults apply otherwise. + */ +export interface NormalizedNoticeRetention { + readonly declared: Partial; + readonly provenance: SourceProvenance; + readonly resolved: NormalizedNoticeRetentionPolicy; +} + +export interface NormalizedNotices { + readonly retention: NormalizedNoticeRetention; +} + export interface NormalizedPlugin { /** * Project-level copied assets. Normalizers always provide this collection; @@ -661,6 +703,8 @@ export interface NormalizedPlugin { */ readonly mcpApps?: readonly NormalizedMcpApp[]; readonly nativeHooks?: readonly NormalizedNativeHook[]; + /** Notice ledger policy declared by `notices` config; absent means runtime defaults. */ + readonly notices?: NormalizedNotices; /** * The framework-owned npm package build (bin + lib outputs). Present only * when configured or discovered by convention; optional so hand-constructed diff --git a/packages/agent-bundle/src/dev/playground/mcp-probe-service.ts b/packages/agent-bundle/src/dev/playground/mcp-probe-service.ts index 437b11cd2..4fa6ae4ae 100644 --- a/packages/agent-bundle/src/dev/playground/mcp-probe-service.ts +++ b/packages/agent-bundle/src/dev/playground/mcp-probe-service.ts @@ -22,7 +22,7 @@ import type { McpProbeSnapshot, McpProbeTool, } from '../../contracts/mcp-probe.ts'; -import { redactCredentialText } from '../../core/credentials.ts'; +import { redactCredentialText, urlUserinfoPattern } from '../../core/credentials.ts'; import { parseJsonWithoutDuplicateKeys } from '../../core/strict-json.ts'; import { resolveBundleRoot } from '../../install/doctor.ts'; import { @@ -190,30 +190,16 @@ const hasAbsolutePath = (value: string): boolean => localUriPathPattern.test(value) || /(?:file:|(?:^|[\s"'([{=,]|:(?!\/\/))\/[^\s,;{}()[\]<>"']+|(?:^|[\s"'([{=,:])[A-Za-z]:[\\/]|\\\\)/u.test(value); -/** - * URL userinfo (`scheme://user:secret@host`) is a credential that the generic - * credential redaction does not recognize; because URLs are exempt from the - * absolute-path fail-closed rule, the userinfo is stripped before that check. - * The authority runs until one of the terminators every WHATWG scheme shares - * (`/`, `?`, `#`); within it the match is greedy through the *final* `@`, the - * delimiter URL parsers honour, so a raw `@`, quote, backslash, or whitespace - * inside a password (parsers percent-encode spaces and strip embedded tabs and - * newlines) cannot leave part of the credential behind. Nothing short of those - * three terminators ends the run on purpose — `\` is userinfo for non-special - * schemes and whitespace is encoded rather than rejected — so a path-less URL - * followed on the same text by an `@` before any `/`, `?`, or `#` is masked - * as well: for a browser-facing report that over-redaction is the safe side. - * Like the local-URI rule, the scheme is anchored to the start of its own - * character run rather than to a word boundary, so a URL glued to a preceding - * identifier (`_https://user:secret@…`) is masked too. - */ -const urlUserinfoPattern = /(? { const redacted = redactCredentialText(value).replace(urlUserinfoPattern, '$1[REDACTED]@').replace( diff --git a/packages/agent-bundle/src/dev/routes/route-manifest.ts b/packages/agent-bundle/src/dev/routes/route-manifest.ts index f7728aaec..9a386a31d 100644 --- a/packages/agent-bundle/src/dev/routes/route-manifest.ts +++ b/packages/agent-bundle/src/dev/routes/route-manifest.ts @@ -4,7 +4,7 @@ import { stateDefinitionProjection, type StateDefinitionProjection, } from '../../core/state-inspection.ts'; -import type { NormalizedStateDefinition } from '../../core/types.ts'; +import type { NormalizedNotices, NormalizedStateDefinition } from '../../core/types.ts'; import type { CompiledAgentRoute, CompiledCliCommand, @@ -242,6 +242,7 @@ export const routeManifestFor = ( graph: CompiledRouteGraph, sourceRevision: string, state?: NormalizedStateDefinition, + notices?: NormalizedNotices, ): RouteManifest => deepFreeze({ ...(graph.cli === undefined ? {} : { cli: manifestCli(graph.cli) }), diagnostics: graph.diagnostics.map((diagnostic) => ({ ...diagnostic })), @@ -250,6 +251,6 @@ export const routeManifestFor = ( providers: graph.providers.map(manifestProvider), scripts: graph.scripts.map(manifestRoute), servers: graph.servers.map(manifestServer), - ...(state === undefined ? {} : { state: stateDefinitionProjection(state, 'src/state.ts') }), + ...(state === undefined ? {} : { state: stateDefinitionProjection(state, 'src/state.ts', notices) }), sourceRevision, }); diff --git a/packages/agent-bundle/src/dev/workbench-server.ts b/packages/agent-bundle/src/dev/workbench-server.ts index 120ecf64a..7e2c5ca48 100644 --- a/packages/agent-bundle/src/dev/workbench-server.ts +++ b/packages/agent-bundle/src/dev/workbench-server.ts @@ -832,6 +832,7 @@ export const startDevServer = async (options: StartDevServerOptions): Promise; + readonly host: Observed; + readonly session: Observed; + readonly workspace: Observed; +} + +/** Outcome of one post-render inbox observation; mirrors the runtime's `AgentNoticeInboxSignalOutcome`. */ +export type GeneratedNoticeInboxSignalOutcome = + | { + readonly kind: 'idle'; + readonly reason: 'no-subscription' | 'nothing-eligible'; + readonly revision: number | undefined; + } + | { readonly kind: 'signalled'; readonly noticeIds: readonly string[]; readonly revision: number } + | { readonly error: unknown; readonly kind: 'failed'; readonly stage: 'read' | 'record' | 'send' }; + /** * The `mcp-resource-updated` delivery route for the notice inbox (#99 stage * 4): the server process's own handle on the durable notice store its Flight @@ -548,8 +569,20 @@ export interface GeneratedEventRuntimeBinding { * the artifact's state is workspace-durable — that is the only lifetime two * processes can share — so `resources.subscribe` is advertised exactly when a * subscription can be honoured. Closed when the server closes. + * + * Structurally the runtime's `AgentNoticeInboxSignaller`, spelled locally so + * the emitted `mcp-server-runtime.d.ts` stays self-contained for a packed + * consumer without the optional `@agent-bundle/runtime/notices` subpath; + * `mcp-server-runtime.test.ts` pins the two mutually assignable. */ -export type GeneratedNoticeDeliveryBinding = AgentNoticeInboxSignaller; +export interface GeneratedNoticeDeliveryBinding { + readonly inboxUri: string; + readonly subscribed: boolean; + close(): Promise; + observe(send: () => Promise): Promise; + subscribe(principal: GeneratedNoticePrincipal): Promise; + unsubscribe(): Promise; +} export interface CreateGeneratedRouteMcpServerOptions { readonly apps?: readonly GeneratedMcpAppRecord[]; @@ -639,7 +672,7 @@ const installNoticeInboxSubscriptions = ( const send = async (): Promise => { await protocol.sendResourceUpdated({ uri: notices.inboxUri }); }; - const report = (outcome: AgentNoticeInboxSignalOutcome): void => { + const report = (outcome: GeneratedNoticeInboxSignalOutcome): void => { switch (outcome.kind) { case 'idle': case 'signalled': diff --git a/packages/agent-bundle/src/test/mcp.ts b/packages/agent-bundle/src/test/mcp.ts index 29e13a1a1..787696e5f 100644 --- a/packages/agent-bundle/src/test/mcp.ts +++ b/packages/agent-bundle/src/test/mcp.ts @@ -23,9 +23,11 @@ import type { import type { LineageHost } from '@agent-bundle/runtime'; import type { AgentLineageRegistry } from '@agent-bundle/runtime/lineage'; import type { createGeneratedRuntimeState } from '@agent-bundle/runtime/mount'; -import type { AgentNoticeInboxSignaller, AgentNoticePrincipal } from '@agent-bundle/runtime/notices'; import type { ReactNode } from 'react'; +import type { NoticeDeliveryAdvertisement } from '../adapters/notice-delivery.ts'; +import type { NormalizedNoticeRetentionPolicy } from '../core/types.ts'; +import type { GeneratedNoticeDeliveryBinding, GeneratedNoticePrincipal } from '../mcp-server-runtime.ts'; import { createProviderProcessLifetime } from '../routes/provider-execution.ts'; import { AgentTestError, captured } from './errors.ts'; import { composeLayouts, loadLayoutChain, type LoadedLayout } from './layouts.ts'; @@ -83,6 +85,15 @@ export interface InMemoryMcpSessionOptionsBase< readonly state?: { readonly definition: AgentStateDefinition; readonly driver: AgentStateDriver; + /** + * The host advertisement the generated runtime would mount (a + * `TargetAdapter.noticeDelivery` value): its per-route sensitivity + * ceilings decide what the inbox discloses. Absent means every route + * admits `internal`, exactly as a generated artifact without one. + */ + readonly noticeDelivery?: NoticeDeliveryAdvertisement; + /** The project's resolved `notices.retention`; the runtime defaults apply when absent. */ + readonly noticeRetention?: NormalizedNoticeRetentionPolicy; }; } @@ -250,16 +261,16 @@ const drain = async (stream: ReadableStream): Promise }; const withContextIdentity = ( - signaller: AgentNoticeInboxSignaller, + signaller: GeneratedNoticeDeliveryBinding, context: RenderRouteContext, -): AgentNoticeInboxSignaller => Object.freeze({ +): GeneratedNoticeDeliveryBinding => Object.freeze({ inboxUri: signaller.inboxUri, get subscribed(): boolean { return signaller.subscribed; }, close: () => signaller.close(), observe: (send: () => Promise) => signaller.observe(send), - subscribe: (principal: AgentNoticePrincipal) => signaller.subscribe({ + subscribe: (principal: GeneratedNoticePrincipal) => signaller.subscribe({ actor: context.actor ?? principal.actor, host: context.host ?? principal.host, session: context.session ?? principal.session, @@ -441,6 +452,7 @@ export const openInMemoryMcpServer = async < const notices = runtimeState === undefined || options.state?.definition.lifetime !== 'workspace-durable' ? undefined : withContextIdentity(dependencies.createNoticeInboxSignaller({ + ...(options.state.noticeDelivery === undefined ? {} : { delivery: options.state.noticeDelivery }), store: { close: async () => undefined, noticeLedger: () => runtimeState.noticeLedger() }, }), context); const server = await dependencies.createGeneratedRouteMcpServer({ diff --git a/packages/agent-bundle/src/test/workbench.ts b/packages/agent-bundle/src/test/workbench.ts index 693831841..ceca34ffe 100644 --- a/packages/agent-bundle/src/test/workbench.ts +++ b/packages/agent-bundle/src/test/workbench.ts @@ -22,7 +22,7 @@ import { resolve } from 'node:path'; import type { Lifecycle, LifecycleListResponse } from '../contracts/lifecycles.ts'; import type { Diagnostic } from '../core/diagnostics.ts'; import { deepFreeze } from '../core/freeze.ts'; -import type { NormalizedStateDefinition } from '../core/types.ts'; +import type { NormalizedNotices, NormalizedStateDefinition } from '../core/types.ts'; import { routeManifestFor } from '../dev/routes/route-manifest.ts'; import type { RouteManifest, @@ -327,6 +327,7 @@ export interface WorkbenchSurfaceFromGraphInput { readonly lifecycles: LifecycleListResponse; readonly projectRoot: string; readonly sourceRevision: string; + readonly notices?: NormalizedNotices; readonly state?: NormalizedStateDefinition; readonly targets: readonly string[]; } @@ -337,7 +338,7 @@ export interface WorkbenchSurfaceFromGraphInput { * rules, with the navigation rule applied over the declared counts. */ export const workbenchSurfaceFromRouteGraph = (input: WorkbenchSurfaceFromGraphInput): WorkbenchSurface => { - const manifest = routeManifestFor(input.graph, input.sourceRevision, input.state); + const manifest = routeManifestFor(input.graph, input.sourceRevision, input.state, input.notices); const catalog = workbenchRouteCatalog(manifest); const pages = workbenchPagesFor(input.counts, catalog); return deepFreeze({ diff --git a/packages/agent-bundle/tests/adapter-capability-states.test.ts b/packages/agent-bundle/tests/adapter-capability-states.test.ts index dd87a8ed5..1dfb22c3e 100644 --- a/packages/agent-bundle/tests/adapter-capability-states.test.ts +++ b/packages/agent-bundle/tests/adapter-capability-states.test.ts @@ -1,6 +1,6 @@ import { expect, it } from '@rstest/core'; -import { AGENT_NOTICE_DELIVERY_ROUTES, selectNoticeDeliveryRoutes } from '@agent-bundle/runtime/notices'; +import { AGENT_NOTICE_DELIVERY_ROUTES, resolveNoticeDisclosure, selectNoticeDeliveryRoutes } from '@agent-bundle/runtime/notices'; import type { AgentNoticeDeliveryAdvertisement, AgentNoticeDeliveryRoute } from '@agent-bundle/runtime/notices'; import { @@ -1589,6 +1589,93 @@ it('spells the notice delivery taxonomy locally so public declarations never res .toEqual([...AGENT_NOTICE_DELIVERY_ROUTES].map(routeFromRuntime).toSorted()); }); +it('advertises dated sensitivity ceilings per route and host (#99 acceptance item 7)', () => { + const registry = createDefaultRegistry(); + const ceiling = (host: string, route: NoticeDeliveryRoute): string | undefined => { + const entry = registry.noticeDelivery(host)![route]; + return entry.state === 'supported' ? entry.sensitivity : undefined; + }; + for (const host of ['claude', 'codex', 'cursor']) { + // The hook response returns to the recipient's own host process: the + // recipient's trust boundary, so a secret notice may travel in full. + expect(ceiling(host, 'current-response')).toBe('secret'); + expect(ceiling(host, 'next-event')).toBe('secret'); + // MCP identity is transport-derived and unauthenticated to the plugin. + expect(ceiling(host, 'mcp-inbox')).toBe('internal'); + expect(ceiling(host, 'mcp-resource-updated')).toBe('internal'); + } + expect(ceiling('portable', 'mcp-inbox')).toBe('internal'); + expect(ceiling('portable', 'mcp-resource-updated')).toBe('internal'); + // Every named ceiling carries dated evidence. + for (const host of ['claude', 'codex', 'cursor', 'portable', 'plugin']) { + for (const route of NOTICE_DELIVERY_ROUTES) { + const entry = registry.noticeDelivery(host)![route]; + if (entry.state !== 'supported' || entry.sensitivity === undefined) continue; + expect(entry.sensitivityEvidence).toMatch(/2026-09-03/u); + } + } + // The composite plugin target takes the lowest ceiling of its hosts. + expect(ceiling('plugin', 'next-event')).toBe('secret'); + expect(ceiling('plugin', 'mcp-inbox')).toBe('internal'); + // The runtime resolves the same ceilings into disclosure decisions. + expect(resolveNoticeDisclosure('mcp-inbox', 'secret', registry.noticeDelivery('claude')!)) + .toEqual({ kind: 'withheld', reason: 'sensitivity-exceeds-route' }); + expect(resolveNoticeDisclosure('next-event', 'secret', registry.noticeDelivery('claude')!)) + .toEqual({ kind: 'disclosed', redacted: false, shape: 'body' }); + expect(resolveNoticeDisclosure('mcp-inbox', 'internal', registry.noticeDelivery('portable')!)) + .toEqual({ kind: 'disclosed', redacted: true, shape: 'body' }); +}); + +it('fails closed on a sensitivity ceiling it cannot describe honestly', () => { + const rows = { ...claudeCapabilityTable.noticeDelivery } as Record; + expect(() => noticeDeliveryAdvertisementFrom('fixture', { ...rows, 'mcp-inbox': { sensitivity: 'top-secret', sensitivityEvidence: '2026-09-03: x', state: 'supported' } })) + .toThrow(/Unsupported notice sensitivity "top-secret" for mcp-inbox/u); + expect(() => noticeDeliveryAdvertisementFrom('fixture', { ...rows, 'mcp-inbox': { sensitivity: 'secret', state: 'supported' } })) + .toThrow(/secret sensitivity ceiling for notice delivery route mcp-inbox without dated evidence/u); + expect(() => noticeDeliveryAdvertisementFrom('fixture', { ...rows, 'mcp-inbox': { sensitivity: 'secret', sensitivityEvidence: 'trust me', state: 'supported' } })) + .toThrow(CapabilityStateError); + // A bare supported row is still the pre-sensitivity contract (internal). + expect(noticeDeliveryAdvertisementFrom('fixture', { ...rows, 'mcp-inbox': { state: 'supported' } })['mcp-inbox']).toEqual({ state: 'supported' }); +}); + +it('intersects sensitivity ceilings to the lowest host, keeping that host\'s evidence', () => { + const claude = createDefaultRegistry().noticeDelivery('claude')!; + const lowered: AgentNoticeDeliveryAdvertisement = Object.freeze({ + ...claude, + 'next-event': Object.freeze({ sensitivity: 'public' as const, sensitivityEvidence: '2026-09-03: host B echoes hook responses to a shared log.', state: 'supported' as const }), + 'mcp-inbox': Object.freeze({ state: 'supported' as const }), + }); + const merged = intersectNoticeDeliveryAdvertisements(claude, lowered); + expect(merged['next-event']).toEqual({ + sensitivity: 'public', + sensitivityEvidence: '2026-09-03: host B echoes hook responses to a shared log.', + state: 'supported', + }); + // An unevidenced bare row is `internal`; the evidenced internal row's evidence survives. + expect(merged['mcp-inbox']).toEqual(claude['mcp-inbox']); + // Two hosts at the same ceiling keep both pieces of evidence, deduplicated and ordered. + const same = intersectNoticeDeliveryAdvertisements(claude, Object.freeze({ + ...claude, + 'next-event': Object.freeze({ sensitivity: 'secret' as const, sensitivityEvidence: '2026-09-03: host C, same boundary.', state: 'supported' as const }), + })); + const claudeNextEvent = claude['next-event']; + const claudeEvidence = claudeNextEvent.state === 'supported' ? claudeNextEvent.sensitivityEvidence ?? '' : ''; + expect(claudeEvidence).toMatch(/2026-09-03/u); + expect(same['next-event']).toEqual({ + sensitivity: 'secret', + sensitivityEvidence: [claudeEvidence, '2026-09-03: host C, same boundary.'] + .sort((first, second) => first.localeCompare(second)) + .join('; '), + state: 'supported', + }); + // Neither host named a ceiling: the bare row survives. + const bare = intersectNoticeDeliveryAdvertisements( + Object.freeze({ ...claude, 'mcp-inbox': Object.freeze({ state: 'supported' as const }) }), + Object.freeze({ ...claude, 'mcp-inbox': Object.freeze({ state: 'supported' as const }) }), + ); + expect(bare['mcp-inbox']).toEqual({ state: 'supported' }); +}); + it('intersects host advertisements so a composite only claims routes every host supports', () => { const claude = createDefaultRegistry().noticeDelivery('claude')!; const partial: AgentNoticeDeliveryAdvertisement = Object.freeze({ @@ -1597,7 +1684,9 @@ it('intersects host advertisements so a composite only claims routes every host 'next-event': Object.freeze({ reason: '2026-09-02: host B has no hooks.', state: 'unavailable' as const }), }); const merged = intersectNoticeDeliveryAdvertisements(claude, partial); - expect(merged['mcp-inbox']).toEqual({ state: 'supported' }); + // Both hosts carry Claude's evidenced `internal` inbox ceiling; it survives intact. + expect(merged['mcp-inbox']).toEqual(claude['mcp-inbox']); + expect(merged['mcp-inbox']).toMatchObject({ sensitivity: 'internal', state: 'supported' }); expect(merged['mcp-resource-updated']).toEqual({ reason: '2026-09-02: host B drops resources/updated.', state: 'unavailable' }); expect(merged['next-event']).toEqual({ reason: '2026-09-02: host B has no hooks.', state: 'unavailable' }); // Both reasons survive, deduplicated and ordered, when both hosts decline. diff --git a/packages/agent-bundle/tests/adapter-metadata.test.ts b/packages/agent-bundle/tests/adapter-metadata.test.ts index fc741310e..ec65c3550 100644 --- a/packages/agent-bundle/tests/adapter-metadata.test.ts +++ b/packages/agent-bundle/tests/adapter-metadata.test.ts @@ -52,7 +52,7 @@ it('records exact immutable metadata for every built-in target', () => { const registry = createDefaultRegistry(); expect(registryMetadata(registry, 'portable')).toEqual({ - adapterRevision: '1.8.0', + adapterRevision: '1.9.0', observedVersion: '1.0.0', schemas: [ { @@ -99,7 +99,7 @@ it('records exact immutable metadata for every built-in target', () => { ], }); expect(registryMetadata(registry, 'claude')).toEqual({ - adapterRevision: '1.24.0', + adapterRevision: '1.25.0', observedVersion: '2.1.250', schemas: [ { @@ -145,7 +145,7 @@ it('records exact immutable metadata for every built-in target', () => { ], }); expect(registryMetadata(registry, 'cursor')).toEqual({ - adapterRevision: '1.11.0', + adapterRevision: '1.12.0', observedVersion: '2026-08-28', schemas: [ { @@ -170,7 +170,7 @@ it('records exact immutable metadata for every built-in target', () => { }, ], }); - expect(registryMetadata(registry, 'plugin').adapterRevision).toBe('1.27.0'); + expect(registryMetadata(registry, 'plugin').adapterRevision).toBe('1.28.0'); }); it('records observed capability versions and rehashes schema snapshots against pinned provenance', async () => { diff --git a/packages/agent-bundle/tests/entry-shell.test.ts b/packages/agent-bundle/tests/entry-shell.test.ts index cef08cc97..900ab5160 100644 --- a/packages/agent-bundle/tests/entry-shell.test.ts +++ b/packages/agent-bundle/tests/entry-shell.test.ts @@ -10,6 +10,7 @@ import { claudeAdapter } from '../src/adapters/claude.ts'; import type { NoticeDeliveryAdvertisement } from '../src/adapters/notice-delivery.ts'; import { scanEntryExportsSource, stripCommentsAndStrings } from '../src/build/entry-exports.ts'; import * as entryShellModule from '../src/build/entry-shell.ts'; +import { stableJson } from '../src/core/digest.ts'; import { generatedExecutableEntrySource, generatedRenderedScriptEntrySource, @@ -1025,8 +1026,24 @@ it('conditionally emits generated state mounting without leaking sqlite into vol expect(durableEntry).toContain("import { createNoticeInboxSignaller } from '@agent-bundle/runtime/notices';"); expect(durableEntry).toContain("import { createSqliteStateDriver } from '@agent-bundle/runtime/state/sqlite';"); expect(durableEntry).toContain("const durableAnchor = process.env.AGENT_BUNDLE_PLUGIN_ROOT ?? fileURLToPath(new URL('..', import.meta.url));"); - expect(durableEntry).toContain("createGeneratedNoticeRuntime({ driver: createSqliteStateDriver({ root: join(durableAnchor, 'state') }), lifetime: 'workspace-durable' })"); + // The host's advertisement is declared once and handed to both the ledger + // (whose sensitivity ceilings it carries) and the signaller (#99 item 7). + expect(durableEntry).toContain(`const noticeDeliveryAdvertisement = Object.freeze(${stableJson(claudeAdapter.noticeDelivery)});`); + expect(durableEntry).toContain("createNoticeInboxSignaller({ delivery: noticeDeliveryAdvertisement, store: createGeneratedNoticeRuntime({ driver: createSqliteStateDriver({ root: join(durableAnchor, 'state') }), lifetime: 'workspace-durable', noticeDelivery: noticeDeliveryAdvertisement }) })"); + expect(durableEntry).not.toContain('noticeRetentionPolicy'); expect(durableEntry).toContain(' notices: noticeDelivery,'); + // A declared `notices.retention` travels as one frozen literal too. + const retainingEntry = entryShellModule.generatedRouteMcpEntrySource({ + noticeDelivery: claudeAdapter.noticeDelivery!, + noticeRetention: { maxJournalBytes: 1024, maxTerminal: 3, terminalTtlMs: 60_000 }, + plugin: { name: 'route-fixture', version: '1.2.3' }, + routes: [route], + serverName: 'curator', + state: state('workspace-durable'), + workerFile: 'mcp-curator-flight.mjs', + }); + expect(retainingEntry).toContain('const noticeRetentionPolicy = Object.freeze({"maxJournalBytes":1024,"maxTerminal":3,"terminalTtlMs":60000});'); + expect(retainingEntry).toContain("lifetime: 'workspace-durable', noticeDelivery: noticeDeliveryAdvertisement, noticeRetention: noticeRetentionPolicy })"); // The server process never evaluates the project's own state definition. expect(durableEntry).not.toContain('import stateDefinition from'); expect(durableEntry).not.toContain('createGeneratedRuntimeState'); diff --git a/packages/agent-bundle/tests/inspect-state.test.ts b/packages/agent-bundle/tests/inspect-state.test.ts index a07f8cd47..2b15271b3 100644 --- a/packages/agent-bundle/tests/inspect-state.test.ts +++ b/packages/agent-bundle/tests/inspect-state.test.ts @@ -82,7 +82,15 @@ it('inspects volatile and workspace-durable state without inventing runtime path driver: 'memory', id: 'fixture/process-state', lifetime: 'process', - notices: [expect.stringContaining('@agent-bundle/runtime/agent-notice-ledger/v1')], + // Retention is static policy: the runtime defaults until `notices.retention` says otherwise. + noticeRetention: { + resolved: { maxJournalBytes: 16_777_216, maxTerminal: 500, terminalTtlMs: 604_800_000 }, + source: 'defaults', + }, + notices: [ + expect.stringContaining('@agent-bundle/runtime/agent-notice-ledger/v1'), + expect.stringContaining('AgentNoticeLedger.inspect()'), + ], provenance: { kind: 'conventional', sourcePath: stateSource }, source: stateSource, }, @@ -91,6 +99,41 @@ it('inspects volatile and workspace-durable state without inventing runtime path }); expect(JSON.parse(volatile.stdout).selected.state).not.toHaveProperty('durableLocation'); + // A declared `notices.retention` resolves over the defaults and is reported as declared. + await writeFile(join(root, 'agent-bundle.config.ts'), [ + 'export default {', + " notices: { retention: { maxTerminal: 12, terminalTtl: '2d' } },", + " plugin: { name: 'state-fixture', version: '1.0.0' },", + " targets: ['portable'],", + '};', + '', + ].join('\n')); + const retaining = await inspectCli(root, ['--state', '--json']); + expect(retaining).toMatchObject({ code: 0, stderr: '' }); + expect(JSON.parse(retaining.stdout).selected.state.noticeRetention).toEqual({ + resolved: { maxJournalBytes: 16_777_216, maxTerminal: 12, terminalTtlMs: 172_800_000 }, + source: 'declared', + }); + // A malformed policy is an AB4829 source error, never a silently defaulted one. + await writeFile(join(root, 'agent-bundle.config.ts'), [ + 'export default {', + " notices: { retention: { terminalTtl: 'soon' } },", + " plugin: { name: 'state-fixture', version: '1.0.0' },", + " targets: ['portable'],", + '};', + '', + ].join('\n')); + const malformed = await inspectCli(root, ['--state', '--json']); + expect(malformed.code).not.toBe(0); + expect(`${malformed.stdout}${malformed.stderr}`).toContain('AB4829'); + await writeFile(join(root, 'agent-bundle.config.ts'), [ + 'export default {', + " plugin: { name: 'state-fixture', version: '1.0.0' },", + " targets: ['portable'],", + '};', + '', + ].join('\n')); + await writeFile(stateSource, [ 'export default defineState({', " id: 'fixture/durable-state',", diff --git a/packages/agent-bundle/tests/mcp-server-runtime.test.ts b/packages/agent-bundle/tests/mcp-server-runtime.test.ts index e9f465ef9..7b2839798 100644 --- a/packages/agent-bundle/tests/mcp-server-runtime.test.ts +++ b/packages/agent-bundle/tests/mcp-server-runtime.test.ts @@ -2,13 +2,38 @@ import { Client, InMemoryTransport } from '@modelcontextprotocol/client'; import { describe, expect, it } from '@rstest/core'; import { z } from 'zod'; +import type { + AgentNoticeInboxSignaller, + AgentNoticeInboxSignalOutcome, + AgentNoticePrincipal, +} from '@agent-bundle/runtime/notices'; + import { advertisedOutputSchema, createGeneratedRouteMcpServer, type GeneratedNoticeDeliveryBinding, + type GeneratedNoticeInboxSignalOutcome, + type GeneratedNoticePrincipal, type GeneratedRouteExecutionHost, } from '../src/mcp-server-runtime.ts'; +/** + * The generated server's notice binding is spelled locally so the emitted + * `mcp-server-runtime.d.ts` never references the optional + * `@agent-bundle/runtime/notices` subpath; these assignments fail to compile + * the moment the local shape drifts from the runtime signaller it wraps. + */ +describe('GeneratedNoticeDeliveryBinding', () => { + it('is the runtime inbox signaller, spelled without the optional peer subpath', () => { + const fromRuntime = (signaller: AgentNoticeInboxSignaller): GeneratedNoticeDeliveryBinding => signaller; + const outcomeFromRuntime = (outcome: AgentNoticeInboxSignalOutcome): GeneratedNoticeInboxSignalOutcome => outcome; + const outcomeToRuntime = (outcome: GeneratedNoticeInboxSignalOutcome): AgentNoticeInboxSignalOutcome => outcome; + const principalToRuntime = (principal: GeneratedNoticePrincipal): AgentNoticePrincipal => principal; + const principalFromRuntime = (principal: AgentNoticePrincipal): GeneratedNoticePrincipal => principal; + expect([fromRuntime, outcomeFromRuntime, outcomeToRuntime, principalToRuntime, principalFromRuntime].every((fn) => typeof fn === 'function')).toBe(true); + }); +}); + /** * The MCP specification requires every result of a tool that declares * `outputSchema` to carry `structuredContent`, and the projection emits diff --git a/packages/agent-bundle/tests/notice-redaction-parity.test.ts b/packages/agent-bundle/tests/notice-redaction-parity.test.ts new file mode 100644 index 000000000..c61281d79 --- /dev/null +++ b/packages/agent-bundle/tests/notice-redaction-parity.test.ts @@ -0,0 +1,50 @@ +import { expect, it } from '@rstest/core'; + +import { + AGENT_NOTICE_DEFAULT_RETENTION, + NOTICE_SECRET_PATTERN_SOURCES, + redactSecretText, +} from '@agent-bundle/runtime/notices'; + +import { noticeRetentionDefaults } from '../src/config/notice-retention.ts'; +import { + CREDENTIAL_TEXT_PATTERN_SOURCES, + redactCredentialText, + urlUserinfoPattern, +} from '../src/core/credentials.ts'; + +/** + * `@agent-bundle/runtime` is an optional peer of `agent-bundle`, so the notice + * ledger's secret pass and the compiler's credential redaction cannot share a + * module. They share a definition instead: these pins fail the build the + * moment either copy drifts (the same discipline `inspect-state.test.ts` + * applies to the state budgets). + */ +it('keeps the notice secret patterns byte-identical to the compiler credential patterns', () => { + expect(NOTICE_SECRET_PATTERN_SOURCES).toEqual(CREDENTIAL_TEXT_PATTERN_SOURCES); + expect(NOTICE_SECRET_PATTERN_SOURCES.assignment).toBe(CREDENTIAL_TEXT_PATTERN_SOURCES.assignment); + expect([...NOTICE_SECRET_PATTERN_SOURCES.provider]).toEqual([...CREDENTIAL_TEXT_PATTERN_SOURCES.provider]); + expect(NOTICE_SECRET_PATTERN_SOURCES.urlUserinfo).toBe(urlUserinfoPattern.source); +}); + +it('redacts the same corpus the same way on both sides of the peer boundary', () => { + const corpus = [ + 'token=abc123def456 shipped', + 'authorization: Bearer abcdefghijklmnopqrstuvwxyz0123', + JSON.stringify({ api_key: 'xyz', note: 'keep', password: 'p' }), + 'sk-ant-0123456789abcdef0123 and ghp_abcdefghijklmnopqrstuvwxyz1234', + 'plain coordination text about /repo/src/secrets.ts', + 'status=ok request-id: build-123', + ]; + for (const sample of corpus) { + expect(redactSecretText(sample)).toBe(redactCredentialText(sample)); + } + // The notice pass adds the probe's URL userinfo mask on top of the credential pass. + const url = 'see https://ops:hunter2@vault.example.test/x and wss://u@relay.example.test/'; + expect(redactSecretText(url)).toBe(redactCredentialText(url).replace(urlUserinfoPattern, '$1[REDACTED]@')); + expect(redactSecretText(url)).toBe('see https://[REDACTED]@vault.example.test/x and wss://[REDACTED]@relay.example.test/'); +}); + +it('keeps the static notice retention defaults equal to the runtime defaults', () => { + expect(noticeRetentionDefaults).toEqual(AGENT_NOTICE_DEFAULT_RETENTION); +}); diff --git a/packages/agent-bundle/tests/notice-retention-config.test.ts b/packages/agent-bundle/tests/notice-retention-config.test.ts new file mode 100644 index 000000000..230c3cb7d --- /dev/null +++ b/packages/agent-bundle/tests/notice-retention-config.test.ts @@ -0,0 +1,83 @@ +import { describe, expect, it } from '@rstest/core'; + +import { + normalizeNoticeRetention, + noticeRetentionDefaults, + parseNoticeRetentionDuration, +} from '../src/config/notice-retention.ts'; +import type { AgentBundleConfig } from '../src/core/types.ts'; + +const config = (notices: unknown): AgentBundleConfig => ({ + notices, + plugin: { name: 'fixture', version: '1.0.0' }, +} as AgentBundleConfig); + +describe('notices.retention config (AB4829)', () => { + it('parses durations as positive integers of milliseconds or unit literals', () => { + expect(parseNoticeRetentionDuration(1)).toBe(1); + expect(parseNoticeRetentionDuration(86_400_000)).toBe(86_400_000); + expect(parseNoticeRetentionDuration('7d')).toBe(7 * 24 * 60 * 60 * 1000); + expect(parseNoticeRetentionDuration(' 12h ')).toBe(12 * 60 * 60 * 1000); + expect(parseNoticeRetentionDuration('30m')).toBe(30 * 60 * 1000); + expect(parseNoticeRetentionDuration('90s')).toBe(90_000); + expect(parseNoticeRetentionDuration('250ms')).toBe(250); + for (const invalid of [0, -1, 1.5, Number.NaN, '', '0d', '7', '7 d', '1w', 'seven days', '1e3', null, true, {}]) { + expect(parseNoticeRetentionDuration(invalid)).toBeUndefined(); + } + }); + + it('resolves declared fields over the runtime defaults with config provenance', () => { + const result = normalizeNoticeRetention( + config({ retention: { maxTerminal: 25, terminalTtl: '2d' } }), + '/project/agent-bundle.config.ts', + true, + ); + expect(result.diagnostics).toEqual([]); + expect(result.retention).toEqual({ + declared: { maxTerminal: 25, terminalTtlMs: 2 * 24 * 60 * 60 * 1000 }, + provenance: { kind: 'config', sourcePath: '/project/agent-bundle.config.ts' }, + resolved: { ...noticeRetentionDefaults, maxTerminal: 25, terminalTtlMs: 2 * 24 * 60 * 60 * 1000 }, + }); + expect(Object.isFrozen(result.retention)).toBe(true); + expect(Object.isFrozen(result.retention?.resolved)).toBe(true); + // No config at all, or `notices: {}`, means the runtime defaults and nothing to report. + expect(normalizeNoticeRetention({ plugin: { name: 'f', version: '1.0.0' } }, '/p/c.ts', true)).toEqual({ diagnostics: [] }); + expect(normalizeNoticeRetention(config({}), '/p/c.ts', false)).toEqual({ diagnostics: [] }); + }); + + it('reports malformed shapes, unknown keys, and non-positive values as AB4829 errors', () => { + const cases: readonly [unknown, RegExp][] = [ + ['nope', /`notices` configuration must be an object/u], + [{ retentoin: {} }, /unknown key "retentoin"/u], + [{ retention: 5 }, /`notices.retention` must be an object/u], + [{ retention: { maxTerminal: 0 } }, /maxTerminal` must be a positive integer/u], + [{ retention: { maxJournalBytes: 1.5 } }, /maxJournalBytes` must be a positive integer/u], + [{ retention: { terminalTtl: '1w' } }, /terminalTtl` must be a positive integer of milliseconds or a duration/u], + [{ retention: { ttl: '7d' } }, /unknown key "ttl"/u], + ]; + for (const [notices, message] of cases) { + const result = normalizeNoticeRetention(config(notices), '/project/agent-bundle.config.ts', true); + expect(result.retention).toBeUndefined(); + expect(result.diagnostics).toHaveLength(1); + expect(result.diagnostics[0]).toMatchObject({ + code: 'AB4829', + message: expect.stringMatching(message), + severity: 'error', + sourcePath: '/project/agent-bundle.config.ts', + }); + } + // Several bad fields are reported together, once each. + const many = normalizeNoticeRetention(config({ retention: { maxTerminal: -1, terminalTtl: 'x' } }), '/p/c.ts', true); + expect(many.diagnostics.map((diagnostic) => diagnostic.code)).toEqual(['AB4829', 'AB4829']); + }); + + it('refuses a retention policy for a project without a state module', () => { + const result = normalizeNoticeRetention(config({ retention: { maxTerminal: 3 } }), '/p/agent-bundle.config.ts', false); + expect(result.retention).toBeUndefined(); + expect(result.diagnostics).toEqual([expect.objectContaining({ + code: 'AB4829', + message: expect.stringContaining('declares no state module'), + recovery: expect.stringContaining('src/state.ts'), + })]); + }); +}); diff --git a/packages/agent-bundle/tests/projection/mcp-in-memory.test.ts b/packages/agent-bundle/tests/projection/mcp-in-memory.test.ts index a1d6efdc0..abfba1534 100644 --- a/packages/agent-bundle/tests/projection/mcp-in-memory.test.ts +++ b/packages/agent-bundle/tests/projection/mcp-in-memory.test.ts @@ -4,11 +4,13 @@ import { join } from 'node:path'; import type { McpServer } from '@modelcontextprotocol/server'; import { describe, expect, it } from '@rstest/core'; +import { agentNoticeStateDefinition } from '@agent-bundle/runtime/notices'; import { createMemoryStateDriver, defineState, type AgentStateDriver } from '@agent-bundle/runtime/state'; import { createSqliteStateDriver } from '@agent-bundle/runtime/state/sqlite'; import { z } from 'zod'; import stateDefinition from '../../fixtures/route-harness/src/state.ts'; +import { createDefaultRegistry } from '../../src/adapters/registry.ts'; import { AgentTestError } from '../../src/test/errors.ts'; import { getMcpPrompt, @@ -329,6 +331,76 @@ describe('the in-memory MCP projection level', () => { expect((await listMcpSurface()).resources).not.toContain('agent-bundle://notices/inbox'); }); + it('discloses inbox content per sensitivity under the host advertisement: redacted, full, or withheld (#99 item 7)', async () => { + const root = await mkdtemp(join(tmpdir(), 'agent-bundle-mcp-inbox-redaction-')); + const secretText = 'Rotate token=abc123def456 at https://ops:hunter2@vault.example.test/x'; + const readInbox = async (session: Awaited>) => { + const read = await session.client.readResource({ uri: 'agent-bundle://notices/inbox' }); + const content = read.contents[0]; + if (content === undefined || !('text' in content)) throw new TypeError('Expected text inbox content'); + return (JSON.parse(content.text) as { notices: readonly Readonly>[] }).notices; + }; + try { + // The claude advertisement admits `internal` on the inbox, so a secret + // notice is withheld there while it is still admitted on next-event. + const session = await openInMemoryMcpServer({ + context: { session: { source: 'native', state: 'available', value: { sessionId: 's1' } } }, + state: { + definition: stateDefinition, + driver: createSqliteStateDriver({ root }), + noticeDelivery: createDefaultRegistry().noticeDelivery('claude'), + }, + }); + try { + // The fixture keys idempotency on the message, so each class gets its own text. + for (const sensitivity of ['internal', 'public', 'secret'] as const) { + await expect(session.client.callTool({ + arguments: { message: `${secretText} (${sensitivity})`, recipientSession: 's1', sensitivity }, + name: 'publish-notice', + })).resolves.toMatchObject({ structuredContent: { sensitivity, state: 'pending' } }); + } + const notices = await readInbox(session); + const shown = notices + .map((notice) => ({ disclosure: notice.disclosure, sensitivity: notice.sensitivity, text: (notice.content as { root: { text: string } }).root.text })) + .toSorted((left, right) => String(left.sensitivity).localeCompare(String(right.sensitivity))); + expect(shown).toEqual([ + { + disclosure: { redacted: true, route: 'mcp-inbox' }, + sensitivity: 'internal', + text: 'Rotate token=[REDACTED] at https://[REDACTED]@vault.example.test/x (internal)', + }, + { + disclosure: { redacted: false, route: 'mcp-inbox' }, + sensitivity: 'public', + text: `${secretText} (public)`, + }, + ]); + } finally { + await session.close(); + } + // Reading the inbox exposed the disclosed notices and recorded the + // refusal on the withheld one; nothing was exposed for it. The store + // keeps every notice as authored: redaction happened on egress only. + const driver = createSqliteStateDriver({ root }); + try { + const store = await driver.open(agentNoticeStateDefinition()); + const durable = await store.read(); + const byClass = new Map(durable.state.notices.map((notice) => [notice.sensitivity, notice])); + expect(byClass.get('internal')?.exposure?.count).toBe(1); + expect(byClass.get('public')?.exposure?.count).toBe(1); + expect(byClass.get('secret')?.exposure).toBeUndefined(); + expect(byClass.get('secret')?.withheld).toEqual({ + 'mcp-inbox': expect.objectContaining({ count: 1, reason: 'sensitivity-exceeds-route' }), + }); + expect(durable.state.notices.every((notice) => (notice.content.root as { text: string }).text.startsWith(secretText))).toBe(true); + } finally { + await driver.close(); + } + } finally { + await rm(root, { force: true, recursive: true }); + } + }); + // Issue #369: task-augmented tool calls are deferred until the MCP SDK ships // a task runtime (docs/mcp-conformance.md). Until then the generated server // must stay fail-closed — no `tasks` capability claim — and must process a diff --git a/packages/agent-bundle/tests/public-api-packed.test.ts b/packages/agent-bundle/tests/public-api-packed.test.ts index 9cdb2c105..27c6cd0d6 100644 --- a/packages/agent-bundle/tests/public-api-packed.test.ts +++ b/packages/agent-bundle/tests/public-api-packed.test.ts @@ -176,6 +176,17 @@ it('imports the externalized config entry from a packed npm consumer', async () : ''; throw new Error(`Packed config typecheck failed.\nstdout:\n${stdout}\nstderr:\n${stderr}`, { cause: error }); } + // The aliased artifact runtime's declarations must stay self-contained + // for a consumer without the optional runtime peer's `notices` subpath + // (#99 close-out): its notice binding is spelled locally, so no public or + // aliased declaration resolves through `@agent-bundle/runtime/notices`. + const installedDist = join(consumerRoot, 'node_modules', 'agent-bundle', 'dist'); + for (const declaration of ['mcp-server-runtime.d.ts', 'api.d.ts', 'index.d.ts', 'adapters/notice-delivery.d.ts', 'adapters/types.d.ts']) { + const text = await readFile(join(installedDist, declaration), 'utf8'); + expect(text, declaration).not.toContain('@agent-bundle/runtime/notices'); + } + const aliasedRuntime = await readFile(join(installedDist, 'mcp-server-runtime.d.ts'), 'utf8'); + expect(aliasedRuntime).toContain('GeneratedNoticeDeliveryBinding'); } finally { await rm(consumerRoot, { force: true, recursive: true }); } diff --git a/packages/agent-bundle/tests/route-manifest-routes.test.ts b/packages/agent-bundle/tests/route-manifest-routes.test.ts index 15a8c2077..207a205e4 100644 --- a/packages/agent-bundle/tests/route-manifest-routes.test.ts +++ b/packages/agent-bundle/tests/route-manifest-routes.test.ts @@ -98,7 +98,14 @@ it('serves the normalized state catalog on the manifest wire', async () => { durableLocation: '$AGENT_BUNDLE_PLUGIN_ROOT/state (falls back to the artifact root or ./.agent-bundle/state for CLI bins)', id: 'fixture/catalog-state', lifetime: 'workspace-durable', - notices: [expect.stringContaining('@agent-bundle/runtime/agent-notice-ledger/v1')], + noticeRetention: { + resolved: { maxJournalBytes: 16_777_216, maxTerminal: 500, terminalTtlMs: 604_800_000 }, + source: 'defaults', + }, + notices: [ + expect.stringContaining('@agent-bundle/runtime/agent-notice-ledger/v1'), + expect.stringContaining('Notice retention'), + ], source: 'src/state.ts', }, }, diff --git a/packages/agent-bundle/tests/route-unit/render-route.test.ts b/packages/agent-bundle/tests/route-unit/render-route.test.ts index bcbc8c557..baa270ff4 100644 --- a/packages/agent-bundle/tests/route-unit/render-route.test.ts +++ b/packages/agent-bundle/tests/route-unit/render-route.test.ts @@ -183,6 +183,35 @@ describe('renderRoute through the real renderer', () => { expect(typeof (rendered.result as { noticeId: unknown }).noticeId).toBe('string'); }); + it('publishes with an author-declared sensitivity and defaults to internal (#99 redaction contract)', async () => { + const state = { + lifetime: 'workspace-durable', + changes: async function*() {}, + dispatch: async () => ({ replayed: false, revision: 0, state: { entries: [] } }), + read: async () => ({ revision: 0, state: { entries: [] } }), + } as never; + const defaulted = await renderRoute('tool:harness/publish-notice', { + context: { state }, + input: { message: 'token=abc123 for the next session', recipientSession: 'sess-b' }, + }); + expectDocument(defaulted).toHaveStatus('success'); + expect(defaulted.result).toMatchObject({ sensitivity: 'internal', state: 'pending' }); + + const classified = await renderRoute('tool:harness/publish-notice', { + context: { state }, + input: { message: 'rotate the deploy key', recipientSession: 'sess-b', sensitivity: 'secret' }, + }); + expectDocument(classified).toHaveStatus('success'); + expect(classified.result).toMatchObject({ sensitivity: 'secret', state: 'pending' }); + expectDocument(classified).toContainText('(secret)'); + + // The route's own input schema, not the ledger, refuses an unknown class. + await expect(renderRoute('tool:harness/publish-notice', { + context: { state }, + input: { message: 'x', recipientSession: 'sess-b', sensitivity: 'loud' } as never, + })).rejects.toThrow(); + }); + it('auto-mounts state when the caller supplied noticeLedger alone', async () => { const noticeLedger = { expire: async () => ({ notices: [] }), diff --git a/packages/rsc-runtime/README.md b/packages/rsc-runtime/README.md index 685941cf0..8df82082b 100644 --- a/packages/rsc-runtime/README.md +++ b/packages/rsc-runtime/README.md @@ -321,6 +321,108 @@ availability and exposure receipts. `selectNoticeDeliveryRoutes()` chooses cross-request routes from a per-host advertisement and returns a typed unavailable outcome when none is supported; it never fabricates a channel. +### Redaction (#99 acceptance item 7) + +A notice's free text lives only in its detached `AgentDocumentSnapshot` — +`text`, `markdown`, `context`, `progress.message`, `error.message`, +`resource.name`/`uri`, and every string inside `json.value`, `result.metadata`, +and the document `value`. Recipient, priority, dedupe key, timestamps, and +receipts are identity and evidence, never prose; hosts receive them unredacted +and they must not carry secrets. The store keeps content exactly as authored; +redaction happens on egress, per route. + +`publish()` accepts `sensitivity: 'public' | 'internal' | 'secret'` (default +`internal`, persisted explicitly on new notices; notices journaled before the +contract have no class and are `internal`): + +- `public` — safe for any surface; delivered as authored. +- `internal` — for the recipient's own context; every route passes it through + `redactSecretText()` first, so a credential pasted into a coordination + message never crosses into another actor's context. +- `secret` — delivered as authored, but only over a route whose host row + admits `secret`; otherwise it never leaves the store through that route. + +Each route has a structural shape (`AGENT_NOTICE_ROUTE_SHAPES`): `mcp-inbox`, +`next-event`, `current-response`, and `directed-push` carry the document +(`body`); `host-toast` carries one bounded line (`title`, `noticeTitle()`); +`mcp-resource-updated` carries only the inbox URI (`signal`). A supported route +in an `AgentNoticeDeliveryAdvertisement` may name `sensitivity`, the most +sensitive class it carries in full, with dated `sensitivityEvidence`; an +absent ceiling means `internal`, the pre-sensitivity contract, so `secret` +notices are withheld everywhere until a host row says otherwise. +`resolveNoticeDisclosure(route, sensitivity, advertisement)` is the whole +decision: `withheld` (`route-unavailable` or `sensitivity-exceeds-route`) or +`disclosed` with `shape` and `redacted`. `createAgentNoticeLedger(store, { +delivery })` and `createNoticeInboxSignaller({ delivery })` take the host's +advertisement: `inbox()` omits withheld notices and hands out disclosed content +(the inbox resource projection reports `sensitivity` and +`disclosure.redacted`), event admission neither authorizes nor attempts a +withheld notice, `read()` deliveries carry `disclosure` and the disclosed +`content`, and the signaller never sends `resources/updated` for a notice the +inbox would withhold. Every refusal is durable evidence, not a state change: +the notice records `withheld[route] = { count, firstAt, lastAt, reason }` and +stays eligible for a route whose row admits it. The built-in hosts admit +`secret` on `current-response` and `next-event` (the hook response returns to +the recipient's own host process) and `internal` on `mcp-inbox` and +`mcp-resource-updated` (transport-derived identity the host does not +authenticate to the plugin); `portable` has only the MCP routes. + +The secret pass (`redactSecretText()`, `redactNoticeDocument()`) masks +credential assignments (`token: …`, `password=…`), recognizable provider +tokens, and URL userinfo with `[REDACTED]`, the same patterns the compiler's +credential and probe redaction use; `NOTICE_SECRET_PATTERN_SOURCES` is pinned +byte-identical to the compiler copy by test because the runtime is an optional +peer and cannot share the module. Paths are not redacted: coordination +notices legitimately name files. + +### Retention (#99 acceptance item 7) + +Terminal notices — `expired`, `unavailable`, `withdrawn`, `acknowledged`, and +`attempted` with an exhausted retry budget (`noticeSettledAt()`) — no longer +stay in the ledger forever. `createAgentNoticeLedger(store, { retention })` +takes an `AgentNoticeRetentionPolicy` (`resolveNoticeRetentionPolicy()` +validates it; defaults are `AGENT_NOTICE_DEFAULT_RETENTION`: `terminalTtlMs` +seven days, `maxTerminal` 500, `maxJournalBytes` 16 MiB). Generated runtimes +resolve it from the project's `notices.retention` config (`AB4829` when +malformed). `retain({ at, idempotencyKey })` applies it once: settled notices +older than the TTL, plus the earliest-settled beyond the cap, leave the state +through one `pruned` event (the reducer skips any id that is live again, so a +stale decision can never drop a pending notice, and records +`retention = { lastPrunedAt, pruneRuns, prunedTotal }` on the state and +snapshot); then, when the store's retained journal exceeds `maxJournalBytes`, +the journal is compacted (`store.compact()`). Event admission runs the same +pass after it commits, under a per-invocation key, so retention rides +admitted events only — V1 implies no timer — and the prune key is +content-addressed on the selected ids, so a retry that selects the same set +replays and one that selects a different set commits its own decision instead +of an idempotency conflict. `inspect()` reports the policy, live counts by +state, the number of terminal notices, the retention summary, and the store's +`AgentStateJournalInspection`; it never returns content. A process killed +between the prune and the compaction leaves a pruned state over an unfolded +journal, which the next pass finishes; a compaction over an already-compact +journal is a no-op with no new revision. + +Journal compaction is a state-kernel operation (`AgentStateStore.compact()`, +`AgentStateStore.inspect()`), available on both drivers and pinned by the +conformance suite: the head is materialized as a `compact` baseline record +that takes the next revision, every earlier record is deleted, exact reads +below the baseline become `revision-unavailable` (as below a migration), the +change cursor delivers the baseline as a `compact` discontinuity so a +subscriber positioned before it re-reads, and the idempotency keys of the +deleted records are remembered without their results — replaying one is +`revision-unavailable` (the commit happened; its result is gone) and reusing +one with a different input is still `idempotency-conflict`. On SQLite the +baseline insert, key bookkeeping, delete, head update, and the kernel-format +bump commit in one `BEGIN IMMEDIATE` transaction under `synchronous = FULL`, +so a writer killed mid-compaction leaves the full journal or the compacted +one, never a journal missing records its head needs; concurrent processes +serialize on the database lock and reopen through the same head-vs-replay +check. The first compaction moves a store to kernel format 2, which a +pre-compaction kernel refuses with a typed `corrupt` error rather than +misreading the truncated journal; a store that was never compacted stays +readable by both. The `maxRevisions` budget still counts absolute revisions: +compaction bounds bytes and terminal history, not the revision counter. + ## License Apache License 2.0. The published tarball carries the repository diff --git a/packages/rsc-runtime/src/mount/index.ts b/packages/rsc-runtime/src/mount/index.ts index 1551b3532..7e3ec4fbb 100644 --- a/packages/rsc-runtime/src/mount/index.ts +++ b/packages/rsc-runtime/src/mount/index.ts @@ -1,7 +1,10 @@ import { agentNoticeStateDefinition, createAgentNoticeLedger, + type AgentNoticeDeliveryAdvertisement, type AgentNoticeLedger, + type AgentNoticeRetentionInput, + resolveNoticeRetentionPolicy, } from '../notices/index.js'; import { AgentStateError, @@ -14,10 +17,20 @@ import { type AgentStateStore, } from '../state/index.js'; +/** + * Notice ledger policy a generated runtime mounts beside the project state: + * the host's delivery advertisement (whose per-route `sensitivity` ceilings + * the ledger honours) and the project's retention overrides. + */ +export interface GeneratedNoticePolicyOptions { + readonly noticeDelivery?: AgentNoticeDeliveryAdvertisement; + readonly noticeRetention?: AgentNoticeRetentionInput; +} + export interface CreateGeneratedRuntimeStateOptions< TState, TEvents extends AgentStateEventSchemas, -> { +> extends GeneratedNoticePolicyOptions { readonly definition: AgentStateDefinition; readonly driver: AgentStateDriver; } @@ -52,7 +65,7 @@ export interface GeneratedRuntimeState< ): Promise>; } -export interface CreateGeneratedNoticeRuntimeOptions { +export interface CreateGeneratedNoticeRuntimeOptions extends GeneratedNoticePolicyOptions { readonly driver: AgentStateDriver; readonly lifetime: AgentStateLifetime; } @@ -82,6 +95,7 @@ const failedLedger = (failure: AgentStateError): AgentNoticeLedger => { const reject = async (): Promise => Promise.reject(failure); return Object.freeze({ expire: reject, + inspect: reject, openRequest: async () => Object.freeze({ close: () => undefined, handle: Object.freeze({ @@ -94,6 +108,7 @@ const failedLedger = (failure: AgentStateError): AgentNoticeLedger => { read: reject, releaseAvailability: reject, reserveAvailability: reject, + retain: reject, signalAvailability: reject, withdraw: reject, }); @@ -108,9 +123,14 @@ const generatedNoticeAuthorizer = { authorize: () => ({ state: 'authorized' as c type NoticeStore = Parameters[0]; -const ledgerFrom = (result: OpenResult): AgentNoticeLedger => result.kind === 'opened' - ? createAgentNoticeLedger(result.value, generatedNoticeAuthorizer) - : failedLedger(result.error); +const ledgerFrom = (result: OpenResult, policy: GeneratedNoticePolicyOptions): AgentNoticeLedger => + result.kind === 'opened' + ? createAgentNoticeLedger(result.value, { + ...generatedNoticeAuthorizer, + ...(policy.noticeDelivery === undefined ? {} : { delivery: policy.noticeDelivery }), + ...(policy.noticeRetention === undefined ? {} : { retention: policy.noticeRetention }), + }) + : failedLedger(result.error); interface StoreSlot { open(): Promise>>; @@ -223,13 +243,16 @@ export const createGeneratedRuntimeState = < const shared = definition.lifetime !== 'request'; const projectSlot = owner.createSlot(definition); const noticeSlot = owner.createSlot(agentNoticeStateDefinition(definition.lifetime)); + // The retention policy is validated once, when the runtime is created, so a + // malformed override fails the process at startup rather than the first request. + resolveNoticeRetentionPolicy(options.noticeRetention); return Object.freeze({ close: owner.close, async noticeLedger(): Promise { if (!shared) return requestLifetimeLedger(); - return ledgerFrom(await noticeSlot.open()); + return ledgerFrom(await noticeSlot.open(), options); }, async requestBindings( @@ -243,7 +266,7 @@ export const createGeneratedRuntimeState = < const state = project.kind === 'opened' ? createAgentStateHandle(project.value, bindingOptions) : failedHandle(definition.lifetime, project.error); - const noticeLedger = ledgerFrom(notices); + const noticeLedger = ledgerFrom(notices, options); let released = false; return Object.freeze({ noticeLedger, @@ -270,11 +293,12 @@ export const createGeneratedNoticeRuntime = ( const owner = createStoreOwner(options.driver); const shared = options.lifetime !== 'request'; const noticeSlot = owner.createSlot(agentNoticeStateDefinition(options.lifetime)); + resolveNoticeRetentionPolicy(options.noticeRetention); return Object.freeze({ close: owner.close, async noticeLedger(): Promise { if (!shared) return requestLifetimeLedger(); - return ledgerFrom(await noticeSlot.open()); + return ledgerFrom(await noticeSlot.open(), options); }, }); }; diff --git a/packages/rsc-runtime/src/notices/contract.ts b/packages/rsc-runtime/src/notices/contract.ts index 39756c3de..7f4084816 100644 --- a/packages/rsc-runtime/src/notices/contract.ts +++ b/packages/rsc-runtime/src/notices/contract.ts @@ -7,6 +7,9 @@ import type { AgentWorkspaceIdentity, Observed, } from '../agent-request.js'; +import type { AgentStateJournalInspection } from '../state/contract.js'; +import type { AgentNoticeSensitivity } from './redaction.js'; +import type { AgentNoticeDeliveryRoute } from './router.js'; export const AGENT_NOTICE_STATES = Object.freeze([ 'pending', @@ -95,11 +98,36 @@ export interface AgentNoticeAcknowledgement { export type AgentNoticeUnavailableReason = 'delivery-authorization-unavailable'; +/** + * Evidence that a route refused to disclose the notice: the recipient's route + * was reached but the redaction policy withheld the content (its sensitivity + * exceeds the host row's ceiling). Recorded per route so the ledger says why + * a matching recipient never saw a notice; it moves no state. + */ +export type AgentNoticeWithholdingReason = 'route-unavailable' | 'sensitivity-exceeds-route'; + +export interface AgentNoticeWithholding { + readonly count: number; + readonly firstAt: string; + readonly lastAt: string; + /** The latest reason; a route that became unavailable after a sensitivity refusal reports the newer one. */ + readonly reason: AgentNoticeWithholdingReason; +} + +export type AgentNoticeWithholdings = Readonly>>; + +/** One withholding decision as an event payload records it. */ +export interface AgentNoticeWithheldEntry { + readonly id: string; + readonly reason: AgentNoticeWithholdingReason; +} + export interface AgentNotice { readonly acknowledgement?: AgentNoticeAcknowledgement; readonly attempts: readonly AgentNoticeAttemptReceipt[]; readonly availability?: AgentNoticeAvailability; readonly availabilityReservation?: AgentNoticeAvailabilityReservation; + /** The persisted snapshot as authored; routes disclose it per {@link AgentNotice.sensitivity}. */ readonly content: AgentDocumentSnapshot; readonly createdAt: string; readonly dedupeKey?: string; @@ -113,14 +141,51 @@ export interface AgentNotice { readonly recipient: AgentRecipient; /** Maximum next-event attempt receipts before admission stops re-attempting; absent means 1. */ readonly retryBudget?: number; + /** Author-declared disclosure class; absent on notices persisted before the redaction contract and means `internal`. */ + readonly sensitivity?: AgentNoticeSensitivity; readonly state: AgentNoticeState; readonly unavailableAt?: string; readonly unavailableReason?: AgentNoticeUnavailableReason; readonly withdrawnAt?: string; + /** Routes that withheld this notice from a matching recipient, with counts. */ + readonly withheld?: AgentNoticeWithholdings; +} + +/** + * Retention policy of one ledger (#99 acceptance item 7). Terminal notices — + * `expired`, `unavailable`, `withdrawn`, `acknowledged`, and `attempted` + * with an exhausted retry budget — are pruned from the state once they have + * been settled for `terminalTtlMs`, or earliest-settled first once more than + * `maxTerminal` remain; the store's journal is compacted onto its head once + * it exceeds `maxJournalBytes`. Pruning runs only on admitted events and + * explicit `retain()` calls: V1 implies no timer. + */ +export interface AgentNoticeRetentionPolicy { + /** Retained journal bytes above which `retain()` compacts the store's journal. */ + readonly maxJournalBytes: number; + /** Most terminal notices kept regardless of age. */ + readonly maxTerminal: number; + /** Milliseconds a terminal notice is kept after it settled. */ + readonly terminalTtlMs: number; +} + +/** Sane defaults: a week of terminal history, at most 500 terminal notices, a 16 MiB journal. */ +export const AGENT_NOTICE_DEFAULT_RETENTION: AgentNoticeRetentionPolicy = Object.freeze({ + maxJournalBytes: 16 * 1024 * 1024, + maxTerminal: 500, + terminalTtlMs: 7 * 24 * 60 * 60 * 1000, +}); + +/** Durable summary of pruning that has happened; absent until the first prune. */ +export interface AgentNoticeRetentionSummary { + readonly lastPrunedAt: string; + readonly pruneRuns: number; + readonly prunedTotal: number; } export interface AgentNoticeLedgerSnapshot { readonly notices: readonly AgentNotice[]; + readonly retention?: AgentNoticeRetentionSummary; readonly revision: number; } @@ -133,6 +198,8 @@ export interface AgentNoticePublishInput { readonly recipient: AgentRecipient; /** Defaults to 1 (single next-event attempt). */ readonly retryBudget?: number; + /** Defaults to `internal`; see `redaction.ts` for what each class means per route. */ + readonly sensitivity?: AgentNoticeSensitivity; } export interface AgentNoticePublishOptions { @@ -171,7 +238,16 @@ export type AgentNoticeAuthorizer = ( request: AgentNoticeAuthorizationRequest, ) => AgentNoticeAuthorizationDecision | Promise; +/** What the `next-event` route disclosed of a delivered notice. */ +export interface AgentNoticeDisclosureReceipt { + /** True when the secret-pattern pass ran over `notice.content` (every `internal` notice). */ + readonly redacted: boolean; + readonly route: AgentNoticeDeliveryRoute; +} + export interface AgentNoticeDelivery { + readonly disclosure: AgentNoticeDisclosureReceipt; + /** The notice with `content` as the route disclosed it, not necessarily as persisted. */ readonly notice: AgentNotice; readonly receipt: AgentNoticeAttemptReceipt; } @@ -179,6 +255,7 @@ export interface AgentNoticeDelivery { export interface AgentNoticesHandle { /** Recipient-scoped explicit acknowledgement; the strongest evidenced state. */ acknowledge(id: string): Promise; + /** Pending notices as the `mcp-inbox` route discloses them (`content` redacted per sensitivity; withheld ones omitted). */ inbox(): Promise; publish(input: AgentNoticePublishInput, options: AgentNoticePublishOptions): Promise; read(): Promise; @@ -231,10 +308,42 @@ export interface AgentNoticeAvailabilityReleaseOptions { readonly reservationKey: string; } +export interface AgentNoticeRetainOptions { + readonly at: string; + readonly idempotencyKey: string; +} + +export interface AgentNoticeRetentionReport { + /** True when the store's journal was compacted onto its head by this call. */ + readonly compacted: boolean; + readonly journal: AgentStateJournalInspection; + /** Ids of terminal notices this call removed from the ledger state. */ + readonly prunedIds: readonly string[]; + readonly revision: number; +} + +/** Read-only retention facts of a ledger: policy, live counts, and storage. */ +export interface AgentNoticeLedgerInspection { + readonly counts: { + readonly byState: Readonly>; + /** Notices the retention policy treats as terminal (including exhausted `attempted`). */ + readonly terminal: number; + readonly total: number; + }; + readonly journal: AgentStateJournalInspection; + readonly policy: AgentNoticeRetentionPolicy; + readonly retention?: AgentNoticeRetentionSummary; + readonly revision: number; +} + export interface AgentNoticeLedger { expire(options: AgentNoticeExpiryOptions): Promise; + /** Retention facts for diagnostics; never notice content. */ + inspect(): Promise; openRequest(request: AgentNoticeRequest): Promise; read(): Promise; + /** Applies the retention policy now: prunes eligible terminal notices, then compacts an oversized journal. */ + retain(options: AgentNoticeRetainOptions): Promise; /** Releases a reservation whose resources/updated send failed; no budget was spent. */ releaseAvailability(options: AgentNoticeAvailabilityReleaseOptions): Promise; /** Holds one budget slot for a resources/updated send about to happen; records no receipt. */ diff --git a/packages/rsc-runtime/src/notices/inbox-route.ts b/packages/rsc-runtime/src/notices/inbox-route.ts index 7c74be945..b0e1806eb 100644 --- a/packages/rsc-runtime/src/notices/inbox-route.ts +++ b/packages/rsc-runtime/src/notices/inbox-route.ts @@ -2,7 +2,12 @@ import { createElement } from 'react'; import { z } from 'zod'; import { Agent, agent, type JsonValue } from '../index.js'; -import { AGENT_NOTICE_INBOX_URI, AgentNoticeError, type AgentNotice } from './index.js'; +import { + AGENT_NOTICE_DEFAULT_SENSITIVITY, + AGENT_NOTICE_INBOX_URI, + AgentNoticeError, + type AgentNotice, +} from './index.js'; export { AGENT_NOTICE_INBOX_URI }; export const AGENT_NOTICE_INBOX_ROUTE_ID = 'agent-bundle:notice-inbox'; @@ -24,18 +29,26 @@ export const resultSchema = z.object({ }).strict()), }).strict(); -const projectNotice = (notice: AgentNotice) => Object.freeze({ - // Receipts, not state claims: `availability` counts resources/updated - // signals sent for this notice; `exposure` counts inbox reads that served it. - availability: notice.availability, - content: notice.content, - createdAt: notice.createdAt, - ...(notice.expiresAt === undefined ? {} : { expiresAt: notice.expiresAt }), - exposure: notice.exposure, - id: notice.id, - priority: notice.priority, - state: notice.state, -}); +const projectNotice = (notice: AgentNotice) => { + const sensitivity = notice.sensitivity ?? AGENT_NOTICE_DEFAULT_SENSITIVITY; + return Object.freeze({ + // Receipts, not state claims: `availability` counts resources/updated + // signals sent for this notice; `exposure` counts inbox reads that served it. + availability: notice.availability, + // Already disclosed by the ledger for the `mcp-inbox` route: `internal` + // content has passed the secret pass, `public` is as authored, and a + // `secret` notice this host's row does not admit is not in the list. + content: notice.content, + createdAt: notice.createdAt, + disclosure: Object.freeze({ redacted: sensitivity === 'internal', route: 'mcp-inbox' as const }), + ...(notice.expiresAt === undefined ? {} : { expiresAt: notice.expiresAt }), + exposure: notice.exposure, + id: notice.id, + priority: notice.priority, + sensitivity, + state: notice.state, + }); +}; export function noticeInboxRouteRecord(module: TModule) { return Object.freeze({ diff --git a/packages/rsc-runtime/src/notices/index.ts b/packages/rsc-runtime/src/notices/index.ts index d85bdec3b..24f82f057 100644 --- a/packages/rsc-runtime/src/notices/index.ts +++ b/packages/rsc-runtime/src/notices/index.ts @@ -9,6 +9,7 @@ */ export { AGENT_NOTICE_AVAILABILITY_RESERVATION_TTL_MS, + AGENT_NOTICE_DEFAULT_RETENTION, AGENT_NOTICE_STATES, AgentNoticeError, } from './contract.js'; @@ -25,10 +26,12 @@ export type { AgentNoticeAvailabilityReservationOptions, AgentNoticeAvailabilitySignalOptions, AgentNoticeDelivery, + AgentNoticeDisclosureReceipt, AgentNoticeErrorCode, AgentNoticeExposure, AgentNoticeExpiryOptions, AgentNoticeLedger, + AgentNoticeLedgerInspection, AgentNoticeLedgerSnapshot, AgentNoticePrincipal, AgentNoticePriority, @@ -36,14 +39,49 @@ export type { AgentNoticePublishOptions, AgentNoticePublishResult, AgentNoticeRequest, + AgentNoticeRetainOptions, + AgentNoticeRetentionPolicy, + AgentNoticeRetentionReport, + AgentNoticeRetentionSummary, AgentNoticeState, AgentNoticesHandle, AgentNoticeUnavailableReason, AgentNoticeWithdrawOptions, + AgentNoticeWithheldEntry, + AgentNoticeWithholding, + AgentNoticeWithholdingReason, + AgentNoticeWithholdings, AgentRecipient, } from './contract.js'; +export { + AGENT_NOTICE_DEFAULT_SENSITIVITY, + AGENT_NOTICE_SENSITIVITIES, + NOTICE_REDACTION_MARK, + NOTICE_SECRET_PATTERN_SOURCES, + NOTICE_TITLE_MAX_LENGTH, + compareNoticeSensitivity, + containsSecretText, + disclosedNoticeContent, + isNoticeSensitivity, + noticeTitle, + redactNoticeDocument, + redactSecretText, +} from './redaction.js'; +export type { + AgentNoticeDisclosure, + AgentNoticeDisclosureShape, + AgentNoticeSensitivity, +} from './redaction.js'; +export { + resolveNoticeRetentionPolicy, + selectPrunableNotices, +} from './retention.js'; +export type { AgentNoticeRetentionInput } from './retention.js'; export { AGENT_NOTICE_DELIVERY_ROUTES, + AGENT_NOTICE_ROUTE_SHAPES, + resolveNoticeDisclosure, + routeSensitivityCeiling, selectNoticeDeliveryRoutes, } from './router.js'; export type { @@ -72,6 +110,7 @@ export { AGENT_NOTICE_STATE_VERSION, agentNoticeEventSchemas, agentNoticeStateDefinition, + noticeSettledAt, recipientMatchesPrincipal, } from './state.js'; export type { diff --git a/packages/rsc-runtime/src/notices/ledger.ts b/packages/rsc-runtime/src/notices/ledger.ts index 4a45e4ceb..e14c2d9ea 100644 --- a/packages/rsc-runtime/src/notices/ledger.ts +++ b/packages/rsc-runtime/src/notices/ledger.ts @@ -13,6 +13,7 @@ import { import type { AgentStateStore } from '../state/contract.js'; import { AgentStateError, canonicalJson } from '../state/index.js'; import { + AGENT_NOTICE_STATES, AgentNoticeError, type AgentNotice, type AgentNoticeAuthorizationDecision, @@ -24,6 +25,7 @@ import { type AgentNoticeDelivery, type AgentNoticeExpiryOptions, type AgentNoticeLedger, + type AgentNoticeLedgerInspection, type AgentNoticeLedgerSnapshot, type AgentNoticePrincipal, type AgentNoticePublishInput, @@ -31,10 +33,32 @@ import { type AgentNoticePublishResult, type AgentNoticeRequest, type AgentNoticeRequestLease, + type AgentNoticeRetainOptions, + type AgentNoticeRetentionReport, type AgentNoticesHandle, + type AgentNoticeState, type AgentNoticeWithdrawOptions, + type AgentNoticeWithheldEntry, type AgentRecipient, } from './contract.js'; +import { + AGENT_NOTICE_DEFAULT_SENSITIVITY, + disclosedNoticeContent, + isNoticeSensitivity, + type AgentNoticeDisclosure, + type AgentNoticeSensitivity, +} from './redaction.js'; +import { + noticeIsTerminal, + resolveNoticeRetentionPolicy, + selectPrunableNotices, + type AgentNoticeRetentionInput, +} from './retention.js'; +import { + resolveNoticeDisclosure, + type AgentNoticeDeliveryAdvertisement, + type AgentNoticeDeliveryRoute, +} from './router.js'; import { agentNoticeEventSchemas, type AgentNoticeLedgerState, @@ -43,8 +67,58 @@ import { export interface CreateAgentNoticeLedgerOptions { readonly authorize: AgentNoticeAuthorizer; + /** + * The host's notice delivery advertisement, whose per-route `sensitivity` + * ceilings the ledger honours when it discloses content through the inbox + * and next-event routes. Absent means every route admits `internal` (the + * pre-sensitivity contract) and `secret` notices are withheld everywhere. + */ + readonly delivery?: AgentNoticeDeliveryAdvertisement; + /** Retention overrides; defaults are `AGENT_NOTICE_DEFAULT_RETENTION`. */ + readonly retention?: AgentNoticeRetentionInput; } +/** A notice persisted before the redaction contract carries no class and is `internal`. */ +const sensitivityOf = (notice: AgentNotice): AgentNoticeSensitivity => + notice.sensitivity ?? AGENT_NOTICE_DEFAULT_SENSITIVITY; + +type Disclosed = Extract; + +/** Splits matching notices into what a route discloses and what it withholds, with the reason. */ +const discloseFor = ( + route: AgentNoticeDeliveryRoute, + notices: readonly AgentNotice[], + advertisement: AgentNoticeDeliveryAdvertisement | undefined, +): { + readonly disclosed: readonly { readonly disclosure: Disclosed; readonly notice: AgentNotice }[]; + readonly withheld: readonly AgentNoticeWithheldEntry[]; +} => { + const disclosed: { readonly disclosure: Disclosed; readonly notice: AgentNotice }[] = []; + const withheld: AgentNoticeWithheldEntry[] = []; + for (const notice of notices) { + const disclosure = resolveNoticeDisclosure(route, sensitivityOf(notice), advertisement); + switch (disclosure.kind) { + case 'disclosed': + disclosed.push({ disclosure, notice }); + break; + case 'withheld': + withheld.push(Object.freeze({ id: notice.id, reason: disclosure.reason })); + break; + default: { + const exhaustive: never = disclosure; + return exhaustive; + } + } + } + return { disclosed: Object.freeze(disclosed), withheld: Object.freeze(withheld) }; +}; + +/** The notice as the route hands it out: `content` replaced by the disclosed document. */ +const disclosedNotice = (notice: AgentNotice, disclosure: Disclosed): AgentNotice => { + const content = disclosedNoticeContent(notice.content, disclosure); + return content === undefined || content === notice.content ? notice : Object.freeze({ ...notice, content }); +}; + type NoticeStore = AgentStateStore; /** Revision races tolerated while committing a reserved receipt over the state it was judged against. */ @@ -110,6 +184,14 @@ const priority = (value: AgentNoticePublishInput['priority']): AgentNoticePublis } }; +const sensitivity = (value: AgentNoticeSensitivity | undefined): AgentNoticeSensitivity => { + if (value === undefined) return AGENT_NOTICE_DEFAULT_SENSITIVITY; + if (!isNoticeSensitivity(value)) { + throw new AgentNoticeError('invalid-input', `Unknown notice sensitivity ${JSON.stringify(value)}`); + } + return value; +}; + const recipient = (input: AgentRecipient): AgentRecipient => { const result: AgentRecipient = Object.freeze({ ...(input.actor === undefined @@ -175,6 +257,7 @@ const snapshotFrom = ( state: AgentNoticeLedgerState, ): AgentNoticeLedgerSnapshot => Object.freeze({ notices: state.notices, + ...(state.retention === undefined ? {} : { retention: state.retention }), revision, }); @@ -232,6 +315,9 @@ const publishProgram = Effect.fnUntraced(function*( priority: priority(input.priority), recipient: target, ...(retryBudget === undefined ? {} : { retryBudget }), + // Persisted explicitly: only notices from before the redaction contract + // leave the class absent. + sensitivity: sensitivity(input.sensitivity), state: 'pending', }); return { dedupeKey, id, idempotencyKey, notice, target }; @@ -275,26 +361,41 @@ const publishProgram = Effect.fnUntraced(function*( const deliveryFor = ( notice: AgentNotice, invocationId: string, + disclosures: ReadonlyMap, ): AgentNoticeDelivery | undefined => { if (notice.state !== 'attempted') return undefined; const receipt = notice.attempts.find((attempt) => attempt.invocationId === invocationId); - return receipt === undefined ? undefined : Object.freeze({ notice, receipt }); + if (receipt === undefined) return undefined; + // Attempted in this invocation means the route disclosed it; a receipt from + // this invocation without a decision (a replayed admission whose state was + // read fresh) falls back to the notice's own class. + const disclosure = disclosures.get(notice.id) + ?? Object.freeze({ kind: 'disclosed' as const, redacted: sensitivityOf(notice) === 'internal', shape: 'body' as const }); + return Object.freeze({ + disclosure: Object.freeze({ redacted: disclosure.redacted, route: 'next-event' as const }), + notice: disclosedNotice(notice, disclosure), + receipt, + }); }; const inboxProgram = Effect.fnUntraced(function*( store: NoticeStore, authorize: AgentNoticeAuthorizer, request: AgentNoticeRequest, + advertisement: AgentNoticeDeliveryAdvertisement | undefined, ): Effect.fn.Return { const before = yield* storeEffect(() => store.read({ signal: request.signal })); const readTime = Date.parse(request.invocation.startedAt); - const candidates = before.state.notices.filter((notice) => + const matching = before.state.notices.filter((notice) => notice.state === 'pending' && Date.parse(notice.createdAt) <= readTime // Inbox reads do not own durable expiry; event admission remains the expiry boundary. && (notice.expiresAt === undefined || Date.parse(notice.expiresAt) > readTime) && recipientMatchesPrincipal(notice.recipient, request.principal)); - const decisions = yield* Effect.forEach(candidates, (notice) => + // Redaction policy first: a withheld notice is never authorized for read, + // exposed, or returned; the refusal itself is the only thing recorded. + const { disclosed, withheld } = discloseFor('mcp-inbox', matching, advertisement); + const decisions = yield* Effect.forEach(disclosed, ({ notice }) => authorizeEffect(authorize, { noticeId: notice.id, phase: 'read', @@ -304,7 +405,7 @@ const inboxProgram = Effect.fnUntraced(function*( const noticeIds = decisions .filter(({ decision }) => decision.state === 'authorized') .map(({ id }) => id); - if (noticeIds.length === 0) return Object.freeze([]); + if (noticeIds.length === 0 && withheld.length === 0) return Object.freeze([]); const committed = yield* storeEffect(() => store.dispatch( 'exposed', { @@ -312,6 +413,7 @@ const inboxProgram = Effect.fnUntraced(function*( channel: 'mcp-inbox', invocationId: request.invocation.id, noticeIds, + ...(withheld.length === 0 ? {} : { withheld: [...withheld] }), }, { idempotencyKey: `agent-notices:expose:${request.invocation.id}`, @@ -319,15 +421,74 @@ const inboxProgram = Effect.fnUntraced(function*( }, )); const returnedIds = new Set(noticeIds); + const disclosures = new Map(disclosed.map(({ disclosure, notice }) => [notice.id, disclosure])); return Object.freeze(committed.state.notices .filter((notice) => notice.state === 'pending' && returnedIds.has(notice.id)) - .toSorted((left, right) => left.createdAt.localeCompare(right.createdAt) || left.id.localeCompare(right.id))); + .toSorted((left, right) => left.createdAt.localeCompare(right.createdAt) || left.id.localeCompare(right.id)) + .map((notice) => { + const disclosure = disclosures.get(notice.id); + return disclosure === undefined ? notice : disclosedNotice(notice, disclosure); + })); +}); + +const stateCounts = (notices: readonly AgentNotice[]): AgentNoticeLedgerInspection['counts'] => { + const byState = Object.fromEntries(AGENT_NOTICE_STATES.map((state) => [state, 0])) as Record; + let terminal = 0; + for (const notice of notices) { + byState[notice.state] += 1; + if (noticeIsTerminal(notice)) terminal += 1; + } + return Object.freeze({ byState: Object.freeze(byState), terminal, total: notices.length }); +}; + +/** + * Applies the retention policy once: prunes the terminal notices the policy + * selects at `at`, then compacts the journal when it exceeds the byte bound. + * Both steps are idempotent (a replayed prune returns its committed result; + * an already-compact journal is left alone), so a process killed between them + * simply finishes on the next call. + */ +const retainProgram = Effect.fnUntraced(function*( + store: NoticeStore, + policy: ReturnType, + at: string, + idempotencyKey: string, + signal: AbortSignal | undefined, +): Effect.fn.Return { + const before = yield* storeEffect(() => store.read({ signal })); + const prunedIds = selectPrunableNotices(before.state.notices, policy, at); + let revision = before.revision; + if (prunedIds.length > 0) { + // The key is content-addressed under the caller's key: a retry that + // selects the same ids replays, one that selects a different set (the + // ledger moved on) commits its own prune instead of an idempotency + // conflict against the earlier decision. + const digest = createHash('sha256').update(canonicalJson(prunedIds), 'utf8').digest('hex').slice(0, 16); + const committed = yield* storeEffect(() => store.dispatch( + 'pruned', + { at, noticeIds: [...prunedIds] }, + { idempotencyKey: `${idempotencyKey}:${digest}`, ...(signal === undefined ? {} : { signal }) }, + )); + revision = committed.revision; + } + let journal = yield* storeEffect(() => store.inspect(signal === undefined ? {} : { signal })); + let compacted = false; + if (journal.journalBytes > policy.maxJournalBytes) { + const result = yield* storeEffect(() => store.compact(signal === undefined ? {} : { signal })); + compacted = result.prunedRecords > 0; + revision = result.revision; + journal = yield* storeEffect(() => store.inspect(signal === undefined ? {} : { signal })); + } + return Object.freeze({ compacted, journal, prunedIds, revision }); }); export const createAgentNoticeLedger = ( store: NoticeStore, options: CreateAgentNoticeLedgerOptions, -): AgentNoticeLedger => Object.freeze({ +): AgentNoticeLedger => { + const policy = resolveNoticeRetentionPolicy(options.retention); + const advertisement = options.delivery; + return Object.freeze({ expire(expiry: AgentNoticeExpiryOptions): Promise { return runPromise(Effect.gen(function*() { const at = yield* noticeEffect(() => timestamp(expiry.at, 'Notice expiry time')); @@ -342,6 +503,20 @@ export const createAgentNoticeLedger = ( })); }, + inspect(): Promise { + return runPromise(Effect.gen(function*() { + const snapshot = yield* storeEffect(() => store.read()); + const journal = yield* storeEffect(() => store.inspect()); + return Object.freeze({ + counts: stateCounts(snapshot.state.notices), + journal, + policy, + ...(snapshot.state.retention === undefined ? {} : { retention: snapshot.state.retention }), + revision: snapshot.revision, + }); + })); + }, + openRequest(request: AgentNoticeRequest): Promise { return runPromise(Effect.gen(function*() { let deliveries: readonly AgentNoticeDelivery[] = Object.freeze([]); @@ -354,7 +529,7 @@ export const createAgentNoticeLedger = ( || notice.state === 'attempted' && notice.attempts.length < (notice.retryBudget ?? 1)) && notice.expiresAt !== undefined && Date.parse(notice.expiresAt) <= admissionTime); - const candidates = before.state.notices.filter((notice) => + const matching = before.state.notices.filter((notice) => (notice.state === 'pending' || notice.state === 'attempted' && notice.attempts.length < (notice.retryBudget ?? 1)) && Date.parse(notice.createdAt) <= admissionTime @@ -362,14 +537,18 @@ export const createAgentNoticeLedger = ( && (notice.expiresAt === undefined || Date.parse(notice.expiresAt) > admissionTime) && recipientMatchesPrincipal(notice.recipient, request.principal)); - const decisions = yield* Effect.forEach(candidates, (notice) => + // Redaction policy before authorization: a notice the next-event route + // may not carry is neither authorized nor attempted, only refused. + const { disclosed, withheld } = discloseFor('next-event', matching, advertisement); + const disclosures = new Map(disclosed.map(({ disclosure, notice }) => [notice.id, disclosure])); + const decisions = yield* Effect.forEach(disclosed, ({ notice }) => authorizeEffect(options.authorize, { noticeId: notice.id, phase: 'deliver', principal: request.principal, recipient: notice.recipient, }).pipe(Effect.map((decision) => ({ decision, id: notice.id })))); - if (expiring.length > 0 || decisions.length > 0) { + if (expiring.length > 0 || decisions.length > 0 || withheld.length > 0) { const committed = yield* storeEffect(() => store.dispatch( 'admitted', { @@ -382,6 +561,7 @@ export const createAgentNoticeLedger = ( unavailableIds: decisions .filter(({ decision }) => decision.state === 'unavailable') .map(({ id }) => id), + ...(withheld.length === 0 ? {} : { withheld: [...withheld] }), }, { idempotencyKey: `agent-notices:admit:${request.invocation.id}`, @@ -392,8 +572,18 @@ export const createAgentNoticeLedger = ( } deliveries = Object.freeze(admitted.notices .filter((notice) => recipientMatchesPrincipal(notice.recipient, request.principal)) - .map((notice) => deliveryFor(notice, request.invocation.id)) + .map((notice) => deliveryFor(notice, request.invocation.id, disclosures)) .filter((delivery): delivery is AgentNoticeDelivery => delivery !== undefined)); + // Retention rides admitted events only (V1 implies no timer): settled + // history past the policy leaves the ledger, then an oversized journal + // is folded onto its head. Both steps are idempotent per invocation. + yield* retainProgram( + store, + policy, + request.invocation.startedAt, + `agent-notices:retain:${request.invocation.id}`, + request.signal, + ); } let closed = false; @@ -459,7 +649,7 @@ export const createAgentNoticeLedger = ( inbox() { return runPromise(Effect.gen(function*() { yield* noticeEffect(() => assertOpen(closed, request.signal)); - return yield* inboxProgram(store, options.authorize, request); + return yield* inboxProgram(store, options.authorize, request, advertisement); })); }, publish(input: AgentNoticePublishInput, publishOptions: AgentNoticePublishOptions) { @@ -489,6 +679,15 @@ export const createAgentNoticeLedger = ( return snapshotFrom(snapshot.revision, snapshot.state); }, + retain(retention: AgentNoticeRetainOptions): Promise { + return runPromise(Effect.gen(function*() { + const at = yield* noticeEffect(() => timestamp(retention.at, 'Notice retention time')); + const idempotencyKey = yield* noticeEffect(() => + nonEmptyText(retention.idempotencyKey, 'Notice retention idempotency key')); + return yield* retainProgram(store, policy, at, idempotencyKey, undefined); + })); + }, + releaseAvailability(options: AgentNoticeAvailabilityReleaseOptions): Promise { return runPromise(Effect.gen(function*() { const idempotencyKey = yield* noticeEffect(() => @@ -606,4 +805,5 @@ export const createAgentNoticeLedger = ( return snapshotFrom(committed.revision, committed.state); })); }, -}); + }); +}; diff --git a/packages/rsc-runtime/src/notices/redaction.ts b/packages/rsc-runtime/src/notices/redaction.ts new file mode 100644 index 000000000..5e23e1128 --- /dev/null +++ b/packages/rsc-runtime/src/notices/redaction.ts @@ -0,0 +1,240 @@ +import type { AgentDocumentNode, AgentDocumentSnapshot } from '../agent-document.js'; +import type { JsonValue } from '../lower-mcp.js'; + +/** + * Notice content redaction (#99 acceptance item 7). + * + * A notice's free text lives only in its detached `AgentDocumentSnapshot` + * (`text`, `markdown`, `context`, `progress.message`, `error.message`, + * `resource.name`/`uri`, and every string inside `json.value`, + * `result.metadata`, and the document `value`). Recipient, priority, dedupe + * key, timestamps, and receipts are identity and evidence, never prose, and + * must not carry secrets; hosts receive them unredacted. + * + * Authors classify each notice with a {@link AgentNoticeSensitivity}: + * + * - `public`: safe for any surface; delivered as authored. + * - `internal` (default): for the recipient's own context; delivered after + * the secret-pattern pass below, so a credential pasted into a coordination + * message never crosses into another actor's context. + * - `secret`: delivered as authored, but only over a route whose host + * capability row admits `secret`; otherwise it never leaves the durable + * store (see `resolveNoticeDisclosure` in `router.ts`). + * + * The secret patterns mirror the compiler's credential redaction + * (`packages/agent-bundle/src/core/credentials.ts`, reused by the Workbench + * probe redaction). The two packages cannot share a module — the runtime is an + * optional peer of the compiler — so `notice-redaction-parity.test.ts` pins + * the pattern sources equal on both sides. + */ +export const AGENT_NOTICE_SENSITIVITIES = Object.freeze(['public', 'internal', 'secret'] as const); + +export type AgentNoticeSensitivity = (typeof AGENT_NOTICE_SENSITIVITIES)[number]; + +/** A notice published without `sensitivity` is `internal`. */ +export const AGENT_NOTICE_DEFAULT_SENSITIVITY: AgentNoticeSensitivity = 'internal'; + +const sensitivityRank = Object.freeze({ internal: 1, public: 0, secret: 2 } as const satisfies Record); + +/** Negative when `left` is less sensitive than `right`, zero when equal. */ +export const compareNoticeSensitivity = ( + left: AgentNoticeSensitivity, + right: AgentNoticeSensitivity, +): number => sensitivityRank[left] - sensitivityRank[right]; + +export const isNoticeSensitivity = (value: unknown): value is AgentNoticeSensitivity => + typeof value === 'string' && (AGENT_NOTICE_SENSITIVITIES as readonly string[]).includes(value); + +/** The replacement every redaction surface in the repository uses. */ +export const NOTICE_REDACTION_MARK = '[REDACTED]'; + +/** + * Pattern sources of the secret pass, exported so the compiler-side copy can + * be pinned identical by test. `assignment` masks `key: value` / `key=value` + * credential assignments; `provider` masks recognizable provider tokens; + * `urlUserinfo` masks `scheme://user:secret@host` credentials. + */ +export const NOTICE_SECRET_PATTERN_SOURCES = Object.freeze({ + assignment: String.raw`((?:["']?)(?:api[-_ ]?key|api[-_ ]?token|access[-_ ]?token|authorization|credential|password|secret|token)(?:["']?)\s*[:=]\s*)("[^"\r\n]*"|'[^'\r\n]*'|[^\s,;\r\n]+)`, + provider: Object.freeze([ + String.raw`\bsk-(?:proj-|ant-|live-)?[a-z0-9_-]{16,}\b`, + String.raw`\b(?:gh[pousr]_[a-z0-9]{20,}|github_pat_[a-z0-9_]{20,}|xox[baprs]-[a-z0-9-]{16,}|akia[a-z0-9]{16})\b`, + String.raw`\bbearer[ \t]+[a-z0-9._~+/=-]{20,}\b`, + ]), + urlUserinfo: String.raw`(? new RegExp(source, 'giu')); +const urlUserinfoPattern = new RegExp(NOTICE_SECRET_PATTERN_SOURCES.urlUserinfo, 'giu'); + +/** + * Irreversibly removes recognizable credential material from free text. + * Provider forms go first: an unquoted `authorization: Bearer ` would + * otherwise lose only the word `Bearer` to the assignment pass and keep the + * token. + */ +export const redactSecretText = (value: string): string => { + let redacted = value; + for (const pattern of providerPatterns) { + redacted = redacted.replace(pattern, NOTICE_REDACTION_MARK); + } + redacted = redacted.replace(assignmentPattern, (_match, prefix: string, assigned: string) => { + const quote = assigned[0] === '"' || assigned[0] === "'" ? assigned[0] : ''; + return `${prefix}${quote}${NOTICE_REDACTION_MARK}${quote}`; + }); + return redacted.replace(urlUserinfoPattern, `$1${NOTICE_REDACTION_MARK}@`); +}; + +/** True when the secret pass would change `value`. */ +export const containsSecretText = (value: string): boolean => redactSecretText(value) !== value; + +const redactJson = (value: JsonValue): JsonValue => { + if (typeof value === 'string') return redactSecretText(value); + if (value === null || typeof value !== 'object') return value; + if (Array.isArray(value)) return Object.freeze(value.map((entry) => redactJson(entry as JsonValue))); + return Object.freeze(Object.fromEntries( + Object.entries(value as Readonly>).map(([key, entry]) => [key, redactJson(entry)]), + )); +}; + +const redactNode = (node: AgentDocumentNode): AgentDocumentNode => { + switch (node.kind) { + case 'result': + return Object.freeze({ + ...node, + children: Object.freeze(node.children.map(redactNode)), + ...(node.metadata === undefined ? {} : { metadata: redactJson(node.metadata) }), + }); + case 'markdown': + case 'text': + case 'context': + return Object.freeze({ ...node, text: redactSecretText(node.text) }); + case 'json': + return Object.freeze({ ...node, value: redactJson(node.value) }); + case 'progress': + return node.message === undefined + ? node + : Object.freeze({ ...node, message: redactSecretText(node.message) }); + case 'image': + case 'audio': + // Binary payloads carry no prose; their MIME type is a vocabulary value. + return node; + case 'resource': + return Object.freeze({ + ...node, + name: redactSecretText(node.name), + uri: redactSecretText(node.uri), + }); + case 'error': + // Codes are vocabulary; the message is prose. + return Object.freeze({ ...node, message: redactSecretText(node.message) }); + default: { + const exhaustive: never = node; + return exhaustive; + } + } +}; + +/** + * Applies the secret pass to every free-text field of a detached snapshot. + * Structure, node count, status, and codes are unchanged, so the result still + * satisfies the Agent Document bounds the original passed; a string only ever + * shrinks or is replaced by the fixed mark. + */ +export const redactNoticeDocument = (snapshot: AgentDocumentSnapshot): AgentDocumentSnapshot => Object.freeze({ + ...snapshot, + root: redactNode(snapshot.root), + ...(snapshot.value === undefined ? {} : { value: redactJson(snapshot.value) }), +}); + +const firstProse = (node: AgentDocumentNode): string | undefined => { + switch (node.kind) { + case 'result': + for (const child of node.children) { + const found = firstProse(child); + if (found !== undefined) return found; + } + return undefined; + case 'markdown': + case 'text': + case 'context': + return node.text; + case 'progress': + return node.message; + case 'error': + return node.message; + case 'resource': + return node.name; + case 'json': + case 'image': + case 'audio': + return undefined; + default: { + const exhaustive: never = node; + return exhaustive; + } + } +}; + +/** Upper bound on a title-only projection, in UTF-16 code units. */ +export const NOTICE_TITLE_MAX_LENGTH = 120; + +/** + * The single-line title a title-only route (host toast) may carry: the first + * non-empty line of the first prose node, bounded to + * {@link NOTICE_TITLE_MAX_LENGTH}. A document without prose has no title and + * yields an empty string, which title-only routes treat as nothing to show. + */ +export const noticeTitle = (snapshot: AgentDocumentSnapshot): string => { + const prose = firstProse(snapshot.root) ?? ''; + const line = prose.split(/\r?\n/u).map((part) => part.trim()).find((part) => part.length > 0) ?? ''; + return line.length <= NOTICE_TITLE_MAX_LENGTH ? line : `${line.slice(0, NOTICE_TITLE_MAX_LENGTH - 1)}…`; +}; + +/** + * What a delivery route may carry of a notice's content: the whole document + * (`body`), a bounded single-line `title`, or only the fact that the inbox + * changed (`signal`, for `resources/updated`, which names the inbox URI and + * nothing else). + */ +export type AgentNoticeDisclosureShape = 'body' | 'signal' | 'title'; + +/** + * A route's disclosure decision for one notice. `disclosed.redacted` says + * whether the secret pass ran over the content handed out; `withheld` means + * nothing about the notice leaves the store through that route. + */ +export type AgentNoticeDisclosure = + | { readonly kind: 'disclosed'; readonly redacted: boolean; readonly shape: AgentNoticeDisclosureShape } + | { readonly kind: 'withheld'; readonly reason: 'route-unavailable' | 'sensitivity-exceeds-route' }; + +/** + * The content a disclosed route hands out for a notice: the full document or + * a one-line title document, secret-passed when the decision says so. A + * `signal` route carries no content and gets `undefined`. + */ +export const disclosedNoticeContent = ( + content: AgentDocumentSnapshot, + disclosure: Extract, +): AgentDocumentSnapshot | undefined => { + switch (disclosure.shape) { + case 'body': + return disclosure.redacted ? redactNoticeDocument(content) : content; + case 'title': { + const title = noticeTitle(disclosure.redacted ? redactNoticeDocument(content) : content); + return Object.freeze({ + root: Object.freeze({ kind: 'text' as const, text: title }), + status: content.status, + version: content.version, + }); + } + case 'signal': + return undefined; + default: { + const exhaustive: never = disclosure.shape; + return exhaustive; + } + } +}; diff --git a/packages/rsc-runtime/src/notices/resource-updated.ts b/packages/rsc-runtime/src/notices/resource-updated.ts index a4e1432c7..484224d2e 100644 --- a/packages/rsc-runtime/src/notices/resource-updated.ts +++ b/packages/rsc-runtime/src/notices/resource-updated.ts @@ -8,6 +8,8 @@ import { type AgentNoticeLedger, type AgentNoticePrincipal, } from './contract.js'; +import { AGENT_NOTICE_DEFAULT_SENSITIVITY } from './redaction.js'; +import { resolveNoticeDisclosure, type AgentNoticeDeliveryAdvertisement } from './router.js'; import { recipientMatchesPrincipal } from './state.js'; /** Consecutive compare-and-swap losses tolerated before a reservation reports failure. */ @@ -42,6 +44,13 @@ export interface CreateNoticeInboxSignallerOptions { * its hold lapses after the reservation TTL. Defaults to 5 seconds. */ readonly closeTimeoutMs?: number; + /** + * The host's notice delivery advertisement. A notice the inbox route would + * withhold (its sensitivity exceeds the `mcp-inbox` or `mcp-resource-updated` + * ceiling) is never signalled: a refetch that shows nothing would leak only + * that something was withheld. Absent means every route admits `internal`. + */ + readonly delivery?: AgentNoticeDeliveryAdvertisement; /** Clock injection for deterministic tests. */ readonly now?: () => Date; /** @@ -132,10 +141,18 @@ interface PendingReceipt { readonly reservationKey: string; } +/** The signal and the inbox it points at must both admit the notice's class. */ +const disclosable = (notice: AgentNotice, advertisement: AgentNoticeDeliveryAdvertisement | undefined): boolean => { + const sensitivity = notice.sensitivity ?? AGENT_NOTICE_DEFAULT_SENSITIVITY; + return resolveNoticeDisclosure('mcp-resource-updated', sensitivity, advertisement).kind === 'disclosed' + && resolveNoticeDisclosure('mcp-inbox', sensitivity, advertisement).kind === 'disclosed'; +}; + const eligibleForSignal = ( notice: AgentNotice, principal: AgentNoticePrincipal, nowMs: number, + advertisement: AgentNoticeDeliveryAdvertisement | undefined, ): boolean => { switch (notice.state) { case 'pending': @@ -163,7 +180,7 @@ const eligibleForSignal = ( if (reservation !== undefined && Date.parse(reservation.at) + AGENT_NOTICE_AVAILABILITY_RESERVATION_TTL_MS > nowMs) { return false; } - return recipientMatchesPrincipal(notice.recipient, principal); + return disclosable(notice, advertisement) && recipientMatchesPrincipal(notice.recipient, principal); }; export const createNoticeInboxSignaller = ( @@ -250,7 +267,7 @@ export const createNoticeInboxSignaller = ( const at = now().toISOString(); const nowMs = Date.parse(at); const noticeIds = Object.freeze(snapshot.notices - .filter((notice) => !current.signalled.has(notice.id) && eligibleForSignal(notice, current.principal, nowMs)) + .filter((notice) => !current.signalled.has(notice.id) && eligibleForSignal(notice, current.principal, nowMs, options.delivery)) .map((notice) => notice.id) .toSorted((left, right) => left.localeCompare(right))); if (noticeIds.length === 0) return { kind: 'nothing-eligible', revision: snapshot.revision }; diff --git a/packages/rsc-runtime/src/notices/retention.ts b/packages/rsc-runtime/src/notices/retention.ts new file mode 100644 index 000000000..cfb58dd8e --- /dev/null +++ b/packages/rsc-runtime/src/notices/retention.ts @@ -0,0 +1,59 @@ +import { + AGENT_NOTICE_DEFAULT_RETENTION, + AgentNoticeError, + type AgentNotice, + type AgentNoticeRetentionPolicy, +} from './contract.js'; +import { noticeSettledAt } from './state.js'; + +/** Caller-supplied overrides; omitted fields resolve from {@link AGENT_NOTICE_DEFAULT_RETENTION}. */ +export type AgentNoticeRetentionInput = Partial; + +/** + * Resolves and validates a retention policy. Every field is a positive + * integer: a zero TTL or cap would prune notices the moment they settle, + * which is a distinct feature (and one no acceptance item asks for), and a + * zero journal bound would compact on every write. + */ +export const resolveNoticeRetentionPolicy = ( + input: AgentNoticeRetentionInput | undefined, +): AgentNoticeRetentionPolicy => { + const resolved = { ...AGENT_NOTICE_DEFAULT_RETENTION, ...input }; + for (const field of ['maxJournalBytes', 'maxTerminal', 'terminalTtlMs'] as const) { + const value = resolved[field]; + if (!Number.isInteger(value) || value < 1) { + throw new AgentNoticeError('invalid-input', `Notice retention ${field} must be an integer >= 1`); + } + } + return Object.freeze(resolved); +}; + +/** + * Ids the policy prunes at `at`: every terminal notice settled at least + * `terminalTtlMs` ago, plus — when more than `maxTerminal` terminal notices + * would remain — the earliest-settled of the rest until the cap holds. Order + * is deterministic (settled time, then id) so two processes evaluating the + * same state choose the same ids. + */ +export const selectPrunableNotices = ( + notices: readonly AgentNotice[], + policy: AgentNoticeRetentionPolicy, + at: string, +): readonly string[] => { + const nowMs = Date.parse(at); + const settled = notices + .flatMap((notice) => { + const settledAt = noticeSettledAt(notice); + return settledAt === undefined ? [] : [{ id: notice.id, settledAt, settledMs: Date.parse(settledAt) }]; + }) + .toSorted((left, right) => left.settledMs - right.settledMs || left.id.localeCompare(right.id)); + const expired = settled.filter((entry) => entry.settledMs + policy.terminalTtlMs <= nowMs); + const kept = settled.length - expired.length; + const overflow = kept > policy.maxTerminal + ? settled.filter((entry) => entry.settledMs + policy.terminalTtlMs > nowMs).slice(0, kept - policy.maxTerminal) + : []; + return Object.freeze([...expired, ...overflow].map((entry) => entry.id)); +}; + +/** True while the policy treats the notice as terminal history rather than live work. */ +export const noticeIsTerminal = (notice: AgentNotice): boolean => noticeSettledAt(notice) !== undefined; diff --git a/packages/rsc-runtime/src/notices/router.ts b/packages/rsc-runtime/src/notices/router.ts index 2a9429973..3357e2720 100644 --- a/packages/rsc-runtime/src/notices/router.ts +++ b/packages/rsc-runtime/src/notices/router.ts @@ -1,4 +1,12 @@ import { AgentNoticeError } from './contract.js'; +import { + AGENT_NOTICE_DEFAULT_SENSITIVITY, + compareNoticeSensitivity, + isNoticeSensitivity, + type AgentNoticeDisclosure, + type AgentNoticeDisclosureShape, + type AgentNoticeSensitivity, +} from './redaction.js'; /** * Delivery routes from the #99 taxonomy. `current-response` is the only @@ -17,14 +25,40 @@ export const AGENT_NOTICE_DELIVERY_ROUTES = Object.freeze([ export type AgentNoticeDeliveryRoute = (typeof AGENT_NOTICE_DELIVERY_ROUTES)[number]; +/** + * A supported route may name the most sensitive notice it carries in full. + * Absent means `internal`: the pre-sensitivity contract, under which default + * notices flowed and nothing was classified `secret`. A `secret` notice is + * therefore withheld from every route until a host row says otherwise. + */ export type AgentNoticeDeliveryRouteState = - | { readonly state: 'supported' } + | { + readonly sensitivity?: AgentNoticeSensitivity; + /** Dated evidence for the ceiling, as the host's capability table records it. */ + readonly sensitivityEvidence?: string; + readonly state: 'supported'; + } | { readonly reason: string; readonly state: 'unavailable' }; export type AgentNoticeDeliveryAdvertisement = Readonly< Record >; +/** + * What each route structurally carries. The inbox and the hook-response + * routes return the recipient's document; a toast has room for one line; a + * `resources/updated` notification names the inbox URI and nothing else. + */ +export const AGENT_NOTICE_ROUTE_SHAPES: Readonly> = + Object.freeze({ + 'current-response': 'body', + 'directed-push': 'body', + 'host-toast': 'title', + 'mcp-inbox': 'body', + 'mcp-resource-updated': 'signal', + 'next-event': 'body', + }); + /** Stable preference order for cross-request routes; all supported routes run. */ const crossRequestPreference = Object.freeze([ 'directed-push', @@ -49,6 +83,12 @@ const validateAdvertisement = ( if (entry.state === 'unavailable' && entry.reason.trim() === '') { throw new AgentNoticeError('invalid-input', `Unavailable route ${route} requires a dated reason`); } + if (entry.state === 'supported' && entry.sensitivity !== undefined && !isNoticeSensitivity(entry.sensitivity)) { + throw new AgentNoticeError( + 'invalid-input', + `Supported route ${route} names an unknown sensitivity ${JSON.stringify(entry.sensitivity)}`, + ); + } } }; @@ -68,3 +108,43 @@ export const selectNoticeDeliveryRoutes = ( ? Object.freeze({ kind: 'unavailable', reason: 'no-supported-cross-request-route' }) : Object.freeze({ kind: 'selected', routes: Object.freeze(routes) }); }; + +/** The sensitivity ceiling a route row admits; absent rows admit `internal`. */ +export const routeSensitivityCeiling = ( + entry: AgentNoticeDeliveryRouteState, +): AgentNoticeSensitivity | undefined => + entry.state === 'supported' ? entry.sensitivity ?? AGENT_NOTICE_DEFAULT_SENSITIVITY : undefined; + +/** + * Decides what one route may disclose of a notice, from the notice's declared + * sensitivity and the host's capability row for that route. Fails closed: an + * unsupported route, or a notice more sensitive than the row's ceiling, + * withholds the notice entirely; nothing about it leaves the store that way. + * Within the ceiling the route carries its structural shape, and `internal` + * content is secret-passed on every route so an unclassified credential never + * crosses into another actor's context. An absent advertisement (an embedder + * that wired no host) is the pre-sensitivity contract: every route admits + * `internal`. + */ +export const resolveNoticeDisclosure = ( + route: AgentNoticeDeliveryRoute, + sensitivity: AgentNoticeSensitivity, + advertisement: AgentNoticeDeliveryAdvertisement | undefined, +): AgentNoticeDisclosure => { + const entry: AgentNoticeDeliveryRouteState | undefined = advertisement === undefined + ? { state: 'supported' } + : advertisement[route]; + // A row the advertisement does not spell is not a supported route. + const ceiling = entry === undefined ? undefined : routeSensitivityCeiling(entry); + if (ceiling === undefined) { + return Object.freeze({ kind: 'withheld', reason: 'route-unavailable' }); + } + if (compareNoticeSensitivity(sensitivity, ceiling) > 0) { + return Object.freeze({ kind: 'withheld', reason: 'sensitivity-exceeds-route' }); + } + return Object.freeze({ + kind: 'disclosed', + redacted: sensitivity === 'internal', + shape: AGENT_NOTICE_ROUTE_SHAPES[route], + }); +}; diff --git a/packages/rsc-runtime/src/notices/state.ts b/packages/rsc-runtime/src/notices/state.ts index 59a7addc1..4cdfedbd2 100644 --- a/packages/rsc-runtime/src/notices/state.ts +++ b/packages/rsc-runtime/src/notices/state.ts @@ -15,8 +15,14 @@ import { AGENT_NOTICE_AVAILABILITY_RESERVATION_TTL_MS, type AgentNotice, type AgentNoticePrincipal, + type AgentNoticeRetentionSummary, + type AgentNoticeWithheldEntry, + type AgentNoticeWithholding, + type AgentNoticeWithholdingReason, type AgentRecipient, } from './contract.js'; +import { AGENT_NOTICE_SENSITIVITIES } from './redaction.js'; +import { AGENT_NOTICE_DELIVERY_ROUTES, type AgentNoticeDeliveryRoute } from './router.js'; const observed = (value: T) => z.discriminatedUnion('state', [ z.object({ @@ -96,6 +102,22 @@ const acknowledgementSchema = z.object({ invocationId: z.string().min(1), }).strict().readonly(); +const withholdingReasonSchema = z.enum(['route-unavailable', 'sensitivity-exceeds-route']); + +const withholdingSchema = z.object({ + count: z.number().int().positive(), + firstAt: z.string().min(1), + lastAt: z.string().min(1), + reason: withholdingReasonSchema, +}).strict().readonly(); + +const withheldEntrySchema = z.object({ + id: z.string().min(1), + reason: withholdingReasonSchema, +}).strict(); + +const routeSchema = z.enum(AGENT_NOTICE_DELIVERY_ROUTES); + const noticeSchema = z.object({ acknowledgement: acknowledgementSchema.optional(), attempts: z.array(attemptSchema).readonly(), @@ -115,14 +137,25 @@ const noticeSchema = z.object({ // stored heads or the journal head-vs-replay consistency check would diverge // on state persisted before the retry contract. Absent means a budget of 1. retryBudget: z.number().int().min(1).optional(), + // Optional for the same reason: absent on pre-redaction notices and means `internal`. + sensitivity: z.enum(AGENT_NOTICE_SENSITIVITIES).optional(), state: z.enum(['pending', 'attempted', 'expired', 'unavailable', 'withdrawn', 'acknowledged']), unavailableAt: z.string().min(1).optional(), unavailableReason: z.literal('delivery-authorization-unavailable').optional(), withdrawnAt: z.string().min(1).optional(), + withheld: z.partialRecord(routeSchema, withholdingSchema).readonly().optional(), +}).strict().readonly(); + +const retentionSummarySchema = z.object({ + lastPrunedAt: z.string().min(1), + pruneRuns: z.number().int().positive(), + prunedTotal: z.number().int().positive(), }).strict().readonly(); export interface AgentNoticeLedgerState { readonly notices: readonly AgentNotice[]; + /** Absent until the first prune; never defaulted, so pre-retention heads replay unchanged. */ + readonly retention?: AgentNoticeRetentionSummary; } /** @@ -145,6 +178,8 @@ export const agentNoticeEventSchemas = { invocationId: z.string().min(1), principal: principalSchema, unavailableIds: z.array(z.string().min(1)), + // Optional so journals written before the redaction contract replay unchanged. + withheld: z.array(withheldEntrySchema).optional(), }).strict(), 'availability-released': z.object({ noticeIds: z.array(z.string().min(1)), @@ -166,10 +201,16 @@ export const agentNoticeEventSchemas = { channel: z.literal('mcp-inbox'), invocationId: z.string().min(1), noticeIds: z.array(z.string().min(1)), + withheld: z.array(withheldEntrySchema).optional(), }).strict(), expired: z.object({ at: z.string().min(1), }).strict(), + /** Retention: removes the listed notices from the state when they are terminal. */ + pruned: z.object({ + at: z.string().min(1), + noticeIds: z.array(z.string().min(1)).min(1), + }).strict(), published: z.object({ notice: noticeSchema, }).strict(), @@ -364,6 +405,63 @@ const transitionAvailabilityReservation = ( } }; +/** + * Records that a route withheld the notice from a matching recipient because + * its sensitivity exceeds the route's ceiling. Evidence only: the notice keeps + * its state and stays eligible for a route whose row admits it. + */ +const withholding = ( + notice: AgentNotice, + route: AgentNoticeDeliveryRoute, + at: string, + reason: AgentNoticeWithholdingReason, +): AgentNotice => { + const previous: AgentNoticeWithholding | undefined = notice.withheld?.[route]; + return Object.freeze({ + ...notice, + withheld: Object.freeze({ + ...notice.withheld, + [route]: Object.freeze({ + count: (previous?.count ?? 0) + 1, + firstAt: previous?.firstAt ?? at, + lastAt: at, + reason, + }), + }), + }); +}; + +/** Withheld entries keyed by id for the reducer's per-notice pass. */ +const withheldById = ( + entries: readonly AgentNoticeWithheldEntry[] | undefined, +): ReadonlyMap => + new Map((entries ?? []).map((entry) => [entry.id, entry.reason])); + +/** Settled terminal notices the retention policy may prune; exhausted attempts count as settled. */ +export const noticeSettledAt = (notice: AgentNotice): string | undefined => { + switch (notice.state) { + case 'pending': + return undefined; + case 'attempted': { + if (notice.attempts.length < (notice.retryBudget ?? 1)) return undefined; + const last = notice.attempts[notice.attempts.length - 1]; + return last?.attemptedAt; + } + case 'expired': + return notice.expiredAt; + case 'unavailable': + return notice.unavailableAt; + case 'withdrawn': + return notice.withdrawnAt; + case 'acknowledged': + return notice.acknowledgement?.acknowledgedAt; + default: { + const exhaustive: never = notice.state; + return exhaustive; + } + } +}; + const transitionAdmission = ( notice: AgentNotice, input: { @@ -372,6 +470,7 @@ const transitionAdmission = ( readonly invocationId: string; readonly principal: AgentNoticePrincipal; readonly unavailableIds: ReadonlySet; + readonly withheld: ReadonlyMap; }, ): AgentNotice => { const current = transitionExpiry(notice, input.at); @@ -384,6 +483,10 @@ const transitionAdmission = ( return current; } if (current.attempts.length >= (current.retryBudget ?? 1)) return current; + // The route refused the content: nothing is attempted and no budget is + // spent, only the refusal is remembered. + const refused = input.withheld.get(current.id); + if (refused !== undefined) return withholding(current, 'next-event', input.at, refused); if (input.unavailableIds.has(current.id)) { return current.state === 'pending' ? Object.freeze({ @@ -425,10 +528,13 @@ const transitionExposure = ( readonly at: string; readonly invocationId: string; readonly noticeIds: ReadonlySet; + readonly withheld: ReadonlyMap; }, ): AgentNotice => { switch (notice.state) { case 'pending': { + const refused = input.withheld.get(notice.id); + if (refused !== undefined) return withholding(notice, 'mcp-inbox', input.at, refused); if (!input.noticeIds.has(notice.id)) return notice; return Object.freeze({ ...notice, @@ -478,47 +584,57 @@ export const agentNoticeStateDefinition = ( : state.notices.some((notice) => notice.dedupeKey === candidate.dedupeKey && sameRecipient(notice.recipient, candidate.recipient)); - return duplicate ? state : { notices: [...state.notices, candidate] }; + return duplicate ? state : { ...state, notices: [...state.notices, candidate] }; } case 'expired': return { + ...state, notices: state.notices.map((notice) => transitionExpiry(notice, event.payload.at)), }; case 'withdrawn': return { + ...state, notices: state.notices.map((notice) => transitionWithdrawal(notice, event.payload.id, event.payload.at)), }; case 'admitted': { const authorizedIds = new Set(event.payload.authorizedIds); const unavailableIds = new Set(event.payload.unavailableIds); + const withheld = withheldById(event.payload.withheld); return { + ...state, notices: state.notices.map((notice) => transitionAdmission(notice, { at: event.payload.at, authorizedIds, invocationId: event.payload.invocationId, principal: event.payload.principal, unavailableIds, + withheld, })), }; } case 'exposed': { const noticeIds = new Set(event.payload.noticeIds); + const withheld = withheldById(event.payload.withheld); return { + ...state, notices: state.notices.map((notice) => transitionExposure(notice, { at: event.payload.at, invocationId: event.payload.invocationId, noticeIds, + withheld, })), }; } case 'acknowledged': return { + ...state, notices: state.notices.map((notice) => transitionAcknowledgement(notice, event.payload)), }; case 'availability-signalled': { const noticeIds = new Set(event.payload.noticeIds); return { + ...state, notices: state.notices.map((notice) => transitionAvailability(notice, { at: event.payload.at, noticeIds, @@ -529,6 +645,7 @@ export const agentNoticeStateDefinition = ( case 'availability-reserved': { const noticeIds = new Set(event.payload.noticeIds); return { + ...state, notices: state.notices.map((notice) => transitionAvailabilityReservation(notice, { at: event.payload.at, noticeIds, @@ -539,11 +656,30 @@ export const agentNoticeStateDefinition = ( case 'availability-released': { const noticeIds = new Set(event.payload.noticeIds); return { + ...state, notices: state.notices.map((notice) => noticeIds.has(notice.id) ? withoutReservation(notice, event.payload.reservationKey) : notice), }; } + case 'pruned': { + // Only settled notices leave; an id that is live again (or unknown) + // is skipped, so a stale prune decision can never drop a pending + // notice. The summary counts what was actually removed. + const noticeIds = new Set(event.payload.noticeIds); + const remaining = state.notices.filter((notice) => + !(noticeIds.has(notice.id) && noticeSettledAt(notice) !== undefined)); + const removed = state.notices.length - remaining.length; + if (removed === 0) return state; + return { + notices: remaining, + retention: { + lastPrunedAt: event.payload.at, + pruneRuns: (state.retention?.pruneRuns ?? 0) + 1, + prunedTotal: (state.retention?.prunedTotal ?? 0) + removed, + }, + }; + } default: { const exhaustive: never = event; return exhaustive; @@ -552,6 +688,7 @@ export const agentNoticeStateDefinition = ( }, schema: z.object({ notices: z.array(noticeSchema).readonly(), + retention: retentionSummarySchema.optional(), }).strict().readonly(), version: AGENT_NOTICE_STATE_VERSION, }); diff --git a/packages/rsc-runtime/src/state/conformance.ts b/packages/rsc-runtime/src/state/conformance.ts index 66025981b..961743905 100644 --- a/packages/rsc-runtime/src/state/conformance.ts +++ b/packages/rsc-runtime/src/state/conformance.ts @@ -653,4 +653,102 @@ export const stateDriverConformanceCases: readonly StateConformanceCase[] = Obje assert.equal(replayed.revision, 1); }, }, + { + name: 'compaction folds the journal onto the head, keeps revisions monotonic, and is idempotent', + run: async (context) => { + const store = await context.open(taskDefinition(context.lifetime)); + const untouched = await store.inspect(); + assert.deepEqual(untouched, { baselineRevision: 0, headRevision: 0, journalBytes: 0, records: 0 }); + // Nothing to fold: an empty journal is already compact and no revision is spent. + const empty = await store.compact(); + assert.deepEqual(empty, { baselineRevision: 0, prunedRecords: 0, revision: 0, state: { tasks: [], total: 0 } }); + await addTask(store, 'a'); + await addTask(store, 'b'); + const before = await store.inspect(); + assert.equal(before.records, 2); + assert.ok(before.journalBytes > 0); + + const compacted = await store.compact(); + assert.equal(compacted.prunedRecords, 2); + assert.equal(compacted.baselineRevision, 3); + assert.equal(compacted.revision, 3); + assert.deepEqual(compacted.state.tasks.map((task) => task.id), ['a', 'b']); + const head = await store.read(); + assert.equal(head.revision, 3); + assert.deepEqual(head.state, compacted.state); + const after = await store.inspect(); + assert.equal(after.records, 1); + assert.equal(after.baselineRevision, 3); + assert.equal(after.headRevision, 3); + assert.ok(after.journalBytes > 0); + assert.equal(after.lastCompaction?.revision, 3); + assert.ok(typeof after.lastCompaction?.at === 'string'); + + // A second compaction over a lone baseline is a no-op with no new revision. + const again = await store.compact(); + assert.deepEqual(again, { baselineRevision: 3, prunedRecords: 0, revision: 3, state: head.state }); + assert.equal((await store.read()).revision, 3); + + // History before the baseline is gone, honestly: exact reads fail typed, + // the change cursor delivers the baseline as a discontinuity, and the + // head keeps its exact revision. + await assert.rejects(store.read({ revision: 1 }), rejectsWith('revision-unavailable')); + assert.deepEqual((await store.read({ revision: 3 })).state, head.state); + const changes = await store.changes({ afterRevision: 0 }); + assert.equal(changes.headRevision, 3); + assert.deepEqual(changes.changes.map((change) => [change.kind, change.revision]), [['compact', 3]]); + + // Commits continue past the baseline and replay from it. + const next = await addTask(store, 'c'); + assert.equal(next.revision, 4); + assert.deepEqual((await store.read({ revision: 4 })).state.tasks.map((task) => task.id), ['a', 'b', 'c']); + assert.deepEqual((await store.read({ revision: 3 })).state.tasks.map((task) => task.id), ['a', 'b']); + const later = await store.compact({ expectedRevision: 4 }); + assert.equal(later.prunedRecords, 2); + assert.equal(later.revision, 5); + await assert.rejects(store.compact({ expectedRevision: 4 }), rejectsWith('revision-conflict')); + }, + }, + { + name: 'compaction remembers pruned idempotency keys without their results', + run: async (context) => { + const store = await context.open(taskDefinition(context.lifetime)); + await addTask(store, 'a'); + await store.compact(); + // The commit happened but compaction dropped its result: a retry cannot + // be answered with a fabricated replay, and must not run the reducer again. + await assert.rejects(addTask(store, 'a'), rejectsWith('revision-unavailable')); + assert.equal((await store.read()).revision, 2); + // Reusing the key with a different input is still a conflict. + await assert.rejects( + store.dispatch('taskAdded', { id: 'z', title: 'Task z' }, { idempotencyKey: 'add:a' }), + rejectsWith('idempotency-conflict'), + ); + assert.equal((await store.read()).revision, 2); + }, + }, + { + durableOnly: true, + name: 'a compacted store reopens with its head agreeing with journal replay', + run: async (context) => { + const writer = await context.open(taskDefinition(context.lifetime)); + await addTask(writer, 'a'); + await addTask(writer, 'b'); + await writer.compact(); + await addTask(writer, 'c'); + await writer.close(); + const reopened = await context.reopen(taskDefinition(context.lifetime)); + const snapshot = await reopened.read(); + assert.equal(snapshot.revision, 4); + assert.deepEqual(snapshot.state.tasks.map((task) => task.id), ['a', 'b', 'c']); + const inspection = await reopened.inspect(); + assert.equal(inspection.baselineRevision, 3); + assert.equal(inspection.records, 2); + assert.equal(inspection.lastCompaction?.revision, 3); + await assert.rejects(addTask(reopened, 'a'), rejectsWith('revision-unavailable')); + const replayed = await addTask(reopened, 'c'); + assert.equal(replayed.replayed, true); + assert.equal(replayed.revision, 4); + }, + }, ]); diff --git a/packages/rsc-runtime/src/state/contract.ts b/packages/rsc-runtime/src/state/contract.ts index 5859c64e6..618b91026 100644 --- a/packages/rsc-runtime/src/state/contract.ts +++ b/packages/rsc-runtime/src/state/contract.ts @@ -333,8 +333,10 @@ export interface AgentStateCommitResult extends AgentStateSnap /** * One committed journal entry, exposed through the polling change cursor. - * `reset` and `migrate` entries mark history discontinuities: consumers - * re-read instead of folding payloads. + * `reset`, `migrate`, and `compact` entries mark history discontinuities: + * consumers re-read instead of folding payloads. A cursor positioned before + * a compaction baseline receives that baseline first, because the records it + * missed no longer exist. */ export type AgentStateChange = | { @@ -344,9 +346,51 @@ export type AgentStateChange = readonly payload: unknown; readonly revision: number; } + | { readonly committedAt: string; readonly kind: 'compact'; readonly revision: number } | { readonly committedAt: string; readonly kind: 'migrate'; readonly revision: number } | { readonly committedAt: string; readonly kind: 'reset'; readonly revision: number }; +/** + * Journal compaction (#99 retention): materializes the head as a `compact` + * baseline record and deletes every earlier record. Revisions stay monotonic + * (the baseline takes the next revision); exact reads below the baseline + * become `revision-unavailable`, like reads below a migration. Idempotency + * keys of the pruned records are remembered without their results: replaying + * one fails `revision-unavailable` (the commit happened, its result is gone) + * and reusing one with a different input is still an `idempotency-conflict`. + * A journal that is empty or holds only a baseline is already compact, so + * `compact()` is a no-op that reports `prunedRecords: 0`. + */ +export interface AgentStateCompactOptions { + /** Compare-and-swap: fail with `revision-conflict` unless the head revision matches. */ + readonly expectedRevision?: number; + readonly signal?: AbortSignal; +} + +export interface AgentStateCompactResult extends AgentStateSnapshot { + /** Revision of the retained baseline; equal to the previous head when nothing was pruned. */ + readonly baselineRevision: number; + /** Journal records deleted by this call. */ + readonly prunedRecords: number; +} + +/** Storage-level facts about one store's journal; never state contents. */ +export interface AgentStateJournalInspection { + /** Revision of the first retained record; 0 while the full history is retained. */ + readonly baselineRevision: number; + readonly headRevision: number; + /** UTF-8 bytes of the retained journal's payloads and stored states. */ + readonly journalBytes: number; + /** The retained compaction baseline, when the journal starts at one. */ + readonly lastCompaction?: { readonly at: string; readonly revision: number }; + /** Retained journal records. */ + readonly records: number; +} + +export interface AgentStateInspectOptions { + readonly signal?: AbortSignal; +} + export interface AgentStateChangeBatch { readonly changes: readonly AgentStateChange[]; readonly headRevision: number; @@ -398,11 +442,15 @@ export interface AgentStateStore< readonly location: string; changes(options: AgentStateChangesOptions): Promise; close(): Promise; + /** Materializes the head as a baseline and deletes the journal before it; see {@link AgentStateCompactOptions}. */ + compact(options?: AgentStateCompactOptions): Promise>; dispatch>( name: TName, payload: AgentStateEventPayload, options: AgentStateDispatchOptions, ): Promise>; + /** Journal size and compaction facts for retention decisions and diagnostics. */ + inspect(options?: AgentStateInspectOptions): Promise; read(options?: AgentStateReadOptions): Promise>; reset(options: AgentStateResetOptions): Promise>; } diff --git a/packages/rsc-runtime/src/state/index.ts b/packages/rsc-runtime/src/state/index.ts index 16fe1268c..0e908df41 100644 --- a/packages/rsc-runtime/src/state/index.ts +++ b/packages/rsc-runtime/src/state/index.ts @@ -24,6 +24,8 @@ export type { AgentStateChangeBatch, AgentStateChangesOptions, AgentStateCommitResult, + AgentStateCompactOptions, + AgentStateCompactResult, AgentStateDefinition, AgentStateDefinitionInput, AgentStateDispatchOptions, @@ -34,6 +36,8 @@ export type { AgentStateEventPayload, AgentStateEventSchemas, AgentStateHandle, + AgentStateInspectOptions, + AgentStateJournalInspection, AgentStateLifetime, AgentStateMigrations, AgentStateReadOptions, @@ -47,9 +51,14 @@ export { applyStateEvent, canonicalCommitInput, changeFromJournalRecord, + compactionIdempotencyKey, expectCommitWithinBudgets, expectConsistentJournal, expectMigrationWithinStateBudget, + inspectJournalRecords, + isBaselineRecord, + isCompactionIdempotencyKey, + journalIsCompactable, migrationIdempotencyKey, parseEventPayload, reduceStateEvent, diff --git a/packages/rsc-runtime/src/state/journal.ts b/packages/rsc-runtime/src/state/journal.ts index f395a3dfc..d187f30b8 100644 --- a/packages/rsc-runtime/src/state/journal.ts +++ b/packages/rsc-runtime/src/state/journal.ts @@ -28,6 +28,14 @@ export type AgentStateJournalRecord = readonly payload: unknown; readonly revision: number; } + | { + readonly committedAt: string; + readonly idempotencyKey: string; + readonly kind: 'compact'; + readonly revision: number; + /** The head state materialized as the retained baseline. */ + readonly state: unknown; + } | { readonly committedAt: string; readonly idempotencyKey: string; @@ -55,6 +63,8 @@ export const canonicalCommitInput = (record: AgentStateJournalRecord): string => return canonicalJson({ kind: 'reset', state: record.state }); case 'migrate': return canonicalJson({ kind: 'migrate', toVersion: record.toVersion }); + case 'compact': + return canonicalJson({ kind: 'compact' }); default: { const unreachable: never = record; throw new AgentStateError('corrupt', `Unknown journal record kind ${String(unreachable)}`); @@ -65,6 +75,71 @@ export const canonicalCommitInput = (record: AgentStateJournalRecord): string => export const migrationIdempotencyKey = (toVersion: number): string => `${AGENT_STATE_RESERVED_KEY_PREFIX}migrate:${String(toVersion)}`; +const COMPACTION_KEY_PREFIX = `${AGENT_STATE_RESERVED_KEY_PREFIX}compact:`; + +/** Kernel-owned key of the compaction baseline committed at `revision`. */ +export const compactionIdempotencyKey = (revision: number): string => + `${COMPACTION_KEY_PREFIX}${String(revision)}`; + +/** True for keys minted by {@link compactionIdempotencyKey}. */ +export const isCompactionIdempotencyKey = (key: string): boolean => key.startsWith(COMPACTION_KEY_PREFIX); + +/** A journal record that carries a full state and starts replay. */ +export const isBaselineRecord = ( + record: AgentStateJournalRecord, +): record is Extract => { + switch (record.kind) { + case 'compact': + case 'migrate': + case 'reset': + return true; + case 'event': + return false; + default: { + const unreachable: never = record; + throw new AgentStateError('corrupt', `Unknown journal record kind ${String(unreachable)}`); + } + } +}; + +/** + * Whether compaction has anything to do: a journal that is empty or holds a + * single baseline is already compact. Any event, and any baseline with + * history behind it, is worth folding onto the head. + */ +export const journalIsCompactable = (records: readonly AgentStateJournalRecord[]): boolean => + records.length > 1 || (records.length === 1 && !isBaselineRecord(records[0] as AgentStateJournalRecord)); + +/** + * Storage-neutral view of a retained journal for + * {@link AgentStateJournalInspection}: the first retained record fixes the + * baseline, and it is the last compaction exactly when it is a `compact` + * record (compaction deletes everything before its baseline, so only the + * latest one can ever be retained). + */ +export const inspectJournalRecords = ( + records: readonly AgentStateJournalRecord[], + headRevision: number, + journalBytes: number, +): { + readonly baselineRevision: number; + readonly headRevision: number; + readonly journalBytes: number; + readonly lastCompaction?: { readonly at: string; readonly revision: number }; + readonly records: number; +} => { + const first = records[0]; + return Object.freeze({ + baselineRevision: first === undefined || first.revision === 1 ? 0 : first.revision, + headRevision, + journalBytes, + ...(first?.kind === 'compact' + ? { lastCompaction: Object.freeze({ at: first.committedAt, revision: first.revision }) } + : {}), + records: records.length, + }); +}; + /** * Validates one event payload against its declared schema without running * the reducer. Idempotency-key replay must be decided from the validated @@ -239,7 +314,7 @@ export const resolveResetState = ( definition: AgentStateDefinition, - record: Extract, + record: Extract, ): TState => { const parsed = definition.schema.safeParse(record.state); if (!parsed.success) { @@ -255,7 +330,9 @@ const parseBaselineState = ( * Reconstructs the exact state at `targetRevision` from ordered journal * records. Revisions below the latest migration are `revision-unavailable`: * migration rebases history because older records were written under an - * earlier definition version. Replay failures are `corrupt` (fail closed). + * earlier definition version. Revisions below a compaction baseline are + * `revision-unavailable` too: compaction deleted the records that produced + * them. Replay failures are `corrupt` (fail closed). */ export const replayJournal = ( definition: AgentStateDefinition, @@ -269,11 +346,18 @@ export const replayJournal = ( `State '${definition.id}' revision ${String(targetRevision)} predates the migration at revision ${String(latestMigration.revision)}`, ); } + const first = records[0]; + if (first !== undefined && first.kind === 'compact' && targetRevision < first.revision) { + throw new AgentStateError( + 'revision-unavailable', + `State '${definition.id}' revision ${String(targetRevision)} predates the compaction at revision ${String(first.revision)}`, + ); + } let state = definition.initial; let baselineRevision = 0; for (const record of records) { if (record.revision > targetRevision) break; - if (record.kind === 'reset' || record.kind === 'migrate') { + if (isBaselineRecord(record)) { state = parseBaselineState(definition, record); baselineRevision = record.revision; } @@ -300,17 +384,24 @@ export const replayJournal = ( return state; }; -/** Asserts revisions run 1..n without gaps and idempotency keys never repeat. */ +/** + * Asserts revisions run contiguously and idempotency keys never repeat. The + * journal starts at revision 1 unless compaction truncated it, in which case + * its first record is the retained `compact` baseline; a journal that starts + * anywhere else lost records it should still have. + */ export const expectConsistentJournal = ( definitionId: string, records: readonly AgentStateJournalRecord[], ): void => { const keys = new Set(); + const first = records[0]; + const start = first === undefined || first.kind !== 'compact' ? 1 : first.revision; for (const [index, record] of records.entries()) { - if (record.revision !== index + 1) { + if (record.revision !== start + index) { throw new AgentStateError( 'corrupt', - `State '${definitionId}' journal expected revision ${String(index + 1)} but found ${String(record.revision)}`, + `State '${definitionId}' journal expected revision ${String(start + index)} but found ${String(record.revision)}`, ); } if (keys.has(record.idempotencyKey)) { @@ -386,6 +477,8 @@ export const changeFromJournalRecord = (record: AgentStateJournalRecord): AgentS return { committedAt: record.committedAt, kind: 'reset', revision: record.revision }; case 'migrate': return { committedAt: record.committedAt, kind: 'migrate', revision: record.revision }; + case 'compact': + return { committedAt: record.committedAt, kind: 'compact', revision: record.revision }; default: { const unreachable: never = record; throw new AgentStateError('corrupt', `Unknown journal record kind ${String(unreachable)}`); diff --git a/packages/rsc-runtime/src/state/memory-driver.ts b/packages/rsc-runtime/src/state/memory-driver.ts index 047f271e7..fad14c683 100644 --- a/packages/rsc-runtime/src/state/memory-driver.ts +++ b/packages/rsc-runtime/src/state/memory-driver.ts @@ -8,10 +8,14 @@ import type { AgentStateChangeBatch, AgentStateChangesOptions, AgentStateCommitResult, + AgentStateCompactOptions, + AgentStateCompactResult, AgentStateDefinition, AgentStateDispatchOptions, AgentStateDriver, AgentStateEventSchemas, + AgentStateInspectOptions, + AgentStateJournalInspection, AgentStateLifetime, AgentStateReadOptions, AgentStateResetOptions, @@ -23,8 +27,11 @@ import type { AgentStateJournalRecord } from './journal.js'; import { canonicalCommitInput, changeFromJournalRecord, + compactionIdempotencyKey, expectCommitWithinBudgets, expectMigrationWithinStateBudget, + inspectJournalRecords, + journalIsCompactable, migrationIdempotencyKey, parseEventPayload, reduceStateEvent, @@ -76,19 +83,48 @@ const expectVolatileLifetime = (lifetime: AgentStateLifetime): MemoryLifetime => }; interface CommittedResult { + readonly kind: 'committed'; readonly record: AgentStateJournalRecord; /** Post-commit state, kept per key so replay survives history rebases. */ readonly state: TState; } +/** + * A key whose record compaction deleted: the commit is remembered by its + * canonical input and revision so a retry replays as `revision-unavailable` + * (it happened; its result is gone) and a conflicting reuse is still refused. + */ +interface PrunedCommit { + readonly canonicalInput: string; + readonly kind: 'pruned'; + readonly revision: number; +} + interface MemoryStoreInternals { closed: boolean; definition: AgentStateDefinition; head: AgentStateSnapshot; - readonly journal: AgentStateJournalRecord[]; - keys: Map>; + journal: AgentStateJournalRecord[]; + keys: Map | PrunedCommit>; } +/** Approximates the sqlite driver's byte accounting: payloads plus stored states. */ +const journalBytesOf = (records: readonly AgentStateJournalRecord[]): number => + records.reduce((total, record) => { + switch (record.kind) { + case 'event': + return total + Buffer.byteLength(canonicalJson(record.payload), 'utf8'); + case 'compact': + case 'migrate': + case 'reset': + return total + Buffer.byteLength(canonicalJson(record.state), 'utf8'); + default: { + const unreachable: never = record; + throw new AgentStateError('corrupt', `Unknown journal record kind ${String(unreachable)}`); + } + } + }, 0); + /** Generics-erased entry type for the driver-wide open-store collections. */ type AnyMemoryStoreEntry = MemoryStoreEntry; @@ -155,12 +191,21 @@ const createMemoryStore = ( ): AgentStateCommitResult => { const committed = internals.keys.get(input.key); if (committed !== undefined) { - if (canonicalCommitInput(committed.record) !== input.canonicalInput) { + const committedInput = committed.kind === 'committed' + ? canonicalCommitInput(committed.record) + : committed.canonicalInput; + if (committedInput !== input.canonicalInput) { throw new AgentStateError( 'idempotency-conflict', `State '${internals.definition.id}' idempotency key was reused with a conflicting input`, ); } + if (committed.kind === 'pruned') { + throw new AgentStateError( + 'revision-unavailable', + `State '${internals.definition.id}' idempotency key committed at revision ${String(committed.revision)}, which compaction pruned; its result is no longer available`, + ); + } return Object.freeze({ replayed: true, revision: committed.record.revision, state: committed.state }); } if (expectedRevision !== undefined && expectedRevision !== internals.head.revision) { @@ -200,11 +245,57 @@ const createMemoryStore = ( state: input.state, }; internals.journal.push(journalRecord); - internals.keys.set(journalRecord.idempotencyKey, { record: journalRecord, state }); + internals.keys.set(journalRecord.idempotencyKey, { kind: 'committed', record: journalRecord, state }); internals.head = Object.freeze({ revision: journalRecord.revision, state }); return Object.freeze({ replayed: false, revision: journalRecord.revision, state }); }; + /** + * Folds the journal onto the head: one `compact` baseline carrying the head + * state takes the next revision and every earlier record is dropped, its key + * kept as a pruned marker. Synchronous, so atomic per store in this process. + */ + const compact = (expectedRevision: number | undefined): AgentStateCompactResult => { + if (expectedRevision !== undefined && expectedRevision !== internals.head.revision) { + throw new AgentStateError( + 'revision-conflict', + `State '${internals.definition.id}' expected revision ${String(expectedRevision)} but the head is ${String(internals.head.revision)}`, + ); + } + if (!journalIsCompactable(internals.journal)) { + return Object.freeze({ + baselineRevision: internals.journal[0]?.revision ?? internals.head.revision, + prunedRecords: 0, + revision: internals.head.revision, + state: internals.head.state, + }); + } + const baseline: AgentStateJournalRecord = { + committedAt: now().toISOString(), + idempotencyKey: compactionIdempotencyKey(internals.head.revision + 1), + kind: 'compact', + revision: internals.head.revision + 1, + state: internals.head.state, + }; + const pruned = internals.journal; + for (const record of pruned) { + internals.keys.set(record.idempotencyKey, { + canonicalInput: canonicalCommitInput(record), + kind: 'pruned', + revision: record.revision, + }); + } + internals.journal = [baseline]; + internals.keys.set(baseline.idempotencyKey, { kind: 'committed', record: baseline, state: internals.head.state }); + internals.head = Object.freeze({ revision: baseline.revision, state: internals.head.state }); + return Object.freeze({ + baselineRevision: baseline.revision, + prunedRecords: pruned.length, + revision: baseline.revision, + state: internals.head.state, + }); + }; + const store: AgentStateStore = { get definition() { return internals.definition; @@ -238,6 +329,25 @@ const createMemoryStore = ( return runtime.close(); }, + compact(options: AgentStateCompactOptions = {}): Promise> { + return runStore( + stateEffect(() => { + expectOperable(internals.closed, internals.definition.id, options.signal); + expectRevisionShape(options.expectedRevision, `State '${internals.definition.id}' expectedRevision`); + return compact(options.expectedRevision); + }), + ); + }, + + inspect(options: AgentStateInspectOptions = {}): Promise { + return runStore( + stateEffect(() => { + expectOperable(internals.closed, internals.definition.id, options.signal); + return inspectJournalRecords(internals.journal, internals.head.revision, journalBytesOf(internals.journal)); + }), + ); + }, + dispatch(name, payload, options: AgentStateDispatchOptions): Promise> { return runStore( stateEffect(() => { @@ -336,17 +446,18 @@ const migrateOpenStore = ( state: migrated, toVersion: definition.version, }; - const keys = new Map>(); + const keys = new Map | PrunedCommit>(); // Committed results replay across migrations: every stored result sits at // `fromVersion` (this loop maintains that inductively), so each one rides - // the same migration chain as the head. + // the same migration chain as the head. Pruned keys carry no result. for (const [key, entry] of internals.keys) { - keys.set(key, { + keys.set(key, entry.kind === 'pruned' ? entry : { + kind: 'committed', record: entry.record, state: runStateMigrations(definition, fromVersion, entry.state), }); } - keys.set(record.idempotencyKey, { record, state: migrated }); + keys.set(record.idempotencyKey, { kind: 'committed', record, state: migrated }); internals.keys = keys; internals.journal.push(record); internals.head = Object.freeze({ revision: record.revision, state: migrated }); diff --git a/packages/rsc-runtime/src/state/sqlite.ts b/packages/rsc-runtime/src/state/sqlite.ts index de73c619d..2bcb9089e 100644 --- a/packages/rsc-runtime/src/state/sqlite.ts +++ b/packages/rsc-runtime/src/state/sqlite.ts @@ -28,10 +28,14 @@ import type { AgentStateChangeBatch, AgentStateChangesOptions, AgentStateCommitResult, + AgentStateCompactOptions, + AgentStateCompactResult, AgentStateDefinition, AgentStateDispatchOptions, AgentStateDriver, AgentStateEventSchemas, + AgentStateInspectOptions, + AgentStateJournalInspection, AgentStateJournalRecord, AgentStateReadOptions, AgentStateResetOptions, @@ -43,12 +47,16 @@ import { canonicalCommitInput, canonicalJson, changeFromJournalRecord, + compactionIdempotencyKey, deepFreezeJson, describeSchemaIssues, expectCommitWithinBudgets, expectConsistentJournal, expectIdempotencyKey, expectMigrationWithinStateBudget, + inspectJournalRecords, + isCompactionIdempotencyKey, + journalIsCompactable, migrationIdempotencyKey, parseEventPayload, reduceStateEvent, @@ -81,9 +89,17 @@ import { createPendingOpenTracker } from './pending-opens.js'; * * Subscriptions are polling change cursors only; nothing stronger is * promised from short-lived processes. + * + * Kernel formats: format 1 stores are never compacted; the first compaction + * moves a store to format 2, whose journal may start at a `compact` baseline + * and whose `agent_state_pruned_keys` table remembers the idempotency keys of + * deleted records. A format-1 kernel therefore refuses a compacted store with + * a typed `corrupt` error instead of misreading its truncated journal. */ const KERNEL_FORMAT = 1; +const COMPACTED_KERNEL_FORMAT = 2; +const READABLE_KERNEL_FORMATS: readonly number[] = Object.freeze([KERNEL_FORMAT, COMPACTED_KERNEL_FORMAT]); export interface SqliteStateDriverOptions { /** @@ -195,13 +211,24 @@ interface JournalRow { readonly to_version: number | null; } +/** + * Compaction baselines are stored under the `reset` row kind (the journal + * table's kind constraint predates them) and told apart by their kernel-owned + * idempotency key, which no caller can mint. + */ +const storedKind = (record: AgentStateJournalRecord): 'event' | 'migrate' | 'reset' => + record.kind === 'compact' ? 'reset' : record.kind; + const recordFromRow = (definitionId: string, row: JournalRow): AgentStateJournalRecord => { const base = { committedAt: row.committed_at, idempotencyKey: row.idempotency_key, revision: row.revision }; if (row.kind === 'event' && row.name !== null && row.payload !== null) { return { ...base, kind: 'event', name: row.name, payload: parseStoredJson(definitionId, 'payload', row.revision, row.payload) }; } if (row.kind === 'reset' && row.state !== null) { - return { ...base, kind: 'reset', state: parseStoredJson(definitionId, 'state', row.revision, row.state) }; + const state = parseStoredJson(definitionId, 'state', row.revision, row.state); + return isCompactionIdempotencyKey(row.idempotency_key) + ? { ...base, kind: 'compact', state } + : { ...base, kind: 'reset', state }; } if (row.kind === 'migrate' && row.state !== null && row.to_version !== null) { return { @@ -353,13 +380,22 @@ class SqliteStore implements Age #committedByKey( db: DatabaseSync, key: string, - ): { readonly record: AgentStateJournalRecord; readonly resultStateText: string | null } | undefined { + ): + | { readonly kind: 'committed'; readonly record: AgentStateJournalRecord; readonly resultStateText: string | null } + | { readonly canonicalInput: string; readonly kind: 'pruned'; readonly revision: number } + | undefined { const row = this.#prepare(db, 'SELECT * FROM agent_state_journal WHERE idempotency_key = ?').get(key) as | JournalRow | undefined; - return row === undefined + if (row !== undefined) { + return { kind: 'committed', record: recordFromRow(this.#definition.id, row), resultStateText: row.result_state ?? row.state }; + } + const pruned = this + .#prepare(db, 'SELECT revision, canonical_input FROM agent_state_pruned_keys WHERE idempotency_key = ?') + .get(key) as { canonical_input: string; revision: number } | undefined; + return pruned === undefined ? undefined - : { record: recordFromRow(this.#definition.id, row), resultStateText: row.result_state ?? row.state }; + : { canonicalInput: pruned.canonical_input, kind: 'pruned', revision: pruned.revision }; } /** @@ -370,7 +406,7 @@ class SqliteStore implements Age */ #committedState( db: DatabaseSync, - committed: { readonly record: AgentStateJournalRecord; readonly resultStateText: string | null }, + committed: { readonly kind: 'committed'; readonly record: AgentStateJournalRecord; readonly resultStateText: string | null }, ): TState { const raw = committed.resultStateText !== null @@ -394,6 +430,17 @@ class SqliteStore implements Age `State '${this.#definition.id}' revision ${String(revision)} predates the migration at revision ${String(latestMigration)}`, ); } + // The records below a compaction baseline no longer exist, so the filtered + // read below would otherwise replay the initial state for them. + const first = this + .#prepare(db, 'SELECT revision, idempotency_key FROM agent_state_journal ORDER BY revision LIMIT 1') + .get() as { idempotency_key: string; revision: number } | undefined; + if (first !== undefined && isCompactionIdempotencyKey(first.idempotency_key) && revision < first.revision) { + throw new AgentStateError( + 'revision-unavailable', + `State '${this.#definition.id}' revision ${String(revision)} predates the compaction at revision ${String(first.revision)}`, + ); + } return replayJournal(this.#definition, this.#journalRecords(db, revision), revision); } @@ -410,7 +457,7 @@ class SqliteStore implements Age ) .run( record.revision, - record.kind, + storedKind(record), record.kind === 'event' ? record.name : null, record.kind === 'event' ? canonicalJson(record.payload) : null, // Event rows store their post-commit state too, so idempotent replay @@ -475,12 +522,21 @@ class SqliteStore implements Age return yield* this.#transaction('write', input.kind === 'event' ? `dispatch '${input.name}'` : 'reset', (db) => { const committed = this.#committedByKey(db, key); if (committed !== undefined) { - if (canonicalCommitInput(committed.record) !== prepared.canonicalInput) { + const committedInput = committed.kind === 'committed' + ? canonicalCommitInput(committed.record) + : committed.canonicalInput; + if (committedInput !== prepared.canonicalInput) { throw new AgentStateError( 'idempotency-conflict', `State '${definition.id}' idempotency key was reused with a conflicting input`, ); } + if (committed.kind === 'pruned') { + throw new AgentStateError( + 'revision-unavailable', + `State '${definition.id}' idempotency key committed at revision ${String(committed.revision)}, which compaction pruned; its result is no longer available`, + ); + } return Object.freeze({ replayed: true, revision: committed.record.revision, @@ -570,6 +626,104 @@ class SqliteStore implements Age return this.#run(this.#commit({ kind: 'reset', seed: options.seed }, options)); } + /** + * Compaction runs inside one `BEGIN IMMEDIATE` transaction like every other + * mutation: the baseline insert, the pruned-key bookkeeping, the delete, the + * head update, and the kernel-format bump commit together or not at all, so + * a process killed mid-compaction leaves either the full journal or the + * compacted one — never a journal missing records its head still needs. + * Concurrent writers in other processes serialize on the database lock and + * observe the compacted journal on their next transaction. + */ + compact(options: AgentStateCompactOptions = {}): Promise> { + const definition = this.#definition; + const now = this.#now; + return this.#run( + sqliteEffect(definition.id, 'validate compact', () => { + expectOperable(this.#closed, definition.id, options.signal); + expectRevisionShape(options.expectedRevision, `State '${definition.id}' expectedRevision`); + }).pipe( + Effect.andThen( + this.#transaction('write', 'compact', (db) => { + const head = this.#headState(db, 'compact'); + if (options.expectedRevision !== undefined && options.expectedRevision !== head.revision) { + throw new AgentStateError( + 'revision-conflict', + `State '${definition.id}' expected revision ${String(options.expectedRevision)} but the head is ${String(head.revision)}`, + ); + } + const records = this.#journalRecords(db); + if (!journalIsCompactable(records)) { + return Object.freeze({ + baselineRevision: records[0]?.revision ?? head.revision, + prunedRecords: 0, + revision: head.revision, + state: head.state, + }); + } + const remember = this.#prepare( + db, + 'INSERT OR REPLACE INTO agent_state_pruned_keys (idempotency_key, revision, canonical_input) VALUES (?, ?, ?)', + ); + for (const record of records) { + remember.run(record.idempotencyKey, record.revision, canonicalCommitInput(record)); + } + this.#prepare(db, 'DELETE FROM agent_state_journal WHERE revision <= ?').run(head.revision); + const stateText = canonicalJson(head.state); + const baseline: AgentStateJournalRecord = { + committedAt: now().toISOString(), + idempotencyKey: compactionIdempotencyKey(head.revision + 1), + kind: 'compact', + revision: head.revision + 1, + state: head.state, + }; + const committed = this.#appendRecord(db, baseline, head.state, stateText); + this.#prepare(db, 'UPDATE agent_state_meta SET kernel_format = ? WHERE id = 1').run(COMPACTED_KERNEL_FORMAT); + return Object.freeze({ + baselineRevision: baseline.revision, + prunedRecords: records.length, + revision: committed.revision, + state: committed.state, + }); + }), + ), + ), + ); + } + + inspect(options: AgentStateInspectOptions = {}): Promise { + const definition = this.#definition; + return this.#run( + sqliteEffect(definition.id, 'validate inspect', () => { + expectOperable(this.#closed, definition.id, options.signal); + }).pipe( + Effect.andThen( + this.#transaction('read', 'inspect', (db) => { + const head = this.#headRow(db, 'inspect'); + const size = this + .#prepare( + db, + 'SELECT COUNT(*) AS records, COALESCE(SUM(LENGTH(CAST(COALESCE(payload, \'\') AS BLOB)) + LENGTH(CAST(COALESCE(state, \'\') AS BLOB)) + LENGTH(CAST(COALESCE(result_state, \'\') AS BLOB))), 0) AS bytes FROM agent_state_journal', + ) + .get() as { bytes: number; records: number }; + const first = this + .#prepare(db, 'SELECT * FROM agent_state_journal ORDER BY revision LIMIT 1') + .get() as JournalRow | undefined; + // Only the first record decides the baseline and last compaction; + // the count and bytes come from SQL so a large journal is never + // materialized to inspect it. + const summary = inspectJournalRecords( + first === undefined ? [] : [recordFromRow(definition.id, first)], + head.revision, + size.bytes, + ); + return Object.freeze({ ...summary, records: size.records }); + }), + ), + ), + ); + } + read(options: AgentStateReadOptions = {}): Promise> { return this.#run( sqliteEffect(this.#definition.id, 'validate read', () => { @@ -653,6 +807,11 @@ class SqliteStore implements Age revision INTEGER NOT NULL, state TEXT NOT NULL ); + CREATE TABLE IF NOT EXISTS agent_state_pruned_keys ( + idempotency_key TEXT PRIMARY KEY, + revision INTEGER NOT NULL, + canonical_input TEXT NOT NULL + ); `); const journalColumns = transactionDb.prepare('PRAGMA table_info(agent_state_journal)').all() as unknown as { readonly name: string; @@ -679,10 +838,10 @@ class SqliteStore implements Age `State '${definition.id}' storage at '${this.location}' belongs to definition '${meta.definition_id}'`, ); } - if (meta.kernel_format !== KERNEL_FORMAT) { + if (!READABLE_KERNEL_FORMATS.includes(meta.kernel_format)) { throw new AgentStateError( 'corrupt', - `State '${definition.id}' storage uses kernel format ${String(meta.kernel_format)}; this kernel reads format ${String(KERNEL_FORMAT)}`, + `State '${definition.id}' storage uses kernel format ${String(meta.kernel_format)}; this kernel reads formats ${READABLE_KERNEL_FORMATS.map(String).join(', ')}`, ); } const head = this.#headRow(transactionDb, 'open'); diff --git a/packages/rsc-runtime/tests/fixtures/notices-sqlite-process.mjs b/packages/rsc-runtime/tests/fixtures/notices-sqlite-process.mjs index 6be09a180..ed0fd5216 100644 --- a/packages/rsc-runtime/tests/fixtures/notices-sqlite-process.mjs +++ b/packages/rsc-runtime/tests/fixtures/notices-sqlite-process.mjs @@ -10,14 +10,17 @@ import { import { createSqliteStateDriver } from '../../dist/state/sqlite.js'; const [file, mode] = process.argv.slice(2); -if (typeof file !== 'string' || (mode !== 'publish' && mode !== 'deliver')) { - throw new Error('usage: notices-sqlite-process.mjs '); +if (typeof file !== 'string' || (mode !== 'publish' && mode !== 'deliver' && mode !== 'retain')) { + throw new Error('usage: notices-sqlite-process.mjs '); } const driver = createSqliteStateDriver({ file }); const store = await driver.open(agentNoticeStateDefinition()); const ledger = createAgentNoticeLedger(store, { authorize: () => ({ state: 'authorized' }), + // A one-byte bound makes the retain pass compact whatever journal it finds, + // so the cross-process proof exercises compaction without a large fixture. + ...(mode === 'retain' ? { retention: { maxJournalBytes: 1 } } : {}), }); try { @@ -50,6 +53,12 @@ try { id: result.notice.id, state: result.notice.state, })); + } else if (mode === 'retain') { + const report = await ledger.retain({ + at: '2026-09-01T19:02:00.000Z', + idempotencyKey: 'retain:cross-process', + }); + process.stdout.write(JSON.stringify(report)); } else { const result = await runAgentRequest({ actor: available({ id: 'recipient' }, 'native'), diff --git a/packages/rsc-runtime/tests/notices-redaction.test.ts b/packages/rsc-runtime/tests/notices-redaction.test.ts new file mode 100644 index 000000000..21f39530b --- /dev/null +++ b/packages/rsc-runtime/tests/notices-redaction.test.ts @@ -0,0 +1,370 @@ +import { describe, expect, it } from '@rstest/core'; + +import { + AGENT_NOTICE_DEFAULT_SENSITIVITY, + AGENT_NOTICE_DELIVERY_ROUTES, + AGENT_NOTICE_ROUTE_SHAPES, + AGENT_NOTICE_SENSITIVITIES, + AgentNoticeError, + NOTICE_REDACTION_MARK, + agentNoticeStateDefinition, + containsSecretText, + createAgentNoticeLedger, + createNoticeInboxSignaller, + disclosedNoticeContent, + noticeTitle, + redactNoticeDocument, + redactSecretText, + resolveNoticeDisclosure, + selectNoticeDeliveryRoutes, + type AgentNoticeDeliveryAdvertisement, + type AgentNoticeDeliveryRoute, + type AgentNoticeSensitivity, + type AgentNoticePrincipal, +} from '../src/notices/index.js'; +import type { AgentDocumentSnapshot } from '../src/index.js'; +import { + agent, + available, + runAgentRequest, + unavailable, +} from '../src/index.js'; +import { createMemoryStateDriver } from '../src/state/index.js'; + +const document = (text: string): AgentDocumentSnapshot => ({ + root: { kind: 'text' as const, text }, + status: 'success' as const, + version: 1 as const, +}); + +const actor = (id: string) => available({ id }, 'native'); +const host = available({ name: 'claude' }, 'native'); +const session = available({ sessionId: 'session-1' }, 'native'); +const workspace = available({ root: '/workspace' }, 'native'); + +/** Every route supported; each names the ceiling given (absent = pre-sensitivity contract). */ +const advertisement = ( + ceilings: Partial>, +): AgentNoticeDeliveryAdvertisement => Object.fromEntries(AGENT_NOTICE_DELIVERY_ROUTES.map((route) => { + const ceiling = ceilings[route]; + if (ceiling === 'unavailable') return [route, { reason: '2026-09-03: fixture', state: 'unavailable' }]; + return [route, ceiling === undefined ? { state: 'supported' } : { sensitivity: ceiling, state: 'supported' }]; +})) as AgentNoticeDeliveryAdvertisement; + +const openLedger = async (delivery?: AgentNoticeDeliveryAdvertisement) => { + const driver = createMemoryStateDriver({ lifetime: 'process' }); + const store = await driver.open(agentNoticeStateDefinition('process')); + const ledger = createAgentNoticeLedger(store, { + authorize: () => ({ state: 'authorized' }), + ...(delivery === undefined ? {} : { delivery }), + }); + return { driver, ledger, store }; +}; + +const run = async ( + ledger: Awaited>['ledger'], + input: { readonly actorId: string; readonly id: string; readonly kind: 'event' | 'tool'; readonly startedAt: string }, + operation: () => Promise, +): Promise => runAgentRequest({ + actor: actor(input.actorId), + host, + invocation: { id: input.id, kind: input.kind, startedAt: input.startedAt }, + noticeLedger: ledger, + session, + workspace, +}, operation); + +const SECRET_TEXT = 'Rotate token=abc123def456 before https://ops:hunter2@vault.example.test/x and sk-ant-0123456789abcdef0123'; + +const publish = ( + ledger: Awaited>['ledger'], + input: { readonly id: string; readonly sensitivity?: AgentNoticeSensitivity; readonly text?: string }, +) => run(ledger, { + actorId: 'publisher', + id: `publish-${input.id}`, + kind: 'tool', + startedAt: '2026-09-03T10:00:00.000Z', +}, async () => (await agent()).notices!.publish({ + content: document(input.text ?? SECRET_TEXT), + dedupeKey: input.id, + priority: 'normal', + recipient: { actor: { id: 'recipient' } }, + ...(input.sensitivity === undefined ? {} : { sensitivity: input.sensitivity }), +}, { idempotencyKey: `publish:${input.id}` })); + +describe('secret-pattern redaction', () => { + it('masks credential assignments, provider tokens, and URL userinfo while keeping structure', () => { + const redacted = redactSecretText(SECRET_TEXT); + expect(redacted).toBe( + `Rotate token=${NOTICE_REDACTION_MARK} before https://${NOTICE_REDACTION_MARK}@vault.example.test/x and ${NOTICE_REDACTION_MARK}`, + ); + expect(containsSecretText(SECRET_TEXT)).toBe(true); + expect(containsSecretText('Another worktree is editing /repo/src/secrets.ts')).toBe(false); + // Idempotent: a redacted text is a fixed point. + expect(redactSecretText(redacted)).toBe(redacted); + // Quotes are preserved around the mask so JSON-shaped text stays parseable. + expect(redactSecretText('{"api_key": "xyz", "note": "keep"}')).toBe(`{"api_key": "${NOTICE_REDACTION_MARK}", "note": "keep"}`); + }); + + it('redacts every prose field of a document and nothing else', () => { + const snapshot: AgentDocumentSnapshot = { + root: { + children: [ + { kind: 'markdown', text: 'password: p4ss' }, + { kind: 'context', text: 'clean' }, + { kind: 'json', value: { nested: ['token: t0k3n', 1, true, null], plain: 'ok' } }, + { completed: 1, kind: 'progress', message: 'secret=abc', total: 2 }, + { data: 'QUJD', kind: 'image', mimeType: 'image/png' }, + { kind: 'resource', mimeType: 'text/plain', name: 'token: n', uri: 'https://u:p@h.example.test/r' }, + { code: 'E_SECRET', kind: 'error', message: 'authorization: Bearer abcdefghijklmnopqrstuvwxyz' }, + ], + kind: 'result', + metadata: { credential: 'x' }, + }, + status: 'success', + value: { secret: 'v' }, + version: 1, + }; + const redacted = redactNoticeDocument(snapshot); + expect(redacted).toEqual({ + root: { + children: [ + { kind: 'markdown', text: `password: ${NOTICE_REDACTION_MARK}` }, + { kind: 'context', text: 'clean' }, + { kind: 'json', value: { nested: [`token: ${NOTICE_REDACTION_MARK}`, 1, true, null], plain: 'ok' } }, + { completed: 1, kind: 'progress', message: `secret=${NOTICE_REDACTION_MARK}`, total: 2 }, + { data: 'QUJD', kind: 'image', mimeType: 'image/png' }, + { kind: 'resource', mimeType: 'text/plain', name: `token: ${NOTICE_REDACTION_MARK}`, uri: `https://${NOTICE_REDACTION_MARK}@h.example.test/r` }, + { code: 'E_SECRET', kind: 'error', message: `authorization: ${NOTICE_REDACTION_MARK}` }, + ], + kind: 'result', + metadata: { credential: 'x' }, + }, + status: 'success', + value: { secret: 'v' }, + version: 1, + }); + expect(Object.isFrozen(redacted.root)).toBe(true); + // The original is untouched: redaction is applied on egress, never in place. + expect((snapshot.root as { children: readonly { text?: string }[] }).children[0]!.text).toBe('password: p4ss'); + }); + + it('projects a bounded single-line title for title-only routes', () => { + expect(noticeTitle(document(' \n First line here\nsecond'))).toBe('First line here'); + expect(noticeTitle(document('x'.repeat(200))).length).toBe(120); + expect(noticeTitle({ root: { kind: 'json', value: 1 }, status: 'success', version: 1 })).toBe(''); + const title = disclosedNoticeContent(document(SECRET_TEXT), { kind: 'disclosed', redacted: true, shape: 'title' }); + expect(title).toEqual(document(redactSecretText(SECRET_TEXT))); + expect(disclosedNoticeContent(document('x'), { kind: 'disclosed', redacted: false, shape: 'signal' })).toBeUndefined(); + }); +}); + +describe('route disclosure decisions', () => { + it('spells the sensitivity vocabulary and the per-route shapes', () => { + expect(AGENT_NOTICE_SENSITIVITIES).toEqual(['public', 'internal', 'secret']); + expect(AGENT_NOTICE_DEFAULT_SENSITIVITY).toBe('internal'); + expect(AGENT_NOTICE_ROUTE_SHAPES).toEqual({ + 'current-response': 'body', + 'directed-push': 'body', + 'host-toast': 'title', + 'mcp-inbox': 'body', + 'mcp-resource-updated': 'signal', + 'next-event': 'body', + }); + }); + + it('withholds above the row ceiling, redacts internal, passes public and admitted secret verbatim', () => { + const rows = advertisement({ 'host-toast': 'public', 'mcp-inbox': 'internal', 'next-event': 'secret' }); + expect(resolveNoticeDisclosure('mcp-inbox', 'secret', rows)).toEqual({ kind: 'withheld', reason: 'sensitivity-exceeds-route' }); + expect(resolveNoticeDisclosure('mcp-inbox', 'internal', rows)).toEqual({ kind: 'disclosed', redacted: true, shape: 'body' }); + expect(resolveNoticeDisclosure('mcp-inbox', 'public', rows)).toEqual({ kind: 'disclosed', redacted: false, shape: 'body' }); + expect(resolveNoticeDisclosure('next-event', 'secret', rows)).toEqual({ kind: 'disclosed', redacted: false, shape: 'body' }); + expect(resolveNoticeDisclosure('host-toast', 'internal', rows)).toEqual({ kind: 'withheld', reason: 'sensitivity-exceeds-route' }); + expect(resolveNoticeDisclosure('host-toast', 'public', rows)).toEqual({ kind: 'disclosed', redacted: false, shape: 'title' }); + // Absent row field and absent advertisement both mean the pre-sensitivity contract: internal, not secret. + expect(resolveNoticeDisclosure('mcp-resource-updated', 'internal', rows)).toEqual({ kind: 'disclosed', redacted: true, shape: 'signal' }); + expect(resolveNoticeDisclosure('mcp-resource-updated', 'secret', rows)).toEqual({ kind: 'withheld', reason: 'sensitivity-exceeds-route' }); + expect(resolveNoticeDisclosure('directed-push', 'internal', undefined)).toEqual({ kind: 'disclosed', redacted: true, shape: 'body' }); + expect(resolveNoticeDisclosure('directed-push', 'secret', undefined)).toEqual({ kind: 'withheld', reason: 'sensitivity-exceeds-route' }); + // Unsupported routes withhold everything, public included. + const noToast = advertisement({ 'host-toast': 'unavailable' }); + expect(resolveNoticeDisclosure('host-toast', 'public', noToast)).toEqual({ kind: 'withheld', reason: 'route-unavailable' }); + }); + + it('fails closed on an unknown sensitivity in a row', () => { + const rows = { ...advertisement({}), 'mcp-inbox': { sensitivity: 'top-secret', state: 'supported' } } as unknown as AgentNoticeDeliveryAdvertisement; + expect(() => selectNoticeDeliveryRoutes(rows)).toThrow(AgentNoticeError); + expect(() => selectNoticeDeliveryRoutes(rows)).toThrow(/unknown sensitivity "top-secret"/u); + }); +}); + +describe('ledger disclosure through the inbox and next-event routes', () => { + it('rejects an unknown sensitivity at publish before persistence', async () => { + const { driver, ledger } = await openLedger(); + await expect(publish(ledger, { id: 'bad', sensitivity: 'loud' as AgentNoticeSensitivity })) + .rejects.toMatchObject({ code: 'invalid-input', name: 'AgentNoticeError' }); + expect((await ledger.read()).notices).toEqual([]); + await driver.close(); + }); + + it('persists the authored content and discloses redacted, full, or nothing per class in the inbox', async () => { + const { driver, ledger } = await openLedger(advertisement({ 'mcp-inbox': 'internal' })); + const internal = await publish(ledger, { id: 'internal' }); + const explicit = await publish(ledger, { id: 'public', sensitivity: 'public' }); + const secret = await publish(ledger, { id: 'secret', sensitivity: 'secret' }); + expect(internal.notice.sensitivity).toBe('internal'); + // The store keeps what the author wrote; redaction happens on egress. + const persisted = await ledger.read(); + expect(persisted.notices.map((notice) => (notice.content.root as { text: string }).text)).toEqual([ + SECRET_TEXT, + SECRET_TEXT, + SECRET_TEXT, + ]); + + const inbox = await run(ledger, { + actorId: 'recipient', + id: 'read-1', + kind: 'tool', + startedAt: '2026-09-03T10:05:00.000Z', + }, async () => (await agent()).notices!.inbox()); + expect(inbox.map((notice) => [notice.id, (notice.content.root as { text: string }).text]).toSorted()).toEqual([ + [internal.notice.id, redactSecretText(SECRET_TEXT)], + [explicit.notice.id, SECRET_TEXT], + ].toSorted()); + expect(inbox.map((notice) => notice.id)).not.toContain(secret.notice.id); + + const after = await ledger.read(); + const byId = new Map(after.notices.map((notice) => [notice.id, notice])); + // Disclosed notices carry the exposure receipt; the withheld one carries + // the refusal instead, still pending, never exposed. + expect(byId.get(internal.notice.id)?.exposure?.count).toBe(1); + expect(byId.get(explicit.notice.id)?.exposure?.count).toBe(1); + expect(byId.get(secret.notice.id)?.exposure).toBeUndefined(); + expect(byId.get(secret.notice.id)).toMatchObject({ + state: 'pending', + withheld: { + 'mcp-inbox': { + count: 1, + firstAt: '2026-09-03T10:05:00.000Z', + lastAt: '2026-09-03T10:05:00.000Z', + reason: 'sensitivity-exceeds-route', + }, + }, + }); + await driver.close(); + }); + + it('withholds a secret from next-event admission without spending an attempt, and delivers it where the row admits it', async () => { + const closedRows = advertisement({ 'next-event': 'internal' }); + const closed = await openLedger(closedRows); + const secret = await publish(closed.ledger, { id: 'secret', sensitivity: 'secret' }); + const first = await run(closed.ledger, { + actorId: 'recipient', + id: 'event-1', + kind: 'event', + startedAt: '2026-09-03T10:10:00.000Z', + }, async () => (await agent()).notices!.read()); + expect(first).toEqual([]); + const held = (await closed.ledger.read()).notices.find((notice) => notice.id === secret.notice.id); + expect(held).toMatchObject({ + attempts: [], + state: 'pending', + withheld: { 'next-event': { count: 1, reason: 'sensitivity-exceeds-route' } }, + }); + await closed.driver.close(); + + const open = await openLedger(advertisement({ 'next-event': 'secret' })); + const admitted = await publish(open.ledger, { id: 'secret', sensitivity: 'secret' }); + const delivered = await run(open.ledger, { + actorId: 'recipient', + id: 'event-2', + kind: 'event', + startedAt: '2026-09-03T10:10:00.000Z', + }, async () => (await agent()).notices!.read()); + expect(delivered).toEqual([expect.objectContaining({ + disclosure: { redacted: false, route: 'next-event' }, + notice: expect.objectContaining({ content: document(SECRET_TEXT), id: admitted.notice.id, state: 'attempted' }), + })]); + await open.driver.close(); + }); + + it('delivers internal notices redacted on next-event and treats pre-sensitivity notices as internal', async () => { + const { driver, ledger, store } = await openLedger(); + const internal = await publish(ledger, { id: 'internal' }); + // A notice journaled before the redaction contract has no class at all. + const legacy = await store.dispatch('published', { + notice: { + attempts: [], + content: document(SECRET_TEXT), + createdAt: '2026-09-03T10:00:00.000Z', + id: 'notice_legacy', + priority: 'normal', + recipient: { actor: { id: 'recipient' } }, + state: 'pending', + }, + }, { idempotencyKey: 'legacy' }); + expect(legacy.state.notices.find((notice) => notice.id === 'notice_legacy')?.sensitivity).toBeUndefined(); + + const deliveries = await run(ledger, { + actorId: 'recipient', + id: 'event-3', + kind: 'event', + startedAt: '2026-09-03T10:10:00.000Z', + }, async () => (await agent()).notices!.read()); + expect(deliveries.map((delivery) => [delivery.notice.id, delivery.disclosure.redacted, (delivery.notice.content.root as { text: string }).text])).toEqual([ + [internal.notice.id, true, redactSecretText(SECRET_TEXT)], + ['notice_legacy', true, redactSecretText(SECRET_TEXT)], + ]); + // The persisted content is still the authored one. + expect((await ledger.read()).notices.every((notice) => (notice.content.root as { text: string }).text === SECRET_TEXT)).toBe(true); + await driver.close(); + }); + + it('records a route-unavailable refusal when an embedder runs admission on a host without the route', async () => { + const { driver, ledger } = await openLedger(advertisement({ 'next-event': 'unavailable' })); + const notice = await publish(ledger, { id: 'n', sensitivity: 'public' }); + const deliveries = await run(ledger, { + actorId: 'recipient', + id: 'event-4', + kind: 'event', + startedAt: '2026-09-03T10:10:00.000Z', + }, async () => (await agent()).notices!.read()); + expect(deliveries).toEqual([]); + expect((await ledger.read()).notices.find((candidate) => candidate.id === notice.notice.id)).toMatchObject({ + state: 'pending', + withheld: { 'next-event': { count: 1, reason: 'route-unavailable' } }, + }); + await driver.close(); + }); + + it('never signals resources/updated for a notice the inbox would withhold', async () => { + const { driver, ledger } = await openLedger(advertisement({ 'mcp-inbox': 'internal' })); + await publish(ledger, { id: 'secret', sensitivity: 'secret' }); + const visible = await publish(ledger, { id: 'internal' }); + const principal: AgentNoticePrincipal = { + actor: actor('recipient'), + host: unavailable(), + session: unavailable(), + workspace: unavailable(), + }; + const signaller = createNoticeInboxSignaller({ + delivery: advertisement({ 'mcp-inbox': 'internal' }), + now: () => new Date('2026-09-03T10:20:00.000Z'), + store: { close: async () => undefined, noticeLedger: async () => ledger }, + }); + await signaller.subscribe(principal); + const sends: number[] = []; + const outcome = await signaller.observe(async () => { + sends.push(1); + }); + expect(outcome).toEqual({ kind: 'signalled', noticeIds: [visible.notice.id], revision: expect.any(Number) }); + expect(sends).toHaveLength(1); + const again = await signaller.observe(async () => { + sends.push(1); + }); + expect(again).toEqual({ kind: 'idle', reason: 'nothing-eligible', revision: expect.any(Number) }); + expect(sends).toHaveLength(1); + await signaller.close(); + await driver.close(); + }); +}); diff --git a/packages/rsc-runtime/tests/notices-retention.test.ts b/packages/rsc-runtime/tests/notices-retention.test.ts new file mode 100644 index 000000000..119283c7e --- /dev/null +++ b/packages/rsc-runtime/tests/notices-retention.test.ts @@ -0,0 +1,317 @@ +import { mkdtemp, rm } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; + +import { describe, expect, it } from '@rstest/core'; + +import { + AGENT_NOTICE_DEFAULT_RETENTION, + AgentNoticeError, + agentNoticeStateDefinition, + createAgentNoticeLedger, + noticeSettledAt, + resolveNoticeRetentionPolicy, + selectPrunableNotices, + type AgentNotice, + type AgentNoticeLedger, + type AgentNoticeRetentionInput, +} from '../src/notices/index.js'; +import { + agent, + available, + runAgentRequest, +} from '../src/index.js'; +import { createMemoryStateDriver, type AgentStateDriver, type AgentStateStore } from '../src/state/index.js'; +import { createSqliteStateDriver } from '../src/state/sqlite.js'; + +const document = (text: string) => ({ + root: { kind: 'text' as const, text }, + status: 'success' as const, + version: 1 as const, +}); + +const host = available({ name: 'claude' }, 'native'); +const session = available({ sessionId: 'session-1' }, 'native'); +const workspace = available({ root: '/workspace' }, 'native'); + +const T0 = Date.parse('2026-09-01T00:00:00.000Z'); +const at = (offsetMs: number): string => new Date(T0 + offsetMs).toISOString(); +const HOUR = 60 * 60 * 1000; +const DAY = 24 * HOUR; + +type NoticeStore = Parameters[0]; + +const openLedger = async ( + retention?: AgentNoticeRetentionInput, + driver: AgentStateDriver = createMemoryStateDriver({ lifetime: 'process' }), + decorate: (store: NoticeStore) => NoticeStore = (store) => store, +) => { + const store = decorate(await driver.open(agentNoticeStateDefinition(driver.lifetime))); + const ledger = createAgentNoticeLedger(store, { + authorize: () => ({ state: 'authorized' }), + ...(retention === undefined ? {} : { retention }), + }); + return { driver, ledger, store }; +}; + +const run = async ( + ledger: AgentNoticeLedger, + input: { readonly actorId: string; readonly id: string; readonly kind: 'event' | 'tool'; readonly startedAt: string }, + operation: () => Promise, +): Promise => runAgentRequest({ + actor: available({ id: input.actorId }, 'native'), + host, + invocation: { id: input.id, kind: input.kind, startedAt: input.startedAt }, + noticeLedger: ledger, + session, + workspace, +}, operation); + +const publish = (ledger: AgentNoticeLedger, id: string, startedAt: string, recipient = 'recipient') => run(ledger, { + actorId: 'publisher', + id: `publish-${id}`, + kind: 'tool', + startedAt, +}, async () => (await agent()).notices!.publish({ + content: document(`notice ${id}`), + dedupeKey: id, + priority: 'normal', + recipient: { actor: { id: recipient } }, +}, { idempotencyKey: `publish:${id}` })); + +const ackFor = (ledger: AgentNoticeLedger, noticeId: string, startedAt: string, invocation: string) => run(ledger, { + actorId: 'recipient', + id: invocation, + kind: 'tool', + startedAt, +}, async () => (await agent()).notices!.acknowledge(noticeId)); + +const settledNotice = (id: string, settledAt: string): AgentNotice => Object.freeze({ + acknowledgement: { acknowledgedAt: settledAt, invocationId: 'i' }, + attempts: [], + content: document(id), + createdAt: at(0), + id, + priority: 'normal', + recipient: { actor: { id: 'r' } }, + state: 'acknowledged', +}); + +describe('notice retention policy', () => { + it('resolves defaults and rejects non-positive or fractional values', () => { + expect(AGENT_NOTICE_DEFAULT_RETENTION).toEqual({ + maxJournalBytes: 16 * 1024 * 1024, + maxTerminal: 500, + terminalTtlMs: 7 * DAY, + }); + expect(resolveNoticeRetentionPolicy(undefined)).toEqual(AGENT_NOTICE_DEFAULT_RETENTION); + expect(resolveNoticeRetentionPolicy({ maxTerminal: 3 })).toEqual({ ...AGENT_NOTICE_DEFAULT_RETENTION, maxTerminal: 3 }); + for (const bad of [{ maxTerminal: 0 }, { terminalTtlMs: -1 }, { maxJournalBytes: 1.5 }, { terminalTtlMs: Number.NaN }]) { + expect(() => resolveNoticeRetentionPolicy(bad)).toThrow(AgentNoticeError); + } + expect(() => createAgentNoticeLedger({} as NoticeStore, { authorize: () => ({ state: 'authorized' }), retention: { maxTerminal: 0 } })) + .toThrow(/maxTerminal must be an integer >= 1/u); + }); + + it('treats exhausted attempts as settled and live work as never prunable', () => { + const pending: AgentNotice = { ...settledNotice('p', at(0)), acknowledgement: undefined, state: 'pending' }; + expect(noticeSettledAt(pending)).toBeUndefined(); + const attempted: AgentNotice = { + ...pending, + attempts: [{ attemptedAt: at(HOUR), channel: 'next-event', invocationId: 'e1' }], + retryBudget: 2, + state: 'attempted', + }; + expect(noticeSettledAt(attempted)).toBeUndefined(); + expect(noticeSettledAt({ + ...attempted, + attempts: [...attempted.attempts, { attemptedAt: at(2 * HOUR), channel: 'next-event', invocationId: 'e2' }], + })).toBe(at(2 * HOUR)); + expect(noticeSettledAt({ ...pending, expiredAt: at(3 * HOUR), state: 'expired' })).toBe(at(3 * HOUR)); + expect(noticeSettledAt({ ...pending, state: 'withdrawn', withdrawnAt: at(4 * HOUR) })).toBe(at(4 * HOUR)); + expect(noticeSettledAt({ ...pending, state: 'unavailable', unavailableAt: at(5 * HOUR), unavailableReason: 'delivery-authorization-unavailable' })).toBe(at(5 * HOUR)); + }); + + it('selects settled notices past the TTL, then the earliest-settled beyond the cap, deterministically', () => { + const policy = resolveNoticeRetentionPolicy({ maxTerminal: 2, terminalTtlMs: DAY }); + const notices = [ + settledNotice('old-b', at(0)), + settledNotice('old-a', at(0)), + settledNotice('recent-1', at(2 * DAY + HOUR / 2)), + settledNotice('recent-2', at(2 * DAY + HOUR)), + settledNotice('recent-3', at(2 * DAY + 2 * HOUR)), + { ...settledNotice('live', at(0)), acknowledgement: undefined, state: 'pending' as const }, + ]; + // Two days later: the two old ones are past the TTL; three recent remain, + // one over the cap, so the earliest-settled recent one goes too. + expect(selectPrunableNotices(notices, policy, at(3 * DAY))).toEqual(['old-a', 'old-b', 'recent-1']); + expect(selectPrunableNotices(notices, resolveNoticeRetentionPolicy({ terminalTtlMs: DAY }), at(3 * DAY))).toEqual(['old-a', 'old-b']); + // The cap holds regardless of age; a generous cap with nothing past the TTL prunes nothing. + expect(selectPrunableNotices(notices, policy, at(HOUR))).toEqual(['old-a', 'old-b', 'recent-1']); + expect(selectPrunableNotices(notices, resolveNoticeRetentionPolicy({ terminalTtlMs: DAY }), at(HOUR))).toEqual([]); + expect(selectPrunableNotices(notices, resolveNoticeRetentionPolicy({ maxTerminal: 1, terminalTtlMs: 30 * DAY }), at(3 * DAY))) + .toEqual(['old-a', 'old-b', 'recent-1', 'recent-2']); + }); +}); + +describe('ledger retention', () => { + it('prunes settled notices past the TTL on retain(), records the summary, and replays idempotently', async () => { + const { driver, ledger } = await openLedger({ terminalTtlMs: DAY }); + const first = await publish(ledger, 'a', at(0)); + const second = await publish(ledger, 'b', at(0)); + await ackFor(ledger, first.notice.id, at(HOUR), 'ack-a'); + await ackFor(ledger, second.notice.id, at(2 * DAY), 'ack-b'); + await publish(ledger, 'live', at(2 * DAY)); + const before = await ledger.read(); + expect(before.retention).toBeUndefined(); + + const report = await ledger.retain({ at: at(2 * DAY + HOUR), idempotencyKey: 'retain:1' }); + expect(report.prunedIds).toEqual([first.notice.id]); + expect(report.compacted).toBe(false); + expect(report.revision).toBe(before.revision + 1); + const after = await ledger.read(); + expect(after.notices.map((notice) => notice.id).toSorted()).toEqual([second.notice.id, (await publish(ledger, 'live', at(2 * DAY))).notice.id].toSorted()); + expect(after.retention).toEqual({ lastPrunedAt: at(2 * DAY + HOUR), pruneRuns: 1, prunedTotal: 1 }); + + // Same key, same decision: replayed, no new revision. + const replayed = await ledger.retain({ at: at(2 * DAY + HOUR), idempotencyKey: 'retain:1' }); + expect(replayed.prunedIds).toEqual([]); + expect(replayed.revision).toBe(after.revision); + expect((await ledger.read()).retention?.pruneRuns).toBe(1); + + const inspection = await ledger.inspect(); + expect(inspection).toMatchObject({ + counts: { byState: { acknowledged: 1, attempted: 0, expired: 0, pending: 1, unavailable: 0, withdrawn: 0 }, terminal: 1, total: 2 }, + policy: { ...AGENT_NOTICE_DEFAULT_RETENTION, terminalTtlMs: DAY }, + retention: { pruneRuns: 1, prunedTotal: 1 }, + revision: after.revision, + }); + expect(inspection.journal.records).toBeGreaterThan(0); + await driver.close(); + }); + + it('runs retention on admitted events only, never on tool invocations', async () => { + const { driver, ledger } = await openLedger({ terminalTtlMs: HOUR }); + const stale = await publish(ledger, 'stale', at(0)); + await ackFor(ledger, stale.notice.id, at(1), 'ack-stale'); + const untouched = await run(ledger, { actorId: 'recipient', id: 'tool-1', kind: 'tool', startedAt: at(DAY) }, async () => + (await agent()).notices!.inbox()); + expect(untouched).toEqual([]); + expect((await ledger.read()).notices).toHaveLength(1); + + await run(ledger, { actorId: 'someone-else', id: 'event-1', kind: 'event', startedAt: at(DAY) }, async () => + (await agent()).notices!.read()); + const after = await ledger.read(); + expect(after.notices).toEqual([]); + expect(after.retention).toEqual({ lastPrunedAt: at(DAY), pruneRuns: 1, prunedTotal: 1 }); + // The same admitted event replayed makes no second prune. + await run(ledger, { actorId: 'someone-else', id: 'event-1', kind: 'event', startedAt: at(DAY) }, async () => + (await agent()).notices!.read()); + expect((await ledger.read()).retention?.pruneRuns).toBe(1); + await driver.close(); + }); + + it('never prunes a notice that is still live at reduce time', async () => { + const { driver, ledger, store } = await openLedger(); + const live = await publish(ledger, 'live', at(0)); + // A prune decision naming a pending notice is a stale decision: the + // reducer skips it and records nothing. + const committed = await store.dispatch('pruned', { at: at(DAY), noticeIds: [live.notice.id, 'notice_missing'] }, { idempotencyKey: 'stale-prune' }); + expect(committed.state.notices.map((notice) => notice.id)).toEqual([live.notice.id]); + expect(committed.state.retention).toBeUndefined(); + await driver.close(); + }); + + it('compacts the journal once it exceeds the byte bound and recovers from a crash between prune and compaction', async () => { + let failCompactOnce = true; + const decorate = (store: NoticeStore): NoticeStore => Object.freeze({ + ...store, + get definition() { + return store.definition; + }, + compact: async (options) => { + if (failCompactOnce) { + failCompactOnce = false; + throw new Error('killed mid-compaction'); + } + return store.compact(options); + }, + } as NoticeStore); + const { driver, ledger, store } = await openLedger( + { maxJournalBytes: 1, terminalTtlMs: HOUR }, + createMemoryStateDriver({ lifetime: 'process' }), + decorate, + ); + const stale = await publish(ledger, 'stale', at(0)); + await ackFor(ledger, stale.notice.id, at(1), 'ack'); + const live = await publish(ledger, 'live', at(2 * HOUR)); + + // The prune commits, then compaction "crashes": the ledger state is already + // pruned, the journal is not yet folded. + await expect(ledger.retain({ at: at(3 * HOUR), idempotencyKey: 'retain:1' })).rejects.toThrow(/killed mid-compaction/u); + const midway = await ledger.read(); + expect(midway.notices.map((notice) => notice.id)).toEqual([live.notice.id]); + expect(midway.retention?.prunedTotal).toBe(1); + expect((await store.inspect()).records).toBeGreaterThan(1); + + // The next pass finds nothing left to prune and finishes the compaction. + const recovered = await ledger.retain({ at: at(3 * HOUR), idempotencyKey: 'retain:2' }); + expect(recovered.prunedIds).toEqual([]); + expect(recovered.compacted).toBe(true); + expect(recovered.journal.records).toBe(1); + expect(recovered.journal.lastCompaction?.revision).toBe(recovered.revision); + expect(recovered.revision).toBe(midway.revision + 1); + const settled = await ledger.read(); + expect(settled.revision).toBe(recovered.revision); + expect(settled.notices).toEqual(midway.notices); + expect(settled.retention).toEqual(midway.retention); + + // Compacting again is a no-op: idempotent, no new revision. + const again = await ledger.retain({ at: at(4 * HOUR), idempotencyKey: 'retain:3' }); + expect(again.compacted).toBe(false); + expect(again.revision).toBe(recovered.revision); + // The ledger keeps working past the baseline. + const later = await publish(ledger, 'later', at(5 * HOUR)); + expect(later.revision).toBe(recovered.revision + 1); + await driver.close(); + }); +}); + +describe('durable retention', () => { + it('keeps the SQLite head and journal replay in agreement across compaction and reopen', async () => { + const root = await mkdtemp(join(tmpdir(), 'agent-notices-retention-')); + const file = join(root, 'notices.sqlite'); + try { + const first = await openLedger({ maxJournalBytes: 1, terminalTtlMs: HOUR }, createSqliteStateDriver({ file })); + const stale = await publish(first.ledger, 'stale', at(0)); + await ackFor(first.ledger, stale.notice.id, at(1), 'ack'); + const live = await publish(first.ledger, 'live', at(2 * HOUR)); + const report = await first.ledger.retain({ at: at(3 * HOUR), idempotencyKey: 'retain:1' }); + expect(report.prunedIds).toEqual([stale.notice.id]); + expect(report.compacted).toBe(true); + expect(report.journal).toMatchObject({ baselineRevision: report.revision, records: 1 }); + const beforeClose = await first.ledger.read(); + await first.driver.close(); + + // Reopening runs the kernel's head-vs-journal-replay check, which must + // accept the compacted journal, and exact reads below the baseline are + // honestly unavailable. + const second = await openLedger(undefined, createSqliteStateDriver({ file })); + const reopened = await second.ledger.read(); + expect(reopened).toEqual(beforeClose); + expect(reopened.notices.map((notice) => notice.id)).toEqual([live.notice.id]); + expect(reopened.retention).toEqual({ lastPrunedAt: at(3 * HOUR), pruneRuns: 1, prunedTotal: 1 }); + const inspection = await second.ledger.inspect(); + expect(inspection.journal.lastCompaction?.revision).toBe(report.revision); + await expect((second.store as AgentStateStore).read({ revision: report.revision - 1 })) + .rejects.toMatchObject({ code: 'revision-unavailable' }); + // Delivery continues over the compacted store. + const deliveries = await run(second.ledger, { actorId: 'recipient', id: 'event-1', kind: 'event', startedAt: at(4 * HOUR) }, async () => + (await agent()).notices!.read()); + expect(deliveries.map((delivery) => delivery.notice.id)).toEqual([live.notice.id]); + await second.driver.close(); + } finally { + await rm(root, { force: true, recursive: true }); + } + }); +}); diff --git a/packages/rsc-runtime/tests/notices-sqlite-cross-process.test.ts b/packages/rsc-runtime/tests/notices-sqlite-cross-process.test.ts index 0b60f8be9..c50d2ccaa 100644 --- a/packages/rsc-runtime/tests/notices-sqlite-cross-process.test.ts +++ b/packages/rsc-runtime/tests/notices-sqlite-cross-process.test.ts @@ -18,7 +18,7 @@ const fixture = join(packageRoot, 'tests', 'fixtures', 'notices-sqlite-process.m const runProcess = async ( file: string, - mode: 'deliver' | 'publish', + mode: 'deliver' | 'publish' | 'retain', ): Promise => { const child = spawn(process.execPath, [fixture, file, mode], { stdio: ['ignore', 'pipe', 'pipe'], @@ -40,6 +40,22 @@ describe.sequential('notice ledger cross-process proof', () => { const published = await runProcess(file, 'publish') as { readonly id: string; readonly state: string }; expect(published.state).toBe('pending'); + // A third process compacts the journal between publish and delivery: + // the head is materialized as the baseline and the publish record is + // gone, yet the delivering process must see exactly the same ledger. + const retained = await runProcess(file, 'retain') as { + readonly compacted: boolean; + readonly journal: { readonly baselineRevision: number; readonly records: number }; + readonly prunedIds: readonly string[]; + readonly revision: number; + }; + expect(retained).toMatchObject({ + compacted: true, + journal: { baselineRevision: 2, records: 1 }, + prunedIds: [], + revision: 2, + }); + const observed = await runProcess(file, 'deliver') as readonly [{ readonly notice: { readonly id: string; readonly state: string }; readonly receipt: { readonly channel: string; readonly invocationId: string }; @@ -68,6 +84,16 @@ describe.sequential('notice ledger cross-process proof', () => { invocationId: 'delivery-process', })], })]); + // Opening in this process re-ran the head-vs-replay check over the + // compacted journal; the baseline is the retained first record and the + // admission landed on top of it. + expect(await store.inspect()).toMatchObject({ + baselineRevision: 2, + headRevision: 3, + lastCompaction: { revision: 2 }, + records: 2, + }); + await expect(store.read({ revision: 1 })).rejects.toMatchObject({ code: 'revision-unavailable' }); await driver.close(); } finally { await rm(root, { force: true, recursive: true }); diff --git a/packages/workbench/src/routes/routes-page.tsx b/packages/workbench/src/routes/routes-page.tsx index b76eb52dd..70e16a795 100644 --- a/packages/workbench/src/routes/routes-page.tsx +++ b/packages/workbench/src/routes/routes-page.tsx @@ -73,6 +73,13 @@ const EmptyServerSurface = ({ server }: { readonly server: RouteCatalogServer })

{emptyServerSummary(server)}

; +/** Whole units only, so `7d` reads as the config author wrote it; odd values fall back to milliseconds. */ +const formatDuration = (milliseconds: number): string => { + const units: readonly [string, number][] = [['d', 86_400_000], ['h', 3_600_000], ['m', 60_000], ['s', 1000]]; + const unit = units.find(([, size]) => milliseconds % size === 0); + return unit === undefined ? `${milliseconds}ms` : `${milliseconds / unit[1]}${unit[0]} (${milliseconds}ms)`; +}; + const StatePanel = ({ state }: { readonly state?: RouteManifestState }) =>
Durable location

{state.durableLocation}

} + {state.noticeRetention === undefined ? undefined :
+

Notice retention

+

{state.noticeRetention.source}

+
+
terminalTtl
{formatDuration(state.noticeRetention.resolved.terminalTtlMs)}
+
maxTerminal
{state.noticeRetention.resolved.maxTerminal}
+
maxJournalBytes
{state.noticeRetention.resolved.maxJournalBytes}
+
+
} {state.notices.map((notice) =>

{notice}

)} }
; diff --git a/packages/workbench/tests/examples-real.e2e.test.ts b/packages/workbench/tests/examples-real.e2e.test.ts index 89b6ae355..35cf33ae6 100644 --- a/packages/workbench/tests/examples-real.e2e.test.ts +++ b/packages/workbench/tests/examples-real.e2e.test.ts @@ -595,6 +595,12 @@ e2e('renders the flagship compiled route catalog by server and kind in real Chro await expect(state).toContainText('workspace-durable', { timeout: browserTimeout }); await expect(state).toContainText('sqlite', { timeout: browserTimeout }); await expect(state).toContainText('src/state.ts', { timeout: browserTimeout }); + // The co-mounted notice ledger's retention policy (#99 item 7): the + // curator declares none, so the runtime defaults are shown as such. + await expect(state).toContainText('Notice retention', { timeout: browserTimeout }); + await expect(state.locator('.route-state-retention')).toContainText('defaults'); + await expect(state.locator('.route-state-retention')).toContainText('7d (604800000ms)'); + await expect(state.locator('.route-state-retention')).toContainText('500'); await expect(state.locator('button, input, select, textarea')).toHaveCount(0); // One generated server owns every MCP kind the curator declares, so each diff --git a/packages/workbench/tests/routes-page.test.ts b/packages/workbench/tests/routes-page.test.ts index a8e4ceed1..33e95ae6d 100644 --- a/packages/workbench/tests/routes-page.test.ts +++ b/packages/workbench/tests/routes-page.test.ts @@ -80,6 +80,10 @@ const manifest: RouteManifest = { durableLocation: '$AGENT_BUNDLE_PLUGIN_ROOT/state', id: 'library/catalog', lifetime: 'workspace-durable', + noticeRetention: { + resolved: { maxJournalBytes: 16_777_216, maxTerminal: 500, terminalTtlMs: 172_800_000 }, + source: 'declared', + }, notices: ['The notice ledger is co-mounted at the same lifetime.'], source: 'src/state.ts', }, @@ -116,9 +120,24 @@ it('renders the declared state catalog as read-only facts', () => { expect(statePanel).toContain('$AGENT_BUNDLE_PLUGIN_ROOT/state'); expect(statePanel).toContain('notice ledger is co-mounted'); expect(statePanel).toContain('src/state.ts'); + // The notice retention policy is static configuration, shown in the units + // the config author used and in the runtime's milliseconds. + expect(statePanel).toContain('Notice retention'); + expect(statePanel).toContain('terminalTtl'); + expect(statePanel).toContain('2d (172800000ms)'); + expect(statePanel).toContain('maxTerminal'); + expect(statePanel).toContain('16777216'); expect(statePanel).not.toMatch(/<(?:button|input|select|textarea)\b/u); }); +it('omits the notice retention block for manifests that predate it', () => { + const { noticeRetention: _retention, ...legacyState } = manifest.state!; + const markup = render(routeCatalogFor({ ...manifest, state: legacyState })); + const statePanel = markup.match(/]*aria-label="State"[^>]*>(.*?)<\/section>/u)?.[1] ?? ''; + expect(statePanel).toContain('library/catalog'); + expect(statePanel).not.toContain('Notice retention'); +}); + it('renders honest state absence without an alert', () => { const { state: _state, ...statelessManifest } = manifest; const markup = render(routeCatalogFor(statelessManifest)); diff --git a/website/docs/en/reference/configuration.mdx b/website/docs/en/reference/configuration.mdx index ed6517e13..5fca1d3a4 100644 --- a/website/docs/en/reference/configuration.mdx +++ b/website/docs/en/reference/configuration.mdx @@ -34,6 +34,7 @@ export default defineConfig({ | `runtime` | `{ node }` | Node 22.12. | | `payload` | `Record` | None. | | `state` | `false` | The `src/state.ts` convention. | +| `notices` | `{ retention?: { terminalTtl?, maxTerminal?, maxJournalBytes? } }` | Runtime defaults (`7d`, `500`, `16777216`). | | `marketplace` | `boolean` | Adapter-selected. | | `dev` | `{ agentApi?, contracts?, runtime? }` | None. | | `evals` | `{ include?, runsDir?, semanticGrader? }` | See below. | @@ -59,6 +60,7 @@ by TypeDoc on every documentation build, so they cannot drift from the published | `payload` | [`AgentBundlePayloadConfig`](../api/types/index.AgentBundlePayloadConfig.md) · [`AgentBundlePayloadEntry`](../api/interfaces/index.AgentBundlePayloadEntry.md) · [`AgentBundlePrebuiltEntry`](../api/interfaces/index.AgentBundlePrebuiltEntry.md) | | `output` | [`AgentBundleOutputConfig`](../api/interfaces/config.AgentBundleOutputConfig.md) | | `runtime` | [`AgentBundleRuntimeConfig`](../api/interfaces/index.AgentBundleRuntimeConfig.md) | +| `notices` | [`AgentBundleNoticesConfig`](../api/interfaces/index.AgentBundleNoticesConfig.md) · [`AgentBundleNoticeRetentionConfig`](../api/interfaces/index.AgentBundleNoticeRetentionConfig.md) | | `dev` | [`AgentBundleDevConfig`](../api/interfaces/index.AgentBundleDevConfig.md) · [`AgentBundleDevContractsConfig`](../api/interfaces/index.AgentBundleDevContractsConfig.md) · [`AgentBundleDevRuntimeConfig`](../api/interfaces/api.AgentBundleDevRuntimeConfig.md) | | host extensions | [`AgentBundleConfigExtensions`](../api/interfaces/index.AgentBundleConfigExtensions.md) · [`AgentBundlePortableConfig`](../api/interfaces/config.AgentBundlePortableConfig.md) | | validation | [`validateSource`](../api/functions/config.validateSource.md) · [`validateModel`](../api/functions/config.validateModel.md) · [`loadConfig`](../api/functions/config.loadConfig.md) | @@ -105,6 +107,27 @@ default floor for generated executables, never lower it, and the selected floor `runtime.node` in the artifact manifest. The floor itself is described in [Configuration model](../guide/authoring/index.mdx). +## notices + +`notices.retention` sets the retention policy of the notice ledger a stateful project co-mounts +beside `src/state.ts`. Terminal notices — `expired`, `unavailable`, `withdrawn`, `acknowledged`, +and `attempted` with an exhausted retry budget — are pruned from the ledger once they have been +settled for `terminalTtl`, or earliest-settled first once more than `maxTerminal` remain, and the +store's journal is compacted onto its head once it exceeds `maxJournalBytes`. Pruning runs only +on admitted events and explicit `retain()` calls; no timer is implied. + +| Field | Default | Rule | +| --- | --- | --- | +| `terminalTtl` | `'7d'` | Positive integer of milliseconds, or a duration `` such as `'7d'`, `'12h'`, `'30m'`. | +| `maxTerminal` | `500` | Positive integer. | +| `maxJournalBytes` | `16777216` | Positive integer of bytes. | + +Anything else — an unknown key, a non-positive or fractional value, a duration the grammar does +not spell, or a policy declared by a project without a state module — is `AB4829`. `inspect +--state` and the Workbench State panel show the resolved policy and whether it was declared or +defaulted; live counts and the last compaction belong to each installed store +(`AgentNoticeLedger.inspect()`). + ## payload Keys are artifact-root destination directories — one safe path segment outside the diff --git a/website/docs/zh/reference/configuration.mdx b/website/docs/zh/reference/configuration.mdx index f9ab8dc87..15c936455 100644 --- a/website/docs/zh/reference/configuration.mdx +++ b/website/docs/zh/reference/configuration.mdx @@ -34,6 +34,7 @@ export default defineConfig({ | `runtime` | `{ node }` | Node 22.12。 | | `payload` | `Record` | 无。 | | `state` | `false` | `src/state.ts` 约定。 | +| `notices` | `{ retention?: { terminalTtl?, maxTerminal?, maxJournalBytes? } }` | 运行时默认值(`7d`、`500`、`16777216`)。 | | `marketplace` | `boolean` | 由适配器选择。 | | `dev` | `{ agentApi?, contracts?, runtime? }` | 无。 | | `evals` | `{ include?, runsDir?, semanticGrader? }` | 见下文。 | @@ -57,6 +58,7 @@ TypeDoc 从包源码生成,因此不会与已发布的类型产生偏差。 | `payload` | [`AgentBundlePayloadConfig`](../api/types/index.AgentBundlePayloadConfig.md) · [`AgentBundlePayloadEntry`](../api/interfaces/index.AgentBundlePayloadEntry.md) · [`AgentBundlePrebuiltEntry`](../api/interfaces/index.AgentBundlePrebuiltEntry.md) | | `output` | [`AgentBundleOutputConfig`](../api/interfaces/config.AgentBundleOutputConfig.md) | | `runtime` | [`AgentBundleRuntimeConfig`](../api/interfaces/index.AgentBundleRuntimeConfig.md) | +| `notices` | [`AgentBundleNoticesConfig`](../api/interfaces/index.AgentBundleNoticesConfig.md) · [`AgentBundleNoticeRetentionConfig`](../api/interfaces/index.AgentBundleNoticeRetentionConfig.md) | | `dev` | [`AgentBundleDevConfig`](../api/interfaces/index.AgentBundleDevConfig.md) · [`AgentBundleDevContractsConfig`](../api/interfaces/index.AgentBundleDevContractsConfig.md) · [`AgentBundleDevRuntimeConfig`](../api/interfaces/api.AgentBundleDevRuntimeConfig.md) | | 宿主扩展 | [`AgentBundleConfigExtensions`](../api/interfaces/index.AgentBundleConfigExtensions.md) · [`AgentBundlePortableConfig`](../api/interfaces/config.AgentBundlePortableConfig.md) | | 校验 | [`validateSource`](../api/functions/config.validateSource.md) · [`validateModel`](../api/functions/config.validateModel.md) · [`loadConfig`](../api/functions/config.loadConfig.md) | @@ -97,6 +99,24 @@ TypeDoc 从包源码生成,因此不会与已发布的类型产生偏差。 绝不能降低,且所选下限会作为 `runtime.node` 记录在产物清单中。这个下限本身在 [配置模型](../guide/authoring/index.mdx)中介绍。 +## notices + +`notices.retention` 设置有状态项目在 `src/state.ts` 旁共同挂载的通知账本的保留策略。终态通知—— +`expired`、`unavailable`、`withdrawn`、`acknowledged`,以及重试预算已耗尽的 `attempted`——在结算满 +`terminalTtl` 后会从账本中清理;当终态通知超过 `maxTerminal` 条时,最早结算的会先被清理;存储的日志 +超过 `maxJournalBytes` 时会被压实到当前头部。清理只在被接纳的事件和显式的 `retain()` 调用时运行; +不隐含任何定时器。 + +| 字段 | 默认值 | 规则 | +| --- | --- | --- | +| `terminalTtl` | `'7d'` | 正整数毫秒,或形如 `<整数>` 的时长,例如 `'7d'`、`'12h'`、`'30m'`。 | +| `maxTerminal` | `500` | 正整数。 | +| `maxJournalBytes` | `16777216` | 正整数字节数。 | + +其他任何情况——未知键、非正数或小数、语法之外的时长写法,或在没有状态模块的项目中声明策略——都是 +`AB4829`。`inspect --state` 与 Workbench 的 State 面板会显示解析后的策略以及它是声明的还是默认的; +实时计数与最近一次压实属于每个已安装的存储(`AgentNoticeLedger.inspect()`)。 + ## payload 键是产物根目录下的目标目录——一个位于编译器自有命名空间之外的安全单路径段——值是已经构建好的源目录。 diff --git a/website/plugins/generated-reference.ts b/website/plugins/generated-reference.ts index 22a816fd4..dd1a1ec38 100644 --- a/website/plugins/generated-reference.ts +++ b/website/plugins/generated-reference.ts @@ -191,6 +191,10 @@ const messages = { 'A notice is an entry in the append-only notice ledger co-mounted with project state (the reserved store id `@agent-bundle/runtime/agent-notice-ledger/v1`). It targets a recipient and moves only through evidenced states — `pending`, `attempted`, `acknowledged`, `expired`, `unavailable`, `withdrawn`. Delivery is attempted through the channels below, and a generated MCP server wires each cross-request route only where its host advertises it: the recipient-scoped inbox resource `agent-bundle://notices/inbox` is registered for stateful projects on hosts advertising `mcp-inbox` (every built-in host), and `resources/subscribe` plus one `notifications/resources/updated` per newly eligible pending notice is offered only where the host additionally advertises `mcp-resource-updated` and the state lifetime is workspace-durable — recorded on the ledger as an availability receipt, never a delivery claim. No host delivery is claimed without a supported channel.', noticeChannels: 'Delivery channels', unavailableChannels: 'Why a channel is unavailable', + sensitivityCeilings: 'Sensitivity ceilings', + sensitivityIntro: + 'Every notice carries an author-declared `sensitivity` — `public`, `internal` (the default), or `secret`. A supported channel names the most sensitive class it carries in full, with the dated evidence for that ceiling; a channel without a named ceiling admits `internal`. A notice above a channel\'s ceiling is withheld from that channel and the refusal is recorded on the notice; `internal` content is passed through the secret-pattern redaction before it leaves the store, `public` content travels as authored, and `secret` content travels as authored only where a channel admits it.', + sensitivityEvidence: 'Ceiling evidence', diagnosticsTitle: 'Diagnostics reference', diagnosticsDescription: 'Every agent-bundle diagnostic code family, severity, trigger, and recovery hint, copied at build time from the repository diagnostics contract.', @@ -264,6 +268,10 @@ const messages = { '通知是与项目状态共同挂载的只追加通知账本中的一条记录(保留的存储 id 为 `@agent-bundle/runtime/agent-notice-ledger/v1`)。它面向一个接收者,并且只会经历有证据的状态——`pending`、`attempted`、`acknowledged`、`expired`、`unavailable`、`withdrawn`。投递通过下列通道尝试,生成的 MCP 服务器只在宿主宣告了某条跨请求路由时才接线:按接收者限定的收件箱资源 `agent-bundle://notices/inbox` 会为宣告 `mcp-inbox` 的宿主(所有内置宿主)上的有状态项目注册;只有当宿主还宣告了 `mcp-resource-updated` 且 state 生命周期为工作区持久时,才提供 `resources/subscribe` 以及每条新近可用的待处理通知一次 `notifications/resources/updated`——它以可用性回执记录在账本上,绝不是投递声明。没有受支持的通道时,绝不声称已投递到宿主。', noticeChannels: '投递通道', unavailableChannels: '通道不可用的原因', + sensitivityCeilings: '敏感度上限', + sensitivityIntro: + '每条通知都带有作者声明的 `sensitivity`——`public`、`internal`(默认)或 `secret`。受支持的通道会声明它能完整承载的最高敏感类别,并附上该上限的带日期证据;未声明上限的通道接受 `internal`。高于通道上限的通知会被该通道拒绝,且拒绝会记录在通知上;`internal` 内容在离开存储前会经过密钥模式脱敏,`public` 内容按作者原文传递,`secret` 内容仅在通道允许时按原文传递。', + sensitivityEvidence: '上限证据', diagnosticsTitle: '诊断参考', diagnosticsDescription: 'agent-bundle 的全部诊断代码族、严重级别、触发条件与恢复提示,在构建时从仓库诊断契约复制而来。', @@ -612,6 +620,49 @@ function renderNotices(hosts: readonly HostCapabilityTable[], m: Messages): stri ), ); + sections.push(`## ${m.sensitivityCeilings}\n`); + sections.push(m.sensitivityIntro); + sections.push( + table( + [m.headers.channel, ...hosts.map(hostHeader)], + channels.map(channel => [ + code(channel), + ...hosts.map(host => { + const row = asObject(asObject(host.data.noticeDelivery)[channel]); + if (row.state !== 'supported') return '—'; + return code(typeof row.sensitivity === 'string' ? row.sensitivity : 'internal'); + }), + ]), + ), + ); + const ceilingEvidence = new Map(); + for (const host of hosts) { + for (const [channel, value] of Object.entries(asObject(host.data.noticeDelivery))) { + const row = asObject(value); + if (row.state !== 'supported' || typeof row.sensitivityEvidence !== 'string') continue; + const key = `${channel}\u0000${row.sensitivityEvidence}`; + const existing = ceilingEvidence.get(key); + if (existing !== undefined) { + existing.hosts.push(host.host); + } else { + ceilingEvidence.set(key, { channel, hosts: [host.host] }); + } + } + } + sections.push(`### ${m.sensitivityEvidence}\n`); + sections.push( + table( + [m.headers.channel, m.headers.hosts, m.headers.reason], + [...ceilingEvidence.entries()] + .sort(([left], [right]) => left.localeCompare(right)) + .map(([key, entry]) => [ + code(entry.channel), + entry.hosts.map(code).join(', '), + escapeProse(key.split('\u0000')[1] ?? ''), + ]), + ), + ); + sections.push(`## ${m.unavailableChannels}\n`); const reasons = new Map(); for (const host of hosts) { From 4e978da1ecab8b819bfc5837f9be3a2d9c4deb1c Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Thu, 3 Sep 2026 19:12:28 +0000 Subject: [PATCH 02/12] test(workbench): bound the State-panel retention assertions by the browser timeout --- packages/workbench/tests/examples-real.e2e.test.ts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/packages/workbench/tests/examples-real.e2e.test.ts b/packages/workbench/tests/examples-real.e2e.test.ts index 35cf33ae6..ca948ee21 100644 --- a/packages/workbench/tests/examples-real.e2e.test.ts +++ b/packages/workbench/tests/examples-real.e2e.test.ts @@ -598,10 +598,10 @@ e2e('renders the flagship compiled route catalog by server and kind in real Chro // The co-mounted notice ledger's retention policy (#99 item 7): the // curator declares none, so the runtime defaults are shown as such. await expect(state).toContainText('Notice retention', { timeout: browserTimeout }); - await expect(state.locator('.route-state-retention')).toContainText('defaults'); - await expect(state.locator('.route-state-retention')).toContainText('7d (604800000ms)'); - await expect(state.locator('.route-state-retention')).toContainText('500'); - await expect(state.locator('button, input, select, textarea')).toHaveCount(0); + await expect(state.locator('.route-state-retention')).toContainText('defaults', { timeout: browserTimeout }); + await expect(state.locator('.route-state-retention')).toContainText('7d (604800000ms)', { timeout: browserTimeout }); + await expect(state.locator('.route-state-retention')).toContainText('500', { timeout: browserTimeout }); + await expect(state.locator('button, input, select, textarea')).toHaveCount(0, { timeout: browserTimeout }); // One generated server owns every MCP kind the curator declares, so each // kind must appear as its own server-scoped group rather than a flat list. From 76ca1e16bcc5c36bc84f98fc53e02ee07c2fe5f2 Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Thu, 3 Sep 2026 19:13:22 +0000 Subject: [PATCH 03/12] chore(changeset): reference #437 --- .changeset/99-notice-redaction-retention.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.changeset/99-notice-redaction-retention.md b/.changeset/99-notice-redaction-retention.md index 543f12cc3..163ea555d 100644 --- a/.changeset/99-notice-redaction-retention.md +++ b/.changeset/99-notice-redaction-retention.md @@ -3,4 +3,4 @@ "agent-bundle": minor --- -Close out #99 acceptance item 7 with a notice redaction contract and a retention policy. `notices.publish()` accepts `sensitivity: 'public' | 'internal' | 'secret'` (default `internal`); each host's `noticeDelivery` row may name a dated `sensitivity` ceiling, and the ledger, inbox resource (`agent-bundle://notices/inbox`, which now reports `sensitivity` and `disclosure`), event admission, and `resources/updated` signaller withhold a notice above the route's ceiling, recording the refusal as `withheld[route]` on the notice; `internal` content is secret-pattern redacted on every route (`redactSecretText`, `redactNoticeDocument`, `resolveNoticeDisclosure`, `AGENT_NOTICE_ROUTE_SHAPES` from `@agent-bundle/runtime/notices`). `notices.retention: { terminalTtl, maxTerminal, maxJournalBytes }` in `agent-bundle.config.ts` (validated as `AB4829`, shown by `inspect --state` and the Workbench State panel) prunes settled terminal notices on admitted events and compacts the ledger journal past its byte bound through the new `AgentNoticeLedger.retain()` / `inspect()` and the state kernel's `AgentStateStore.compact()` / `inspect()` (a `compact` journal record and `AgentStateChange` kind; a compacted SQLite store moves to kernel format 2). Built-in hosts admit `secret` on `current-response` / `next-event` and `internal` on `mcp-inbox` / `mcp-resource-updated` (adapter revisions bumped). Breaking: `AgentStateStore` implementations must add `compact()` and `inspect()`, `AgentNoticeLedger` gains `retain()` / `inspect()`, `AgentNoticeDelivery` gains `disclosure`, and `AgentStateChange` / `AgentStateJournalRecord` gain the `compact` kind. The aliased `mcp-server-runtime.d.ts` no longer imports from `@agent-bundle/runtime/notices` (`GeneratedNoticeDeliveryBinding` is spelled locally). (#TBD) +Close out #99 acceptance item 7 with a notice redaction contract and a retention policy. `notices.publish()` accepts `sensitivity: 'public' | 'internal' | 'secret'` (default `internal`); each host's `noticeDelivery` row may name a dated `sensitivity` ceiling, and the ledger, inbox resource (`agent-bundle://notices/inbox`, which now reports `sensitivity` and `disclosure`), event admission, and `resources/updated` signaller withhold a notice above the route's ceiling, recording the refusal as `withheld[route]` on the notice; `internal` content is secret-pattern redacted on every route (`redactSecretText`, `redactNoticeDocument`, `resolveNoticeDisclosure`, `AGENT_NOTICE_ROUTE_SHAPES` from `@agent-bundle/runtime/notices`). `notices.retention: { terminalTtl, maxTerminal, maxJournalBytes }` in `agent-bundle.config.ts` (validated as `AB4829`, shown by `inspect --state` and the Workbench State panel) prunes settled terminal notices on admitted events and compacts the ledger journal past its byte bound through the new `AgentNoticeLedger.retain()` / `inspect()` and the state kernel's `AgentStateStore.compact()` / `inspect()` (a `compact` journal record and `AgentStateChange` kind; a compacted SQLite store moves to kernel format 2). Built-in hosts admit `secret` on `current-response` / `next-event` and `internal` on `mcp-inbox` / `mcp-resource-updated` (adapter revisions bumped). Breaking: `AgentStateStore` implementations must add `compact()` and `inspect()`, `AgentNoticeLedger` gains `retain()` / `inspect()`, `AgentNoticeDelivery` gains `disclosure`, and `AgentStateChange` / `AgentStateJournalRecord` gain the `compact` kind. The aliased `mcp-server-runtime.d.ts` no longer imports from `@agent-bundle/runtime/notices` (`GeneratedNoticeDeliveryBinding` is spelled locally). (#437) From d2405dc37cc70f1478932c71ffc532c31b6618ff Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Thu, 3 Sep 2026 19:18:11 +0000 Subject: [PATCH 04/12] fix(workbench): admit noticeRetention in the strict route-manifest wire schema The dev server's manifest now carries the resolved notice retention policy; the browser client's strictObject rejected the unknown key and rendered the whole Routes catalog as unavailable (caught by examples-real.e2e). --- .../workbench/src/routes/route-manifest-client.ts | 11 +++++++++++ .../workbench/tests/route-manifest-client.test.ts | 7 +++++++ 2 files changed, 18 insertions(+) diff --git a/packages/workbench/src/routes/route-manifest-client.ts b/packages/workbench/src/routes/route-manifest-client.ts index acc48f696..5b51ce795 100644 --- a/packages/workbench/src/routes/route-manifest-client.ts +++ b/packages/workbench/src/routes/route-manifest-client.ts @@ -150,6 +150,15 @@ const stateBudgetsSchema = z.strictObject({ maxStateBytes: z.number().finite(), }); +const noticeRetentionSchema = z.strictObject({ + resolved: z.strictObject({ + maxJournalBytes: z.number().finite(), + maxTerminal: z.number().finite(), + terminalTtlMs: z.number().finite(), + }), + source: z.enum(['declared', 'defaults']), +}); + const stateSchema: z.ZodType = z.strictObject({ budgets: z.union([ z.strictObject({ @@ -164,6 +173,8 @@ const stateSchema: z.ZodType = z.strictObject({ durableLocation: z.string().optional(), id: z.string(), lifetime: z.enum(['process', 'request', 'workspace-durable']), + // Optional: a dev server predating the retention projection omits it. + noticeRetention: noticeRetentionSchema.optional(), notices: z.array(z.string()), source: z.string(), }); diff --git a/packages/workbench/tests/route-manifest-client.test.ts b/packages/workbench/tests/route-manifest-client.test.ts index 690a77189..5aacd1bb4 100644 --- a/packages/workbench/tests/route-manifest-client.test.ts +++ b/packages/workbench/tests/route-manifest-client.test.ts @@ -88,6 +88,13 @@ const manifest = { durableLocation: '$AGENT_BUNDLE_PLUGIN_ROOT/state (falls back to the artifact root or ./.agent-bundle/state for CLI bins)', id: 'library/catalog', lifetime: 'workspace-durable', + // The dev server's manifest carries the resolved notice retention policy + // (#99 item 7); the strict wire schema must admit it or the whole catalog + // reads as unavailable. + noticeRetention: { + resolved: { maxJournalBytes: 16_777_216, maxTerminal: 500, terminalTtlMs: 604_800_000 }, + source: 'defaults', + }, notices: ['Generated runtimes co-mount the notice ledger store at the same lifetime.'], source: 'src/state.ts', }, From 6cd5a1410cd7b6c37bb3ad075f4e0d72f4ab0c0d Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Thu, 3 Sep 2026 19:26:15 +0000 Subject: [PATCH 05/12] fix(notices): disclose acknowledged content per route and record signaller withholdings - acknowledge() returns the notice as the acknowledging route may disclose it (next-event for events, the inbox ceiling otherwise); a withheld class comes back as a [REDACTED] placeholder, so an id from a redacted inbox unlocks nothing - the resources/updated signaller records its refusal durably through the new AgentNoticeLedger.recordWithholding() / 'withheld' event, once per subscription --- packages/rsc-runtime/README.md | 14 ++-- packages/rsc-runtime/src/mount/index.ts | 1 + packages/rsc-runtime/src/notices/contract.ts | 14 ++++ packages/rsc-runtime/src/notices/index.ts | 1 + packages/rsc-runtime/src/notices/ledger.ts | 72 ++++++++++++++++++- .../src/notices/resource-updated.ts | 64 ++++++++++++++--- packages/rsc-runtime/src/notices/state.ts | 16 +++++ .../tests/notices-redaction.test.ts | 50 ++++++++++++- 8 files changed, 215 insertions(+), 17 deletions(-) diff --git a/packages/rsc-runtime/README.md b/packages/rsc-runtime/README.md index 8df82082b..b6e5b96f6 100644 --- a/packages/rsc-runtime/README.md +++ b/packages/rsc-runtime/README.md @@ -358,10 +358,16 @@ advertisement: `inbox()` omits withheld notices and hands out disclosed content (the inbox resource projection reports `sensitivity` and `disclosure.redacted`), event admission neither authorizes nor attempts a withheld notice, `read()` deliveries carry `disclosure` and the disclosed -`content`, and the signaller never sends `resources/updated` for a notice the -inbox would withhold. Every refusal is durable evidence, not a state change: -the notice records `withheld[route] = { count, firstAt, lastAt, reason }` and -stays eligible for a route whose row admits it. The built-in hosts admit +`content`, `acknowledge()` returns the notice only as the acknowledging +request's route may disclose it (an admitted event is held to `next-event`, +every other invocation to the inbox ceiling; a withheld class comes back as +the `[REDACTED]` mark, so an id learned from a redacted inbox unlocks nothing), +and the signaller never sends `resources/updated` for a notice the inbox would +withhold, recording that refusal itself through +`recordWithholding()` once per subscription. Every refusal is durable evidence, +not a state change: the notice records +`withheld[route] = { count, firstAt, lastAt, reason }` and stays eligible for +a route whose row admits it. The built-in hosts admit `secret` on `current-response` and `next-event` (the hook response returns to the recipient's own host process) and `internal` on `mcp-inbox` and `mcp-resource-updated` (transport-derived identity the host does not diff --git a/packages/rsc-runtime/src/mount/index.ts b/packages/rsc-runtime/src/mount/index.ts index 7e3ec4fbb..24a62b37d 100644 --- a/packages/rsc-runtime/src/mount/index.ts +++ b/packages/rsc-runtime/src/mount/index.ts @@ -106,6 +106,7 @@ const failedLedger = (failure: AgentStateError): AgentNoticeLedger => { }), }), read: reject, + recordWithholding: reject, releaseAvailability: reject, reserveAvailability: reject, retain: reject, diff --git a/packages/rsc-runtime/src/notices/contract.ts b/packages/rsc-runtime/src/notices/contract.ts index 7f4084816..297b29d42 100644 --- a/packages/rsc-runtime/src/notices/contract.ts +++ b/packages/rsc-runtime/src/notices/contract.ts @@ -336,12 +336,26 @@ export interface AgentNoticeLedgerInspection { readonly revision: number; } +/** + * A route's refusal to carry notices, recorded by the surface that made the + * decision (the `resources/updated` signaller records its own; the inbox and + * event admission record theirs inside their own events). + */ +export interface AgentNoticeWithholdingOptions { + readonly at: string; + readonly idempotencyKey: string; + readonly route: AgentNoticeDeliveryRoute; + readonly withheld: readonly AgentNoticeWithheldEntry[]; +} + export interface AgentNoticeLedger { expire(options: AgentNoticeExpiryOptions): Promise; /** Retention facts for diagnostics; never notice content. */ inspect(): Promise; openRequest(request: AgentNoticeRequest): Promise; read(): Promise; + /** Records that a route withheld the listed notices; evidence only, no state moves. */ + recordWithholding(options: AgentNoticeWithholdingOptions): Promise; /** Applies the retention policy now: prunes eligible terminal notices, then compacts an oversized journal. */ retain(options: AgentNoticeRetainOptions): Promise; /** Releases a reservation whose resources/updated send failed; no budget was spent. */ diff --git a/packages/rsc-runtime/src/notices/index.ts b/packages/rsc-runtime/src/notices/index.ts index 24f82f057..a6f0ea9bc 100644 --- a/packages/rsc-runtime/src/notices/index.ts +++ b/packages/rsc-runtime/src/notices/index.ts @@ -49,6 +49,7 @@ export type { AgentNoticeWithdrawOptions, AgentNoticeWithheldEntry, AgentNoticeWithholding, + AgentNoticeWithholdingOptions, AgentNoticeWithholdingReason, AgentNoticeWithholdings, AgentRecipient, diff --git a/packages/rsc-runtime/src/notices/ledger.ts b/packages/rsc-runtime/src/notices/ledger.ts index e14c2d9ea..238d90538 100644 --- a/packages/rsc-runtime/src/notices/ledger.ts +++ b/packages/rsc-runtime/src/notices/ledger.ts @@ -39,10 +39,12 @@ import { type AgentNoticeState, type AgentNoticeWithdrawOptions, type AgentNoticeWithheldEntry, + type AgentNoticeWithholdingOptions, type AgentRecipient, } from './contract.js'; import { AGENT_NOTICE_DEFAULT_SENSITIVITY, + NOTICE_REDACTION_MARK, disclosedNoticeContent, isNoticeSensitivity, type AgentNoticeDisclosure, @@ -119,6 +121,47 @@ const disclosedNotice = (notice: AgentNotice, disclosure: Disclosed): AgentNotic return content === undefined || content === notice.content ? notice : Object.freeze({ ...notice, content }); }; +/** + * The route whose disclosure an acknowledging request is held to: an admitted + * event could have received the notice through `next-event`; every other + * invocation kind could only have read it through the inbox, whose ceiling is + * the conservative one for transport-derived identity. + */ +const acknowledgementRoute = (request: AgentNoticeRequest): AgentNoticeDeliveryRoute => + request.invocation.kind === 'event' ? 'next-event' : 'mcp-inbox'; + +/** + * A notice returned from `acknowledge()`: the state moved, but the content + * comes back only as the request's route may disclose it. A withheld class + * yields a placeholder document (the mark, same status and version) rather + * than the authored text an id learned from a redacted inbox would otherwise + * unlock. + */ +const acknowledgedNotice = ( + notice: AgentNotice, + route: AgentNoticeDeliveryRoute, + advertisement: AgentNoticeDeliveryAdvertisement | undefined, +): AgentNotice => { + const disclosure = resolveNoticeDisclosure(route, sensitivityOf(notice), advertisement); + switch (disclosure.kind) { + case 'disclosed': + return disclosedNotice(notice, disclosure); + case 'withheld': + return Object.freeze({ + ...notice, + content: Object.freeze({ + root: Object.freeze({ kind: 'text' as const, text: NOTICE_REDACTION_MARK }), + status: notice.content.status, + version: notice.content.version, + }), + }); + default: { + const exhaustive: never = disclosure; + return exhaustive; + } + } +}; + type NoticeStore = AgentStateStore; /** Revision races tolerated while committing a reserved receipt over the state it was judged against. */ @@ -643,7 +686,11 @@ export const createAgentNoticeLedger = ( `Notice ${noticeId} is not acknowledgeable from state ${acknowledged?.state ?? 'missing'}`, )); } - return acknowledged; + // The acknowledgement is a state transition, not a read surface: + // the content comes back exactly as the route that could have + // shown it to this request discloses it, so an id learned from a + // redacted inbox cannot fetch the authored text through here. + return acknowledgedNotice(acknowledged, acknowledgementRoute(request), advertisement); })); }, inbox() { @@ -679,6 +726,29 @@ export const createAgentNoticeLedger = ( return snapshotFrom(snapshot.revision, snapshot.state); }, + recordWithholding(withholdingOptions: AgentNoticeWithholdingOptions): Promise { + return runPromise(Effect.gen(function*() { + const at = yield* noticeEffect(() => timestamp(withholdingOptions.at, 'Notice withholding time')); + const idempotencyKey = yield* noticeEffect(() => + nonEmptyText(withholdingOptions.idempotencyKey, 'Notice withholding idempotency key')); + const entries = yield* noticeEffect(() => { + if (withholdingOptions.withheld.length === 0) { + throw new AgentNoticeError('invalid-input', 'Notice withholding requires at least one notice'); + } + return withholdingOptions.withheld.map((entry) => ({ + id: nonEmptyText(entry.id, 'Notice id'), + reason: entry.reason, + })); + }); + const committed = yield* storeEffect(() => store.dispatch( + 'withheld', + { at, entries, route: withholdingOptions.route }, + { idempotencyKey }, + )); + return snapshotFrom(committed.revision, committed.state); + })); + }, + retain(retention: AgentNoticeRetainOptions): Promise { return runPromise(Effect.gen(function*() { const at = yield* noticeEffect(() => timestamp(retention.at, 'Notice retention time')); diff --git a/packages/rsc-runtime/src/notices/resource-updated.ts b/packages/rsc-runtime/src/notices/resource-updated.ts index 484224d2e..8a9c55dd9 100644 --- a/packages/rsc-runtime/src/notices/resource-updated.ts +++ b/packages/rsc-runtime/src/notices/resource-updated.ts @@ -10,6 +10,7 @@ import { } from './contract.js'; import { AGENT_NOTICE_DEFAULT_SENSITIVITY } from './redaction.js'; import { resolveNoticeDisclosure, type AgentNoticeDeliveryAdvertisement } from './router.js'; +import type { AgentNoticeWithheldEntry, AgentNoticeWithholdingReason } from './contract.js'; import { recipientMatchesPrincipal } from './state.js'; /** Consecutive compare-and-swap losses tolerated before a reservation reports failure. */ @@ -132,6 +133,8 @@ interface InboxSubscription { readonly id: string; readonly principal: AgentNoticePrincipal; readonly signalled: Set; + /** Notices whose refusal this subscription already recorded, so a refusal is evidence once, not once per render. */ + readonly withheld: Set; } /** A send that succeeded on the wire whose availability receipt has not been committed yet. */ @@ -141,18 +144,28 @@ interface PendingReceipt { readonly reservationKey: string; } -/** The signal and the inbox it points at must both admit the notice's class. */ -const disclosable = (notice: AgentNotice, advertisement: AgentNoticeDeliveryAdvertisement | undefined): boolean => { +/** + * The signal and the inbox it points at must both admit the notice's class; + * a refetch of an inbox that would withhold the notice would announce only + * that something was withheld. Returns the refusal to record, or `undefined` + * when the notice may be signalled. + */ +const signalRefusal = ( + notice: AgentNotice, + advertisement: AgentNoticeDeliveryAdvertisement | undefined, +): AgentNoticeWithholdingReason | undefined => { const sensitivity = notice.sensitivity ?? AGENT_NOTICE_DEFAULT_SENSITIVITY; - return resolveNoticeDisclosure('mcp-resource-updated', sensitivity, advertisement).kind === 'disclosed' - && resolveNoticeDisclosure('mcp-inbox', sensitivity, advertisement).kind === 'disclosed'; + for (const route of ['mcp-resource-updated', 'mcp-inbox'] as const) { + const disclosure = resolveNoticeDisclosure(route, sensitivity, advertisement); + if (disclosure.kind === 'withheld') return disclosure.reason; + } + return undefined; }; const eligibleForSignal = ( notice: AgentNotice, principal: AgentNoticePrincipal, nowMs: number, - advertisement: AgentNoticeDeliveryAdvertisement | undefined, ): boolean => { switch (notice.state) { case 'pending': @@ -180,7 +193,7 @@ const eligibleForSignal = ( if (reservation !== undefined && Date.parse(reservation.at) + AGENT_NOTICE_AVAILABILITY_RESERVATION_TTL_MS > nowMs) { return false; } - return disclosable(notice, advertisement) && recipientMatchesPrincipal(notice.recipient, principal); + return recipientMatchesPrincipal(notice.recipient, principal); }; export const createNoticeInboxSignaller = ( @@ -266,10 +279,40 @@ export const createNoticeInboxSignaller = ( if (snapshot === CLOSED || pendingUnsubscribes > 0 || subscription !== current) return { kind: 'unsubscribed' }; const at = now().toISOString(); const nowMs = Date.parse(at); - const noticeIds = Object.freeze(snapshot.notices - .filter((notice) => !current.signalled.has(notice.id) && eligibleForSignal(notice, current.principal, nowMs, options.delivery)) - .map((notice) => notice.id) - .toSorted((left, right) => left.localeCompare(right))); + const candidates = snapshot.notices + .filter((notice) => !current.signalled.has(notice.id) && eligibleForSignal(notice, current.principal, nowMs)) + .toSorted((left, right) => left.id.localeCompare(right.id)); + // A notice the inbox would withhold is refused here too, and the + // refusal is durable evidence like the inbox's own: recorded once per + // subscription, before any send, so the ledger says why a matching + // subscriber was never signalled about it. + const refused: AgentNoticeWithheldEntry[] = []; + const noticeIds: string[] = []; + for (const notice of candidates) { + const reason = signalRefusal(notice, options.delivery); + if (reason === undefined) { + noticeIds.push(notice.id); + } else if (!current.withheld.has(notice.id)) { + refused.push(Object.freeze({ id: notice.id, reason })); + } + } + if (refused.length > 0) { + signalSequence += 1; + try { + const recorded = await untilClosed(ledger.recordWithholding({ + at, + idempotencyKey: `agent-notices:withheld:${current.id}:${String(signalSequence)}`, + route: 'mcp-resource-updated', + withheld: refused, + })); + if (recorded === CLOSED) return { kind: 'unsubscribed' }; + for (const entry of refused) current.withheld.add(entry.id); + // The recording moved the revision the eligibility was judged against. + continue; + } catch (error) { + return { error, kind: 'failed', stage: 'record' }; + } + } if (noticeIds.length === 0) return { kind: 'nothing-eligible', revision: snapshot.revision }; signalSequence += 1; const reservationKey = `${current.id}:${String(signalSequence)}`; @@ -583,6 +626,7 @@ export const createNoticeInboxSignaller = ( id: randomUUID(), principal, signalled: new Set(), + withheld: new Set(), }); }); }, diff --git a/packages/rsc-runtime/src/notices/state.ts b/packages/rsc-runtime/src/notices/state.ts index 4cdfedbd2..c4a97c497 100644 --- a/packages/rsc-runtime/src/notices/state.ts +++ b/packages/rsc-runtime/src/notices/state.ts @@ -218,6 +218,12 @@ export const agentNoticeEventSchemas = { at: z.string().min(1), id: z.string().min(1), }).strict(), + /** A route refused the listed notices; recorded by the surface that decided (the signaller). */ + withheld: z.object({ + at: z.string().min(1), + entries: z.array(withheldEntrySchema).min(1), + route: routeSchema, + }).strict(), } as const satisfies AgentStateEventSchemas; const sameRecipient = (left: AgentRecipient, right: AgentRecipient): boolean => @@ -662,6 +668,16 @@ export const agentNoticeStateDefinition = ( : notice), }; } + case 'withheld': { + const refused = withheldById(event.payload.entries); + return { + ...state, + notices: state.notices.map((notice) => { + const reason = refused.get(notice.id); + return reason === undefined ? notice : withholding(notice, event.payload.route, event.payload.at, reason); + }), + }; + } case 'pruned': { // Only settled notices leave; an id that is live again (or unknown) // is skipped, so a stale prune decision can never drop a pending diff --git a/packages/rsc-runtime/tests/notices-redaction.test.ts b/packages/rsc-runtime/tests/notices-redaction.test.ts index 21f39530b..0caab3b9f 100644 --- a/packages/rsc-runtime/tests/notices-redaction.test.ts +++ b/packages/rsc-runtime/tests/notices-redaction.test.ts @@ -337,9 +337,39 @@ describe('ledger disclosure through the inbox and next-event routes', () => { await driver.close(); }); - it('never signals resources/updated for a notice the inbox would withhold', async () => { + it('returns acknowledged notices only as the acknowledging route may disclose them', async () => { + const { driver, ledger } = await openLedger(advertisement({ 'mcp-inbox': 'internal', 'next-event': 'secret' })); + const internal = await publish(ledger, { id: 'internal' }); + const secret = await publish(ledger, { id: 'secret', sensitivity: 'secret' }); + const open = await publish(ledger, { id: 'public', sensitivity: 'public' }); + const ack = (id: string, invocation: string, kind: 'event' | 'tool') => run(ledger, { + actorId: 'recipient', + id: invocation, + kind, + startedAt: '2026-09-03T10:30:00.000Z', + }, async () => (await agent()).notices!.acknowledge(id)); + // A tool invocation is held to the inbox ceiling: internal comes back + // redacted, a secret's id learned from the inbox unlocks only the mark. + const internalAck = await ack(internal.notice.id, 'ack-1', 'tool'); + expect(internalAck.state).toBe('acknowledged'); + expect((internalAck.content.root as { text: string }).text).toBe(redactSecretText(SECRET_TEXT)); + const secretAck = await ack(secret.notice.id, 'ack-2', 'tool'); + expect(secretAck.state).toBe('acknowledged'); + expect(secretAck.content).toEqual({ root: { kind: 'text', text: NOTICE_REDACTION_MARK }, status: 'success', version: 1 }); + const publicAck = await ack(open.notice.id, 'ack-3', 'tool'); + expect((publicAck.content.root as { text: string }).text).toBe(SECRET_TEXT); + // The store still holds every notice as authored; only the handle's view was disclosed. + expect((await ledger.read()).notices.every((notice) => (notice.content.root as { text: string }).text === SECRET_TEXT)).toBe(true); + // An admitted event is held to the next-event ceiling, which admits secret here. + const another = await publish(ledger, { id: 'secret-2', sensitivity: 'secret' }); + const eventAck = await ack(another.notice.id, 'ack-4', 'event'); + expect((eventAck.content.root as { text: string }).text).toBe(SECRET_TEXT); + await driver.close(); + }); + + it('never signals resources/updated for a notice the inbox would withhold, and records that refusal once', async () => { const { driver, ledger } = await openLedger(advertisement({ 'mcp-inbox': 'internal' })); - await publish(ledger, { id: 'secret', sensitivity: 'secret' }); + const hidden = await publish(ledger, { id: 'secret', sensitivity: 'secret' }); const visible = await publish(ledger, { id: 'internal' }); const principal: AgentNoticePrincipal = { actor: actor('recipient'), @@ -364,6 +394,22 @@ describe('ledger disclosure through the inbox and next-event routes', () => { }); expect(again).toEqual({ kind: 'idle', reason: 'nothing-eligible', revision: expect.any(Number) }); expect(sends).toHaveLength(1); + // The refusal is durable evidence on the withheld notice — recorded by + // the signaller itself, once for the subscription, never per render. + const refused = (await ledger.read()).notices.find((notice) => notice.id === hidden.notice.id); + expect(refused).toMatchObject({ + state: 'pending', + withheld: { + 'mcp-resource-updated': { + count: 1, + firstAt: '2026-09-03T10:20:00.000Z', + lastAt: '2026-09-03T10:20:00.000Z', + reason: 'sensitivity-exceeds-route', + }, + }, + }); + expect(refused?.availability).toBeUndefined(); + expect((await ledger.read()).notices.find((notice) => notice.id === visible.notice.id)?.withheld).toBeUndefined(); await signaller.close(); await driver.close(); }); From 9c7726abe952d5216f2474d178f6015ada1db58e Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Thu, 3 Sep 2026 19:53:31 +0000 Subject: [PATCH 06/12] fix(notices): disclose replayed deliveries against the current ceiling; renumber the retention diagnostic to AB4833 - a replayed admission (attempt receipt already present) resolves the next-event ceiling from the ledger's current advertisement instead of the notice's class, so a store reopened under a lowered ceiling never replays the authored secret - AB4829 was taken by #430 on main; the notices.retention diagnostic is AB4833 - inspect-state: the retention CLI assertions get their own bounded test --- .changeset/99-notice-redaction-retention.md | 2 +- docs/entry-conventions.md | 2 +- docs/framework-mode.md | 2 +- .../src/config/notice-retention.ts | 4 +- packages/agent-bundle/src/config/validate.ts | 2 +- .../agent-bundle/tests/inspect-state.test.ts | 78 ++++++++++--------- .../tests/notice-retention-config.test.ts | 10 +-- packages/rsc-runtime/README.md | 2 +- packages/rsc-runtime/src/notices/ledger.ts | 63 +++++++++------ .../tests/notices-redaction.test.ts | 28 +++++++ website/docs/en/reference/configuration.mdx | 2 +- website/docs/zh/reference/configuration.mdx | 2 +- 12 files changed, 124 insertions(+), 73 deletions(-) diff --git a/.changeset/99-notice-redaction-retention.md b/.changeset/99-notice-redaction-retention.md index 163ea555d..633c7b502 100644 --- a/.changeset/99-notice-redaction-retention.md +++ b/.changeset/99-notice-redaction-retention.md @@ -3,4 +3,4 @@ "agent-bundle": minor --- -Close out #99 acceptance item 7 with a notice redaction contract and a retention policy. `notices.publish()` accepts `sensitivity: 'public' | 'internal' | 'secret'` (default `internal`); each host's `noticeDelivery` row may name a dated `sensitivity` ceiling, and the ledger, inbox resource (`agent-bundle://notices/inbox`, which now reports `sensitivity` and `disclosure`), event admission, and `resources/updated` signaller withhold a notice above the route's ceiling, recording the refusal as `withheld[route]` on the notice; `internal` content is secret-pattern redacted on every route (`redactSecretText`, `redactNoticeDocument`, `resolveNoticeDisclosure`, `AGENT_NOTICE_ROUTE_SHAPES` from `@agent-bundle/runtime/notices`). `notices.retention: { terminalTtl, maxTerminal, maxJournalBytes }` in `agent-bundle.config.ts` (validated as `AB4829`, shown by `inspect --state` and the Workbench State panel) prunes settled terminal notices on admitted events and compacts the ledger journal past its byte bound through the new `AgentNoticeLedger.retain()` / `inspect()` and the state kernel's `AgentStateStore.compact()` / `inspect()` (a `compact` journal record and `AgentStateChange` kind; a compacted SQLite store moves to kernel format 2). Built-in hosts admit `secret` on `current-response` / `next-event` and `internal` on `mcp-inbox` / `mcp-resource-updated` (adapter revisions bumped). Breaking: `AgentStateStore` implementations must add `compact()` and `inspect()`, `AgentNoticeLedger` gains `retain()` / `inspect()`, `AgentNoticeDelivery` gains `disclosure`, and `AgentStateChange` / `AgentStateJournalRecord` gain the `compact` kind. The aliased `mcp-server-runtime.d.ts` no longer imports from `@agent-bundle/runtime/notices` (`GeneratedNoticeDeliveryBinding` is spelled locally). (#437) +Close out #99 acceptance item 7 with a notice redaction contract and a retention policy. `notices.publish()` accepts `sensitivity: 'public' | 'internal' | 'secret'` (default `internal`); each host's `noticeDelivery` row may name a dated `sensitivity` ceiling, and the ledger, inbox resource (`agent-bundle://notices/inbox`, which now reports `sensitivity` and `disclosure`), event admission, and `resources/updated` signaller withhold a notice above the route's ceiling, recording the refusal as `withheld[route]` on the notice; `internal` content is secret-pattern redacted on every route (`redactSecretText`, `redactNoticeDocument`, `resolveNoticeDisclosure`, `AGENT_NOTICE_ROUTE_SHAPES` from `@agent-bundle/runtime/notices`). `notices.retention: { terminalTtl, maxTerminal, maxJournalBytes }` in `agent-bundle.config.ts` (validated as `AB4833`, shown by `inspect --state` and the Workbench State panel) prunes settled terminal notices on admitted events and compacts the ledger journal past its byte bound through the new `AgentNoticeLedger.retain()` / `inspect()` and the state kernel's `AgentStateStore.compact()` / `inspect()` (a `compact` journal record and `AgentStateChange` kind; a compacted SQLite store moves to kernel format 2). Built-in hosts admit `secret` on `current-response` / `next-event` and `internal` on `mcp-inbox` / `mcp-resource-updated` (adapter revisions bumped). Breaking: `AgentStateStore` implementations must add `compact()` and `inspect()`, `AgentNoticeLedger` gains `retain()` / `inspect()`, `AgentNoticeDelivery` gains `disclosure`, and `AgentStateChange` / `AgentStateJournalRecord` gain the `compact` kind. The aliased `mcp-server-runtime.d.ts` no longer imports from `@agent-bundle/runtime/notices` (`GeneratedNoticeDeliveryBinding` is spelled locally). (#437) diff --git a/docs/entry-conventions.md b/docs/entry-conventions.md index e2ba58bc0..8d9a631a7 100644 --- a/docs/entry-conventions.md +++ b/docs/entry-conventions.md @@ -203,7 +203,7 @@ admit `secret` on `current-response` and `next-event` and `internal` on evidence and the generated notice reference page renders it. `notices.retention` in `agent-bundle.config.ts` (`terminalTtl`, `maxTerminal`, -`maxJournalBytes`; `AB4829` when malformed or declared without `src/state.ts`) +`maxJournalBytes`; `AB4833` when malformed or declared without `src/state.ts`) resolves over the runtime defaults (`7d`, `500`, `16777216`) and is emitted as `noticeRetentionPolicy` into every generated module that mounts the ledger, so the MCP worker, the server process, the routed CLI bin, and rendered scripts diff --git a/docs/framework-mode.md b/docs/framework-mode.md index 9d387253b..3e261559c 100644 --- a/docs/framework-mode.md +++ b/docs/framework-mode.md @@ -327,7 +327,7 @@ exceeds `maxJournalBytes`. Every field is optional and defaults to the values shown; pruning runs only on admitted events and explicit `retain()` calls, so no timer is implied. A malformed policy — an unknown key, a non-positive or fractional value, a duration outside that grammar, or a policy declared by a -project without a state module — is `AB4829`. `inspect --state` and the +project without a state module — is `AB4833`. `inspect --state` and the Workbench State panel show the resolved policy and whether it was declared or defaulted. diff --git a/packages/agent-bundle/src/config/notice-retention.ts b/packages/agent-bundle/src/config/notice-retention.ts index 354b8e864..98fff9ff4 100644 --- a/packages/agent-bundle/src/config/notice-retention.ts +++ b/packages/agent-bundle/src/config/notice-retention.ts @@ -10,7 +10,7 @@ import type { /** * `notices.retention` (#99 acceptance item 7): the retention policy of the * notice ledger a stateful project co-mounts beside `src/state.ts`. Validated - * here as `AB4829`; the runtime re-validates the resolved policy when the + * here as `AB4833`; the runtime re-validates the resolved policy when the * generated runtime mounts it. */ @@ -57,7 +57,7 @@ const isPlainRecord = (value: unknown): value is Readonly ({ - code: 'AB4829', + code: 'AB4833', message, recovery: hasState ? 'Declare `notices.retention` as an object whose `terminalTtl` is a positive integer of milliseconds or a duration such as "7d", "12h", or "30m", and whose `maxTerminal` and `maxJournalBytes` are positive integers; omit a field to keep its default.' diff --git a/packages/agent-bundle/src/config/validate.ts b/packages/agent-bundle/src/config/validate.ts index 8e346a7bd..0c90748cb 100644 --- a/packages/agent-bundle/src/config/validate.ts +++ b/packages/agent-bundle/src/config/validate.ts @@ -2201,7 +2201,7 @@ export const validateSource = ( loaded.configPath, )); } - // `notices.retention` (AB4829) needs the co-mounted ledger a state module brings. + // `notices.retention` (AB4833) needs the co-mounted ledger a state module brings. diagnostics.push(...normalizeNoticeRetention( loaded.config, loaded.configPath, diff --git a/packages/agent-bundle/tests/inspect-state.test.ts b/packages/agent-bundle/tests/inspect-state.test.ts index 2b15271b3..8b4a16595 100644 --- a/packages/agent-bundle/tests/inspect-state.test.ts +++ b/packages/agent-bundle/tests/inspect-state.test.ts @@ -99,41 +99,6 @@ it('inspects volatile and workspace-durable state without inventing runtime path }); expect(JSON.parse(volatile.stdout).selected.state).not.toHaveProperty('durableLocation'); - // A declared `notices.retention` resolves over the defaults and is reported as declared. - await writeFile(join(root, 'agent-bundle.config.ts'), [ - 'export default {', - " notices: { retention: { maxTerminal: 12, terminalTtl: '2d' } },", - " plugin: { name: 'state-fixture', version: '1.0.0' },", - " targets: ['portable'],", - '};', - '', - ].join('\n')); - const retaining = await inspectCli(root, ['--state', '--json']); - expect(retaining).toMatchObject({ code: 0, stderr: '' }); - expect(JSON.parse(retaining.stdout).selected.state.noticeRetention).toEqual({ - resolved: { maxJournalBytes: 16_777_216, maxTerminal: 12, terminalTtlMs: 172_800_000 }, - source: 'declared', - }); - // A malformed policy is an AB4829 source error, never a silently defaulted one. - await writeFile(join(root, 'agent-bundle.config.ts'), [ - 'export default {', - " notices: { retention: { terminalTtl: 'soon' } },", - " plugin: { name: 'state-fixture', version: '1.0.0' },", - " targets: ['portable'],", - '};', - '', - ].join('\n')); - const malformed = await inspectCli(root, ['--state', '--json']); - expect(malformed.code).not.toBe(0); - expect(`${malformed.stdout}${malformed.stderr}`).toContain('AB4829'); - await writeFile(join(root, 'agent-bundle.config.ts'), [ - 'export default {', - " plugin: { name: 'state-fixture', version: '1.0.0' },", - " targets: ['portable'],", - '};', - '', - ].join('\n')); - await writeFile(stateSource, [ 'export default defineState({', " id: 'fixture/durable-state',", @@ -200,6 +165,49 @@ it('inspects volatile and workspace-durable state without inventing runtime path } }); +it('reports the declared notice retention policy and rejects a malformed one as AB4833', { timeout: 20_000 }, async () => { + const root = await createProject(); + try { + await writeFile(join(root, 'src', 'state.ts'), [ + 'export default defineState({', + " id: 'fixture/retained-state',", + " lifetime: 'workspace-durable',", + '});', + '', + ].join('\n')); + // A declared `notices.retention` resolves over the defaults and is reported as declared. + await writeFile(join(root, 'agent-bundle.config.ts'), [ + 'export default {', + " notices: { retention: { maxTerminal: 12, terminalTtl: '2d' } },", + " plugin: { name: 'state-fixture', version: '1.0.0' },", + " targets: ['portable'],", + '};', + '', + ].join('\n')); + const retaining = await inspectCli(root, ['--state', '--json']); + expect(retaining).toMatchObject({ code: 0, stderr: '' }); + expect(JSON.parse(retaining.stdout).selected.state.noticeRetention).toEqual({ + resolved: { maxJournalBytes: 16_777_216, maxTerminal: 12, terminalTtlMs: 172_800_000 }, + source: 'declared', + }); + + // A malformed policy is an AB4833 source error, never a silently defaulted one. + await writeFile(join(root, 'agent-bundle.config.ts'), [ + 'export default {', + " notices: { retention: { terminalTtl: 'soon' } },", + " plugin: { name: 'state-fixture', version: '1.0.0' },", + " targets: ['portable'],", + '};', + '', + ].join('\n')); + const malformed = await inspectCli(root, ['--state', '--json']); + expect(malformed.code).not.toBe(0); + expect(`${malformed.stdout}${malformed.stderr}`).toContain('AB4833'); + } finally { + await rm(root, { force: true, recursive: true }); + } +}); + it('reports stateless inspection and rejects competing state focuses', async () => { const root = await createProject(); try { diff --git a/packages/agent-bundle/tests/notice-retention-config.test.ts b/packages/agent-bundle/tests/notice-retention-config.test.ts index 230c3cb7d..85d236b6b 100644 --- a/packages/agent-bundle/tests/notice-retention-config.test.ts +++ b/packages/agent-bundle/tests/notice-retention-config.test.ts @@ -12,7 +12,7 @@ const config = (notices: unknown): AgentBundleConfig => ({ plugin: { name: 'fixture', version: '1.0.0' }, } as AgentBundleConfig); -describe('notices.retention config (AB4829)', () => { +describe('notices.retention config (AB4833)', () => { it('parses durations as positive integers of milliseconds or unit literals', () => { expect(parseNoticeRetentionDuration(1)).toBe(1); expect(parseNoticeRetentionDuration(86_400_000)).toBe(86_400_000); @@ -45,7 +45,7 @@ describe('notices.retention config (AB4829)', () => { expect(normalizeNoticeRetention(config({}), '/p/c.ts', false)).toEqual({ diagnostics: [] }); }); - it('reports malformed shapes, unknown keys, and non-positive values as AB4829 errors', () => { + it('reports malformed shapes, unknown keys, and non-positive values as AB4833 errors', () => { const cases: readonly [unknown, RegExp][] = [ ['nope', /`notices` configuration must be an object/u], [{ retentoin: {} }, /unknown key "retentoin"/u], @@ -60,7 +60,7 @@ describe('notices.retention config (AB4829)', () => { expect(result.retention).toBeUndefined(); expect(result.diagnostics).toHaveLength(1); expect(result.diagnostics[0]).toMatchObject({ - code: 'AB4829', + code: 'AB4833', message: expect.stringMatching(message), severity: 'error', sourcePath: '/project/agent-bundle.config.ts', @@ -68,14 +68,14 @@ describe('notices.retention config (AB4829)', () => { } // Several bad fields are reported together, once each. const many = normalizeNoticeRetention(config({ retention: { maxTerminal: -1, terminalTtl: 'x' } }), '/p/c.ts', true); - expect(many.diagnostics.map((diagnostic) => diagnostic.code)).toEqual(['AB4829', 'AB4829']); + expect(many.diagnostics.map((diagnostic) => diagnostic.code)).toEqual(['AB4833', 'AB4833']); }); it('refuses a retention policy for a project without a state module', () => { const result = normalizeNoticeRetention(config({ retention: { maxTerminal: 3 } }), '/p/agent-bundle.config.ts', false); expect(result.retention).toBeUndefined(); expect(result.diagnostics).toEqual([expect.objectContaining({ - code: 'AB4829', + code: 'AB4833', message: expect.stringContaining('declares no state module'), recovery: expect.stringContaining('src/state.ts'), })]); diff --git a/packages/rsc-runtime/README.md b/packages/rsc-runtime/README.md index b6e5b96f6..9956366bf 100644 --- a/packages/rsc-runtime/README.md +++ b/packages/rsc-runtime/README.md @@ -389,7 +389,7 @@ stay in the ledger forever. `createAgentNoticeLedger(store, { retention })` takes an `AgentNoticeRetentionPolicy` (`resolveNoticeRetentionPolicy()` validates it; defaults are `AGENT_NOTICE_DEFAULT_RETENTION`: `terminalTtlMs` seven days, `maxTerminal` 500, `maxJournalBytes` 16 MiB). Generated runtimes -resolve it from the project's `notices.retention` config (`AB4829` when +resolve it from the project's `notices.retention` config (`AB4833` when malformed). `retain({ at, idempotencyKey })` applies it once: settled notices older than the TTL, plus the earliest-settled beyond the cap, leave the state through one `pruned` event (the reducer skips any id that is live again, so a diff --git a/packages/rsc-runtime/src/notices/ledger.ts b/packages/rsc-runtime/src/notices/ledger.ts index 238d90538..38e24bf14 100644 --- a/packages/rsc-runtime/src/notices/ledger.ts +++ b/packages/rsc-runtime/src/notices/ledger.ts @@ -131,30 +131,35 @@ const acknowledgementRoute = (request: AgentNoticeRequest): AgentNoticeDeliveryR request.invocation.kind === 'event' ? 'next-event' : 'mcp-inbox'; /** - * A notice returned from `acknowledge()`: the state moved, but the content - * comes back only as the request's route may disclose it. A withheld class - * yields a placeholder document (the mark, same status and version) rather - * than the authored text an id learned from a redacted inbox would otherwise - * unlock. + * A notice handed out by a route that decided nothing about it in this call + * (an acknowledgement, or a replayed admission whose attempt receipt already + * exists): the content comes back only as the route's *current* ceiling may + * disclose it, so a store reopened under a lowered ceiling never replays the + * authored text. A withheld class yields a placeholder document (the mark, + * same status and version) rather than the authored text an id learned from a + * redacted inbox would otherwise unlock. */ -const acknowledgedNotice = ( +const currentlyDisclosedNotice = ( notice: AgentNotice, route: AgentNoticeDeliveryRoute, advertisement: AgentNoticeDeliveryAdvertisement | undefined, -): AgentNotice => { +): { readonly notice: AgentNotice; readonly redacted: boolean } => { const disclosure = resolveNoticeDisclosure(route, sensitivityOf(notice), advertisement); switch (disclosure.kind) { case 'disclosed': - return disclosedNotice(notice, disclosure); + return { notice: disclosedNotice(notice, disclosure), redacted: disclosure.redacted }; case 'withheld': - return Object.freeze({ - ...notice, - content: Object.freeze({ - root: Object.freeze({ kind: 'text' as const, text: NOTICE_REDACTION_MARK }), - status: notice.content.status, - version: notice.content.version, + return { + notice: Object.freeze({ + ...notice, + content: Object.freeze({ + root: Object.freeze({ kind: 'text' as const, text: NOTICE_REDACTION_MARK }), + status: notice.content.status, + version: notice.content.version, + }), }), - }); + redacted: true, + }; default: { const exhaustive: never = disclosure; return exhaustive; @@ -405,18 +410,28 @@ const deliveryFor = ( notice: AgentNotice, invocationId: string, disclosures: ReadonlyMap, + advertisement: AgentNoticeDeliveryAdvertisement | undefined, ): AgentNoticeDelivery | undefined => { if (notice.state !== 'attempted') return undefined; const receipt = notice.attempts.find((attempt) => attempt.invocationId === invocationId); if (receipt === undefined) return undefined; - // Attempted in this invocation means the route disclosed it; a receipt from - // this invocation without a decision (a replayed admission whose state was - // read fresh) falls back to the notice's own class. - const disclosure = disclosures.get(notice.id) - ?? Object.freeze({ kind: 'disclosed' as const, redacted: sensitivityOf(notice) === 'internal', shape: 'body' as const }); + const decided = disclosures.get(notice.id); + if (decided !== undefined) { + return Object.freeze({ + disclosure: Object.freeze({ redacted: decided.redacted, route: 'next-event' as const }), + notice: disclosedNotice(notice, decided), + receipt, + }); + } + // A receipt from this invocation without a decision is a replayed admission + // (the attempt already existed, so the notice was not a candidate). The + // content is disclosed against the route's *current* ceiling, never the one + // that held when the receipt was written: a store reopened under a lowered + // ceiling hands out the placeholder, not the authored secret. + const current = currentlyDisclosedNotice(notice, 'next-event', advertisement); return Object.freeze({ - disclosure: Object.freeze({ redacted: disclosure.redacted, route: 'next-event' as const }), - notice: disclosedNotice(notice, disclosure), + disclosure: Object.freeze({ redacted: current.redacted, route: 'next-event' as const }), + notice: current.notice, receipt, }); }; @@ -615,7 +630,7 @@ export const createAgentNoticeLedger = ( } deliveries = Object.freeze(admitted.notices .filter((notice) => recipientMatchesPrincipal(notice.recipient, request.principal)) - .map((notice) => deliveryFor(notice, request.invocation.id, disclosures)) + .map((notice) => deliveryFor(notice, request.invocation.id, disclosures, advertisement)) .filter((delivery): delivery is AgentNoticeDelivery => delivery !== undefined)); // Retention rides admitted events only (V1 implies no timer): settled // history past the policy leaves the ledger, then an oversized journal @@ -690,7 +705,7 @@ export const createAgentNoticeLedger = ( // the content comes back exactly as the route that could have // shown it to this request discloses it, so an id learned from a // redacted inbox cannot fetch the authored text through here. - return acknowledgedNotice(acknowledged, acknowledgementRoute(request), advertisement); + return currentlyDisclosedNotice(acknowledged, acknowledgementRoute(request), advertisement).notice; })); }, inbox() { diff --git a/packages/rsc-runtime/tests/notices-redaction.test.ts b/packages/rsc-runtime/tests/notices-redaction.test.ts index 0caab3b9f..6d09d963a 100644 --- a/packages/rsc-runtime/tests/notices-redaction.test.ts +++ b/packages/rsc-runtime/tests/notices-redaction.test.ts @@ -288,6 +288,34 @@ describe('ledger disclosure through the inbox and next-event routes', () => { await open.driver.close(); }); + it('discloses a replayed admission against the current ceiling, not the one that held when it was attempted', async () => { + // One durable store, two ledgers over it: the host first admitted secret + // on next-event, then a reconfiguration lowered the ceiling to internal. + const driver = createMemoryStateDriver({ lifetime: 'process' }); + const store = await driver.open(agentNoticeStateDefinition('process')); + const permissive = createAgentNoticeLedger(store, { + authorize: () => ({ state: 'authorized' }), + delivery: advertisement({ 'next-event': 'secret' }), + }); + const lowered = createAgentNoticeLedger(store, { + authorize: () => ({ state: 'authorized' }), + delivery: advertisement({ 'next-event': 'internal' }), + }); + const secret = await publish(permissive, { id: 'secret', sensitivity: 'secret' }); + const event = { actorId: 'recipient', id: 'event-replay', kind: 'event' as const, startedAt: '2026-09-03T10:10:00.000Z' }; + const first = await run(permissive, event, async () => (await agent()).notices!.read()); + expect(first.map((delivery) => [delivery.disclosure.redacted, (delivery.notice.content.root as { text: string }).text])) + .toEqual([[false, SECRET_TEXT]]); + // The same invocation replayed under the lowered ceiling: the attempt + // receipt already exists, so nothing is re-attempted, and the content + // comes back as the placeholder rather than the authored secret. + const replayed = await run(lowered, event, async () => (await agent()).notices!.read()); + expect(replayed.map((delivery) => [delivery.disclosure.redacted, delivery.notice.id, delivery.notice.content])) + .toEqual([[true, secret.notice.id, { root: { kind: 'text', text: NOTICE_REDACTION_MARK }, status: 'success', version: 1 }]]); + expect((await lowered.read()).notices[0]?.attempts).toHaveLength(1); + await driver.close(); + }); + it('delivers internal notices redacted on next-event and treats pre-sensitivity notices as internal', async () => { const { driver, ledger, store } = await openLedger(); const internal = await publish(ledger, { id: 'internal' }); diff --git a/website/docs/en/reference/configuration.mdx b/website/docs/en/reference/configuration.mdx index 5fca1d3a4..0f5387451 100644 --- a/website/docs/en/reference/configuration.mdx +++ b/website/docs/en/reference/configuration.mdx @@ -123,7 +123,7 @@ on admitted events and explicit `retain()` calls; no timer is implied. | `maxJournalBytes` | `16777216` | Positive integer of bytes. | Anything else — an unknown key, a non-positive or fractional value, a duration the grammar does -not spell, or a policy declared by a project without a state module — is `AB4829`. `inspect +not spell, or a policy declared by a project without a state module — is `AB4833`. `inspect --state` and the Workbench State panel show the resolved policy and whether it was declared or defaulted; live counts and the last compaction belong to each installed store (`AgentNoticeLedger.inspect()`). diff --git a/website/docs/zh/reference/configuration.mdx b/website/docs/zh/reference/configuration.mdx index 15c936455..a0a09fd4f 100644 --- a/website/docs/zh/reference/configuration.mdx +++ b/website/docs/zh/reference/configuration.mdx @@ -114,7 +114,7 @@ TypeDoc 从包源码生成,因此不会与已发布的类型产生偏差。 | `maxJournalBytes` | `16777216` | 正整数字节数。 | 其他任何情况——未知键、非正数或小数、语法之外的时长写法,或在没有状态模块的项目中声明策略——都是 -`AB4829`。`inspect --state` 与 Workbench 的 State 面板会显示解析后的策略以及它是声明的还是默认的; +`AB4833`。`inspect --state` 与 Workbench 的 State 面板会显示解析后的策略以及它是声明的还是默认的; 实时计数与最近一次压实属于每个已安装的存储(`AgentNoticeLedger.inspect()`)。 ## payload From a3336a15fad2f408f3e5c3026d18c912f4afaa4d Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Thu, 3 Sep 2026 20:11:33 +0000 Subject: [PATCH 07/12] fix(notices): mask credential-keyed JSON whole; replay only the live baseline; match d.ts specifiers not prose - redactJson masks every string under a credential-shaped key (isSecretKey, pinned equal to the compiler's isCredentialKey) so { password: 'hunter2' } never leaks - replayJournal parses only the latest baseline at or below the target, so a compact/reset baseline written before a later migration is never fed to the current schema (conformance case on both drivers) - the packed d.ts assertion matches import specifiers, not doc comments --- packages/agent-bundle/src/core/credentials.ts | 41 +++++++++++------- .../agent-bundle/src/mcp-server-runtime.ts | 4 +- .../tests/notice-redaction-parity.test.ts | 16 +++++++ .../tests/public-api-packed.test.ts | 4 +- packages/rsc-runtime/src/notices/index.ts | 2 + packages/rsc-runtime/src/notices/redaction.ts | 43 +++++++++++++++++-- packages/rsc-runtime/src/state/conformance.ts | 38 ++++++++++++++++ packages/rsc-runtime/src/state/journal.ts | 15 ++++--- .../tests/notices-redaction.test.ts | 18 ++++++-- 9 files changed, 148 insertions(+), 33 deletions(-) diff --git a/packages/agent-bundle/src/core/credentials.ts b/packages/agent-bundle/src/core/credentials.ts index f87566cba..61d31fa29 100644 --- a/packages/agent-bundle/src/core/credentials.ts +++ b/packages/agent-bundle/src/core/credentials.ts @@ -8,22 +8,31 @@ * detect or irreversibly remove credential *values* in arbitrary text. */ -const credentialKeywords = Object.freeze([ - 'authorization', - 'credential', - 'credentials', - 'password', - 'secret', - 'token', -]); +/** + * Sources of the key-name classifier. The notice ledger in + * `@agent-bundle/runtime` (`notices/redaction.ts`, `NOTICE_SECRET_KEY_SOURCES`) + * carries the same table for structured notice content; the parity test pins + * them equal. Edit both together. + */ +export const CREDENTIAL_KEY_SOURCES = Object.freeze({ + compactSuffix: String.raw`(?:apikey|apitoken|authtoken|accesstoken)$`, + keywords: Object.freeze(['authorization', 'credential', 'credentials', 'password', 'secret', 'token']), + // The segment heuristic subsumes these today (every match contains a `token` + // segment or an apikey/apitoken/accesstoken suffix), but they stay explicit + // so the union survives future keyword-list edits. + provider: Object.freeze([ + String.raw`(?:^|_)(?:API_KEY|API_TOKEN|ACCESS_TOKEN)$`, + String.raw`^(?:ANTHROPIC|AZURE_OPENAI|CODEX|COHERE|DEEPSEEK|FIREWORKS|GEMINI|GOOGLE|GROQ|HUGGINGFACE|MISTRAL|OPENAI|PERPLEXITY|TOGETHER|XAI)_(?:API_KEY|TOKEN)$`, + ]), +}); + +const credentialKeywords = CREDENTIAL_KEY_SOURCES.keywords; -// The segment heuristic in isCredentialKey subsumes these today (every match -// contains a `token` segment or an apikey/apitoken/accesstoken suffix), but -// they stay explicit so the union survives future keyword-list edits. -const providerKeyPatterns = Object.freeze([ - /(?:^|_)(?:API_KEY|API_TOKEN|ACCESS_TOKEN)$/iu, - /^(?:ANTHROPIC|AZURE_OPENAI|CODEX|COHERE|DEEPSEEK|FIREWORKS|GEMINI|GOOGLE|GROQ|HUGGINGFACE|MISTRAL|OPENAI|PERPLEXITY|TOGETHER|XAI)_(?:API_KEY|TOKEN)$/iu, -]); +const compactSuffixPattern = new RegExp(CREDENTIAL_KEY_SOURCES.compactSuffix, 'u'); + +const providerKeyPatterns = Object.freeze( + CREDENTIAL_KEY_SOURCES.provider.map((source) => new RegExp(source, 'iu')), +); /** * Union key-name classifier: keyword segments (authorization, credential, @@ -40,7 +49,7 @@ export const isCredentialKey = (key: string): boolean => { .filter((segment) => segment.length > 0); const compact = segments.join(''); return segments.some((segment) => credentialKeywords.includes(segment)) - || /(?:apikey|apitoken|authtoken|accesstoken)$/u.test(compact) + || compactSuffixPattern.test(compact) || providerKeyPatterns.some((pattern) => pattern.test(key)); }; diff --git a/packages/agent-bundle/src/mcp-server-runtime.ts b/packages/agent-bundle/src/mcp-server-runtime.ts index db30fba3b..d329a6347 100644 --- a/packages/agent-bundle/src/mcp-server-runtime.ts +++ b/packages/agent-bundle/src/mcp-server-runtime.ts @@ -543,7 +543,7 @@ export interface GeneratedEventRuntimeBinding { /** * The observed identity a notice inbox subscription is recorded under: the * runtime's `AgentNoticePrincipal`, spelled here from the root runtime types - * so this declaration never resolves through `@agent-bundle/runtime/notices`. + * so this declaration never resolves through the runtime's `notices` subpath. */ export interface GeneratedNoticePrincipal { readonly actor: Observed; @@ -572,7 +572,7 @@ export type GeneratedNoticeInboxSignalOutcome = * * Structurally the runtime's `AgentNoticeInboxSignaller`, spelled locally so * the emitted `mcp-server-runtime.d.ts` stays self-contained for a packed - * consumer without the optional `@agent-bundle/runtime/notices` subpath; + * consumer without the optional runtime peer's `notices` subpath; * `mcp-server-runtime.test.ts` pins the two mutually assignable. */ export interface GeneratedNoticeDeliveryBinding { diff --git a/packages/agent-bundle/tests/notice-redaction-parity.test.ts b/packages/agent-bundle/tests/notice-redaction-parity.test.ts index c61281d79..9c7937517 100644 --- a/packages/agent-bundle/tests/notice-redaction-parity.test.ts +++ b/packages/agent-bundle/tests/notice-redaction-parity.test.ts @@ -2,13 +2,17 @@ import { expect, it } from '@rstest/core'; import { AGENT_NOTICE_DEFAULT_RETENTION, + NOTICE_SECRET_KEY_SOURCES, NOTICE_SECRET_PATTERN_SOURCES, + isSecretKey, redactSecretText, } from '@agent-bundle/runtime/notices'; import { noticeRetentionDefaults } from '../src/config/notice-retention.ts'; import { + CREDENTIAL_KEY_SOURCES, CREDENTIAL_TEXT_PATTERN_SOURCES, + isCredentialKey, redactCredentialText, urlUserinfoPattern, } from '../src/core/credentials.ts'; @@ -45,6 +49,18 @@ it('redacts the same corpus the same way on both sides of the peer boundary', () expect(redactSecretText(url)).toBe('see https://[REDACTED]@vault.example.test/x and wss://[REDACTED]@relay.example.test/'); }); +it('classifies credential-shaped keys identically on both sides of the peer boundary', () => { + expect(NOTICE_SECRET_KEY_SOURCES).toEqual(CREDENTIAL_KEY_SOURCES); + const keys = [ + 'password', 'PASSWORD', 'db_password', 'apiKey', 'API_KEY', 'OPENAI_API_KEY', 'authorization', 'accessToken', + 'refresh-token', 'credentials', 'x-auth-token', 'clientSecret', 'SESSION_SECRET', + 'user', 'path', 'note', 'tokenizer', 'secretary', 'PATH', 'HOME', 'count', 'passwordless', + ]; + for (const key of keys) { + expect(isSecretKey(key), key).toBe(isCredentialKey(key)); + } +}); + it('keeps the static notice retention defaults equal to the runtime defaults', () => { expect(noticeRetentionDefaults).toEqual(AGENT_NOTICE_DEFAULT_RETENTION); }); diff --git a/packages/agent-bundle/tests/public-api-packed.test.ts b/packages/agent-bundle/tests/public-api-packed.test.ts index 27c6cd0d6..60ff5f02d 100644 --- a/packages/agent-bundle/tests/public-api-packed.test.ts +++ b/packages/agent-bundle/tests/public-api-packed.test.ts @@ -181,9 +181,11 @@ it('imports the externalized config entry from a packed npm consumer', async () // (#99 close-out): its notice binding is spelled locally, so no public or // aliased declaration resolves through `@agent-bundle/runtime/notices`. const installedDist = join(consumerRoot, 'node_modules', 'agent-bundle', 'dist'); + // Module specifiers only: a doc comment may name the subpath, an import may not. + const noticesSpecifier = /(?:from\s*|import\(\s*)['"]@agent-bundle\/runtime\/notices(?:\/[^'"]*)?['"]/u; for (const declaration of ['mcp-server-runtime.d.ts', 'api.d.ts', 'index.d.ts', 'adapters/notice-delivery.d.ts', 'adapters/types.d.ts']) { const text = await readFile(join(installedDist, declaration), 'utf8'); - expect(text, declaration).not.toContain('@agent-bundle/runtime/notices'); + expect(text, declaration).not.toMatch(noticesSpecifier); } const aliasedRuntime = await readFile(join(installedDist, 'mcp-server-runtime.d.ts'), 'utf8'); expect(aliasedRuntime).toContain('GeneratedNoticeDeliveryBinding'); diff --git a/packages/rsc-runtime/src/notices/index.ts b/packages/rsc-runtime/src/notices/index.ts index a6f0ea9bc..3e19a343c 100644 --- a/packages/rsc-runtime/src/notices/index.ts +++ b/packages/rsc-runtime/src/notices/index.ts @@ -58,12 +58,14 @@ export { AGENT_NOTICE_DEFAULT_SENSITIVITY, AGENT_NOTICE_SENSITIVITIES, NOTICE_REDACTION_MARK, + NOTICE_SECRET_KEY_SOURCES, NOTICE_SECRET_PATTERN_SOURCES, NOTICE_TITLE_MAX_LENGTH, compareNoticeSensitivity, containsSecretText, disclosedNoticeContent, isNoticeSensitivity, + isSecretKey, noticeTitle, redactNoticeDocument, redactSecretText, diff --git a/packages/rsc-runtime/src/notices/redaction.ts b/packages/rsc-runtime/src/notices/redaction.ts index 5e23e1128..f07c1aa35 100644 --- a/packages/rsc-runtime/src/notices/redaction.ts +++ b/packages/rsc-runtime/src/notices/redaction.ts @@ -90,12 +90,47 @@ export const redactSecretText = (value: string): string => { /** True when the secret pass would change `value`. */ export const containsSecretText = (value: string): boolean => redactSecretText(value) !== value; -const redactJson = (value: JsonValue): JsonValue => { - if (typeof value === 'string') return redactSecretText(value); +/** + * Key-name classifier for structured content, mirroring the compiler's + * `isCredentialKey` (`packages/agent-bundle/src/core/credentials.ts`, pinned + * equal by `notice-redaction-parity.test.ts`): keyword segments, compact + * apikey/apitoken/authtoken/accesstoken suffixes, and provider + * environment-variable names. A JSON value under such a key is a credential + * by position — `{ password: "hunter2" }` never shows the assignment pass a + * `password:` prefix — so every string beneath it is masked whole. + */ +export const NOTICE_SECRET_KEY_SOURCES = Object.freeze({ + compactSuffix: String.raw`(?:apikey|apitoken|authtoken|accesstoken)$`, + keywords: Object.freeze(['authorization', 'credential', 'credentials', 'password', 'secret', 'token']), + provider: Object.freeze([ + String.raw`(?:^|_)(?:API_KEY|API_TOKEN|ACCESS_TOKEN)$`, + String.raw`^(?:ANTHROPIC|AZURE_OPENAI|CODEX|COHERE|DEEPSEEK|FIREWORKS|GEMINI|GOOGLE|GROQ|HUGGINGFACE|MISTRAL|OPENAI|PERPLEXITY|TOGETHER|XAI)_(?:API_KEY|TOKEN)$`, + ]), +}); + +const compactSuffixPattern = new RegExp(NOTICE_SECRET_KEY_SOURCES.compactSuffix, 'u'); +const providerKeyPatterns = NOTICE_SECRET_KEY_SOURCES.provider.map((source) => new RegExp(source, 'iu')); + +/** True when a record key or environment-variable name is credential-shaped. */ +export const isSecretKey = (key: string): boolean => { + const segments = key + .replace(/([a-z0-9])([A-Z])/gu, '$1 $2') + .toLocaleLowerCase('en-US') + .split(/[^a-z0-9]+/u) + .filter((segment) => segment.length > 0); + const compact = segments.join(''); + return segments.some((segment) => NOTICE_SECRET_KEY_SOURCES.keywords.includes(segment)) + || compactSuffixPattern.test(compact) + || providerKeyPatterns.some((pattern) => pattern.test(key)); +}; + +const redactJson = (value: JsonValue, underSecretKey = false): JsonValue => { + if (typeof value === 'string') return underSecretKey ? NOTICE_REDACTION_MARK : redactSecretText(value); if (value === null || typeof value !== 'object') return value; - if (Array.isArray(value)) return Object.freeze(value.map((entry) => redactJson(entry as JsonValue))); + if (Array.isArray(value)) return Object.freeze(value.map((entry) => redactJson(entry as JsonValue, underSecretKey))); return Object.freeze(Object.fromEntries( - Object.entries(value as Readonly>).map(([key, entry]) => [key, redactJson(entry)]), + Object.entries(value as Readonly>) + .map(([key, entry]) => [key, redactJson(entry, underSecretKey || isSecretKey(key))]), )); }; diff --git a/packages/rsc-runtime/src/state/conformance.ts b/packages/rsc-runtime/src/state/conformance.ts index 961743905..ebe8a73b7 100644 --- a/packages/rsc-runtime/src/state/conformance.ts +++ b/packages/rsc-runtime/src/state/conformance.ts @@ -727,6 +727,44 @@ export const stateDriverConformanceCases: readonly StateConformanceCase[] = Obje assert.equal((await store.read()).revision, 2); }, }, + { + name: 'a compaction baseline written before a migration is never replayed against the new schema', + run: async (context) => { + const storeV1 = await context.open(taskDefinition(context.lifetime)); + await addTask(storeV1, 'a'); + await storeV1.compact(); + await addTask(storeV1, 'b'); + // The migration appends its own baseline; the retained version-1 + // compact record stays in the journal but must never be parsed again. + const storeV2 = await context.reopen(taskDefinitionV2(context.lifetime)); + const migrated = await storeV2.read(); + assert.equal(migrated.revision, 4); + assert.deepEqual(migrated.state, { labels: [], tasks: [{ id: 'a', title: 'Task a' }, { id: 'b', title: 'Task b' }], total: 2 }); + assert.deepEqual((await storeV2.read({ revision: 4 })).state, migrated.state); + await assert.rejects(storeV2.read({ revision: 3 }), rejectsWith('revision-unavailable')); + const next = await storeV2.dispatch('taskAdded', { id: 'c', title: 'Task c' }, { idempotencyKey: 'add:c' }); + assert.equal(next.revision, 5); + assert.deepEqual((await storeV2.read({ revision: 5 })).state.tasks.map((task) => task.id), ['a', 'b', 'c']); + if (context.durable) { + await storeV1.close(); + await storeV2.close(); + // Reopening runs the head-vs-replay check over a journal whose first + // record is the stale compact baseline and whose live baseline is the + // migration; only the latter may be parsed. + const reopened = await context.reopen(taskDefinitionV2(context.lifetime)); + const snapshot = await reopened.read(); + assert.equal(snapshot.revision, 5); + assert.deepEqual(snapshot.state.tasks.map((task) => task.id), ['a', 'b', 'c']); + const inspection = await reopened.inspect(); + assert.equal(inspection.baselineRevision, 2); + assert.equal(inspection.lastCompaction?.revision, 2); + // Compacting again folds the stale baseline away with everything else. + const folded = await reopened.compact(); + assert.equal(folded.revision, 6); + assert.equal((await reopened.inspect()).records, 1); + } + }, + }, { durableOnly: true, name: 'a compacted store reopens with its head agreeing with journal replay', diff --git a/packages/rsc-runtime/src/state/journal.ts b/packages/rsc-runtime/src/state/journal.ts index d187f30b8..1a1f11acf 100644 --- a/packages/rsc-runtime/src/state/journal.ts +++ b/packages/rsc-runtime/src/state/journal.ts @@ -353,15 +353,18 @@ export const replayJournal = ( `State '${definition.id}' revision ${String(targetRevision)} predates the compaction at revision ${String(first.revision)}`, ); } - let state = definition.initial; - let baselineRevision = 0; + // Only the latest baseline at or below the target is parsed. Earlier + // baselines — a `compact` or `reset` written before a later migration — + // still carry the state of the definition version they were written under, + // so parsing them against the current schema would fail a journal that is + // perfectly consistent from its live baseline onward. + let baseline: Extract | undefined; for (const record of records) { if (record.revision > targetRevision) break; - if (isBaselineRecord(record)) { - state = parseBaselineState(definition, record); - baselineRevision = record.revision; - } + if (isBaselineRecord(record)) baseline = record; } + let state = baseline === undefined ? definition.initial : parseBaselineState(definition, baseline); + const baselineRevision = baseline?.revision ?? 0; for (const record of records) { if (record.revision <= baselineRevision || record.revision > targetRevision) continue; if (record.kind !== 'event') { diff --git a/packages/rsc-runtime/tests/notices-redaction.test.ts b/packages/rsc-runtime/tests/notices-redaction.test.ts index 6d09d963a..3f3f12408 100644 --- a/packages/rsc-runtime/tests/notices-redaction.test.ts +++ b/packages/rsc-runtime/tests/notices-redaction.test.ts @@ -112,7 +112,7 @@ describe('secret-pattern redaction', () => { children: [ { kind: 'markdown', text: 'password: p4ss' }, { kind: 'context', text: 'clean' }, - { kind: 'json', value: { nested: ['token: t0k3n', 1, true, null], plain: 'ok' } }, + { kind: 'json', value: { auth: { password: 'hunter2', user: 'ops' }, nested: ['token: t0k3n', 1, true, null], plain: 'ok', authorization: ['a', 'b'] } }, { completed: 1, kind: 'progress', message: 'secret=abc', total: 2 }, { data: 'QUJD', kind: 'image', mimeType: 'image/png' }, { kind: 'resource', mimeType: 'text/plain', name: 'token: n', uri: 'https://u:p@h.example.test/r' }, @@ -131,17 +131,27 @@ describe('secret-pattern redaction', () => { children: [ { kind: 'markdown', text: `password: ${NOTICE_REDACTION_MARK}` }, { kind: 'context', text: 'clean' }, - { kind: 'json', value: { nested: [`token: ${NOTICE_REDACTION_MARK}`, 1, true, null], plain: 'ok' } }, + // A value under a credential-shaped key is a credential by position: + // masked whole, recursively, whatever its text looks like. + { + kind: 'json', + value: { + auth: { password: NOTICE_REDACTION_MARK, user: 'ops' }, + nested: [`token: ${NOTICE_REDACTION_MARK}`, 1, true, null], + plain: 'ok', + authorization: [NOTICE_REDACTION_MARK, NOTICE_REDACTION_MARK], + }, + }, { completed: 1, kind: 'progress', message: `secret=${NOTICE_REDACTION_MARK}`, total: 2 }, { data: 'QUJD', kind: 'image', mimeType: 'image/png' }, { kind: 'resource', mimeType: 'text/plain', name: `token: ${NOTICE_REDACTION_MARK}`, uri: `https://${NOTICE_REDACTION_MARK}@h.example.test/r` }, { code: 'E_SECRET', kind: 'error', message: `authorization: ${NOTICE_REDACTION_MARK}` }, ], kind: 'result', - metadata: { credential: 'x' }, + metadata: { credential: NOTICE_REDACTION_MARK }, }, status: 'success', - value: { secret: 'v' }, + value: { secret: NOTICE_REDACTION_MARK }, version: 1, }); expect(Object.isFrozen(redacted.root)).toBe(true); From 5175a8e72458fff98c9053b30f2748059688dce6 Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Thu, 3 Sep 2026 20:29:28 +0000 Subject: [PATCH 08/12] fix(notices): fail closed on unknown ceilings at construction; replay a retried admission instead of conflicting - routeSensitivityCeiling admits nothing for a ceiling outside the vocabulary and the ledger and signaller validate the advertisement when constructed (invalid-input) - a retry of the same event invocation whose recomputed admission differs replays the committed admission on idempotency-conflict instead of failing the request --- packages/rsc-runtime/README.md | 13 +++-- packages/rsc-runtime/src/notices/index.ts | 1 + packages/rsc-runtime/src/notices/ledger.ts | 16 ++++++- .../src/notices/resource-updated.ts | 9 +++- packages/rsc-runtime/src/notices/router.ts | 24 ++++++++-- .../tests/notices-redaction.test.ts | 48 ++++++++++++++++++- 6 files changed, 99 insertions(+), 12 deletions(-) diff --git a/packages/rsc-runtime/README.md b/packages/rsc-runtime/README.md index 9956366bf..5e5af8717 100644 --- a/packages/rsc-runtime/README.md +++ b/packages/rsc-runtime/README.md @@ -352,9 +352,11 @@ absent ceiling means `internal`, the pre-sensitivity contract, so `secret` notices are withheld everywhere until a host row says otherwise. `resolveNoticeDisclosure(route, sensitivity, advertisement)` is the whole decision: `withheld` (`route-unavailable` or `sensitivity-exceeds-route`) or -`disclosed` with `shape` and `redacted`. `createAgentNoticeLedger(store, { -delivery })` and `createNoticeInboxSignaller({ delivery })` take the host's -advertisement: `inbox()` omits withheld notices and hands out disclosed content +`disclosed` with `shape` and `redacted`; a row naming a ceiling outside the +vocabulary admits nothing. `createAgentNoticeLedger(store, { delivery })` and +`createNoticeInboxSignaller({ delivery })` take the host's advertisement and +validate it at construction (`validateNoticeDeliveryAdvertisement`, typed +`invalid-input`): `inbox()` omits withheld notices and hands out disclosed content (the inbox resource projection reports `sensitivity` and `disclosure.redacted`), event admission neither authorizes nor attempts a withheld notice, `read()` deliveries carry `disclosure` and the disclosed @@ -364,7 +366,10 @@ every other invocation to the inbox ceiling; a withheld class comes back as the `[REDACTED]` mark, so an id learned from a redacted inbox unlocks nothing), and the signaller never sends `resources/updated` for a notice the inbox would withhold, recording that refusal itself through -`recordWithholding()` once per subscription. Every refusal is durable evidence, +`recordWithholding()` once per subscription. Admission stays one commit per +invocation: a retry of the same invocation id whose recomputed decision differs +(a notice published or a ceiling changed in between) replays the committed +admission instead of failing `idempotency-conflict`. Every refusal is durable evidence, not a state change: the notice records `withheld[route] = { count, firstAt, lastAt, reason }` and stays eligible for a route whose row admits it. The built-in hosts admit diff --git a/packages/rsc-runtime/src/notices/index.ts b/packages/rsc-runtime/src/notices/index.ts index 3e19a343c..e1271047a 100644 --- a/packages/rsc-runtime/src/notices/index.ts +++ b/packages/rsc-runtime/src/notices/index.ts @@ -86,6 +86,7 @@ export { resolveNoticeDisclosure, routeSensitivityCeiling, selectNoticeDeliveryRoutes, + validateNoticeDeliveryAdvertisement, } from './router.js'; export type { AgentNoticeDeliveryAdvertisement, diff --git a/packages/rsc-runtime/src/notices/ledger.ts b/packages/rsc-runtime/src/notices/ledger.ts index 38e24bf14..293d099d6 100644 --- a/packages/rsc-runtime/src/notices/ledger.ts +++ b/packages/rsc-runtime/src/notices/ledger.ts @@ -58,6 +58,7 @@ import { } from './retention.js'; import { resolveNoticeDisclosure, + validateNoticeDeliveryAdvertisement, type AgentNoticeDeliveryAdvertisement, type AgentNoticeDeliveryRoute, } from './router.js'; @@ -545,7 +546,11 @@ export const createAgentNoticeLedger = ( options: CreateAgentNoticeLedgerOptions, ): AgentNoticeLedger => { const policy = resolveNoticeRetentionPolicy(options.retention); + // Both policies fail closed at construction: a malformed retention value or + // an advertisement naming an unknown ceiling is a typed error here, before + // any request could be judged against it. const advertisement = options.delivery; + if (advertisement !== undefined) validateNoticeDeliveryAdvertisement(advertisement); return Object.freeze({ expire(expiry: AgentNoticeExpiryOptions): Promise { return runPromise(Effect.gen(function*() { @@ -607,6 +612,12 @@ export const createAgentNoticeLedger = ( recipient: notice.recipient, }).pipe(Effect.map((decision) => ({ decision, id: notice.id })))); if (expiring.length > 0 || decisions.length > 0 || withheld.length > 0) { + // One admission per invocation. A retry of the same invocation id + // whose world moved on — a notice published, a ceiling raised or + // lowered — recomputes a different payload; the store refuses to + // commit that under the committed key, and the refusal is the + // signal to replay: the earlier admission already holds for this + // invocation, so its committed state is what this retry sees. const committed = yield* storeEffect(() => store.dispatch( 'admitted', { @@ -625,7 +636,10 @@ export const createAgentNoticeLedger = ( idempotencyKey: `agent-notices:admit:${request.invocation.id}`, signal: request.signal, }, - )); + )).pipe(Effect.catch((error) => + error instanceof AgentStateError && error.code === 'idempotency-conflict' + ? storeEffect(() => store.read({ signal: request.signal })) + : Effect.fail(error))); admitted = committed.state; } deliveries = Object.freeze(admitted.notices diff --git a/packages/rsc-runtime/src/notices/resource-updated.ts b/packages/rsc-runtime/src/notices/resource-updated.ts index 8a9c55dd9..101b5ecf2 100644 --- a/packages/rsc-runtime/src/notices/resource-updated.ts +++ b/packages/rsc-runtime/src/notices/resource-updated.ts @@ -9,7 +9,11 @@ import { type AgentNoticePrincipal, } from './contract.js'; import { AGENT_NOTICE_DEFAULT_SENSITIVITY } from './redaction.js'; -import { resolveNoticeDisclosure, type AgentNoticeDeliveryAdvertisement } from './router.js'; +import { + resolveNoticeDisclosure, + validateNoticeDeliveryAdvertisement, + type AgentNoticeDeliveryAdvertisement, +} from './router.js'; import type { AgentNoticeWithheldEntry, AgentNoticeWithholdingReason } from './contract.js'; import { recipientMatchesPrincipal } from './state.js'; @@ -200,6 +204,9 @@ export const createNoticeInboxSignaller = ( options: CreateNoticeInboxSignallerOptions, ): AgentNoticeInboxSignaller => { const now = options.now ?? ((): Date => new Date()); + // Fail closed at construction, like the ledger: an advertisement naming an + // unknown ceiling is a typed error before any subscription exists. + if (options.delivery !== undefined) validateNoticeDeliveryAdvertisement(options.delivery); const renewalIntervalMs = options.reservationRenewalIntervalMs ?? Math.floor(AGENT_NOTICE_AVAILABILITY_RESERVATION_TTL_MS / 3); let subscription: InboxSubscription | undefined; diff --git a/packages/rsc-runtime/src/notices/router.ts b/packages/rsc-runtime/src/notices/router.ts index 3357e2720..860ae3857 100644 --- a/packages/rsc-runtime/src/notices/router.ts +++ b/packages/rsc-runtime/src/notices/router.ts @@ -72,7 +72,14 @@ export type AgentNoticeRouteSelection = | { readonly kind: 'selected'; readonly routes: readonly AgentNoticeDeliveryRoute[] } | { readonly kind: 'unavailable'; readonly reason: 'no-supported-cross-request-route' }; -const validateAdvertisement = ( +/** + * Fails closed on an advertisement the router cannot honour: a missing route, + * a reasonless unavailability, or a supported row naming a sensitivity the + * vocabulary does not spell. Route selection, the ledger, and the signaller all + * validate at construction so a JavaScript embedder's typo (`"secrect"`) is a + * typed `invalid-input` up front, never a silently disclosed secret. + */ +export const validateNoticeDeliveryAdvertisement = ( advertisement: AgentNoticeDeliveryAdvertisement, ): void => { for (const route of AGENT_NOTICE_DELIVERY_ROUTES) { @@ -100,7 +107,7 @@ const validateAdvertisement = ( export const selectNoticeDeliveryRoutes = ( advertisement: AgentNoticeDeliveryAdvertisement, ): AgentNoticeRouteSelection => { - validateAdvertisement(advertisement); + validateNoticeDeliveryAdvertisement(advertisement); const routes = crossRequestPreference.filter( (route) => advertisement[route].state === 'supported', ); @@ -109,11 +116,18 @@ export const selectNoticeDeliveryRoutes = ( : Object.freeze({ kind: 'selected', routes: Object.freeze(routes) }); }; -/** The sensitivity ceiling a route row admits; absent rows admit `internal`. */ +/** + * The sensitivity ceiling a route row admits; absent rows admit `internal`. + * A ceiling outside the vocabulary admits nothing: `undefined` here withholds + * every class, so an unvalidated row can only ever fail closed. + */ export const routeSensitivityCeiling = ( entry: AgentNoticeDeliveryRouteState, -): AgentNoticeSensitivity | undefined => - entry.state === 'supported' ? entry.sensitivity ?? AGENT_NOTICE_DEFAULT_SENSITIVITY : undefined; +): AgentNoticeSensitivity | undefined => { + if (entry.state !== 'supported') return undefined; + if (entry.sensitivity === undefined) return AGENT_NOTICE_DEFAULT_SENSITIVITY; + return isNoticeSensitivity(entry.sensitivity) ? entry.sensitivity : undefined; +}; /** * Decides what one route may disclose of a notice, from the notice's declared diff --git a/packages/rsc-runtime/tests/notices-redaction.test.ts b/packages/rsc-runtime/tests/notices-redaction.test.ts index 3f3f12408..50983625f 100644 --- a/packages/rsc-runtime/tests/notices-redaction.test.ts +++ b/packages/rsc-runtime/tests/notices-redaction.test.ts @@ -16,6 +16,7 @@ import { redactNoticeDocument, redactSecretText, resolveNoticeDisclosure, + routeSensitivityCeiling, selectNoticeDeliveryRoutes, type AgentNoticeDeliveryAdvertisement, type AgentNoticeDeliveryRoute, @@ -201,10 +202,25 @@ describe('route disclosure decisions', () => { expect(resolveNoticeDisclosure('host-toast', 'public', noToast)).toEqual({ kind: 'withheld', reason: 'route-unavailable' }); }); - it('fails closed on an unknown sensitivity in a row', () => { + it('fails closed on an unknown sensitivity in a row', async () => { const rows = { ...advertisement({}), 'mcp-inbox': { sensitivity: 'top-secret', state: 'supported' } } as unknown as AgentNoticeDeliveryAdvertisement; expect(() => selectNoticeDeliveryRoutes(rows)).toThrow(AgentNoticeError); expect(() => selectNoticeDeliveryRoutes(rows)).toThrow(/unknown sensitivity "top-secret"/u); + // A JavaScript embedder's typo never compares as a ceiling: the row admits + // nothing, and the ledger and signaller refuse the advertisement outright. + expect(routeSensitivityCeiling(rows['mcp-inbox'])).toBeUndefined(); + for (const sensitivity of AGENT_NOTICE_SENSITIVITIES) { + expect(resolveNoticeDisclosure('mcp-inbox', sensitivity, rows)).toEqual({ kind: 'withheld', reason: 'route-unavailable' }); + } + const driver = createMemoryStateDriver({ lifetime: 'process' }); + const store = await driver.open(agentNoticeStateDefinition('process')); + expect(() => createAgentNoticeLedger(store, { authorize: () => ({ state: 'authorized' }), delivery: rows })) + .toThrow(/unknown sensitivity "top-secret"/u); + expect(() => createNoticeInboxSignaller({ + delivery: rows, + store: { close: async () => undefined, noticeLedger: async () => { throw new Error('unused'); } }, + })).toThrow(/unknown sensitivity "top-secret"/u); + await driver.close(); }); }); @@ -326,6 +342,36 @@ describe('ledger disclosure through the inbox and next-event routes', () => { await driver.close(); }); + it('replays a prior admission when the same invocation is retried under a changed ceiling', async () => { + const driver = createMemoryStateDriver({ lifetime: 'process' }); + const store = await driver.open(agentNoticeStateDefinition('process')); + const closed = createAgentNoticeLedger(store, { + authorize: () => ({ state: 'authorized' }), + delivery: advertisement({ 'next-event': 'internal' }), + }); + const raised = createAgentNoticeLedger(store, { + authorize: () => ({ state: 'authorized' }), + delivery: advertisement({ 'next-event': 'secret' }), + }); + const secret = await publish(closed, { id: 'secret', sensitivity: 'secret' }); + const event = { actorId: 'recipient', id: 'event-retry', kind: 'event' as const, startedAt: '2026-09-03T10:10:00.000Z' }; + expect(await run(closed, event, async () => (await agent()).notices!.read())).toEqual([]); + const afterFirst = await closed.read(); + expect(afterFirst.notices[0]).toMatchObject({ state: 'pending', withheld: { 'next-event': { count: 1 } } }); + // The retry recomputes a different admission (the ceiling now admits the + // notice); instead of an idempotency conflict it replays the committed + // one: nothing attempted, nothing re-recorded, the revision unchanged. + expect(await run(raised, event, async () => (await agent()).notices!.read())).toEqual([]); + const afterRetry = await raised.read(); + expect(afterRetry.revision).toBe(afterFirst.revision); + expect(afterRetry.notices[0]).toMatchObject({ attempts: [], id: secret.notice.id, state: 'pending', withheld: { 'next-event': { count: 1 } } }); + // A genuinely new invocation under the raised ceiling attempts it. + const fresh = await run(raised, { ...event, id: 'event-fresh', startedAt: '2026-09-03T10:11:00.000Z' }, async () => (await agent()).notices!.read()); + expect(fresh.map((delivery) => [delivery.notice.id, delivery.notice.state, (delivery.notice.content.root as { text: string }).text])) + .toEqual([[secret.notice.id, 'attempted', SECRET_TEXT]]); + await driver.close(); + }); + it('delivers internal notices redacted on next-event and treats pre-sensitivity notices as internal', async () => { const { driver, ledger, store } = await openLedger(); const internal = await publish(ledger, { id: 'internal' }); From 9f2346bf8fb740beabca7ba26dafabea530b0cc1 Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Thu, 3 Sep 2026 21:15:11 +0000 Subject: [PATCH 09/12] fix(notices): mask JSON keys and keep deduped publish from leaking content A guessed dedupe key must not return another author's notice text, and credential-shaped JSON member names are redacted like any other prose. --- packages/rsc-runtime/src/notices/contract.ts | 6 ++ packages/rsc-runtime/src/notices/ledger.ts | 26 +++++++- packages/rsc-runtime/src/notices/redaction.ts | 8 ++- .../tests/notices-redaction.test.ts | 64 +++++++++++++++++++ 4 files changed, 101 insertions(+), 3 deletions(-) diff --git a/packages/rsc-runtime/src/notices/contract.ts b/packages/rsc-runtime/src/notices/contract.ts index 297b29d42..6613d8c64 100644 --- a/packages/rsc-runtime/src/notices/contract.ts +++ b/packages/rsc-runtime/src/notices/contract.ts @@ -208,6 +208,12 @@ export interface AgentNoticePublishOptions { export interface AgentNoticePublishResult { readonly deduped: boolean; + /** + * The persisted notice. When the publish deduplicated onto a notice another + * publish created (a shared `dedupeKey` for the same recipient), `content` + * is the `[REDACTED]` mark, never the other author's text; a fresh publish + * or an idempotent replay of the caller's own publish carries its content. + */ readonly notice: AgentNotice; readonly replayed: boolean; readonly revision: number; diff --git a/packages/rsc-runtime/src/notices/ledger.ts b/packages/rsc-runtime/src/notices/ledger.ts index 293d099d6..4704f42c1 100644 --- a/packages/rsc-runtime/src/notices/ledger.ts +++ b/packages/rsc-runtime/src/notices/ledger.ts @@ -399,9 +399,24 @@ const publishProgram = Effect.fnUntraced(function*( if (persisted === undefined) { return yield* Effect.die(new Error('Notice publish committed without a persisted notice')); } + // A publish deduplicated onto another author's notice returns that notice's + // identity, state, and receipts — what coordination needs — but not its + // content: a predictable dedupe key must never read a secret someone else + // wrote. Only the caller's own notice (a fresh publish or an idempotent + // replay of it) comes back with content, and that content is the caller's. + const notice = persisted.id === prepared.id + ? persisted + : Object.freeze({ + ...persisted, + content: Object.freeze({ + root: Object.freeze({ kind: 'text' as const, text: NOTICE_REDACTION_MARK }), + status: persisted.content.status, + version: persisted.content.version, + }), + }); return Object.freeze({ deduped: committed.replayed || persisted.id !== prepared.id, - notice: persisted, + notice, replayed: committed.replayed, revision: committed.revision, }); @@ -465,6 +480,10 @@ const inboxProgram = Effect.fnUntraced(function*( .filter(({ decision }) => decision.state === 'authorized') .map(({ id }) => id); if (noticeIds.length === 0 && withheld.length === 0) return Object.freeze([]); + // One exposure per invocation, like admission: a retry whose recomputed + // decision differs (the inbox ceiling changed in between) replays the + // committed exposure instead of failing `idempotency-conflict`; the list + // returned is still judged against the current ceiling. const committed = yield* storeEffect(() => store.dispatch( 'exposed', { @@ -478,7 +497,10 @@ const inboxProgram = Effect.fnUntraced(function*( idempotencyKey: `agent-notices:expose:${request.invocation.id}`, signal: request.signal, }, - )); + )).pipe(Effect.catch((error) => + error instanceof AgentStateError && error.code === 'idempotency-conflict' + ? storeEffect(() => store.read({ signal: request.signal })) + : Effect.fail(error))); const returnedIds = new Set(noticeIds); const disclosures = new Map(disclosed.map(({ disclosure, notice }) => [notice.id, disclosure])); return Object.freeze(committed.state.notices diff --git a/packages/rsc-runtime/src/notices/redaction.ts b/packages/rsc-runtime/src/notices/redaction.ts index f07c1aa35..c888f2263 100644 --- a/packages/rsc-runtime/src/notices/redaction.ts +++ b/packages/rsc-runtime/src/notices/redaction.ts @@ -128,9 +128,15 @@ const redactJson = (value: JsonValue, underSecretKey = false): JsonValue => { if (typeof value === 'string') return underSecretKey ? NOTICE_REDACTION_MARK : redactSecretText(value); if (value === null || typeof value !== 'object') return value; if (Array.isArray(value)) return Object.freeze(value.map((entry) => redactJson(entry as JsonValue, underSecretKey))); + // Member names are prose too: a token used as a key (`{ "sk-…": true }`) + // is masked like any other string. Two names that mask to the same text + // collapse onto one member; the mark carries no information to lose. return Object.freeze(Object.fromEntries( Object.entries(value as Readonly>) - .map(([key, entry]) => [key, redactJson(entry, underSecretKey || isSecretKey(key))]), + .map(([key, entry]) => [ + underSecretKey ? NOTICE_REDACTION_MARK : redactSecretText(key), + redactJson(entry, underSecretKey || isSecretKey(key)), + ]), )); }; diff --git a/packages/rsc-runtime/tests/notices-redaction.test.ts b/packages/rsc-runtime/tests/notices-redaction.test.ts index 50983625f..c71c74f4f 100644 --- a/packages/rsc-runtime/tests/notices-redaction.test.ts +++ b/packages/rsc-runtime/tests/notices-redaction.test.ts @@ -105,6 +105,16 @@ describe('secret-pattern redaction', () => { expect(redactSecretText(redacted)).toBe(redacted); // Quotes are preserved around the mask so JSON-shaped text stays parseable. expect(redactSecretText('{"api_key": "xyz", "note": "keep"}')).toBe(`{"api_key": "${NOTICE_REDACTION_MARK}", "note": "keep"}`); + // JSON member names are prose too: a token used as a key is masked, and a + // credential-shaped key masks its whole subtree, names included. + expect(redactNoticeDocument({ + root: { kind: 'json', value: { 'sk-proj-abcdefghijklmnopqrstuvwxyz': true, ok: 1, secret: { inner: 'v', 'sk-proj-abcdefghijklmnopqrstuvwxyz': 'w' } } }, + status: 'success', + version: 1, + }).root).toEqual({ + kind: 'json', + value: { [NOTICE_REDACTION_MARK]: true, ok: 1, secret: { [NOTICE_REDACTION_MARK]: NOTICE_REDACTION_MARK } }, + }); }); it('redacts every prose field of a document and nothing else', () => { @@ -342,6 +352,60 @@ describe('ledger disclosure through the inbox and next-event routes', () => { await driver.close(); }); + it('returns another author\'s content as the mark when a publish deduplicates onto their notice', async () => { + const { driver, ledger } = await openLedger(); + const original = await publish(ledger, { id: 'shared', sensitivity: 'secret' }); + expect((original.notice.content.root as { text: string }).text).toBe(SECRET_TEXT); + // A second publisher guessing the dedupe key for the same recipient gets + // the identity, state, and receipts — never the other author's text. + const guessed = await run(ledger, { + actorId: 'another-publisher', + id: 'publish-guess', + kind: 'tool', + startedAt: '2026-09-03T10:01:00.000Z', + }, async () => (await agent()).notices!.publish({ + content: document('probe'), + dedupeKey: 'shared', + priority: 'normal', + recipient: { actor: { id: 'recipient' } }, + }, { idempotencyKey: 'publish:guess' })); + expect(guessed.deduped).toBe(true); + expect(guessed.notice.id).toBe(original.notice.id); + expect(guessed.notice.content).toEqual({ root: { kind: 'text', text: NOTICE_REDACTION_MARK }, status: 'success', version: 1 }); + // The original author's idempotent replay still sees their own content. + const replayed = await publish(ledger, { id: 'shared', sensitivity: 'secret' }); + expect(replayed.replayed).toBe(true); + expect((replayed.notice.content.root as { text: string }).text).toBe(SECRET_TEXT); + await driver.close(); + }); + + it('replays a prior inbox exposure when the same invocation is retried under a changed ceiling', async () => { + const driver = createMemoryStateDriver({ lifetime: 'process' }); + const store = await driver.open(agentNoticeStateDefinition('process')); + const closed = createAgentNoticeLedger(store, { + authorize: () => ({ state: 'authorized' }), + delivery: advertisement({ 'mcp-inbox': 'internal' }), + }); + const raised = createAgentNoticeLedger(store, { + authorize: () => ({ state: 'authorized' }), + delivery: advertisement({ 'mcp-inbox': 'secret' }), + }); + const secret = await publish(closed, { id: 'secret', sensitivity: 'secret' }); + const read = { actorId: 'recipient', id: 'read-retry', kind: 'tool' as const, startedAt: '2026-09-03T10:05:00.000Z' }; + expect(await run(closed, read, async () => (await agent()).notices!.inbox())).toEqual([]); + const afterFirst = await closed.read(); + expect(afterFirst.notices[0]).toMatchObject({ withheld: { 'mcp-inbox': { count: 1 } } }); + // The retry recomputes an exposure the committed one did not record; it + // replays instead of conflicting, and the returned list follows the + // current ceiling while the store keeps only the committed exposure. + const retried = await run(raised, read, async () => (await agent()).notices!.inbox()); + expect(retried.map((notice) => [notice.id, (notice.content.root as { text: string }).text])).toEqual([[secret.notice.id, SECRET_TEXT]]); + const afterRetry = await raised.read(); + expect(afterRetry.revision).toBe(afterFirst.revision); + expect(afterRetry.notices[0]?.exposure).toBeUndefined(); + await driver.close(); + }); + it('replays a prior admission when the same invocation is retried under a changed ceiling', async () => { const driver = createMemoryStateDriver({ lifetime: 'process' }); const store = await driver.open(agentNoticeStateDefinition('process')); From 248db45e55a82b1430ba5e19d7655eaf9190f7b3 Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Thu, 3 Sep 2026 21:51:16 +0000 Subject: [PATCH 10/12] feat(notices): redact notice content with flare-redact instead of hand-rolled patterns Per maintainer direction the secret pass is an npm library, not custom code: flare-redact@1.6.1 (MIT, zero deps, browser-safe root entry) becomes an exact-pinned runtime dependency; the ledger runs its default detectors and credential-shaped member names with every finding replaced whole by [REDACTED]. The hand-rolled pattern/key sources, isSecretKey, and the cross-package parity test are removed; credentials.ts and redactProbeText return to main. README records the evaluated libraries. --- .changeset/99-notice-redaction-retention.md | 2 +- docs/entry-conventions.md | 7 +- docs/framework-mode.md | 3 +- .../src/adapters/notice-delivery.ts | 2 +- .../src/config/notice-retention.ts | 2 +- packages/agent-bundle/src/core/credentials.ts | 97 ++++---------- .../src/dev/playground/mcp-probe-service.ts | 30 +++-- .../tests/notice-redaction-parity.test.ts | 66 ---------- .../tests/notice-retention-config.test.ts | 9 ++ .../tests/projection/mcp-in-memory.test.ts | 2 +- packages/rsc-runtime/README.md | 56 +++++++- packages/rsc-runtime/package.json | 1 + packages/rsc-runtime/src/notices/contract.ts | 2 +- packages/rsc-runtime/src/notices/index.ts | 3 - packages/rsc-runtime/src/notices/redaction.ts | 122 ++++++------------ .../tests/notices-redaction.test.ts | 88 +++++++++---- pnpm-lock.yaml | 10 ++ website/plugins/generated-reference.ts | 4 +- 18 files changed, 234 insertions(+), 272 deletions(-) delete mode 100644 packages/agent-bundle/tests/notice-redaction-parity.test.ts diff --git a/.changeset/99-notice-redaction-retention.md b/.changeset/99-notice-redaction-retention.md index 633c7b502..08d67f503 100644 --- a/.changeset/99-notice-redaction-retention.md +++ b/.changeset/99-notice-redaction-retention.md @@ -3,4 +3,4 @@ "agent-bundle": minor --- -Close out #99 acceptance item 7 with a notice redaction contract and a retention policy. `notices.publish()` accepts `sensitivity: 'public' | 'internal' | 'secret'` (default `internal`); each host's `noticeDelivery` row may name a dated `sensitivity` ceiling, and the ledger, inbox resource (`agent-bundle://notices/inbox`, which now reports `sensitivity` and `disclosure`), event admission, and `resources/updated` signaller withhold a notice above the route's ceiling, recording the refusal as `withheld[route]` on the notice; `internal` content is secret-pattern redacted on every route (`redactSecretText`, `redactNoticeDocument`, `resolveNoticeDisclosure`, `AGENT_NOTICE_ROUTE_SHAPES` from `@agent-bundle/runtime/notices`). `notices.retention: { terminalTtl, maxTerminal, maxJournalBytes }` in `agent-bundle.config.ts` (validated as `AB4833`, shown by `inspect --state` and the Workbench State panel) prunes settled terminal notices on admitted events and compacts the ledger journal past its byte bound through the new `AgentNoticeLedger.retain()` / `inspect()` and the state kernel's `AgentStateStore.compact()` / `inspect()` (a `compact` journal record and `AgentStateChange` kind; a compacted SQLite store moves to kernel format 2). Built-in hosts admit `secret` on `current-response` / `next-event` and `internal` on `mcp-inbox` / `mcp-resource-updated` (adapter revisions bumped). Breaking: `AgentStateStore` implementations must add `compact()` and `inspect()`, `AgentNoticeLedger` gains `retain()` / `inspect()`, `AgentNoticeDelivery` gains `disclosure`, and `AgentStateChange` / `AgentStateJournalRecord` gain the `compact` kind. The aliased `mcp-server-runtime.d.ts` no longer imports from `@agent-bundle/runtime/notices` (`GeneratedNoticeDeliveryBinding` is spelled locally). (#437) +Close out #99 acceptance item 7 with a notice redaction contract and a retention policy. `notices.publish()` accepts `sensitivity: 'public' | 'internal' | 'secret'` (default `internal`); each host's `noticeDelivery` row may name a dated `sensitivity` ceiling, and the ledger, inbox resource (`agent-bundle://notices/inbox`, which now reports `sensitivity` and `disclosure`), event admission, and `resources/updated` signaller withhold a notice above the route's ceiling, recording the refusal as `withheld[route]` on the notice; `internal` content is redacted on every route by `flare-redact@1.6.1`, a new exact-pinned runtime dependency of `@agent-bundle/runtime` (default detectors — provider tokens, JWTs, PEM keys, `Bearer`/`Basic` headers, URL credentials, credential assignments, e-mail addresses, cards — plus credential-shaped member names, every finding replaced whole by `[REDACTED]`; `redactSecretText`, `redactNoticeDocument`, `containsSecretText`, `resolveNoticeDisclosure`, `AGENT_NOTICE_ROUTE_SHAPES` from `@agent-bundle/runtime/notices`). `notices.retention: { terminalTtl, maxTerminal, maxJournalBytes }` in `agent-bundle.config.ts` (validated as `AB4833`, shown by `inspect --state` and the Workbench State panel) prunes settled terminal notices on admitted events and compacts the ledger journal past its byte bound through the new `AgentNoticeLedger.retain()` / `inspect()` and the state kernel's `AgentStateStore.compact()` / `inspect()` (a `compact` journal record and `AgentStateChange` kind; a compacted SQLite store moves to kernel format 2). Built-in hosts admit `secret` on `current-response` / `next-event` and `internal` on `mcp-inbox` / `mcp-resource-updated` (adapter revisions bumped). Breaking: `AgentStateStore` implementations must add `compact()` and `inspect()`, `AgentNoticeLedger` gains `retain()` / `inspect()`, `AgentNoticeDelivery` gains `disclosure`, and `AgentStateChange` / `AgentStateJournalRecord` gain the `compact` kind. The aliased `mcp-server-runtime.d.ts` no longer imports from `@agent-bundle/runtime/notices` (`GeneratedNoticeDeliveryBinding` is spelled locally). (#437) diff --git a/docs/entry-conventions.md b/docs/entry-conventions.md index 8d9a631a7..5987d66da 100644 --- a/docs/entry-conventions.md +++ b/docs/entry-conventions.md @@ -195,8 +195,11 @@ notice whose author-declared `sensitivity` exceeds the ceiling of the route about to carry it — the inbox omits it, event admission neither authorizes nor attempts it, the signaller never announces it — recording the refusal on the notice (`withheld[route]`) instead of moving its state. `internal` content -(the default) is passed through the runtime's secret-pattern redaction on -every route before it leaves the store; `public` travels as authored; +(the default) is passed through the runtime's secret pass on every route +before it leaves the store — `flare-redact`, an exact-pinned dependency of +`@agent-bundle/runtime`, with its default detectors and every finding replaced +whole by `[REDACTED]`; the runtime README's notices section lists the coverage +and the libraries evaluated — `public` travels as authored; `secret` travels as authored only where the row admits it. The built-in hosts admit `secret` on `current-response` and `next-event` and `internal` on `mcp-inbox` and `mcp-resource-updated`; the pinned tables carry the dated diff --git a/docs/framework-mode.md b/docs/framework-mode.md index 3e261559c..62dc9b86e 100644 --- a/docs/framework-mode.md +++ b/docs/framework-mode.md @@ -334,7 +334,8 @@ defaulted. Redaction is not configured here: it follows the notice's author-declared `sensitivity` (`public | internal | secret`, default `internal`, passed to `notices.publish()`) and each host's dated per-route ceiling in its pinned -`noticeDelivery` table. `internal` content is secret-pattern redacted on every +`noticeDelivery` table. `internal` content is passed through the runtime's +secret pass (`flare-redact`, pinned exact; see the runtime README) on every route, `public` travels as authored, and `secret` travels only over a route whose ceiling admits it — otherwise it stays in the store and the route records the refusal on the notice. diff --git a/packages/agent-bundle/src/adapters/notice-delivery.ts b/packages/agent-bundle/src/adapters/notice-delivery.ts index 1f5aa3504..469d324b3 100644 --- a/packages/agent-bundle/src/adapters/notice-delivery.ts +++ b/packages/agent-bundle/src/adapters/notice-delivery.ts @@ -21,7 +21,7 @@ export type NoticeDeliveryRoute = (typeof NOTICE_DELIVERY_ROUTES)[number]; /** * Author-declared disclosure classes of a notice, mirroring the runtime's * `AgentNoticeSensitivity`: `public` is delivered as authored, `internal` - * (the default) after the secret-pattern pass, `secret` only over a route + * (the default) after the runtime's secret pass, `secret` only over a route * whose row admits it. */ export const NOTICE_SENSITIVITIES = Object.freeze(['public', 'internal', 'secret'] as const); diff --git a/packages/agent-bundle/src/config/notice-retention.ts b/packages/agent-bundle/src/config/notice-retention.ts index 98fff9ff4..ccb1633be 100644 --- a/packages/agent-bundle/src/config/notice-retention.ts +++ b/packages/agent-bundle/src/config/notice-retention.ts @@ -15,7 +15,7 @@ import type { */ // Kept independent of the optional runtime peer, like the state budgets in -// `core/state-inspection.ts`; `notice-retention-parity.test.ts` compares these +// `core/state-inspection.ts`; `notice-retention-config.test.ts` compares these // with `AGENT_NOTICE_DEFAULT_RETENTION` so the two boundaries cannot drift. export const noticeRetentionDefaults: NormalizedNoticeRetentionPolicy = Object.freeze({ maxJournalBytes: 16 * 1024 * 1024, diff --git a/packages/agent-bundle/src/core/credentials.ts b/packages/agent-bundle/src/core/credentials.ts index 61d31fa29..653f9e19a 100644 --- a/packages/agent-bundle/src/core/credentials.ts +++ b/packages/agent-bundle/src/core/credentials.ts @@ -8,31 +8,22 @@ * detect or irreversibly remove credential *values* in arbitrary text. */ -/** - * Sources of the key-name classifier. The notice ledger in - * `@agent-bundle/runtime` (`notices/redaction.ts`, `NOTICE_SECRET_KEY_SOURCES`) - * carries the same table for structured notice content; the parity test pins - * them equal. Edit both together. - */ -export const CREDENTIAL_KEY_SOURCES = Object.freeze({ - compactSuffix: String.raw`(?:apikey|apitoken|authtoken|accesstoken)$`, - keywords: Object.freeze(['authorization', 'credential', 'credentials', 'password', 'secret', 'token']), - // The segment heuristic subsumes these today (every match contains a `token` - // segment or an apikey/apitoken/accesstoken suffix), but they stay explicit - // so the union survives future keyword-list edits. - provider: Object.freeze([ - String.raw`(?:^|_)(?:API_KEY|API_TOKEN|ACCESS_TOKEN)$`, - String.raw`^(?:ANTHROPIC|AZURE_OPENAI|CODEX|COHERE|DEEPSEEK|FIREWORKS|GEMINI|GOOGLE|GROQ|HUGGINGFACE|MISTRAL|OPENAI|PERPLEXITY|TOGETHER|XAI)_(?:API_KEY|TOKEN)$`, - ]), -}); - -const credentialKeywords = CREDENTIAL_KEY_SOURCES.keywords; +const credentialKeywords = Object.freeze([ + 'authorization', + 'credential', + 'credentials', + 'password', + 'secret', + 'token', +]); -const compactSuffixPattern = new RegExp(CREDENTIAL_KEY_SOURCES.compactSuffix, 'u'); - -const providerKeyPatterns = Object.freeze( - CREDENTIAL_KEY_SOURCES.provider.map((source) => new RegExp(source, 'iu')), -); +// The segment heuristic in isCredentialKey subsumes these today (every match +// contains a `token` segment or an apikey/apitoken/accesstoken suffix), but +// they stay explicit so the union survives future keyword-list edits. +const providerKeyPatterns = Object.freeze([ + /(?:^|_)(?:API_KEY|API_TOKEN|ACCESS_TOKEN)$/iu, + /^(?:ANTHROPIC|AZURE_OPENAI|CODEX|COHERE|DEEPSEEK|FIREWORKS|GEMINI|GOOGLE|GROQ|HUGGINGFACE|MISTRAL|OPENAI|PERPLEXITY|TOGETHER|XAI)_(?:API_KEY|TOKEN)$/iu, +]); /** * Union key-name classifier: keyword segments (authorization, credential, @@ -49,7 +40,7 @@ export const isCredentialKey = (key: string): boolean => { .filter((segment) => segment.length > 0); const compact = segments.join(''); return segments.some((segment) => credentialKeywords.includes(segment)) - || compactSuffixPattern.test(compact) + || /(?:apikey|apitoken|authtoken|accesstoken)$/u.test(compact) || providerKeyPatterns.some((pattern) => pattern.test(key)); }; @@ -61,35 +52,11 @@ export const isCredentialKey = (key: string): boolean => { export const isProviderEndpointKey = (key: string): boolean => /^(?:CODEX|OPENAI)_(?:API_BASE|BASE_URL|URL)$/iu.test(key); -/** - * Pattern sources of the free-text secret pass. The notice ledger in - * `@agent-bundle/runtime` (`notices/redaction.ts`, `NOTICE_SECRET_PATTERN_SOURCES`) - * carries the same three sources: the runtime is an optional peer of this - * package, so neither side can import the other's module, and - * `notice-redaction-parity.test.ts` pins them byte-identical instead. Edit - * both together. - */ -export const CREDENTIAL_TEXT_PATTERN_SOURCES = Object.freeze({ - assignment: String.raw`((?:["']?)(?:api[-_ ]?key|api[-_ ]?token|access[-_ ]?token|authorization|credential|password|secret|token)(?:["']?)\s*[:=]\s*)("[^"\r\n]*"|'[^'\r\n]*'|[^\s,;\r\n]+)`, - provider: Object.freeze([ - String.raw`\bsk-(?:proj-|ant-|live-)?[a-z0-9_-]{16,}\b`, - String.raw`\b(?:gh[pousr]_[a-z0-9]{20,}|github_pat_[a-z0-9_]{20,}|xox[baprs]-[a-z0-9-]{16,}|akia[a-z0-9]{16})\b`, - String.raw`\bbearer[ \t]+[a-z0-9._~+/=-]{20,}\b`, - ]), - /** - * URL userinfo (`scheme://user:secret@host`): the authority runs until one - * of the terminators every WHATWG scheme shares (`/`, `?`, `#`) and the - * match is greedy through the final `@`, so a raw `@`, quote, backslash, or - * whitespace inside a password cannot leave part of it behind. The scheme is - * anchored to the start of its own character run, so a URL glued to an - * identifier (`_https://user:secret@…`) is masked too. - */ - urlUserinfo: String.raw`(? new RegExp(source, 'iu')), -); +const providerCredentialPatterns = Object.freeze([ + /\bsk-(?:proj-|ant-|live-)?[a-z0-9_-]{16,}\b/iu, + /\b(?:gh[pousr]_[a-z0-9]{20,}|github_pat_[a-z0-9_]{20,}|xox[baprs]-[a-z0-9-]{16,}|akia[a-z0-9]{16})\b/iu, + /\bbearer[ \t]+[a-z0-9._~+/=-]{20,}\b/iu, +]); // `String.prototype.replace` resets `lastIndex` on global regexes, so sharing these is safe. const globalProviderCredentialPatterns = Object.freeze( @@ -100,24 +67,16 @@ const globalProviderCredentialPatterns = Object.freeze( export const containsProviderCredential = (value: string): boolean => providerCredentialPatterns.some((pattern) => pattern.test(value)); -const credentialAssignmentPattern = new RegExp(CREDENTIAL_TEXT_PATTERN_SOURCES.assignment, 'giu'); +const credentialAssignmentPattern = /((?:["']?)(?:api[-_ ]?key|api[-_ ]?token|access[-_ ]?token|authorization|credential|password|secret|token)(?:["']?)\s*[:=]\s*)("[^"\r\n]*"|'[^'\r\n]*'|[^\s,;\r\n]+)/giu; -/** Masks `scheme://user:secret@host` credentials; shared by the probe and Workbench log surfaces. */ -export const urlUserinfoPattern = new RegExp(CREDENTIAL_TEXT_PATTERN_SOURCES.urlUserinfo, 'giu'); - -/** - * Raw process output remains useful evidence after known credential material - * is irreversibly removed. Provider forms go first: an unquoted - * `authorization: Bearer ` would otherwise lose only the word `Bearer` - * to the assignment pass and keep the token. - */ +/** Raw process output remains useful evidence after known credential material is irreversibly removed. */ export const redactCredentialText = (value: string): string => { - let redacted = value; - for (const pattern of globalProviderCredentialPatterns) { - redacted = redacted.replace(pattern, '[REDACTED]'); - } - return redacted.replace(credentialAssignmentPattern, (_match, prefix: string, assigned: string) => { + let redacted = value.replace(credentialAssignmentPattern, (_match, prefix: string, assigned: string) => { const quote = assigned[0] === '"' || assigned[0] === "'" ? assigned[0] : ''; return `${prefix}${quote}[REDACTED]${quote}`; }); + for (const pattern of globalProviderCredentialPatterns) { + redacted = redacted.replace(pattern, '[REDACTED]'); + } + return redacted; }; diff --git a/packages/agent-bundle/src/dev/playground/mcp-probe-service.ts b/packages/agent-bundle/src/dev/playground/mcp-probe-service.ts index 4fa6ae4ae..437b11cd2 100644 --- a/packages/agent-bundle/src/dev/playground/mcp-probe-service.ts +++ b/packages/agent-bundle/src/dev/playground/mcp-probe-service.ts @@ -22,7 +22,7 @@ import type { McpProbeSnapshot, McpProbeTool, } from '../../contracts/mcp-probe.ts'; -import { redactCredentialText, urlUserinfoPattern } from '../../core/credentials.ts'; +import { redactCredentialText } from '../../core/credentials.ts'; import { parseJsonWithoutDuplicateKeys } from '../../core/strict-json.ts'; import { resolveBundleRoot } from '../../install/doctor.ts'; import { @@ -190,16 +190,30 @@ const hasAbsolutePath = (value: string): boolean => localUriPathPattern.test(value) || /(?:file:|(?:^|[\s"'([{=,]|:(?!\/\/))\/[^\s,;{}()[\]<>"']+|(?:^|[\s"'([{=,:])[A-Za-z]:[\\/]|\\\\)/u.test(value); +/** + * URL userinfo (`scheme://user:secret@host`) is a credential that the generic + * credential redaction does not recognize; because URLs are exempt from the + * absolute-path fail-closed rule, the userinfo is stripped before that check. + * The authority runs until one of the terminators every WHATWG scheme shares + * (`/`, `?`, `#`); within it the match is greedy through the *final* `@`, the + * delimiter URL parsers honour, so a raw `@`, quote, backslash, or whitespace + * inside a password (parsers percent-encode spaces and strip embedded tabs and + * newlines) cannot leave part of the credential behind. Nothing short of those + * three terminators ends the run on purpose — `\` is userinfo for non-special + * schemes and whitespace is encoded rather than rejected — so a path-less URL + * followed on the same text by an `@` before any `/`, `?`, or `#` is masked + * as well: for a browser-facing report that over-redaction is the safe side. + * Like the local-URI rule, the scheme is anchored to the start of its own + * character run rather than to a word boundary, so a URL glued to a preceding + * identifier (`_https://user:secret@…`) is masked too. + */ +const urlUserinfoPattern = /(? { const redacted = redactCredentialText(value).replace(urlUserinfoPattern, '$1[REDACTED]@').replace( diff --git a/packages/agent-bundle/tests/notice-redaction-parity.test.ts b/packages/agent-bundle/tests/notice-redaction-parity.test.ts deleted file mode 100644 index 9c7937517..000000000 --- a/packages/agent-bundle/tests/notice-redaction-parity.test.ts +++ /dev/null @@ -1,66 +0,0 @@ -import { expect, it } from '@rstest/core'; - -import { - AGENT_NOTICE_DEFAULT_RETENTION, - NOTICE_SECRET_KEY_SOURCES, - NOTICE_SECRET_PATTERN_SOURCES, - isSecretKey, - redactSecretText, -} from '@agent-bundle/runtime/notices'; - -import { noticeRetentionDefaults } from '../src/config/notice-retention.ts'; -import { - CREDENTIAL_KEY_SOURCES, - CREDENTIAL_TEXT_PATTERN_SOURCES, - isCredentialKey, - redactCredentialText, - urlUserinfoPattern, -} from '../src/core/credentials.ts'; - -/** - * `@agent-bundle/runtime` is an optional peer of `agent-bundle`, so the notice - * ledger's secret pass and the compiler's credential redaction cannot share a - * module. They share a definition instead: these pins fail the build the - * moment either copy drifts (the same discipline `inspect-state.test.ts` - * applies to the state budgets). - */ -it('keeps the notice secret patterns byte-identical to the compiler credential patterns', () => { - expect(NOTICE_SECRET_PATTERN_SOURCES).toEqual(CREDENTIAL_TEXT_PATTERN_SOURCES); - expect(NOTICE_SECRET_PATTERN_SOURCES.assignment).toBe(CREDENTIAL_TEXT_PATTERN_SOURCES.assignment); - expect([...NOTICE_SECRET_PATTERN_SOURCES.provider]).toEqual([...CREDENTIAL_TEXT_PATTERN_SOURCES.provider]); - expect(NOTICE_SECRET_PATTERN_SOURCES.urlUserinfo).toBe(urlUserinfoPattern.source); -}); - -it('redacts the same corpus the same way on both sides of the peer boundary', () => { - const corpus = [ - 'token=abc123def456 shipped', - 'authorization: Bearer abcdefghijklmnopqrstuvwxyz0123', - JSON.stringify({ api_key: 'xyz', note: 'keep', password: 'p' }), - 'sk-ant-0123456789abcdef0123 and ghp_abcdefghijklmnopqrstuvwxyz1234', - 'plain coordination text about /repo/src/secrets.ts', - 'status=ok request-id: build-123', - ]; - for (const sample of corpus) { - expect(redactSecretText(sample)).toBe(redactCredentialText(sample)); - } - // The notice pass adds the probe's URL userinfo mask on top of the credential pass. - const url = 'see https://ops:hunter2@vault.example.test/x and wss://u@relay.example.test/'; - expect(redactSecretText(url)).toBe(redactCredentialText(url).replace(urlUserinfoPattern, '$1[REDACTED]@')); - expect(redactSecretText(url)).toBe('see https://[REDACTED]@vault.example.test/x and wss://[REDACTED]@relay.example.test/'); -}); - -it('classifies credential-shaped keys identically on both sides of the peer boundary', () => { - expect(NOTICE_SECRET_KEY_SOURCES).toEqual(CREDENTIAL_KEY_SOURCES); - const keys = [ - 'password', 'PASSWORD', 'db_password', 'apiKey', 'API_KEY', 'OPENAI_API_KEY', 'authorization', 'accessToken', - 'refresh-token', 'credentials', 'x-auth-token', 'clientSecret', 'SESSION_SECRET', - 'user', 'path', 'note', 'tokenizer', 'secretary', 'PATH', 'HOME', 'count', 'passwordless', - ]; - for (const key of keys) { - expect(isSecretKey(key), key).toBe(isCredentialKey(key)); - } -}); - -it('keeps the static notice retention defaults equal to the runtime defaults', () => { - expect(noticeRetentionDefaults).toEqual(AGENT_NOTICE_DEFAULT_RETENTION); -}); diff --git a/packages/agent-bundle/tests/notice-retention-config.test.ts b/packages/agent-bundle/tests/notice-retention-config.test.ts index 85d236b6b..e94e36a3b 100644 --- a/packages/agent-bundle/tests/notice-retention-config.test.ts +++ b/packages/agent-bundle/tests/notice-retention-config.test.ts @@ -1,5 +1,7 @@ import { describe, expect, it } from '@rstest/core'; +import { AGENT_NOTICE_DEFAULT_RETENTION } from '@agent-bundle/runtime/notices'; + import { normalizeNoticeRetention, noticeRetentionDefaults, @@ -13,6 +15,13 @@ const config = (notices: unknown): AgentBundleConfig => ({ } as AgentBundleConfig); describe('notices.retention config (AB4833)', () => { + it('keeps the static defaults equal to the runtime defaults', () => { + // `@agent-bundle/runtime` is an optional peer, so the compiler carries its + // own copy of the defaults; this pin fails the build the moment it drifts + // (the same discipline `inspect-state.test.ts` applies to the state budgets). + expect(noticeRetentionDefaults).toEqual(AGENT_NOTICE_DEFAULT_RETENTION); + }); + it('parses durations as positive integers of milliseconds or unit literals', () => { expect(parseNoticeRetentionDuration(1)).toBe(1); expect(parseNoticeRetentionDuration(86_400_000)).toBe(86_400_000); diff --git a/packages/agent-bundle/tests/projection/mcp-in-memory.test.ts b/packages/agent-bundle/tests/projection/mcp-in-memory.test.ts index abfba1534..41b8fd4e1 100644 --- a/packages/agent-bundle/tests/projection/mcp-in-memory.test.ts +++ b/packages/agent-bundle/tests/projection/mcp-in-memory.test.ts @@ -367,7 +367,7 @@ describe('the in-memory MCP projection level', () => { { disclosure: { redacted: true, route: 'mcp-inbox' }, sensitivity: 'internal', - text: 'Rotate token=[REDACTED] at https://[REDACTED]@vault.example.test/x (internal)', + text: 'Rotate [REDACTED] at [REDACTED]vault.example.test/x (internal)', }, { disclosure: { redacted: false, route: 'mcp-inbox' }, diff --git a/packages/rsc-runtime/README.md b/packages/rsc-runtime/README.md index 5e5af8717..981d82d29 100644 --- a/packages/rsc-runtime/README.md +++ b/packages/rsc-runtime/README.md @@ -378,13 +378,55 @@ the recipient's own host process) and `internal` on `mcp-inbox` and `mcp-resource-updated` (transport-derived identity the host does not authenticate to the plugin); `portable` has only the MCP routes. -The secret pass (`redactSecretText()`, `redactNoticeDocument()`) masks -credential assignments (`token: …`, `password=…`), recognizable provider -tokens, and URL userinfo with `[REDACTED]`, the same patterns the compiler's -credential and probe redaction use; `NOTICE_SECRET_PATTERN_SOURCES` is pinned -byte-identical to the compiler copy by test because the runtime is an optional -peer and cannot share the module. Paths are not redacted: coordination -notices legitimately name files. +The secret pass (`redactSecretText()`, `redactNoticeDocument()`, +`containsSecretText()`) is not written here. Which fields are prose, which +class each route may carry, and what a refusal records are this package's +policy; recognizing a credential is delegated to +[`flare-redact`](https://www.npmjs.com/package/flare-redact), a runtime +dependency pinned to an exact version (a range would let a publish change +what `internal` notices disclose without a changeset; a test keeps the pin +exact). The ledger runs it with its default detectors and default +credential-shaped member names, every finding replaced whole by `[REDACTED]` +(the library's own masks keep a hint such as `AKIA***`; a notice crossing into +another actor's context keeps nothing). Coverage: provider tokens (OpenAI, +Anthropic, AWS, GitHub, GitLab, Slack, Stripe, Google, npm, and the other +default detectors), JWTs, PEM private keys, `Bearer` / `Basic` headers, +`user:password@` URL credentials, `key=value` / `key: value` credential +assignments in any language (the whole assignment is masked, key included), +e-mail addresses (a recipient's identity is never surfaced through another +actor's notice), card numbers, and IBANs; a string held directly under a +credential-shaped member name (`password`, `token`, `apiKey`, +`authorization`, …) is masked whole regardless of content, and member names +themselves are scanned like any other string. Not redacted: paths (coordination +notices legitimately name files), numbers, base64 image and audio payloads, +and vocabulary fields (`kind`, `status`, `code`, `mimeType`). Known gap at the +pinned version: the OpenAI detector caps at 64 key characters, so a +project-scoped `sk-proj-…` key of production length (~160) is not recognized; +authors pasting one should publish as `secret`, and the detector is a +reportable upstream fix, not something to patch here. The compiler keeps its +own, older credential pass for probe and log text +(`packages/agent-bundle/src/core/credentials.ts`); the two are not held in +parity, and no vendored-code notice is involved — `flare-redact` is an +ordinary npm dependency under its own MIT license. + +Libraries evaluated for the pass (September 2026), against: MIT/Apache +license, pure JS with no native dependencies and no Node-only globals (the +Workbench renders notice content in a browser bundle), a stable API with +recent releases, structured-field redaction, and a pattern pass for common +credentials: + +| Package | License | Unpacked size | Last release | Module / runtime | Coverage | Outcome | +| --- | --- | --- | --- | --- | --- | --- | +| `flare-redact` 1.6.1 | MIT | 949 kB (root entry a fraction; zero dependencies) | 2026-08 | ESM, Node ≥ 20, browser and edge safe | Deep object walk with sensitive-member-name masking plus 51 default text detectors (provider tokens, JWT, PEM, Bearer/Basic, URL credentials, generic assignments with a multilingual key vocabulary, e-mail, cards, IBAN); opt-in PII and high-entropy detectors left off | **Chosen**: the only candidate covering both halves in one dependency without Node-only code; young (first release 2026-07), hence the exact pin | +| `fast-redact` 3.5.0 | MIT | 93 kB | 2024-03 | CJS, zero dependencies; compiles redactors with `Function()`, so it needs `unsafe-eval` under a browser CSP | Field-path redaction of known keys only, no pattern detection, no arbitrary-depth wildcards | Not chosen: covers structured fields but has no credential pass, and notice free text needs one | +| `@sanity-labs/secret-scan` 1.1.0 | MIT | 1.1 MB | 2026-09 | ESM + CJS, zero dependencies, browser safe | 1,100+ TruffleHog-derived provider-token rules, JWT, connection strings; no generic `password=` assignments, no key-name masking, no e-mail | Not chosen: strong provider coverage but no structured-field or assignment redaction | +| `redact-pii` 3.4.0 | MIT | 462 kB | 2022-07 | CJS; depends on `lodash` and `@google-cloud/dlp` (gRPC, Node only) | PII (names, addresses, cards, phones, e-mail) with optional Google DLP | Not chosen: Node-only heavy dependency, no releases since 2022, PII rather than credentials | +| `@redact-pii/core`, `secret-scan`, `gitleaks-regexes` | — | — | — | — | — | Do not exist on npm | +| `detect-secrets` 1.0.6 | Apache-2.0 | 37 kB | 2025-10 | CJS, CLI oriented (`which`, `debug`) | Yelp-style detectors for CI and pre-commit scanning, reports rather than redacts | Not chosen: a scanner for files, not a redaction primitive | +| `secretlint` / `@secretlint/secretlint-rule-preset-recommend` 13.0.5 | MIT | 56 kB + 633 kB | 2026-08 | ESM, Node ≥ 22 | gitleaks-class rule presets through an async linting engine | Not chosen: async file-linting API and a large dependency graph for a synchronous egress pass | +| `@zapier/secret-scrubber` 1.1.6 | ISC | 22 kB | 2026-07 | CJS; uses `node:url`, `Buffer`, `process.env`, `create-hash` | Scrubs values you already know are secret from objects | Not chosen: value-driven (needs the secrets up front), Node-only, ISC | +| `is-secret` 1.2.1 | MIT | 4 kB | 2022-06 | CJS | Nine key-name regexes and one card-number regex | Not chosen: a key classifier, not a redactor | +| `scrubtext` 0.1.1 | MIT | 90 kB | 2026-06 | ESM + CJS, zero dependencies | Text-only secrets and PII, no assignment or key-name pass | Not chosen: pre-1.0, two releases, no structured input | ### Retention (#99 acceptance item 7) diff --git a/packages/rsc-runtime/package.json b/packages/rsc-runtime/package.json index 5386c1a21..16c8ec990 100644 --- a/packages/rsc-runtime/package.json +++ b/packages/rsc-runtime/package.json @@ -83,6 +83,7 @@ "@modelcontextprotocol/sdk": "1.30.0", "@modelcontextprotocol/server": "2.0.0", "effect": "4.0.0-rc.112", + "flare-redact": "1.6.1", "react-server-dom-rspack": "0.1.0", "zod": "4.5.4" }, diff --git a/packages/rsc-runtime/src/notices/contract.ts b/packages/rsc-runtime/src/notices/contract.ts index 6613d8c64..60aad6112 100644 --- a/packages/rsc-runtime/src/notices/contract.ts +++ b/packages/rsc-runtime/src/notices/contract.ts @@ -246,7 +246,7 @@ export type AgentNoticeAuthorizer = ( /** What the `next-event` route disclosed of a delivered notice. */ export interface AgentNoticeDisclosureReceipt { - /** True when the secret-pattern pass ran over `notice.content` (every `internal` notice). */ + /** True when the secret pass ran over `notice.content` (every `internal` notice). */ readonly redacted: boolean; readonly route: AgentNoticeDeliveryRoute; } diff --git a/packages/rsc-runtime/src/notices/index.ts b/packages/rsc-runtime/src/notices/index.ts index e1271047a..8afb63450 100644 --- a/packages/rsc-runtime/src/notices/index.ts +++ b/packages/rsc-runtime/src/notices/index.ts @@ -58,14 +58,11 @@ export { AGENT_NOTICE_DEFAULT_SENSITIVITY, AGENT_NOTICE_SENSITIVITIES, NOTICE_REDACTION_MARK, - NOTICE_SECRET_KEY_SOURCES, - NOTICE_SECRET_PATTERN_SOURCES, NOTICE_TITLE_MAX_LENGTH, compareNoticeSensitivity, containsSecretText, disclosedNoticeContent, isNoticeSensitivity, - isSecretKey, noticeTitle, redactNoticeDocument, redactSecretText, diff --git a/packages/rsc-runtime/src/notices/redaction.ts b/packages/rsc-runtime/src/notices/redaction.ts index c888f2263..4e6c88cc8 100644 --- a/packages/rsc-runtime/src/notices/redaction.ts +++ b/packages/rsc-runtime/src/notices/redaction.ts @@ -1,3 +1,5 @@ +import { compilePolicy } from 'flare-redact'; + import type { AgentDocumentNode, AgentDocumentSnapshot } from '../agent-document.js'; import type { JsonValue } from '../lower-mcp.js'; @@ -15,17 +17,28 @@ import type { JsonValue } from '../lower-mcp.js'; * * - `public`: safe for any surface; delivered as authored. * - `internal` (default): for the recipient's own context; delivered after - * the secret-pattern pass below, so a credential pasted into a coordination + * the secret pass below, so a credential pasted into a coordination * message never crosses into another actor's context. * - `secret`: delivered as authored, but only over a route whose host * capability row admits `secret`; otherwise it never leaves the durable * store (see `resolveNoticeDisclosure` in `router.ts`). * - * The secret patterns mirror the compiler's credential redaction - * (`packages/agent-bundle/src/core/credentials.ts`, reused by the Workbench - * probe redaction). The two packages cannot share a module — the runtime is an - * optional peer of the compiler — so `notice-redaction-parity.test.ts` pins - * the pattern sources equal on both sides. + * The secret pass is not ours. Which fields are prose, which class each route + * may carry, and what a refusal records are this module's policy; recognizing + * a credential is `flare-redact`'s job (pinned exact in `package.json`, a + * runtime dependency of this package, zero dependencies of its own, plain + * regular expressions with no Node-only globals). Its default detector set + * covers provider tokens (OpenAI, Anthropic, AWS, GitHub, GitLab, Slack, + * Stripe, Google, npm, …), JWTs, PEM private keys, `Bearer` / `Basic` + * headers, `user:password@` URL credentials, `key=value` / `key: value` + * credential assignments in any language, e-mail addresses (a recipient's + * identity is never surfaced through another actor's notice), card numbers, + * and IBANs; a structured value stored directly under a credential-shaped + * member name (`password`, `token`, `apiKey`, `authorization`, …) is masked + * whole regardless of content. Paths are not redacted: coordination notices + * legitimately name files. The compiler keeps its own, older credential pass + * for probe and log text (`packages/agent-bundle/src/core/credentials.ts`); + * the two are not held in parity. */ export const AGENT_NOTICE_SENSITIVITIES = Object.freeze(['public', 'internal', 'secret'] as const); @@ -49,97 +62,40 @@ export const isNoticeSensitivity = (value: unknown): value is AgentNoticeSensiti export const NOTICE_REDACTION_MARK = '[REDACTED]'; /** - * Pattern sources of the secret pass, exported so the compiler-side copy can - * be pinned identical by test. `assignment` masks `key: value` / `key=value` - * credential assignments; `provider` masks recognizable provider tokens; - * `urlUserinfo` masks `scheme://user:secret@host` credentials. + * The one redaction policy of the notice ledger: `flare-redact`'s default + * detectors and default credential-shaped member names, every finding + * replaced whole by {@link NOTICE_REDACTION_MARK}. The library's own masks + * keep a recognizable prefix (`AKIA***`, `b***@***`) as a debugging hint; a + * notice crossing into another actor's context keeps nothing. */ -export const NOTICE_SECRET_PATTERN_SOURCES = Object.freeze({ - assignment: String.raw`((?:["']?)(?:api[-_ ]?key|api[-_ ]?token|access[-_ ]?token|authorization|credential|password|secret|token)(?:["']?)\s*[:=]\s*)("[^"\r\n]*"|'[^'\r\n]*'|[^\s,;\r\n]+)`, - provider: Object.freeze([ - String.raw`\bsk-(?:proj-|ant-|live-)?[a-z0-9_-]{16,}\b`, - String.raw`\b(?:gh[pousr]_[a-z0-9]{20,}|github_pat_[a-z0-9_]{20,}|xox[baprs]-[a-z0-9-]{16,}|akia[a-z0-9]{16})\b`, - String.raw`\bbearer[ \t]+[a-z0-9._~+/=-]{20,}\b`, - ]), - urlUserinfo: String.raw`(? new RegExp(source, 'giu')); -const urlUserinfoPattern = new RegExp(NOTICE_SECRET_PATTERN_SOURCES.urlUserinfo, 'giu'); - -/** - * Irreversibly removes recognizable credential material from free text. - * Provider forms go first: an unquoted `authorization: Bearer ` would - * otherwise lose only the word `Bearer` to the assignment pass and keep the - * token. - */ -export const redactSecretText = (value: string): string => { - let redacted = value; - for (const pattern of providerPatterns) { - redacted = redacted.replace(pattern, NOTICE_REDACTION_MARK); - } - redacted = redacted.replace(assignmentPattern, (_match, prefix: string, assigned: string) => { - const quote = assigned[0] === '"' || assigned[0] === "'" ? assigned[0] : ''; - return `${prefix}${quote}${NOTICE_REDACTION_MARK}${quote}`; - }); - return redacted.replace(urlUserinfoPattern, `$1${NOTICE_REDACTION_MARK}@`); -}; +/** Irreversibly removes recognizable credential material from free text. */ +export const redactSecretText = (value: string): string => secretPass.redact(value); /** True when the secret pass would change `value`. */ -export const containsSecretText = (value: string): boolean => redactSecretText(value) !== value; +export const containsSecretText = (value: string): boolean => !secretPass.isClean(value); -/** - * Key-name classifier for structured content, mirroring the compiler's - * `isCredentialKey` (`packages/agent-bundle/src/core/credentials.ts`, pinned - * equal by `notice-redaction-parity.test.ts`): keyword segments, compact - * apikey/apitoken/authtoken/accesstoken suffixes, and provider - * environment-variable names. A JSON value under such a key is a credential - * by position — `{ password: "hunter2" }` never shows the assignment pass a - * `password:` prefix — so every string beneath it is masked whole. - */ -export const NOTICE_SECRET_KEY_SOURCES = Object.freeze({ - compactSuffix: String.raw`(?:apikey|apitoken|authtoken|accesstoken)$`, - keywords: Object.freeze(['authorization', 'credential', 'credentials', 'password', 'secret', 'token']), - provider: Object.freeze([ - String.raw`(?:^|_)(?:API_KEY|API_TOKEN|ACCESS_TOKEN)$`, - String.raw`^(?:ANTHROPIC|AZURE_OPENAI|CODEX|COHERE|DEEPSEEK|FIREWORKS|GEMINI|GOOGLE|GROQ|HUGGINGFACE|MISTRAL|OPENAI|PERPLEXITY|TOGETHER|XAI)_(?:API_KEY|TOKEN)$`, - ]), -}); - -const compactSuffixPattern = new RegExp(NOTICE_SECRET_KEY_SOURCES.compactSuffix, 'u'); -const providerKeyPatterns = NOTICE_SECRET_KEY_SOURCES.provider.map((source) => new RegExp(source, 'iu')); - -/** True when a record key or environment-variable name is credential-shaped. */ -export const isSecretKey = (key: string): boolean => { - const segments = key - .replace(/([a-z0-9])([A-Z])/gu, '$1 $2') - .toLocaleLowerCase('en-US') - .split(/[^a-z0-9]+/u) - .filter((segment) => segment.length > 0); - const compact = segments.join(''); - return segments.some((segment) => NOTICE_SECRET_KEY_SOURCES.keywords.includes(segment)) - || compactSuffixPattern.test(compact) - || providerKeyPatterns.some((pattern) => pattern.test(key)); -}; - -const redactJson = (value: JsonValue, underSecretKey = false): JsonValue => { - if (typeof value === 'string') return underSecretKey ? NOTICE_REDACTION_MARK : redactSecretText(value); +const freezeRedactedJson = (value: JsonValue): JsonValue => { if (value === null || typeof value !== 'object') return value; - if (Array.isArray(value)) return Object.freeze(value.map((entry) => redactJson(entry as JsonValue, underSecretKey))); + if (Array.isArray(value)) return Object.freeze(value.map((entry) => freezeRedactedJson(entry as JsonValue))); // Member names are prose too: a token used as a key (`{ "sk-…": true }`) // is masked like any other string. Two names that mask to the same text // collapse onto one member; the mark carries no information to lose. return Object.freeze(Object.fromEntries( Object.entries(value as Readonly>) - .map(([key, entry]) => [ - underSecretKey ? NOTICE_REDACTION_MARK : redactSecretText(key), - redactJson(entry, underSecretKey || isSecretKey(key)), - ]), + .map(([key, entry]) => [redactSecretText(key), freezeRedactedJson(entry)]), )); }; +/** + * Structured content: the library walks the value, masking a string held + * directly under a credential-shaped member name whole and scanning every + * other string; the result is then deep-frozen with its member names passed + * through the same scan. + */ +const redactJson = (value: JsonValue): JsonValue => freezeRedactedJson(secretPass.redact(value)); + const redactNode = (node: AgentDocumentNode): AgentDocumentNode => { switch (node.kind) { case 'result': diff --git a/packages/rsc-runtime/tests/notices-redaction.test.ts b/packages/rsc-runtime/tests/notices-redaction.test.ts index c71c74f4f..99dd54c82 100644 --- a/packages/rsc-runtime/tests/notices-redaction.test.ts +++ b/packages/rsc-runtime/tests/notices-redaction.test.ts @@ -1,3 +1,5 @@ +import { readFile } from 'node:fs/promises'; + import { describe, expect, it } from '@rstest/core'; import { @@ -75,7 +77,7 @@ const run = async ( workspace, }, operation); -const SECRET_TEXT = 'Rotate token=abc123def456 before https://ops:hunter2@vault.example.test/x and sk-ant-0123456789abcdef0123'; +const SECRET_TEXT = 'Rotate token=abc123def456 before https://ops:hunter2@vault.example.test/x and sk-ant-api03-0123456789abcdefghijklmnopqrstuvwxyz0123'; const publish = ( ledger: Awaited>['ledger'], @@ -93,28 +95,56 @@ const publish = ( ...(input.sensitivity === undefined ? {} : { sensitivity: input.sensitivity }), }, { idempotencyKey: `publish:${input.id}` })); -describe('secret-pattern redaction', () => { - it('masks credential assignments, provider tokens, and URL userinfo while keeping structure', () => { +describe('secret pass (flare-redact)', () => { + it('pins the redaction library to an exact version as a runtime dependency', async () => { + const manifest = JSON.parse(await readFile(new URL('../package.json', import.meta.url), 'utf8')) as { + dependencies?: Record; + peerDependencies?: Record; + }; + // Detector coverage is behavior a consumer relies on; a range would let a + // publish change what `internal` notices disclose without a changeset. + expect(manifest.dependencies?.['flare-redact']).toMatch(/^\d+\.\d+\.\d+$/u); + expect(manifest.peerDependencies?.['flare-redact']).toBeUndefined(); + }); + + it('masks credential assignments, provider tokens, and URL credentials whole while keeping the rest', () => { const redacted = redactSecretText(SECRET_TEXT); + // Every finding is replaced by the mark alone: the library's own masks keep + // a hint (`sk-ant-***`, `:***@`) that a cross-actor notice must not. expect(redacted).toBe( - `Rotate token=${NOTICE_REDACTION_MARK} before https://${NOTICE_REDACTION_MARK}@vault.example.test/x and ${NOTICE_REDACTION_MARK}`, + `Rotate ${NOTICE_REDACTION_MARK} before ${NOTICE_REDACTION_MARK}vault.example.test/x and ${NOTICE_REDACTION_MARK}`, ); expect(containsSecretText(SECRET_TEXT)).toBe(true); expect(containsSecretText('Another worktree is editing /repo/src/secrets.ts')).toBe(false); // Idempotent: a redacted text is a fixed point. expect(redactSecretText(redacted)).toBe(redacted); - // Quotes are preserved around the mask so JSON-shaped text stays parseable. - expect(redactSecretText('{"api_key": "xyz", "note": "keep"}')).toBe(`{"api_key": "${NOTICE_REDACTION_MARK}", "note": "keep"}`); - // JSON member names are prose too: a token used as a key is masked, and a - // credential-shaped key masks its whole subtree, names included. - expect(redactNoticeDocument({ - root: { kind: 'json', value: { 'sk-proj-abcdefghijklmnopqrstuvwxyz': true, ok: 1, secret: { inner: 'v', 'sk-proj-abcdefghijklmnopqrstuvwxyz': 'w' } } }, - status: 'success', - version: 1, - }).root).toEqual({ - kind: 'json', - value: { [NOTICE_REDACTION_MARK]: true, ok: 1, secret: { [NOTICE_REDACTION_MARK]: NOTICE_REDACTION_MARK } }, - }); + expect(containsSecretText(redacted)).toBe(false); + }); + + it('covers the credential forms a coordination notice is likely to carry', () => { + const forms = { + anthropic: `sk-ant-api03-${'abcdefghij0123456789'.repeat(4)}AA`, + aws: 'AKIAIOSFODNN7EXAMPLE', + basic: 'Basic dXNlcjpwYXNzd29yZA==', + bearer: 'Bearer abcdefghijklmnopqrstuvwxyz', + // The recipient's identity is never surfaced through another actor's notice. + email: 'alice@example.com', + github: `ghp_${'a'.repeat(36)}`, + jwt: 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIn0.dozjgNryP4J3jVmNHl0w5N_XgL0n3I9PlFUP0THsR8U', + openai: 'sk-proj-abcdefghijklmnopqrstuvwxyz', + password: 'password: hunter2', + pem: '-----BEGIN RSA PRIVATE KEY-----\nMIIEow\n-----END RSA PRIVATE KEY-----', + url: 'postgres://user:pass@db.internal:5432/app', + }; + for (const [name, sample] of Object.entries(forms)) { + expect(containsSecretText(sample), name).toBe(true); + expect(redactSecretText(`see ${sample} now`), name).toContain(NOTICE_REDACTION_MARK); + expect(redactSecretText(`see ${sample} now`), name).not.toContain(name === 'url' ? 'pass@' : sample.slice(-6)); + } + // Paths, identifiers, and short values are not credentials. + for (const clean of ['/repo/src/secrets.ts', 'request-id: build-123', 'status=ok', 'commit 9fceb02d0ae598e95dc970b74767f19372d61af8']) { + expect(containsSecretText(clean), clean).toBe(false); + } }); it('redacts every prose field of a document and nothing else', () => { @@ -123,10 +153,10 @@ describe('secret-pattern redaction', () => { children: [ { kind: 'markdown', text: 'password: p4ss' }, { kind: 'context', text: 'clean' }, - { kind: 'json', value: { auth: { password: 'hunter2', user: 'ops' }, nested: ['token: t0k3n', 1, true, null], plain: 'ok', authorization: ['a', 'b'] } }, - { completed: 1, kind: 'progress', message: 'secret=abc', total: 2 }, + { kind: 'json', value: { auth: { password: 'hunter2', user: 'ops' }, nested: ['token: t0k3n', 1, true, null], plain: 'ok', 'sk-proj-abcdefghijklmnopqrstuvwxyz': 'w' } }, + { completed: 1, kind: 'progress', message: 'secret=abc123', total: 2 }, { data: 'QUJD', kind: 'image', mimeType: 'image/png' }, - { kind: 'resource', mimeType: 'text/plain', name: 'token: n', uri: 'https://u:p@h.example.test/r' }, + { kind: 'resource', mimeType: 'text/plain', name: 'token: n0tes', uri: 'https://u:p@h.example.test/r' }, { code: 'E_SECRET', kind: 'error', message: 'authorization: Bearer abcdefghijklmnopqrstuvwxyz' }, ], kind: 'result', @@ -140,22 +170,26 @@ describe('secret-pattern redaction', () => { expect(redacted).toEqual({ root: { children: [ - { kind: 'markdown', text: `password: ${NOTICE_REDACTION_MARK}` }, + { kind: 'markdown', text: NOTICE_REDACTION_MARK }, { kind: 'context', text: 'clean' }, - // A value under a credential-shaped key is a credential by position: - // masked whole, recursively, whatever its text looks like. + // A string directly under a credential-shaped member name is a + // credential by position and masked whole, however short; every + // other string is scanned; member names are prose too, so a token + // used as a key is masked like any other string. { kind: 'json', value: { auth: { password: NOTICE_REDACTION_MARK, user: 'ops' }, - nested: [`token: ${NOTICE_REDACTION_MARK}`, 1, true, null], + nested: [NOTICE_REDACTION_MARK, 1, true, null], plain: 'ok', - authorization: [NOTICE_REDACTION_MARK, NOTICE_REDACTION_MARK], + [NOTICE_REDACTION_MARK]: 'w', }, }, - { completed: 1, kind: 'progress', message: `secret=${NOTICE_REDACTION_MARK}`, total: 2 }, + { completed: 1, kind: 'progress', message: NOTICE_REDACTION_MARK, total: 2 }, + // Binary payloads carry no prose; base64 is never mistaken for a token. { data: 'QUJD', kind: 'image', mimeType: 'image/png' }, - { kind: 'resource', mimeType: 'text/plain', name: `token: ${NOTICE_REDACTION_MARK}`, uri: `https://${NOTICE_REDACTION_MARK}@h.example.test/r` }, + { kind: 'resource', mimeType: 'text/plain', name: NOTICE_REDACTION_MARK, uri: `${NOTICE_REDACTION_MARK}h.example.test/r` }, + // Codes are vocabulary; the message is prose. { code: 'E_SECRET', kind: 'error', message: `authorization: ${NOTICE_REDACTION_MARK}` }, ], kind: 'result', @@ -166,6 +200,8 @@ describe('secret-pattern redaction', () => { version: 1, }); expect(Object.isFrozen(redacted.root)).toBe(true); + expect(Object.isFrozen((redacted.root as { children: readonly unknown[] }).children[2])).toBe(true); + expect(Object.isFrozen(((redacted.root as { children: readonly { value?: unknown }[] }).children[2]!.value as { auth: unknown }).auth)).toBe(true); // The original is untouched: redaction is applied on egress, never in place. expect((snapshot.root as { children: readonly { text?: string }[] }).children[0]!.text).toBe('password: p4ss'); }); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index faad98031..f028e55ac 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -367,6 +367,9 @@ importers: effect: specifier: 4.0.0-rc.112 version: 4.0.0-rc.112 + flare-redact: + specifier: 1.6.1 + version: 1.6.1 react-server-dom-rspack: specifier: 0.1.0 version: 0.1.0(@rspack/core@2.2.1(@swc/helpers@0.5.23))(react-dom@19.2.8(react@19.2.8))(react@19.2.8) @@ -2033,6 +2036,11 @@ packages: resolution: {integrity: sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA==} engines: {node: '>= 18.0.0'} + flare-redact@1.6.1: + resolution: {integrity: sha512-13Htu6VPk2ttxcoVOP909pXao6orrIBAMS/qWdhQn3/BS5AxSB5fpyqaPZnRGJhfBAM2M9T+RE662hxEpv6nGQ==} + engines: {node: '>=20'} + hasBin: true + flexsearch@0.8.212: resolution: {integrity: sha512-wSyJr1GUWoOOIISRu+X2IXiOcVfg9qqBRyCPRUdLMIGJqPzMo+jMRlvE83t14v1j0dRMEaBbER/adQjp6Du2pw==} @@ -4826,6 +4834,8 @@ snapshots: transitivePeerDependencies: - supports-color + flare-redact@1.6.1: {} + flexsearch@0.8.212: {} forwarded@0.2.0: {} diff --git a/website/plugins/generated-reference.ts b/website/plugins/generated-reference.ts index dd1a1ec38..c72a65336 100644 --- a/website/plugins/generated-reference.ts +++ b/website/plugins/generated-reference.ts @@ -193,7 +193,7 @@ const messages = { unavailableChannels: 'Why a channel is unavailable', sensitivityCeilings: 'Sensitivity ceilings', sensitivityIntro: - 'Every notice carries an author-declared `sensitivity` — `public`, `internal` (the default), or `secret`. A supported channel names the most sensitive class it carries in full, with the dated evidence for that ceiling; a channel without a named ceiling admits `internal`. A notice above a channel\'s ceiling is withheld from that channel and the refusal is recorded on the notice; `internal` content is passed through the secret-pattern redaction before it leaves the store, `public` content travels as authored, and `secret` content travels as authored only where a channel admits it.', + 'Every notice carries an author-declared `sensitivity` — `public`, `internal` (the default), or `secret`. A supported channel names the most sensitive class it carries in full, with the dated evidence for that ceiling; a channel without a named ceiling admits `internal`. A notice above a channel\'s ceiling is withheld from that channel and the refusal is recorded on the notice; `internal` content is passed through the runtime\'s secret pass (`flare-redact`, an exact-pinned dependency of `@agent-bundle/runtime`; every finding is replaced whole by `[REDACTED]`) before it leaves the store, `public` content travels as authored, and `secret` content travels as authored only where a channel admits it.', sensitivityEvidence: 'Ceiling evidence', diagnosticsTitle: 'Diagnostics reference', diagnosticsDescription: @@ -270,7 +270,7 @@ const messages = { unavailableChannels: '通道不可用的原因', sensitivityCeilings: '敏感度上限', sensitivityIntro: - '每条通知都带有作者声明的 `sensitivity`——`public`、`internal`(默认)或 `secret`。受支持的通道会声明它能完整承载的最高敏感类别,并附上该上限的带日期证据;未声明上限的通道接受 `internal`。高于通道上限的通知会被该通道拒绝,且拒绝会记录在通知上;`internal` 内容在离开存储前会经过密钥模式脱敏,`public` 内容按作者原文传递,`secret` 内容仅在通道允许时按原文传递。', + '每条通知都带有作者声明的 `sensitivity`——`public`、`internal`(默认)或 `secret`。受支持的通道会声明它能完整承载的最高敏感类别,并附上该上限的带日期证据;未声明上限的通道接受 `internal`。高于通道上限的通知会被该通道拒绝,且拒绝会记录在通知上;`internal` 内容在离开存储前会经过运行时的密钥脱敏(`flare-redact`,`@agent-bundle/runtime` 精确锁定版本的依赖;每处命中整体替换为 `[REDACTED]`),`public` 内容按作者原文传递,`secret` 内容仅在通道允许时按原文传递。', sensitivityEvidence: '上限证据', diagnosticsTitle: '诊断参考', diagnosticsDescription: From 2e15e35e6c7de434ea1a3727510be61dc569ed6e Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Thu, 3 Sep 2026 22:15:55 +0000 Subject: [PATCH 11/12] fix(notices): keep the byte bound on egress; fail closed on library limits; bounded-history wording Self-review findings: a redacted document that grew past the Agent Document byte bound is handed out as the placeholder; RedactionLimitError falls back to the mark instead of failing the inbox; the pinned detectors' assignment and OpenAI length limits are documented as contract; the generated notice page no longer calls the ledger append-only. --- .changeset/99-notice-redaction-retention.md | 2 +- packages/rsc-runtime/README.md | 16 +++-- packages/rsc-runtime/src/notices/index.ts | 1 + packages/rsc-runtime/src/notices/ledger.ts | 20 +----- packages/rsc-runtime/src/notices/redaction.ts | 68 +++++++++++++++---- .../tests/notices-redaction.test.ts | 32 +++++++++ website/plugins/generated-reference.ts | 4 +- 7 files changed, 105 insertions(+), 38 deletions(-) diff --git a/.changeset/99-notice-redaction-retention.md b/.changeset/99-notice-redaction-retention.md index 08d67f503..19753a4dc 100644 --- a/.changeset/99-notice-redaction-retention.md +++ b/.changeset/99-notice-redaction-retention.md @@ -3,4 +3,4 @@ "agent-bundle": minor --- -Close out #99 acceptance item 7 with a notice redaction contract and a retention policy. `notices.publish()` accepts `sensitivity: 'public' | 'internal' | 'secret'` (default `internal`); each host's `noticeDelivery` row may name a dated `sensitivity` ceiling, and the ledger, inbox resource (`agent-bundle://notices/inbox`, which now reports `sensitivity` and `disclosure`), event admission, and `resources/updated` signaller withhold a notice above the route's ceiling, recording the refusal as `withheld[route]` on the notice; `internal` content is redacted on every route by `flare-redact@1.6.1`, a new exact-pinned runtime dependency of `@agent-bundle/runtime` (default detectors — provider tokens, JWTs, PEM keys, `Bearer`/`Basic` headers, URL credentials, credential assignments, e-mail addresses, cards — plus credential-shaped member names, every finding replaced whole by `[REDACTED]`; `redactSecretText`, `redactNoticeDocument`, `containsSecretText`, `resolveNoticeDisclosure`, `AGENT_NOTICE_ROUTE_SHAPES` from `@agent-bundle/runtime/notices`). `notices.retention: { terminalTtl, maxTerminal, maxJournalBytes }` in `agent-bundle.config.ts` (validated as `AB4833`, shown by `inspect --state` and the Workbench State panel) prunes settled terminal notices on admitted events and compacts the ledger journal past its byte bound through the new `AgentNoticeLedger.retain()` / `inspect()` and the state kernel's `AgentStateStore.compact()` / `inspect()` (a `compact` journal record and `AgentStateChange` kind; a compacted SQLite store moves to kernel format 2). Built-in hosts admit `secret` on `current-response` / `next-event` and `internal` on `mcp-inbox` / `mcp-resource-updated` (adapter revisions bumped). Breaking: `AgentStateStore` implementations must add `compact()` and `inspect()`, `AgentNoticeLedger` gains `retain()` / `inspect()`, `AgentNoticeDelivery` gains `disclosure`, and `AgentStateChange` / `AgentStateJournalRecord` gain the `compact` kind. The aliased `mcp-server-runtime.d.ts` no longer imports from `@agent-bundle/runtime/notices` (`GeneratedNoticeDeliveryBinding` is spelled locally). (#437) +Close out #99 acceptance item 7 with a notice redaction contract and a retention policy. `notices.publish()` accepts `sensitivity: 'public' | 'internal' | 'secret'` (default `internal`); each host's `noticeDelivery` row may name a dated `sensitivity` ceiling, and the ledger, inbox resource (`agent-bundle://notices/inbox`, which now reports `sensitivity` and `disclosure`), event admission, and `resources/updated` signaller withhold a notice above the route's ceiling, recording the refusal as `withheld[route]` on the notice; `internal` content is redacted on every route by `flare-redact@1.6.1`, a new exact-pinned runtime dependency of `@agent-bundle/runtime` (default detectors — provider tokens, JWTs, PEM keys, `Bearer`/`Basic` headers, URL credentials, credential assignments, e-mail addresses, cards — plus credential-shaped member names, every finding replaced whole by `[REDACTED]`; assignment values shorter than four characters and OpenAI keys longer than 64 characters are outside the pinned detectors — publish those as `secret`; `redactSecretText`, `redactNoticeDocument`, `containsSecretText`, `resolveNoticeDisclosure`, `AGENT_NOTICE_ROUTE_SHAPES` from `@agent-bundle/runtime/notices`). `notices.retention: { terminalTtl, maxTerminal, maxJournalBytes }` in `agent-bundle.config.ts` (validated as `AB4833`, shown by `inspect --state` and the Workbench State panel) prunes settled terminal notices on admitted events and compacts the ledger journal past its byte bound through the new `AgentNoticeLedger.retain()` / `inspect()` and the state kernel's `AgentStateStore.compact()` / `inspect()` (a `compact` journal record and `AgentStateChange` kind; a compacted SQLite store moves to kernel format 2). Built-in hosts admit `secret` on `current-response` / `next-event` and `internal` on `mcp-inbox` / `mcp-resource-updated` (adapter revisions bumped). Breaking: `AgentStateStore` implementations must add `compact()` and `inspect()`, `AgentNoticeLedger` gains `retain()` / `inspect()`, `AgentNoticeDelivery` gains `disclosure`, and `AgentStateChange` / `AgentStateJournalRecord` gain the `compact` kind. The aliased `mcp-server-runtime.d.ts` no longer imports from `@agent-bundle/runtime/notices` (`GeneratedNoticeDeliveryBinding` is spelled locally). (#437) diff --git a/packages/rsc-runtime/README.md b/packages/rsc-runtime/README.md index 981d82d29..b4bcf21c7 100644 --- a/packages/rsc-runtime/README.md +++ b/packages/rsc-runtime/README.md @@ -399,11 +399,17 @@ credential-shaped member name (`password`, `token`, `apiKey`, `authorization`, …) is masked whole regardless of content, and member names themselves are scanned like any other string. Not redacted: paths (coordination notices legitimately name files), numbers, base64 image and audio payloads, -and vocabulary fields (`kind`, `status`, `code`, `mimeType`). Known gap at the -pinned version: the OpenAI detector caps at 64 key characters, so a -project-scoped `sk-proj-…` key of production length (~160) is not recognized; -authors pasting one should publish as `secret`, and the detector is a -reportable upstream fix, not something to patch here. The compiler keeps its +and vocabulary fields (`kind`, `status`, `code`, `mimeType`). Two detector +limits at the pinned version are part of the contract, not patched here: the +assignment detector needs a value of at least four characters (`password=abc` +in free text is not a finding; the same value as `{ "password": "abc" }` is +masked by member name), and the OpenAI detector caps at 64 key characters, so +a project-scoped `sk-proj-…` key of production length (~160) is not +recognized. Authors pasting either should publish as `secret`; both are +reportable upstream fixes. A redacted document that has grown past the +Agent Document byte bound (the mark is longer than the shortest values it +replaces) is handed out as the one-line `[REDACTED]` placeholder instead, so +the bound made at publish holds on egress. The compiler keeps its own, older credential pass for probe and log text (`packages/agent-bundle/src/core/credentials.ts`); the two are not held in parity, and no vendored-code notice is involved — `flare-redact` is an diff --git a/packages/rsc-runtime/src/notices/index.ts b/packages/rsc-runtime/src/notices/index.ts index 8afb63450..edfecf97d 100644 --- a/packages/rsc-runtime/src/notices/index.ts +++ b/packages/rsc-runtime/src/notices/index.ts @@ -63,6 +63,7 @@ export { containsSecretText, disclosedNoticeContent, isNoticeSensitivity, + noticeRedactionPlaceholder, noticeTitle, redactNoticeDocument, redactSecretText, diff --git a/packages/rsc-runtime/src/notices/ledger.ts b/packages/rsc-runtime/src/notices/ledger.ts index 4704f42c1..4225bd74b 100644 --- a/packages/rsc-runtime/src/notices/ledger.ts +++ b/packages/rsc-runtime/src/notices/ledger.ts @@ -44,9 +44,9 @@ import { } from './contract.js'; import { AGENT_NOTICE_DEFAULT_SENSITIVITY, - NOTICE_REDACTION_MARK, disclosedNoticeContent, isNoticeSensitivity, + noticeRedactionPlaceholder, type AgentNoticeDisclosure, type AgentNoticeSensitivity, } from './redaction.js'; @@ -151,14 +151,7 @@ const currentlyDisclosedNotice = ( return { notice: disclosedNotice(notice, disclosure), redacted: disclosure.redacted }; case 'withheld': return { - notice: Object.freeze({ - ...notice, - content: Object.freeze({ - root: Object.freeze({ kind: 'text' as const, text: NOTICE_REDACTION_MARK }), - status: notice.content.status, - version: notice.content.version, - }), - }), + notice: Object.freeze({ ...notice, content: noticeRedactionPlaceholder(notice.content) }), redacted: true, }; default: { @@ -406,14 +399,7 @@ const publishProgram = Effect.fnUntraced(function*( // replay of it) comes back with content, and that content is the caller's. const notice = persisted.id === prepared.id ? persisted - : Object.freeze({ - ...persisted, - content: Object.freeze({ - root: Object.freeze({ kind: 'text' as const, text: NOTICE_REDACTION_MARK }), - status: persisted.content.status, - version: persisted.content.version, - }), - }); + : Object.freeze({ ...persisted, content: noticeRedactionPlaceholder(persisted.content) }); return Object.freeze({ deduped: committed.replayed || persisted.id !== prepared.id, notice, diff --git a/packages/rsc-runtime/src/notices/redaction.ts b/packages/rsc-runtime/src/notices/redaction.ts index 4e6c88cc8..a5be4a83a 100644 --- a/packages/rsc-runtime/src/notices/redaction.ts +++ b/packages/rsc-runtime/src/notices/redaction.ts @@ -1,5 +1,6 @@ -import { compilePolicy } from 'flare-redact'; +import { RedactionLimitError, compilePolicy } from 'flare-redact'; +import { DEFAULT_AGENT_RENDER_LIMITS } from '../agent-document.js'; import type { AgentDocumentNode, AgentDocumentSnapshot } from '../agent-document.js'; import type { JsonValue } from '../lower-mcp.js'; @@ -36,7 +37,10 @@ import type { JsonValue } from '../lower-mcp.js'; * and IBANs; a structured value stored directly under a credential-shaped * member name (`password`, `token`, `apiKey`, `authorization`, …) is masked * whole regardless of content. Paths are not redacted: coordination notices - * legitimately name files. The compiler keeps its own, older credential pass + * legitimately name files. Detector limits at the pinned version are part of + * the contract (README, "Redaction"): an assignment value shorter than four + * characters and an OpenAI key longer than 64 characters are not findings. + * The compiler keeps its own, older credential pass * for probe and log text (`packages/agent-bundle/src/core/credentials.ts`); * the two are not held in parity. */ @@ -70,11 +74,26 @@ export const NOTICE_REDACTION_MARK = '[REDACTED]'; */ const secretPass = compilePolicy({ mask: NOTICE_REDACTION_MARK }); +/** + * The library refuses a string it cannot bound (more than 50,000 findings, or + * longer than 16 MiB) with `RedactionLimitError`. On egress that refusal + * fails closed: the value is replaced by the mark whole rather than letting + * one pathological notice fail the inbox for every reader. + */ +const failClosed = (run: () => T, fallback: T): T => { + try { + return run(); + } catch (error) { + if (error instanceof RedactionLimitError) return fallback; + throw error; + } +}; + /** Irreversibly removes recognizable credential material from free text. */ -export const redactSecretText = (value: string): string => secretPass.redact(value); +export const redactSecretText = (value: string): string => failClosed(() => secretPass.redact(value), NOTICE_REDACTION_MARK); /** True when the secret pass would change `value`. */ -export const containsSecretText = (value: string): boolean => !secretPass.isClean(value); +export const containsSecretText = (value: string): boolean => failClosed(() => !secretPass.isClean(value), true); const freezeRedactedJson = (value: JsonValue): JsonValue => { if (value === null || typeof value !== 'object') return value; @@ -94,7 +113,8 @@ const freezeRedactedJson = (value: JsonValue): JsonValue => { * other string; the result is then deep-frozen with its member names passed * through the same scan. */ -const redactJson = (value: JsonValue): JsonValue => freezeRedactedJson(secretPass.redact(value)); +const redactJson = (value: JsonValue): JsonValue => + freezeRedactedJson(failClosed(() => secretPass.redact(value), NOTICE_REDACTION_MARK)); const redactNode = (node: AgentDocumentNode): AgentDocumentNode => { switch (node.kind) { @@ -135,17 +155,39 @@ const redactNode = (node: AgentDocumentNode): AgentDocumentNode => { }; /** - * Applies the secret pass to every free-text field of a detached snapshot. - * Structure, node count, status, and codes are unchanged, so the result still - * satisfies the Agent Document bounds the original passed; a string only ever - * shrinks or is replaced by the fixed mark. + * The document a route hands out in place of content it may not disclose: + * one text node carrying the mark, with the original status and version. */ -export const redactNoticeDocument = (snapshot: AgentDocumentSnapshot): AgentDocumentSnapshot => Object.freeze({ - ...snapshot, - root: redactNode(snapshot.root), - ...(snapshot.value === undefined ? {} : { value: redactJson(snapshot.value) }), +export const noticeRedactionPlaceholder = (snapshot: AgentDocumentSnapshot): AgentDocumentSnapshot => Object.freeze({ + root: Object.freeze({ kind: 'text' as const, text: NOTICE_REDACTION_MARK }), + status: snapshot.status, + version: snapshot.version, }); +const documentBytes = (document: AgentDocumentSnapshot): number => + new TextEncoder().encode(JSON.stringify(document)).byteLength; + +/** + * Applies the secret pass to every free-text field of a detached snapshot. + * Structure, depth, node count, status, and codes are unchanged, so those + * bounds still hold; bytes need not — the mark is longer than the shortest + * values it replaces (`pass=abcd`, `a@b.co`), so a document authored at the + * byte bound can grow past it. A redacted document that no longer fits the + * bound the original passed is replaced by the placeholder rather than handed + * out oversized: the bound is a promise to hosts, made at publish and kept on + * egress. + */ +export const redactNoticeDocument = (snapshot: AgentDocumentSnapshot): AgentDocumentSnapshot => { + const redacted: AgentDocumentSnapshot = Object.freeze({ + ...snapshot, + root: redactNode(snapshot.root), + ...(snapshot.value === undefined ? {} : { value: redactJson(snapshot.value) }), + }); + return documentBytes(redacted) > DEFAULT_AGENT_RENDER_LIMITS.maxDocumentBytes + ? noticeRedactionPlaceholder(snapshot) + : redacted; +}; + const firstProse = (node: AgentDocumentNode): string | undefined => { switch (node.kind) { case 'result': diff --git a/packages/rsc-runtime/tests/notices-redaction.test.ts b/packages/rsc-runtime/tests/notices-redaction.test.ts index 99dd54c82..862496992 100644 --- a/packages/rsc-runtime/tests/notices-redaction.test.ts +++ b/packages/rsc-runtime/tests/notices-redaction.test.ts @@ -14,6 +14,7 @@ import { createAgentNoticeLedger, createNoticeInboxSignaller, disclosedNoticeContent, + noticeRedactionPlaceholder, noticeTitle, redactNoticeDocument, redactSecretText, @@ -27,8 +28,10 @@ import { } from '../src/notices/index.js'; import type { AgentDocumentSnapshot } from '../src/index.js'; import { + DEFAULT_AGENT_RENDER_LIMITS, agent, available, + createAgentDocument, runAgentRequest, unavailable, } from '../src/index.js'; @@ -206,6 +209,35 @@ describe('secret pass (flare-redact)', () => { expect((snapshot.root as { children: readonly { text?: string }[] }).children[0]!.text).toBe('password: p4ss'); }); + it('hands out the placeholder when redaction would grow a document past the byte bound', () => { + // 350,000 chars of six-character e-mails per node: each finding grows by + // four characters, so two nodes authored well inside 1 MiB redact to ~1.1 MiB. + const dense = 'x@y.io '.repeat(50_000); + const authored = createAgentDocument({ + root: { children: [{ kind: 'text', text: dense }, { kind: 'text', text: dense }], kind: 'result' }, + status: 'success', + version: 1, + }); + expect(Buffer.byteLength(JSON.stringify(authored), 'utf8')).toBeLessThan(DEFAULT_AGENT_RENDER_LIMITS.maxDocumentBytes); + expect(redactNoticeDocument(authored)).toEqual(noticeRedactionPlaceholder(authored)); + expect(redactNoticeDocument(authored)).toEqual({ root: { kind: 'text', text: NOTICE_REDACTION_MARK }, status: 'success', version: 1 }); + // One node of the same text still fits and is redacted in place. + const single = createAgentDocument({ root: { kind: 'text', text: dense }, status: 'success', version: 1 }); + expect((redactNoticeDocument(single).root as { text: string }).text).toBe(`${NOTICE_REDACTION_MARK} `.repeat(50_000)); + }); + + it('fails closed to the mark when the library refuses to bound a string', () => { + // flare-redact throws RedactionLimitError past 50,000 findings in one string. + const pathological = 'x@y.io '.repeat(50_001); + expect(redactSecretText(pathological)).toBe(NOTICE_REDACTION_MARK); + expect(containsSecretText(pathological)).toBe(true); + expect(redactNoticeDocument({ + root: { kind: 'json', value: { note: 'keep', wall: [pathological] } }, + status: 'success', + version: 1, + }).root).toEqual({ kind: 'json', value: NOTICE_REDACTION_MARK }); + }); + it('projects a bounded single-line title for title-only routes', () => { expect(noticeTitle(document(' \n First line here\nsecond'))).toBe('First line here'); expect(noticeTitle(document('x'.repeat(200))).length).toBe(120); diff --git a/website/plugins/generated-reference.ts b/website/plugins/generated-reference.ts index c72a65336..46a724815 100644 --- a/website/plugins/generated-reference.ts +++ b/website/plugins/generated-reference.ts @@ -188,7 +188,7 @@ const messages = { noticesDescription: 'Which notice delivery channels each pinned host supports, with the recorded reason for every unavailable channel.', noticesIntro: - 'A notice is an entry in the append-only notice ledger co-mounted with project state (the reserved store id `@agent-bundle/runtime/agent-notice-ledger/v1`). It targets a recipient and moves only through evidenced states — `pending`, `attempted`, `acknowledged`, `expired`, `unavailable`, `withdrawn`. Delivery is attempted through the channels below, and a generated MCP server wires each cross-request route only where its host advertises it: the recipient-scoped inbox resource `agent-bundle://notices/inbox` is registered for stateful projects on hosts advertising `mcp-inbox` (every built-in host), and `resources/subscribe` plus one `notifications/resources/updated` per newly eligible pending notice is offered only where the host additionally advertises `mcp-resource-updated` and the state lifetime is workspace-durable — recorded on the ledger as an availability receipt, never a delivery claim. No host delivery is claimed without a supported channel.', + 'A notice is an entry in the journal-backed notice ledger co-mounted with project state (the reserved store id `@agent-bundle/runtime/agent-notice-ledger/v1`). It targets a recipient and moves only through evidenced states — `pending`, `attempted`, `acknowledged`, `expired`, `unavailable`, `withdrawn`; settled terminal notices are pruned and the journal compacted under the project\'s `notices.retention` policy, so history is bounded, not permanent. Delivery is attempted through the channels below, and a generated MCP server wires each cross-request route only where its host advertises it: the recipient-scoped inbox resource `agent-bundle://notices/inbox` is registered for stateful projects on hosts advertising `mcp-inbox` (every built-in host), and `resources/subscribe` plus one `notifications/resources/updated` per newly eligible pending notice is offered only where the host additionally advertises `mcp-resource-updated` and the state lifetime is workspace-durable — recorded on the ledger as an availability receipt, never a delivery claim. No host delivery is claimed without a supported channel.', noticeChannels: 'Delivery channels', unavailableChannels: 'Why a channel is unavailable', sensitivityCeilings: 'Sensitivity ceilings', @@ -265,7 +265,7 @@ const messages = { noticesDescription: '每个固定宿主支持哪些通知投递通道,以及每个不可用通道的记录原因。', noticesIntro: - '通知是与项目状态共同挂载的只追加通知账本中的一条记录(保留的存储 id 为 `@agent-bundle/runtime/agent-notice-ledger/v1`)。它面向一个接收者,并且只会经历有证据的状态——`pending`、`attempted`、`acknowledged`、`expired`、`unavailable`、`withdrawn`。投递通过下列通道尝试,生成的 MCP 服务器只在宿主宣告了某条跨请求路由时才接线:按接收者限定的收件箱资源 `agent-bundle://notices/inbox` 会为宣告 `mcp-inbox` 的宿主(所有内置宿主)上的有状态项目注册;只有当宿主还宣告了 `mcp-resource-updated` 且 state 生命周期为工作区持久时,才提供 `resources/subscribe` 以及每条新近可用的待处理通知一次 `notifications/resources/updated`——它以可用性回执记录在账本上,绝不是投递声明。没有受支持的通道时,绝不声称已投递到宿主。', + '通知是与项目状态共同挂载、以日志为底的通知账本中的一条记录(保留的存储 id 为 `@agent-bundle/runtime/agent-notice-ledger/v1`)。它面向一个接收者,并且只会经历有证据的状态——`pending`、`attempted`、`acknowledged`、`expired`、`unavailable`、`withdrawn`;已结束的终态通知会按项目的 `notices.retention` 策略被清理、日志被压实,因此历史是有界的,而非永久保留。投递通过下列通道尝试,生成的 MCP 服务器只在宿主宣告了某条跨请求路由时才接线:按接收者限定的收件箱资源 `agent-bundle://notices/inbox` 会为宣告 `mcp-inbox` 的宿主(所有内置宿主)上的有状态项目注册;只有当宿主还宣告了 `mcp-resource-updated` 且 state 生命周期为工作区持久时,才提供 `resources/subscribe` 以及每条新近可用的待处理通知一次 `notifications/resources/updated`——它以可用性回执记录在账本上,绝不是投递声明。没有受支持的通道时,绝不声称已投递到宿主。', noticeChannels: '投递通道', unavailableChannels: '通道不可用的原因', sensitivityCeilings: '敏感度上限', From c3c07218c5052aaaf6660c105839e04f4747822a Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Thu, 3 Sep 2026 22:41:42 +0000 Subject: [PATCH 12/12] docs(notices): name noticeRedactionPlaceholder in the changeset and README Self-review second pass: the helper is exported from @agent-bundle/runtime/notices on purpose (embedders recognise the placeholder a withholding route hands out), so the release text and the README name it alongside the other new exports. --- .changeset/99-notice-redaction-retention.md | 2 +- packages/rsc-runtime/README.md | 6 ++++-- 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/.changeset/99-notice-redaction-retention.md b/.changeset/99-notice-redaction-retention.md index 19753a4dc..2daea9a6e 100644 --- a/.changeset/99-notice-redaction-retention.md +++ b/.changeset/99-notice-redaction-retention.md @@ -3,4 +3,4 @@ "agent-bundle": minor --- -Close out #99 acceptance item 7 with a notice redaction contract and a retention policy. `notices.publish()` accepts `sensitivity: 'public' | 'internal' | 'secret'` (default `internal`); each host's `noticeDelivery` row may name a dated `sensitivity` ceiling, and the ledger, inbox resource (`agent-bundle://notices/inbox`, which now reports `sensitivity` and `disclosure`), event admission, and `resources/updated` signaller withhold a notice above the route's ceiling, recording the refusal as `withheld[route]` on the notice; `internal` content is redacted on every route by `flare-redact@1.6.1`, a new exact-pinned runtime dependency of `@agent-bundle/runtime` (default detectors — provider tokens, JWTs, PEM keys, `Bearer`/`Basic` headers, URL credentials, credential assignments, e-mail addresses, cards — plus credential-shaped member names, every finding replaced whole by `[REDACTED]`; assignment values shorter than four characters and OpenAI keys longer than 64 characters are outside the pinned detectors — publish those as `secret`; `redactSecretText`, `redactNoticeDocument`, `containsSecretText`, `resolveNoticeDisclosure`, `AGENT_NOTICE_ROUTE_SHAPES` from `@agent-bundle/runtime/notices`). `notices.retention: { terminalTtl, maxTerminal, maxJournalBytes }` in `agent-bundle.config.ts` (validated as `AB4833`, shown by `inspect --state` and the Workbench State panel) prunes settled terminal notices on admitted events and compacts the ledger journal past its byte bound through the new `AgentNoticeLedger.retain()` / `inspect()` and the state kernel's `AgentStateStore.compact()` / `inspect()` (a `compact` journal record and `AgentStateChange` kind; a compacted SQLite store moves to kernel format 2). Built-in hosts admit `secret` on `current-response` / `next-event` and `internal` on `mcp-inbox` / `mcp-resource-updated` (adapter revisions bumped). Breaking: `AgentStateStore` implementations must add `compact()` and `inspect()`, `AgentNoticeLedger` gains `retain()` / `inspect()`, `AgentNoticeDelivery` gains `disclosure`, and `AgentStateChange` / `AgentStateJournalRecord` gain the `compact` kind. The aliased `mcp-server-runtime.d.ts` no longer imports from `@agent-bundle/runtime/notices` (`GeneratedNoticeDeliveryBinding` is spelled locally). (#437) +Close out #99 acceptance item 7 with a notice redaction contract and a retention policy. `notices.publish()` accepts `sensitivity: 'public' | 'internal' | 'secret'` (default `internal`); each host's `noticeDelivery` row may name a dated `sensitivity` ceiling, and the ledger, inbox resource (`agent-bundle://notices/inbox`, which now reports `sensitivity` and `disclosure`), event admission, and `resources/updated` signaller withhold a notice above the route's ceiling, recording the refusal as `withheld[route]` on the notice; `internal` content is redacted on every route by `flare-redact@1.6.1`, a new exact-pinned runtime dependency of `@agent-bundle/runtime` (default detectors — provider tokens, JWTs, PEM keys, `Bearer`/`Basic` headers, URL credentials, credential assignments, e-mail addresses, cards — plus credential-shaped member names, every finding replaced whole by `[REDACTED]`; assignment values shorter than four characters and OpenAI keys longer than 64 characters are outside the pinned detectors — publish those as `secret`; `redactSecretText`, `redactNoticeDocument`, `containsSecretText`, `noticeRedactionPlaceholder`, `resolveNoticeDisclosure`, `AGENT_NOTICE_ROUTE_SHAPES` from `@agent-bundle/runtime/notices`). `notices.retention: { terminalTtl, maxTerminal, maxJournalBytes }` in `agent-bundle.config.ts` (validated as `AB4833`, shown by `inspect --state` and the Workbench State panel) prunes settled terminal notices on admitted events and compacts the ledger journal past its byte bound through the new `AgentNoticeLedger.retain()` / `inspect()` and the state kernel's `AgentStateStore.compact()` / `inspect()` (a `compact` journal record and `AgentStateChange` kind; a compacted SQLite store moves to kernel format 2). Built-in hosts admit `secret` on `current-response` / `next-event` and `internal` on `mcp-inbox` / `mcp-resource-updated` (adapter revisions bumped). Breaking: `AgentStateStore` implementations must add `compact()` and `inspect()`, `AgentNoticeLedger` gains `retain()` / `inspect()`, `AgentNoticeDelivery` gains `disclosure`, and `AgentStateChange` / `AgentStateJournalRecord` gain the `compact` kind. The aliased `mcp-server-runtime.d.ts` no longer imports from `@agent-bundle/runtime/notices` (`GeneratedNoticeDeliveryBinding` is spelled locally). (#437) diff --git a/packages/rsc-runtime/README.md b/packages/rsc-runtime/README.md index b4bcf21c7..2d7c6d1e2 100644 --- a/packages/rsc-runtime/README.md +++ b/packages/rsc-runtime/README.md @@ -408,8 +408,10 @@ a project-scoped `sk-proj-…` key of production length (~160) is not recognized. Authors pasting either should publish as `secret`; both are reportable upstream fixes. A redacted document that has grown past the Agent Document byte bound (the mark is longer than the shortest values it -replaces) is handed out as the one-line `[REDACTED]` placeholder instead, so -the bound made at publish holds on egress. The compiler keeps its +replaces) is handed out as the one-line `[REDACTED]` placeholder instead +(`noticeRedactionPlaceholder(snapshot)`, the same document a withholding +route hands out, keeping the original `status` and `version`), so the bound +made at publish holds on egress. The compiler keeps its own, older credential pass for probe and log text (`packages/agent-bundle/src/core/credentials.ts`); the two are not held in parity, and no vendored-code notice is involved — `flare-redact` is an