Skip to content

Commit 8fb3293

Browse files
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.
1 parent 1db972d commit 8fb3293

7 files changed

Lines changed: 105 additions & 38 deletions

File tree

‎.changeset/99-notice-redaction-retention.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,4 +3,4 @@
33
"agent-bundle": minor
44
---
55

6-
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)
6+
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)

‎packages/rsc-runtime/README.md‎

Lines changed: 11 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -399,11 +399,17 @@ credential-shaped member name (`password`, `token`, `apiKey`,
399399
`authorization`, …) is masked whole regardless of content, and member names
400400
themselves are scanned like any other string. Not redacted: paths (coordination
401401
notices legitimately name files), numbers, base64 image and audio payloads,
402-
and vocabulary fields (`kind`, `status`, `code`, `mimeType`). Known gap at the
403-
pinned version: the OpenAI detector caps at 64 key characters, so a
404-
project-scoped `sk-proj-…` key of production length (~160) is not recognized;
405-
authors pasting one should publish as `secret`, and the detector is a
406-
reportable upstream fix, not something to patch here. The compiler keeps its
402+
and vocabulary fields (`kind`, `status`, `code`, `mimeType`). Two detector
403+
limits at the pinned version are part of the contract, not patched here: the
404+
assignment detector needs a value of at least four characters (`password=abc`
405+
in free text is not a finding; the same value as `{ "password": "abc" }` is
406+
masked by member name), and the OpenAI detector caps at 64 key characters, so
407+
a project-scoped `sk-proj-…` key of production length (~160) is not
408+
recognized. Authors pasting either should publish as `secret`; both are
409+
reportable upstream fixes. A redacted document that has grown past the
410+
Agent Document byte bound (the mark is longer than the shortest values it
411+
replaces) is handed out as the one-line `[REDACTED]` placeholder instead, so
412+
the bound made at publish holds on egress. The compiler keeps its
407413
own, older credential pass for probe and log text
408414
(`packages/agent-bundle/src/core/credentials.ts`); the two are not held in
409415
parity, and no vendored-code notice is involved — `flare-redact` is an

‎packages/rsc-runtime/src/notices/index.ts‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,7 @@ export {
6363
containsSecretText,
6464
disclosedNoticeContent,
6565
isNoticeSensitivity,
66+
noticeRedactionPlaceholder,
6667
noticeTitle,
6768
redactNoticeDocument,
6869
redactSecretText,

‎packages/rsc-runtime/src/notices/ledger.ts‎

Lines changed: 3 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -44,9 +44,9 @@ import {
4444
} from './contract.js';
4545
import {
4646
AGENT_NOTICE_DEFAULT_SENSITIVITY,
47-
NOTICE_REDACTION_MARK,
4847
disclosedNoticeContent,
4948
isNoticeSensitivity,
49+
noticeRedactionPlaceholder,
5050
type AgentNoticeDisclosure,
5151
type AgentNoticeSensitivity,
5252
} from './redaction.js';
@@ -151,14 +151,7 @@ const currentlyDisclosedNotice = (
151151
return { notice: disclosedNotice(notice, disclosure), redacted: disclosure.redacted };
152152
case 'withheld':
153153
return {
154-
notice: Object.freeze({
155-
...notice,
156-
content: Object.freeze({
157-
root: Object.freeze({ kind: 'text' as const, text: NOTICE_REDACTION_MARK }),
158-
status: notice.content.status,
159-
version: notice.content.version,
160-
}),
161-
}),
154+
notice: Object.freeze({ ...notice, content: noticeRedactionPlaceholder(notice.content) }),
162155
redacted: true,
163156
};
164157
default: {
@@ -406,14 +399,7 @@ const publishProgram = Effect.fnUntraced(function*(
406399
// replay of it) comes back with content, and that content is the caller's.
407400
const notice = persisted.id === prepared.id
408401
? persisted
409-
: Object.freeze({
410-
...persisted,
411-
content: Object.freeze({
412-
root: Object.freeze({ kind: 'text' as const, text: NOTICE_REDACTION_MARK }),
413-
status: persisted.content.status,
414-
version: persisted.content.version,
415-
}),
416-
});
402+
: Object.freeze({ ...persisted, content: noticeRedactionPlaceholder(persisted.content) });
417403
return Object.freeze({
418404
deduped: committed.replayed || persisted.id !== prepared.id,
419405
notice,

‎packages/rsc-runtime/src/notices/redaction.ts‎

Lines changed: 55 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
1-
import { compilePolicy } from 'flare-redact';
1+
import { RedactionLimitError, compilePolicy } from 'flare-redact';
22

3+
import { DEFAULT_AGENT_RENDER_LIMITS } from '../agent-document.js';
34
import type { AgentDocumentNode, AgentDocumentSnapshot } from '../agent-document.js';
45
import type { JsonValue } from '../lower-mcp.js';
56

@@ -36,7 +37,10 @@ import type { JsonValue } from '../lower-mcp.js';
3637
* and IBANs; a structured value stored directly under a credential-shaped
3738
* member name (`password`, `token`, `apiKey`, `authorization`, …) is masked
3839
* whole regardless of content. Paths are not redacted: coordination notices
39-
* legitimately name files. The compiler keeps its own, older credential pass
40+
* legitimately name files. Detector limits at the pinned version are part of
41+
* the contract (README, "Redaction"): an assignment value shorter than four
42+
* characters and an OpenAI key longer than 64 characters are not findings.
43+
* The compiler keeps its own, older credential pass
4044
* for probe and log text (`packages/agent-bundle/src/core/credentials.ts`);
4145
* the two are not held in parity.
4246
*/
@@ -70,11 +74,26 @@ export const NOTICE_REDACTION_MARK = '[REDACTED]';
7074
*/
7175
const secretPass = compilePolicy({ mask: NOTICE_REDACTION_MARK });
7276

77+
/**
78+
* The library refuses a string it cannot bound (more than 50,000 findings, or
79+
* longer than 16 MiB) with `RedactionLimitError`. On egress that refusal
80+
* fails closed: the value is replaced by the mark whole rather than letting
81+
* one pathological notice fail the inbox for every reader.
82+
*/
83+
const failClosed = <T>(run: () => T, fallback: T): T => {
84+
try {
85+
return run();
86+
} catch (error) {
87+
if (error instanceof RedactionLimitError) return fallback;
88+
throw error;
89+
}
90+
};
91+
7392
/** Irreversibly removes recognizable credential material from free text. */
74-
export const redactSecretText = (value: string): string => secretPass.redact(value);
93+
export const redactSecretText = (value: string): string => failClosed(() => secretPass.redact(value), NOTICE_REDACTION_MARK);
7594

7695
/** True when the secret pass would change `value`. */
77-
export const containsSecretText = (value: string): boolean => !secretPass.isClean(value);
96+
export const containsSecretText = (value: string): boolean => failClosed(() => !secretPass.isClean(value), true);
7897

7998
const freezeRedactedJson = (value: JsonValue): JsonValue => {
8099
if (value === null || typeof value !== 'object') return value;
@@ -94,7 +113,8 @@ const freezeRedactedJson = (value: JsonValue): JsonValue => {
94113
* other string; the result is then deep-frozen with its member names passed
95114
* through the same scan.
96115
*/
97-
const redactJson = (value: JsonValue): JsonValue => freezeRedactedJson(secretPass.redact(value));
116+
const redactJson = (value: JsonValue): JsonValue =>
117+
freezeRedactedJson(failClosed<JsonValue>(() => secretPass.redact(value), NOTICE_REDACTION_MARK));
98118

99119
const redactNode = (node: AgentDocumentNode): AgentDocumentNode => {
100120
switch (node.kind) {
@@ -135,17 +155,39 @@ const redactNode = (node: AgentDocumentNode): AgentDocumentNode => {
135155
};
136156

137157
/**
138-
* Applies the secret pass to every free-text field of a detached snapshot.
139-
* Structure, node count, status, and codes are unchanged, so the result still
140-
* satisfies the Agent Document bounds the original passed; a string only ever
141-
* shrinks or is replaced by the fixed mark.
158+
* The document a route hands out in place of content it may not disclose:
159+
* one text node carrying the mark, with the original status and version.
142160
*/
143-
export const redactNoticeDocument = (snapshot: AgentDocumentSnapshot): AgentDocumentSnapshot => Object.freeze({
144-
...snapshot,
145-
root: redactNode(snapshot.root),
146-
...(snapshot.value === undefined ? {} : { value: redactJson(snapshot.value) }),
161+
export const noticeRedactionPlaceholder = (snapshot: AgentDocumentSnapshot): AgentDocumentSnapshot => Object.freeze({
162+
root: Object.freeze({ kind: 'text' as const, text: NOTICE_REDACTION_MARK }),
163+
status: snapshot.status,
164+
version: snapshot.version,
147165
});
148166

167+
const documentBytes = (document: AgentDocumentSnapshot): number =>
168+
new TextEncoder().encode(JSON.stringify(document)).byteLength;
169+
170+
/**
171+
* Applies the secret pass to every free-text field of a detached snapshot.
172+
* Structure, depth, node count, status, and codes are unchanged, so those
173+
* bounds still hold; bytes need not — the mark is longer than the shortest
174+
* values it replaces (`pass=abcd`, `a@b.co`), so a document authored at the
175+
* byte bound can grow past it. A redacted document that no longer fits the
176+
* bound the original passed is replaced by the placeholder rather than handed
177+
* out oversized: the bound is a promise to hosts, made at publish and kept on
178+
* egress.
179+
*/
180+
export const redactNoticeDocument = (snapshot: AgentDocumentSnapshot): AgentDocumentSnapshot => {
181+
const redacted: AgentDocumentSnapshot = Object.freeze({
182+
...snapshot,
183+
root: redactNode(snapshot.root),
184+
...(snapshot.value === undefined ? {} : { value: redactJson(snapshot.value) }),
185+
});
186+
return documentBytes(redacted) > DEFAULT_AGENT_RENDER_LIMITS.maxDocumentBytes
187+
? noticeRedactionPlaceholder(snapshot)
188+
: redacted;
189+
};
190+
149191
const firstProse = (node: AgentDocumentNode): string | undefined => {
150192
switch (node.kind) {
151193
case 'result':

‎packages/rsc-runtime/tests/notices-redaction.test.ts‎

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ import {
1414
createAgentNoticeLedger,
1515
createNoticeInboxSignaller,
1616
disclosedNoticeContent,
17+
noticeRedactionPlaceholder,
1718
noticeTitle,
1819
redactNoticeDocument,
1920
redactSecretText,
@@ -27,8 +28,10 @@ import {
2728
} from '../src/notices/index.js';
2829
import type { AgentDocumentSnapshot } from '../src/index.js';
2930
import {
31+
DEFAULT_AGENT_RENDER_LIMITS,
3032
agent,
3133
available,
34+
createAgentDocument,
3235
runAgentRequest,
3336
unavailable,
3437
} from '../src/index.js';
@@ -206,6 +209,35 @@ describe('secret pass (flare-redact)', () => {
206209
expect((snapshot.root as { children: readonly { text?: string }[] }).children[0]!.text).toBe('password: p4ss');
207210
});
208211

212+
it('hands out the placeholder when redaction would grow a document past the byte bound', () => {
213+
// 350,000 chars of six-character e-mails per node: each finding grows by
214+
// four characters, so two nodes authored well inside 1 MiB redact to ~1.1 MiB.
215+
const dense = 'x@y.io '.repeat(50_000);
216+
const authored = createAgentDocument({
217+
root: { children: [{ kind: 'text', text: dense }, { kind: 'text', text: dense }], kind: 'result' },
218+
status: 'success',
219+
version: 1,
220+
});
221+
expect(Buffer.byteLength(JSON.stringify(authored), 'utf8')).toBeLessThan(DEFAULT_AGENT_RENDER_LIMITS.maxDocumentBytes);
222+
expect(redactNoticeDocument(authored)).toEqual(noticeRedactionPlaceholder(authored));
223+
expect(redactNoticeDocument(authored)).toEqual({ root: { kind: 'text', text: NOTICE_REDACTION_MARK }, status: 'success', version: 1 });
224+
// One node of the same text still fits and is redacted in place.
225+
const single = createAgentDocument({ root: { kind: 'text', text: dense }, status: 'success', version: 1 });
226+
expect((redactNoticeDocument(single).root as { text: string }).text).toBe(`${NOTICE_REDACTION_MARK} `.repeat(50_000));
227+
});
228+
229+
it('fails closed to the mark when the library refuses to bound a string', () => {
230+
// flare-redact throws RedactionLimitError past 50,000 findings in one string.
231+
const pathological = 'x@y.io '.repeat(50_001);
232+
expect(redactSecretText(pathological)).toBe(NOTICE_REDACTION_MARK);
233+
expect(containsSecretText(pathological)).toBe(true);
234+
expect(redactNoticeDocument({
235+
root: { kind: 'json', value: { note: 'keep', wall: [pathological] } },
236+
status: 'success',
237+
version: 1,
238+
}).root).toEqual({ kind: 'json', value: NOTICE_REDACTION_MARK });
239+
});
240+
209241
it('projects a bounded single-line title for title-only routes', () => {
210242
expect(noticeTitle(document(' \n First line here\nsecond'))).toBe('First line here');
211243
expect(noticeTitle(document('x'.repeat(200))).length).toBe(120);

0 commit comments

Comments
 (0)