Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .changeset/99-notice-redaction-retention.md
Original file line number Diff line number Diff line change
@@ -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 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)
3 changes: 2 additions & 1 deletion docs/diagnostics.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.<field>` 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/<server>/{tools,resources,prompts,apps}/*`, `src/events/*/*`,
Expand Down Expand Up @@ -666,6 +666,7 @@ schema constants), unions, nested objects, transforms, coercions — raises
| `AB4830` | error | A conventional layout module (`src/layout.*`, `src/mcp/<server>/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/<server>/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.<server>` 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. |
Expand Down
35 changes: 35 additions & 0 deletions docs/entry-conventions.md
Original file line number Diff line number Diff line change
Expand Up @@ -182,6 +182,41 @@ 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 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
evidence and the generated notice reference page renders it.

`notices.retention` in `agent-bundle.config.ts` (`terminalTtl`, `maxTerminal`,
`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
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
Expand Down
39 changes: 39 additions & 0 deletions docs/framework-mode.md
Original file line number Diff line number Diff line change
Expand Up @@ -301,6 +301,45 @@ 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 '<n>ms' | '<n>s' | '<n>m' | '<n>h' | '<n>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 `AB4833`. `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 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.

## Live development into hosts

`agent-bundle dev` is the webpack-HMR analog for plugins that are installed
Expand Down
Original file line number Diff line number Diff line change
@@ -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 = {
Expand All @@ -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<typeof inputSchema> }) {
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',
Expand All @@ -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 (
<Agent.Result value={result}>
<Agent.Text>{`notice ${result.noticeId}: ${result.state}`}</Agent.Text>
<Agent.Text>{`notice ${result.noticeId}: ${result.state} (${result.sensitivity})`}</Agent.Text>
</Agent.Result>
);
}
Original file line number Diff line number Diff line change
Expand Up @@ -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": {
Expand All @@ -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"
}
},
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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": {
Expand All @@ -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"
}
},
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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": {
Expand All @@ -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"
}
},
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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": {
Expand Down
Loading
Loading