Skip to content

Commit 94aefeb

Browse files
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.
1 parent 0f5f6da commit 94aefeb

18 files changed

Lines changed: 234 additions & 272 deletions

‎.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 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)
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)

‎docs/entry-conventions.md‎

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -195,8 +195,11 @@ notice whose author-declared `sensitivity` exceeds the ceiling of the route
195195
about to carry it — the inbox omits it, event admission neither authorizes nor
196196
attempts it, the signaller never announces it — recording the refusal on the
197197
notice (`withheld[route]`) instead of moving its state. `internal` content
198-
(the default) is passed through the runtime's secret-pattern redaction on
199-
every route before it leaves the store; `public` travels as authored;
198+
(the default) is passed through the runtime's secret pass on every route
199+
before it leaves the store — `flare-redact`, an exact-pinned dependency of
200+
`@agent-bundle/runtime`, with its default detectors and every finding replaced
201+
whole by `[REDACTED]`; the runtime README's notices section lists the coverage
202+
and the libraries evaluated — `public` travels as authored;
200203
`secret` travels as authored only where the row admits it. The built-in hosts
201204
admit `secret` on `current-response` and `next-event` and `internal` on
202205
`mcp-inbox` and `mcp-resource-updated`; the pinned tables carry the dated

‎docs/framework-mode.md‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -334,7 +334,8 @@ defaulted.
334334
Redaction is not configured here: it follows the notice's author-declared
335335
`sensitivity` (`public | internal | secret`, default `internal`, passed to
336336
`notices.publish()`) and each host's dated per-route ceiling in its pinned
337-
`noticeDelivery` table. `internal` content is secret-pattern redacted on every
337+
`noticeDelivery` table. `internal` content is passed through the runtime's
338+
secret pass (`flare-redact`, pinned exact; see the runtime README) on every
338339
route, `public` travels as authored, and `secret` travels only over a route
339340
whose ceiling admits it — otherwise it stays in the store and the route
340341
records the refusal on the notice.

‎packages/agent-bundle/src/adapters/notice-delivery.ts‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@ export type NoticeDeliveryRoute = (typeof NOTICE_DELIVERY_ROUTES)[number];
2121
/**
2222
* Author-declared disclosure classes of a notice, mirroring the runtime's
2323
* `AgentNoticeSensitivity`: `public` is delivered as authored, `internal`
24-
* (the default) after the secret-pattern pass, `secret` only over a route
24+
* (the default) after the runtime's secret pass, `secret` only over a route
2525
* whose row admits it.
2626
*/
2727
export const NOTICE_SENSITIVITIES = Object.freeze(['public', 'internal', 'secret'] as const);

‎packages/agent-bundle/src/config/notice-retention.ts‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@ import type {
1515
*/
1616

1717
// Kept independent of the optional runtime peer, like the state budgets in
18-
// `core/state-inspection.ts`; `notice-retention-parity.test.ts` compares these
18+
// `core/state-inspection.ts`; `notice-retention-config.test.ts` compares these
1919
// with `AGENT_NOTICE_DEFAULT_RETENTION` so the two boundaries cannot drift.
2020
export const noticeRetentionDefaults: NormalizedNoticeRetentionPolicy = Object.freeze({
2121
maxJournalBytes: 16 * 1024 * 1024,

‎packages/agent-bundle/src/core/credentials.ts‎

Lines changed: 28 additions & 69 deletions
Original file line numberDiff line numberDiff line change
@@ -8,31 +8,22 @@
88
* detect or irreversibly remove credential *values* in arbitrary text.
99
*/
1010

11-
/**
12-
* Sources of the key-name classifier. The notice ledger in
13-
* `@agent-bundle/runtime` (`notices/redaction.ts`, `NOTICE_SECRET_KEY_SOURCES`)
14-
* carries the same table for structured notice content; the parity test pins
15-
* them equal. Edit both together.
16-
*/
17-
export const CREDENTIAL_KEY_SOURCES = Object.freeze({
18-
compactSuffix: String.raw`(?:apikey|apitoken|authtoken|accesstoken)$`,
19-
keywords: Object.freeze(['authorization', 'credential', 'credentials', 'password', 'secret', 'token']),
20-
// The segment heuristic subsumes these today (every match contains a `token`
21-
// segment or an apikey/apitoken/accesstoken suffix), but they stay explicit
22-
// so the union survives future keyword-list edits.
23-
provider: Object.freeze([
24-
String.raw`(?:^|_)(?:API_KEY|API_TOKEN|ACCESS_TOKEN)$`,
25-
String.raw`^(?:ANTHROPIC|AZURE_OPENAI|CODEX|COHERE|DEEPSEEK|FIREWORKS|GEMINI|GOOGLE|GROQ|HUGGINGFACE|MISTRAL|OPENAI|PERPLEXITY|TOGETHER|XAI)_(?:API_KEY|TOKEN)$`,
26-
]),
27-
});
28-
29-
const credentialKeywords = CREDENTIAL_KEY_SOURCES.keywords;
11+
const credentialKeywords = Object.freeze([
12+
'authorization',
13+
'credential',
14+
'credentials',
15+
'password',
16+
'secret',
17+
'token',
18+
]);
3019

31-
const compactSuffixPattern = new RegExp(CREDENTIAL_KEY_SOURCES.compactSuffix, 'u');
32-
33-
const providerKeyPatterns = Object.freeze(
34-
CREDENTIAL_KEY_SOURCES.provider.map((source) => new RegExp(source, 'iu')),
35-
);
20+
// The segment heuristic in isCredentialKey subsumes these today (every match
21+
// contains a `token` segment or an apikey/apitoken/accesstoken suffix), but
22+
// they stay explicit so the union survives future keyword-list edits.
23+
const providerKeyPatterns = Object.freeze([
24+
/(?:^|_)(?:API_KEY|API_TOKEN|ACCESS_TOKEN)$/iu,
25+
/^(?:ANTHROPIC|AZURE_OPENAI|CODEX|COHERE|DEEPSEEK|FIREWORKS|GEMINI|GOOGLE|GROQ|HUGGINGFACE|MISTRAL|OPENAI|PERPLEXITY|TOGETHER|XAI)_(?:API_KEY|TOKEN)$/iu,
26+
]);
3627

3728
/**
3829
* Union key-name classifier: keyword segments (authorization, credential,
@@ -49,7 +40,7 @@ export const isCredentialKey = (key: string): boolean => {
4940
.filter((segment) => segment.length > 0);
5041
const compact = segments.join('');
5142
return segments.some((segment) => credentialKeywords.includes(segment))
52-
|| compactSuffixPattern.test(compact)
43+
|| /(?:apikey|apitoken|authtoken|accesstoken)$/u.test(compact)
5344
|| providerKeyPatterns.some((pattern) => pattern.test(key));
5445
};
5546

@@ -61,35 +52,11 @@ export const isCredentialKey = (key: string): boolean => {
6152
export const isProviderEndpointKey = (key: string): boolean =>
6253
/^(?:CODEX|OPENAI)_(?:API_BASE|BASE_URL|URL)$/iu.test(key);
6354

64-
/**
65-
* Pattern sources of the free-text secret pass. The notice ledger in
66-
* `@agent-bundle/runtime` (`notices/redaction.ts`, `NOTICE_SECRET_PATTERN_SOURCES`)
67-
* carries the same three sources: the runtime is an optional peer of this
68-
* package, so neither side can import the other's module, and
69-
* `notice-redaction-parity.test.ts` pins them byte-identical instead. Edit
70-
* both together.
71-
*/
72-
export const CREDENTIAL_TEXT_PATTERN_SOURCES = Object.freeze({
73-
assignment: String.raw`((?:["']?)(?:api[-_ ]?key|api[-_ ]?token|access[-_ ]?token|authorization|credential|password|secret|token)(?:["']?)\s*[:=]\s*)("[^"\r\n]*"|'[^'\r\n]*'|[^\s,;\r\n]+)`,
74-
provider: Object.freeze([
75-
String.raw`\bsk-(?:proj-|ant-|live-)?[a-z0-9_-]{16,}\b`,
76-
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`,
77-
String.raw`\bbearer[ \t]+[a-z0-9._~+/=-]{20,}\b`,
78-
]),
79-
/**
80-
* URL userinfo (`scheme://user:secret@host`): the authority runs until one
81-
* of the terminators every WHATWG scheme shares (`/`, `?`, `#`) and the
82-
* match is greedy through the final `@`, so a raw `@`, quote, backslash, or
83-
* whitespace inside a password cannot leave part of it behind. The scheme is
84-
* anchored to the start of its own character run, so a URL glued to an
85-
* identifier (`_https://user:secret@…`) is masked too.
86-
*/
87-
urlUserinfo: String.raw`(?<![a-z0-9+.-])([a-z][a-z0-9+.-]*:\/\/)[^/?#]*@`,
88-
});
89-
90-
const providerCredentialPatterns = Object.freeze(
91-
CREDENTIAL_TEXT_PATTERN_SOURCES.provider.map((source) => new RegExp(source, 'iu')),
92-
);
55+
const providerCredentialPatterns = Object.freeze([
56+
/\bsk-(?:proj-|ant-|live-)?[a-z0-9_-]{16,}\b/iu,
57+
/\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,
58+
/\bbearer[ \t]+[a-z0-9._~+/=-]{20,}\b/iu,
59+
]);
9360

9461
// `String.prototype.replace` resets `lastIndex` on global regexes, so sharing these is safe.
9562
const globalProviderCredentialPatterns = Object.freeze(
@@ -100,24 +67,16 @@ const globalProviderCredentialPatterns = Object.freeze(
10067
export const containsProviderCredential = (value: string): boolean =>
10168
providerCredentialPatterns.some((pattern) => pattern.test(value));
10269

103-
const credentialAssignmentPattern = new RegExp(CREDENTIAL_TEXT_PATTERN_SOURCES.assignment, 'giu');
70+
const credentialAssignmentPattern = /((?:["']?)(?:api[-_ ]?key|api[-_ ]?token|access[-_ ]?token|authorization|credential|password|secret|token)(?:["']?)\s*[:=]\s*)("[^"\r\n]*"|'[^'\r\n]*'|[^\s,;\r\n]+)/giu;
10471

105-
/** Masks `scheme://user:secret@host` credentials; shared by the probe and Workbench log surfaces. */
106-
export const urlUserinfoPattern = new RegExp(CREDENTIAL_TEXT_PATTERN_SOURCES.urlUserinfo, 'giu');
107-
108-
/**
109-
* Raw process output remains useful evidence after known credential material
110-
* is irreversibly removed. Provider forms go first: an unquoted
111-
* `authorization: Bearer <token>` would otherwise lose only the word `Bearer`
112-
* to the assignment pass and keep the token.
113-
*/
72+
/** Raw process output remains useful evidence after known credential material is irreversibly removed. */
11473
export const redactCredentialText = (value: string): string => {
115-
let redacted = value;
116-
for (const pattern of globalProviderCredentialPatterns) {
117-
redacted = redacted.replace(pattern, '[REDACTED]');
118-
}
119-
return redacted.replace(credentialAssignmentPattern, (_match, prefix: string, assigned: string) => {
74+
let redacted = value.replace(credentialAssignmentPattern, (_match, prefix: string, assigned: string) => {
12075
const quote = assigned[0] === '"' || assigned[0] === "'" ? assigned[0] : '';
12176
return `${prefix}${quote}[REDACTED]${quote}`;
12277
});
78+
for (const pattern of globalProviderCredentialPatterns) {
79+
redacted = redacted.replace(pattern, '[REDACTED]');
80+
}
81+
return redacted;
12382
};

‎packages/agent-bundle/src/dev/playground/mcp-probe-service.ts‎

Lines changed: 22 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,7 @@ import type {
2222
McpProbeSnapshot,
2323
McpProbeTool,
2424
} from '../../contracts/mcp-probe.ts';
25-
import { redactCredentialText, urlUserinfoPattern } from '../../core/credentials.ts';
25+
import { redactCredentialText } from '../../core/credentials.ts';
2626
import { parseJsonWithoutDuplicateKeys } from '../../core/strict-json.ts';
2727
import { resolveBundleRoot } from '../../install/doctor.ts';
2828
import {
@@ -190,16 +190,30 @@ const hasAbsolutePath = (value: string): boolean =>
190190
localUriPathPattern.test(value) ||
191191
/(?:file:|(?:^|[\s"'([{=,]|:(?!\/\/))\/[^\s,;{}()[\]<>"']+|(?:^|[\s"'([{=,:])[A-Za-z]:[\\/]|\\\\)/u.test(value);
192192

193+
/**
194+
* URL userinfo (`scheme://user:secret@host`) is a credential that the generic
195+
* credential redaction does not recognize; because URLs are exempt from the
196+
* absolute-path fail-closed rule, the userinfo is stripped before that check.
197+
* The authority runs until one of the terminators every WHATWG scheme shares
198+
* (`/`, `?`, `#`); within it the match is greedy through the *final* `@`, the
199+
* delimiter URL parsers honour, so a raw `@`, quote, backslash, or whitespace
200+
* inside a password (parsers percent-encode spaces and strip embedded tabs and
201+
* newlines) cannot leave part of the credential behind. Nothing short of those
202+
* three terminators ends the run on purpose — `\` is userinfo for non-special
203+
* schemes and whitespace is encoded rather than rejected — so a path-less URL
204+
* followed on the same text by an `@` before any `/`, `?`, or `#` is masked
205+
* as well: for a browser-facing report that over-redaction is the safe side.
206+
* Like the local-URI rule, the scheme is anchored to the start of its own
207+
* character run rather than to a word boundary, so a URL glued to a preceding
208+
* identifier (`_https://user:secret@…`) is masked too.
209+
*/
210+
const urlUserinfoPattern = /(?<![a-z0-9+.-])([a-z][a-z0-9+.-]*:\/\/)[^/?#]*@/giu;
211+
193212
/**
194213
* Probe text follows the Dev Log browser-wire precedent without coupling this
195214
* read-only service to log retention: credential text is removed first, URL
196-
* userinfo (`scheme://user:secret@host`, a credential the assignment pass
197-
* does not recognize; because URLs are exempt from the absolute-path
198-
* fail-closed rule it is stripped before that check — the shared
199-
* `urlUserinfoPattern` documents its greedy-through-the-final-`@` rule and
200-
* the deliberate over-redaction of a path-less URL followed by an `@`) is
201-
* masked, bundle paths become a label, and every other absolute path fails
202-
* closed.
215+
* userinfo is masked, bundle paths become a label, and every other absolute
216+
* path fails closed.
203217
*/
204218
const redactProbeText = (value: string, bundleRoot: string, maximum: number): string => {
205219
const redacted = redactCredentialText(value).replace(urlUserinfoPattern, '$1[REDACTED]@').replace(

‎packages/agent-bundle/tests/notice-redaction-parity.test.ts‎

Lines changed: 0 additions & 66 deletions
This file was deleted.

‎packages/agent-bundle/tests/notice-retention-config.test.ts‎

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
import { describe, expect, it } from '@rstest/core';
22

3+
import { AGENT_NOTICE_DEFAULT_RETENTION } from '@agent-bundle/runtime/notices';
4+
35
import {
46
normalizeNoticeRetention,
57
noticeRetentionDefaults,
@@ -13,6 +15,13 @@ const config = (notices: unknown): AgentBundleConfig => ({
1315
} as AgentBundleConfig);
1416

1517
describe('notices.retention config (AB4833)', () => {
18+
it('keeps the static defaults equal to the runtime defaults', () => {
19+
// `@agent-bundle/runtime` is an optional peer, so the compiler carries its
20+
// own copy of the defaults; this pin fails the build the moment it drifts
21+
// (the same discipline `inspect-state.test.ts` applies to the state budgets).
22+
expect(noticeRetentionDefaults).toEqual(AGENT_NOTICE_DEFAULT_RETENTION);
23+
});
24+
1625
it('parses durations as positive integers of milliseconds or unit literals', () => {
1726
expect(parseNoticeRetentionDuration(1)).toBe(1);
1827
expect(parseNoticeRetentionDuration(86_400_000)).toBe(86_400_000);

0 commit comments

Comments
 (0)