pi-verdict 0.14.0 → 0.15.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -110,6 +110,7 @@ pi-verdict 0.13+ requires **pi ≥ 0.99** and runs on pi only (native classifier
110
110
  "classifierModel": null,
111
111
  "toggleShortcut": "ctrl+shift+a",
112
112
  "audit": false,
113
+ "auditRedactSecrets": true,
113
114
  "notifyAllows": false,
114
115
  "classifierMinConfidence": null,
115
116
  "classifierFallbackModel": null,
@@ -123,7 +124,7 @@ pi-verdict 0.13+ requires **pi ≥ 0.99** and runs on pi only (native classifier
123
124
  - `builtinDenyFloor: false` turns off the built-in danger/path floor (your risk; the self-protection layer below always stays on)
124
125
  - `classifierModel` pins the classifier model, e.g. `"zai/glm-5.3-flash:low"` (thinking suffix supported; default: session model with thinking off)
125
126
  - `classifierModel: "typesafe/jev-latest"` opts into the **native jev classifier** — one structured `classify()` call per gray-zone verdict via pi's built-in classifier catalog (TypeSafe direct, or Jev on OpenRouter/OpenCode/Cloudflare/Vercel); see [ADR-0005](docs/adr/0005-native-classifier-migration.md)
126
- - `audit: true` records every **gray-zone adjudication** (the full transcript sent to the classifier, its raw response, the parsed verdict) as JSONL under `~/.pi/agent/verdicts/<sessionId>.jsonl` — one file per session, the 20 most recent kept. Interactive asks also record your answer (`userAnswer` ground truth, written after the confirm resolves), and protected-path asks are recorded too (#62); rule allow/deny stays unaudited. Local-only and full-fidelity (protected-path plaintext may appear — it never leaves your machine; [ADR-0002](docs/adr/0002-deny-paths-deterministic-ask.md) boundary note); the agent can neither read nor write the directory. `/automode` shows the audit state and path while on
127
+ - `audit: true` records every **gray-zone adjudication** (the full transcript sent to the classifier, its raw response, the parsed verdict) as JSONL under `~/.pi/agent/verdicts/<sessionId>.jsonl` — one file per session, the 20 most recent kept. Interactive asks also record your answer (`userAnswer` ground truth, written after the confirm resolves), and protected-path asks are recorded too (#62); rule allow/deny stays unaudited. Local-only and full-fidelity (protected-path plaintext may appear — it never leaves your machine; [ADR-0002](docs/adr/0002-deny-paths-deterministic-ask.md) boundary note); secret-shaped text is redacted on append (`<redacted:type#fingerprint>`, deterministic so cross-record tracing survives — `auditRedactSecrets: false` opts out; [ADR-0007](docs/adr/0007-audit-secret-redaction.md)); the agent can neither read nor write the directory. `/automode` shows the audit state and path while on
127
128
  - `notifyAllows: true` notifies on every **classifier allow** (reason + action line — e.g. jev's probability breakdown); default `false` keeps passes silent. Mechanical passes (your own allow rules, protected-path confirms) never notify; with both switches on the notification appears once
128
129
  - `classifierMinConfidence` (optional, [ADR-0004](docs/adr/0004-classifier-fallback-cascade.md)) sets the **confidence floor**: a native-classifier verdict below it is demoted — cascaded to `classifierFallbackModel` if set (`enforce`, the default = the second layer adjudicates; a demoted **deny or ask** can never be auto-relaxed to an allow; a fail-closed layer emitted no verdict, so its rescue stands; `shadow` = records its opinion only and you are asked — `/automode` hints the activation switch), otherwise asked of you directly. At/above the floor the first layer is autonomous. The floor applies to native classifier models only (protocol-native confidence) — with a chat/LLM classifier it is inert, and a one-time warning says so. A natural pairing: jev first + a haiku/flash-class fallback
129
130
 
@@ -201,7 +202,13 @@ tool_call
201
202
  │ ├─ denyPaths (ADR-0002): protected paths → terminal ask,
202
203
  │ │ before user allow; classifier sees an existence hint only
203
204
  │ ├─ ignoreTools: your declared uncovered tools → allow, zero model calls
204
- │ └─ no built-in allowlist — every "always allow" claim is yours to make
205
+ │ ├─ headless subagent-artifacts writes (ADR-0008): a no-UI session's
206
+ │ │ write/edit under <agentDir>/sessions/**/subagent-artifacts/ →
207
+ │ │ deterministic allow (interactive sessions keep the ask); your
208
+ │ │ deny rules and denyPaths still outrank it
209
+ │ └─ no built-in allowlist on your surfaces — every "always allow"
210
+ │ claim is yours to make (the one built-in allow is pi's own
211
+ │ subagent-artifacts subtree, headless-only, ADR-0008)
205
212
  │
206
213
  ├─ 2. Gray zone → nested-call policy, then model classifier
207
214
  │ ├─ nested call (codemode) + codemodeNestedCalls=rules-only → pass;
@@ -234,7 +241,7 @@ Design decisions here are settled by measurement, and the lab notes ship with th
234
241
 
235
242
  ## Status & limitations
236
243
 
237
- - no built-in allowlist by design (see the [bypass writeup](research/rule-layer-security-audit.md)); with an empty `allow` config most commands go to the classifier — point `--auto-mode-model` at a fast model if per-call latency matters
244
+ - no built-in allowlist on user surfaces by design (see the [bypass writeup](research/rule-layer-security-audit.md); the one built-in allow is pi's own subagent-artifacts subtree, headless-only, [ADR-0008](docs/adr/0008-agentdir-sessions-write-exemption.md)); with an empty `allow` config most commands go to the classifier — point `--auto-mode-model` at a fast model if per-call latency matters
238
245
  - the path sensitivity floor applies to file tools only: bash command strings are matched by the danger regexes alone, so e.g. `cat ~/.ssh/id_rsa` goes to the classifier rather than the deterministic S0 deny (the file-tool spelling `read ~/.ssh/id_rsa` does deny)
239
246
  - on Windows the built-in floor covers bash-shaped patterns only — PowerShell-native dangerous commands (`Remove-Item -Recurse -Force`, `Invoke-Expression`, `Set-ExecutionPolicy`, …) rely on the classifier (fail-closed)
240
247
  - on macOS the per-user temp tree (`$TMPDIR`, the `/var/folders/…/T` confstr dir) is exempt from the system-directory floor: reads allow at zero cost, writes adjudicate as ordinary outside-project writes (classifier). The exemption anchors to the runtime-resolved confstr family — a hand-set `TMPDIR` lifts nothing — and `/var/tmp` (POSIX shared temp) stays denied; symlink spellings whose real form escapes the temp tree still hit S1
package/README.zh-CN.md CHANGED
@@ -110,6 +110,7 @@ pi-verdict 0.13+ 需 **pi ≥ 0.99**,仅支持 pi(原生分类器接入,[ADR-000
110
110
  "classifierModel": null,
111
111
  "toggleShortcut": "ctrl+shift+a",
112
112
  "audit": false,
113
+ "auditRedactSecrets": true,
113
114
  "notifyAllows": false,
114
115
  "classifierMinConfidence": null,
115
116
  "classifierFallbackModel": null,
@@ -123,7 +124,7 @@ pi-verdict 0.13+ 需 **pi ≥ 0.99**,仅支持 pi(原生分类器接入,[ADR-000
123
124
  - `builtinDenyFloor: false` 整体关闭内置危险/路径拦截(风险自担;下方自保护层永远开启)
124
125
  - `classifierModel` 指定分类器模型,如 `"zai/glm-5.3-flash:low"`(支持思考后缀;缺省 = 会话模型且显式关思考)
125
126
  - `classifierModel: "typesafe/jev-latest"` 启用**原生 jev 分类器**——每次灰区裁决经 pi 内置分类器目录发一次结构化 `classify()` 调用(TypeSafe 直连,或 OpenRouter/OpenCode/Cloudflare/Vercel 上的 Jev);详见 [ADR-0005](docs/adr/0005-native-classifier-migration.md)
126
- - `audit: true` 把每次**灰区裁决**(发给分类器的完整转录、其原始响应、解析出的裁决)以 JSONL 记录到 `~/.pi/agent/verdicts/<sessionId>.jsonl`——按会话一分文件,保留最近 20 个。交互式 ask 还会记录你的应答(`userAnswer` ground truth,确认结束后落盘),protected-path ask 也入审计(#62);规则 allow/deny 仍不入。仅存本机且全保真(受保护路径明文可能出现——永不出本机;[ADR-0002](docs/adr/0002-deny-paths-deterministic-ask.md) 边界注);agent 对该目录读写双拒。开启时 `/automode` 会显示审计状态与路径
127
+ - `audit: true` 把每次**灰区裁决**(发给分类器的完整转录、其原始响应、解析出的裁决)以 JSONL 记录到 `~/.pi/agent/verdicts/<sessionId>.jsonl`——按会话一分文件,保留最近 20 个。交互式 ask 还会记录你的应答(`userAnswer` ground truth,确认结束后落盘),protected-path ask 也入审计(#62);规则 allow/deny 仍不入。仅存本机且全保真(受保护路径明文可能出现——永不出本机;[ADR-0002](docs/adr/0002-deny-paths-deterministic-ask.md) 边界注);凭证明文在落盘前脱敏(`<redacted:类型#指纹>`,指纹具确定性、跨记录可追踪——`auditRedactSecrets: false` 可显式关闭;[ADR-0007](docs/adr/0007-audit-secret-redaction.md));agent 对该目录读写双拒。开启时 `/automode` 会显示审计状态与路径
127
128
  - `notifyAllows: true` 对每次 **classifier 放行**发通知(reason + action 行——如 jev 的概率分解);默认 `false` 保持放行静默。机械放行(你自己的 allow 规则、protected-path 确认)永不通知;两开关同开时通知只出现一次
128
129
  - `classifierMinConfidence`(可选,[ADR-0004](docs/adr/0004-classifier-fallback-cascade.md))设定**置信地板**:低于它的原生分类器裁决被降级——配置了 `classifierFallbackModel` 则级联(`enforce`,默认 = 第二层全权裁决;例外:降级的 **deny 与 ask** 永不被自动放宽为 allow;fail-closed 未产生裁决,其获救裁决照常生效;`shadow` = 只记录意见、由你裁决——`/automode` 会提示激活开关),否则直接问你。不低于地板时第一层自主。地板仅作用于原生分类器模型(协议原生置信度)——chat/LLM 分类器下不生效,会有一次中性警告提示。天然搭配:jev 在前 + haiku/flash 级回退
129
130
 
@@ -201,7 +202,13 @@ tool_call
201
202
  │ ├─ denyPaths(ADR-0002):受保护路径 → 终局 ask,先于用户 allow;
202
203
  │ │ 分类器只见存在性话术
203
204
  │ ├─ ignoreTools:用户声明的未覆盖工具 → 直接放行,零模型调用
204
- │ └─ 无内置白名单 —— 「永远放行」的声明由你自己做
205
+ │ ├─ headless subagent-artifacts 写入(ADR-0008):无 UI 会话对
206
+ │ │ <agentDir>/sessions/**/subagent-artifacts/ 的 write/edit →
207
+ │ │ 确定性放行(交互会话仍可 ask);用户 deny 规则与 denyPaths
208
+ │ │ 声明仍然优先
209
+ │ └─ 你的操作面上无内置白名单 —— 「永远放行」的声明由你自己做
210
+ │ (唯一的内置放行是 pi 自身的 subagent-artifacts 子树,仅限
211
+ │ headless,ADR-0008)
205
212
  │
206
213
  ├─ 2. 灰区 → 嵌套策略,再进模型分类器
207
214
  │ ├─ 嵌套调用(codemode)+ codemodeNestedCalls=rules-only → 放行;
@@ -234,7 +241,7 @@ tool_call
234
241
 
235
242
  ## 状态与限制
236
243
 
237
- - 设计上无内置白名单(见[绕过测试](research/rule-layer-security-audit.md)与[用户自定义规则](#用户自定义规则pi-verdictjson));allow 配置为空时大多数命令进分类器 —— 延迟敏感可 `--auto-mode-model` 指向轻量模型
244
+ - 设计上在你的操作面无内置白名单(见[绕过测试](research/rule-layer-security-audit.md)与[用户自定义规则](#用户自定义规则pi-verdictjson);唯一内置放行是 pi 自身的 subagent-artifacts 子树、仅限 headless,[ADR-0008](docs/adr/0008-agentdir-sessions-write-exemption.md));allow 配置为空时大多数命令进分类器 —— 延迟敏感可 `--auto-mode-model` 指向轻量模型
238
245
  - 路径敏感度 floor 只作用于文件类工具:bash 命令串仅匹配危险正则——`cat ~/.ssh/id_rsa` 走分类器而非确定性 S0 拦截(文件工具拼写 `read ~/.ssh/id_rsa` 会拦截)
239
246
  - Windows 下内置 floor 仅覆盖 bash 形态模式——PowerShell 原生危险命令(`Remove-Item -Recurse -Force`、`Invoke-Expression`、`Set-ExecutionPolicy` 等)依赖分类器兜底(fail-closed)
240
247
  - macOS 下 per-user 临时目录(`$TMPDIR`,`/var/folders/…/T` confstr 目录)豁免于系统目录 floor:读零成本放行,写按普通项目外写交分类器裁决。豁免锚定运行时解析的 confstr 族——手工设置 `TMPDIR` 不会解除任何保护;`/var/tmp`(POSIX 共享临时目录)维持拦截;真实形态逃逸临时树的符号链接拼写仍命中 S1
@@ -81,6 +81,7 @@
81
81
  * research/pi-model-call-and-ref-implementations.md
82
82
  */
83
83
 
84
+ import * as crypto from "node:crypto";
84
85
  import * as fs from "node:fs";
85
86
  import * as os from "node:os";
86
87
  import * as path from "node:path";
@@ -284,9 +285,12 @@ interface UserRules {
284
285
  * ~350ms/call). Opt-in: rule-passing actions the classifier would have caught
285
286
  * pass under rules-only (coverage is denyPaths-declaration-dependent). */
286
287
  codemodeNestedCalls: "gate" | "rules-only";
288
+ /** ADR-0007: redact secret-shaped text from audit records before append.
289
+ * Default true; false is the explicit keep-raw escape hatch. */
290
+ auditRedactSecrets: boolean;
287
291
  }
288
292
 
289
- const EMPTY_RULES: UserRules = { allow: [], deny: [], denyPaths: [], ignoreTools: [], builtinDenyFloor: true, classifierModel: null, toggleShortcut: DEFAULT_TOGGLE_SHORTCUT, audit: false, notifyAllows: false, classifierMinConfidence: null, classifierFallbackModel: null, classifierFallbackMode: "enforce", codemodeNestedCalls: "gate" };
293
+ const EMPTY_RULES: UserRules = { allow: [], deny: [], denyPaths: [], ignoreTools: [], builtinDenyFloor: true, classifierModel: null, toggleShortcut: DEFAULT_TOGGLE_SHORTCUT, audit: false, notifyAllows: false, classifierMinConfidence: null, classifierFallbackModel: null, classifierFallbackMode: "enforce", codemodeNestedCalls: "gate", auditRedactSecrets: true };
290
294
 
291
295
  /** This module's own file location (import.meta.url resolved; null = unresolvable). */
292
296
  const OWN_FILE_PATH: string | null = (() => {
@@ -355,6 +359,7 @@ const USER_CONFIG_TEMPLATE = `${JSON.stringify({
355
359
  classifierModel: null,
356
360
  toggleShortcut: DEFAULT_TOGGLE_SHORTCUT,
357
361
  audit: false,
362
+ auditRedactSecrets: true,
358
363
  notifyAllows: false,
359
364
  classifierMinConfidence: null,
360
365
  classifierFallbackModel: null,
@@ -379,7 +384,7 @@ function loadUserRules(): { rules: UserRules; skipped: string[]; shortcutWarning
379
384
  } catch { /* 只读环境静默跳过 */ }
380
385
  return { rules: EMPTY_RULES, skipped: [], shortcutWarning: null };
381
386
  }
382
- let raw: { allow?: unknown; deny?: unknown; denyPaths?: unknown; ignoreTools?: unknown; builtinDenyFloor?: unknown; classifierModel?: unknown; toggleShortcut?: unknown; audit?: unknown; notifyAllows?: unknown; classifierFallbackModel?: unknown; classifierFallbackConfidence?: unknown; classifierMinConfidence?: unknown; classifierFallbackMode?: unknown; codemodeNestedCalls?: unknown };
387
+ let raw: { allow?: unknown; deny?: unknown; denyPaths?: unknown; ignoreTools?: unknown; builtinDenyFloor?: unknown; classifierModel?: unknown; toggleShortcut?: unknown; audit?: unknown; notifyAllows?: unknown; classifierFallbackModel?: unknown; classifierFallbackConfidence?: unknown; classifierMinConfidence?: unknown; classifierFallbackMode?: unknown; codemodeNestedCalls?: unknown; auditRedactSecrets?: unknown };
383
388
  try {
384
389
  raw = JSON.parse(fs.readFileSync(p, "utf8")) as typeof raw;
385
390
  } catch (err) {
@@ -427,6 +432,8 @@ function loadUserRules(): { rules: UserRules; skipped: string[]; shortcutWarning
427
432
  // #90: nested-call policy — invalid values skip into the one-shot warning channel
428
433
  const nestedRaw = raw.codemodeNestedCalls;
429
434
  if (nestedRaw !== undefined && nestedRaw !== "gate" && nestedRaw !== "rules-only") skipped.push(`codemodeNestedCalls: ${JSON.stringify(nestedRaw)}`);
435
+ // ADR-0007: audit redaction — non-boolean values skip into the one-shot warning channel, redaction stays on
436
+ if (raw.auditRedactSecrets !== undefined && typeof raw.auditRedactSecrets !== "boolean") skipped.push(`auditRedactSecrets: ${JSON.stringify(raw.auditRedactSecrets)}`);
430
437
  return {
431
438
  rules: {
432
439
  allow: compile(raw.allow),
@@ -442,6 +449,7 @@ function loadUserRules(): { rules: UserRules; skipped: string[]; shortcutWarning
442
449
  classifierMinConfidence: minConfOk ? minConfRaw : null,
443
450
  classifierFallbackMode: fbModeRaw === undefined ? "enforce" : fbModeRaw === "enforce" ? "enforce" : "shadow", // invalid values land on the conservative shadow (standing invalid-config precedent); the key-less default is enforce
444
451
  codemodeNestedCalls: nestedRaw === "rules-only" ? "rules-only" : "gate", // invalid values keep the safe default (gate)
452
+ auditRedactSecrets: raw.auditRedactSecrets !== false, // default on; only an explicit false disables (ADR-0007)
445
453
  },
446
454
  skipped,
447
455
  shortcutWarning: shortcut.warning,
@@ -820,6 +828,8 @@ interface ProtectedSet {
820
828
  prefixes: string[];
821
829
  /** 读拒绝前缀(#54):verdicts 审计目录——记录含不可信原始输出,禁回流 agent context */
822
830
  readPrefixes: string[];
831
+ /** Subagent-artifacts write-exemption tree (ADR-0008): <agentDir>/sessions — headless write/edit to its subagent-artifacts subtree passes deterministically */
832
+ runtimeWritePrefixes: string[];
823
833
  /** bash/powershell 命令串危险特征(子串匹配,可绕——变更检测兜底) */
824
834
  bashPatterns: RegExp[];
825
835
  /** 变更检测基线(词法路径 + 类别;session_start 时快照全文) */
@@ -977,7 +987,15 @@ export function buildProtectedSet(agentDir: string, ownFile: string | null): Pro
977
987
  }
978
988
  bashPatterns.push(new RegExp(`(?:${[...vAlts].join("|")})`));
979
989
 
980
- return { exact: [...exact], prefixes: [...prefixes], readPrefixes: verdictsForms, bashPatterns, watchBases };
990
+ // ADR-0008: <agentDir>/sessions is pi's own runtime output tree (subagent
991
+ // artifacts live there); write/edit grade as deterministic allow. The prefix
992
+ // uses the ancestor-rebuild tier (rebuiltForms), matching the target-side
993
+ // forms: sessions/ may not exist yet on a fresh install, and an allow-grade
994
+ // exemption must not hinge on directory existence (baseForms alone would
995
+ // miss the firmlink real form and silently void the exemption).
996
+ const runtimeWritePrefixes = rebuiltForms(path.join(agentDir, "sessions"));
997
+
998
+ return { exact: [...exact], prefixes: [...prefixes], readPrefixes: verdictsForms, runtimeWritePrefixes, bashPatterns, watchBases };
981
999
  }
982
1000
 
983
1001
  /** Does the resolved write path hit the protected set (realpath guards against
@@ -1008,6 +1026,20 @@ export function isProtectedReadPath(rawPath: string | undefined, cwd: string, pr
1008
1026
  return false;
1009
1027
  }
1010
1028
 
1029
+ /** ADR-0008: subagent-artifacts write exemption — EVERY canonical form must sit
1030
+ * under <agentDir>/sessions AND carry a subagent-artifacts directory segment
1031
+ * (intersection semantics, same discipline as the in-cwd allowance): a lexical
1032
+ * hit whose real form escapes (symlink alias) earns no exemption and falls back
1033
+ * to the classifier. Gated to headless sessions at the call site — an
1034
+ * interactive session keeps its ask capability. */
1035
+ const SUBAGENT_ARTIFACTS_SEG = /(^|\/)subagent-artifacts(\/|$)/;
1036
+ export function isSubagentArtifactWrite(rawPath: string, cwd: string, prot: ProtectedSet): boolean {
1037
+ if (prot.runtimeWritePrefixes.length === 0 || !rawPath) return false;
1038
+ const forms = rebuiltForms(path.resolve(cwd, expandHome(rawPath)));
1039
+ const inTree = (f: string) => prot.runtimeWritePrefixes.some((p) => f === p || f.startsWith(p + path.sep));
1040
+ return forms.every((f) => inTree(f) && SUBAGENT_ARTIFACTS_SEG.test(f));
1041
+ }
1042
+
1011
1043
  /** 自保护层裁决(第 0 层,先于一切):触碰门禁自身文件 → 不可豁免的 deny;其余 null 交后续层 */
1012
1044
  function selfProtectCheck(toolName: string, input: Record<string, unknown>, cwd: string, prot: ProtectedSet): RuleResult | null {
1013
1045
  switch (toolName) {
@@ -1778,20 +1810,110 @@ export interface AuditRecord {
1778
1810
  fallback?: FallbackAudit;
1779
1811
  }
1780
1812
 
1813
+ // ============================================================================
1814
+ // Audit secret redaction (ADR-0007)
1815
+ // ============================================================================
1816
+
1817
+ /** sha256 fingerprint (first 8 hex), unsalted: deterministic — the same secret
1818
+ * keeps one fingerprint across records, preserving clustering and diffability.
1819
+ * The threat model anchors on high-entropy API keys, for which the rainbow
1820
+ * tables that salting guards against are infeasible (ADR-0007). */
1821
+ function redactionFingerprint(secret: string): string {
1822
+ return crypto.createHash("sha256").update(secret).digest("hex").slice(0, 8);
1823
+ }
1824
+
1825
+ /** Context-guided value redaction — shapeless secrets identified by neighboring
1826
+ * structure (assignment/header/URL position). Every value character class
1827
+ * excludes `<` (every replacement starts with `<`, so layering is idempotent);
1828
+ * `#` is excluded only where a bare fragment could look like a value (bearer,
1829
+ * query). The URL value class also excludes `/` (RFC 3986 userinfo forbids it —
1830
+ * and it keeps `host:8080/path@…` port shapes out of the password slot). */
1831
+ const REDACT_CONTEXT_RULES: ReadonlyArray<{ re: RegExp; build: (...m: string[]) => string }> = [
1832
+ { re: /(https?:\/\/[^/\s:@]+:)([^@/\s<]{8,})(?=@)/g, build: (...m) => `${m[1]}<redacted:url#${redactionFingerprint(m[2])}>` },
1833
+ { re: /(-u\s+"?)([^:\s"<]+):([^"\s<]{8,})(")/g, build: (...m) => `${m[1]}${m[2]}:<redacted:basic#${redactionFingerprint(m[3])}>${m[4]}` },
1834
+ { re: /(-u\s+)([^:\s"<]+):([^"\s<]{8,})/g, build: (...m) => `${m[1]}${m[2]}:<redacted:basic#${redactionFingerprint(m[3])}>` },
1835
+ // query runs before env: a `?token=…` param belongs to the query tag; the env
1836
+ // bare-name branch (TOKEN/APIKEY/SECRET) then only sees non-query positions
1837
+ { re: /([?&](?:token|apikey|api_key|access_token|client_secret|signature|sig)=)([^&\s"'#<>]{8,})/gi, build: (...m) => `${m[1]}<redacted:query#${redactionFingerprint(m[2])}>` },
1838
+ { re: /\b([A-Za-z_][A-Za-z0-9_]*(?:_KEY|_TOKEN|_SECRET|_PASSWORD|_CREDENTIALS?)|APIKEY|TOKEN|SECRET)(=)("[^"<]{8,}"|[^\s"<&;]{8,})/gi, build: (...m) => `${m[1]}${m[2]}<redacted:env#${redactionFingerprint(m[3].replace(/^"|"$/g, ""))}>` },
1839
+ { re: /(\bbearer\s+)([A-Za-z0-9._~+/=-]{8,})/gi, build: (...m) => `${m[1]}<redacted:bearer#${redactionFingerprint(m[2])}>` },
1840
+ ];
1841
+
1842
+ /** Exact provider token shapes (near-zero false positives). Order matters both
1843
+ * here (specific prefix before general: sk-ant before sk) and against the
1844
+ * context layer: shapes run SECOND, so a provider-shaped *username* (e.g.
1845
+ * `-u "sk-…:shapelesspass"`) still has its raw form visible when the context
1846
+ * rules match — running shapes first would rewrite the username into a marker
1847
+ * and the `<` exclusion would defeat the `-u` rule, dropping the shapeless
1848
+ * password raw. All patterns are character classes + bounded/greedy
1849
+ * quantifiers — no nested quantifiers, no backtrack blowup. */
1850
+ const REDACT_TOKEN_SHAPES: ReadonlyArray<{ type: string; re: RegExp }> = [
1851
+ { type: "jwt", re: /eyJ[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{4,}/g },
1852
+ { type: "github_pat", re: /github_pat_[A-Za-z0-9_]{22,}/g },
1853
+ { type: "gh", re: /gh[posur]_[A-Za-z0-9]{30,}/g },
1854
+ { type: "sk-ant", re: /sk-ant-[A-Za-z0-9_-]{20,}/g },
1855
+ { type: "sk", re: /sk-[A-Za-z0-9][A-Za-z0-9_-]{15,}/g },
1856
+ { type: "pk", re: /pk-[A-Za-z0-9][A-Za-z0-9_-]{15,}/g },
1857
+ { type: "aws", re: /AKIA[0-9A-Z]{16}/g },
1858
+ { type: "slack", re: /xox[abprs]-[A-Za-z0-9-]{10,}/g },
1859
+ { type: "google", re: /AIza[0-9A-Za-z_-]{30,}/g },
1860
+ ];
1861
+
1862
+ function redactString(s: string): string {
1863
+ let out = s;
1864
+ for (const { re, build } of REDACT_CONTEXT_RULES) {
1865
+ out = out.replace(re, build);
1866
+ }
1867
+ for (const { type, re } of REDACT_TOKEN_SHAPES) {
1868
+ out = out.replace(re, (m) => `<redacted:${type}#${redactionFingerprint(m)}>`);
1869
+ }
1870
+ return out;
1871
+ }
1872
+
1873
+ /** Audit redaction: recursively processes every string value of the record
1874
+ * (input/actionLine/transcript/reason/fallback…), returning a fresh tree and
1875
+ * never mutating the caller's object (pendingAudit is spread later for the
1876
+ * userAnswer finalize). Exotic objects (Date, …) pass through untouched —
1877
+ * JSON.stringify keeps authority over their serialization. Applies to the
1878
+ * persisted copy only — the adjudication pipeline and the classifier input
1879
+ * keep the raw text: the fact that a command carries a secret is itself an
1880
+ * adjudication signal (ADR-0007). */
1881
+ export function redactSecrets<T>(value: T): T {
1882
+ if (typeof value === "string") return redactString(value) as T;
1883
+ if (Array.isArray(value)) return value.map((v) => redactSecrets(v)) as T;
1884
+ if (value !== null && typeof value === "object") {
1885
+ const proto = Object.getPrototypeOf(value);
1886
+ if (proto !== Object.prototype && proto !== null) return value;
1887
+ const out: Record<string, unknown> = {};
1888
+ for (const [k, v] of Object.entries(value as Record<string, unknown>)) out[k] = redactSecrets(v);
1889
+ return out as T;
1890
+ }
1891
+ return value;
1892
+ }
1893
+
1781
1894
  /** Audit sink (#54): append-only and fail-soft (the first write failure surfaces
1782
1895
  * once via drainWarning; verdicts are never affected). The dir is created
1783
1896
  * lazily — audit on with no gray-zone call all session leaves zero filesystem trace. */
1784
1897
  export class AuditLog {
1785
1898
  private warning: string | null = null;
1786
1899
  private warned = false;
1787
- constructor(readonly dir: string) {}
1900
+ constructor(readonly dir: string, readonly redact = true) {}
1788
1901
 
1789
1902
  append(record: AuditRecord): void {
1790
1903
  // sessionId comes from the host with no shape guarantee: narrow to a safe filename charset
1791
1904
  const file = path.join(this.dir, `${record.sessionId.replace(/[^a-zA-Z0-9_-]/g, "_")}.jsonl`);
1792
1905
  try {
1793
1906
  fs.mkdirSync(this.dir, { recursive: true });
1794
- fs.appendFileSync(file, JSON.stringify(record) + "\n");
1907
+ // ADR-0007: redact the persisted copy only (new tree — the caller's
1908
+ // pendingAudit object is spread later for the userAnswer finalize);
1909
+ // a redaction failure fails open: record integrity over hygiene
1910
+ let persisted: AuditRecord = record;
1911
+ if (this.redact) {
1912
+ try {
1913
+ persisted = redactSecrets(record);
1914
+ } catch { /* fail-open */ }
1915
+ }
1916
+ fs.appendFileSync(file, JSON.stringify(persisted) + "\n");
1795
1917
  } catch (err) {
1796
1918
  if (!this.warned) {
1797
1919
  this.warned = true;
@@ -1859,7 +1981,7 @@ export class SessionState {
1859
1981
 
1860
1982
  /** #54: the audit flag follows the rules (applies to new sessions); the dir is anchored to the install path */
1861
1983
  private makeAudit(rules: UserRules): AuditLog | null {
1862
- return rules.audit && this.agentDir ? new AuditLog(path.join(this.agentDir, "verdicts")) : null;
1984
+ return rules.audit && this.agentDir ? new AuditLog(path.join(this.agentDir, "verdicts"), rules.auditRedactSecrets) : null;
1863
1985
  }
1864
1986
 
1865
1987
  /** 会话重置:重载用户规则(配置改动新会话生效)+ 按会话 cwd 重锚 denyPaths
@@ -2018,6 +2140,14 @@ export async function adjudicate(
2018
2140
  const rule = classifyByRules(call.toolName, call.input, env.cwd, state.userRules, state.prot, state.anchoredDenyPathBases(env.cwd));
2019
2141
  if (rule.verdict === "allow") return { verdict: "allow", reason: rule.reason ?? "", source: "rule", degraded: false };
2020
2142
  if (rule.verdict === "deny") return { verdict: "deny", reason: rule.reason ?? "", source: "rule", degraded: false };
2143
+ // ADR-0008: subagent artifacts — a headless (no-UI) session writing pi's own
2144
+ // runtime artifact tree passes deterministically; user deny and denyPaths have
2145
+ // already ruled above (declarations beat the built-in exemption), and only the
2146
+ // gray grade is lifted. An interactive session keeps its ask capability, so the
2147
+ // exemption stays headless-only. Read and bash are deliberately NOT exempt.
2148
+ if (rule.verdict === "gray" && !env.hasUI && (call.toolName === "write" || call.toolName === "edit") && isSubagentArtifactWrite(String(call.input.path ?? ""), env.cwd, state.prot)) {
2149
+ return { verdict: "allow", reason: "subagent artifacts (headless) — deterministic write exemption (ADR-0008)", source: "rule", degraded: false };
2150
+ }
2021
2151
 
2022
2152
  // #62: the audit surface widens to protected-path asks (their user answers grade the
2023
2153
  // denyPaths rules); rule allow/deny stay unaudited (no corpus value, #54). Record
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-verdict",
3
- "version": "0.14.0",
3
+ "version": "0.15.0",
4
4
  "description": "A minimal permission gate for Pi, inspired by Claude Code's auto mode",
5
5
  "author": "Jesset (https://github.com/jesset)",
6
6
  "type": "module",