@lannguyensi/harness 0.49.0 → 0.51.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.
Files changed (120) hide show
  1. package/CHANGELOG.md +791 -0
  2. package/dist/cli/add/index.js +15 -3
  3. package/dist/cli/add/index.js.map +1 -1
  4. package/dist/cli/apply/apply.js +19 -3
  5. package/dist/cli/apply/apply.js.map +1 -1
  6. package/dist/cli/approve/risk.d.ts +28 -2
  7. package/dist/cli/approve/risk.js +14 -4
  8. package/dist/cli/approve/risk.js.map +1 -1
  9. package/dist/cli/approve/understanding.d.ts +51 -0
  10. package/dist/cli/approve/understanding.js +17 -2
  11. package/dist/cli/approve/understanding.js.map +1 -1
  12. package/dist/cli/diff/index.js +22 -1
  13. package/dist/cli/diff/index.js.map +1 -1
  14. package/dist/cli/doctor/claude-mcp.d.ts +14 -0
  15. package/dist/cli/doctor/claude-mcp.js +7 -1
  16. package/dist/cli/doctor/claude-mcp.js.map +1 -1
  17. package/dist/cli/doctor/format.js +48 -3
  18. package/dist/cli/doctor/format.js.map +1 -1
  19. package/dist/cli/doctor/index.d.ts +20 -0
  20. package/dist/cli/doctor/index.js +66 -2
  21. package/dist/cli/doctor/index.js.map +1 -1
  22. package/dist/cli/doctor/settings-drift.d.ts +19 -0
  23. package/dist/cli/doctor/settings-drift.js +189 -0
  24. package/dist/cli/doctor/settings-drift.js.map +1 -0
  25. package/dist/cli/doctor/types.d.ts +52 -0
  26. package/dist/cli/doctor/types.js.map +1 -1
  27. package/dist/cli/doctor/ug-auto-approvals.d.ts +40 -0
  28. package/dist/cli/doctor/ug-auto-approvals.js +116 -0
  29. package/dist/cli/doctor/ug-auto-approvals.js.map +1 -0
  30. package/dist/cli/doctor/understanding-mode-env.d.ts +10 -0
  31. package/dist/cli/doctor/understanding-mode-env.js +12 -0
  32. package/dist/cli/doctor/understanding-mode-env.js.map +1 -1
  33. package/dist/cli/explain-policy.d.ts +4 -1
  34. package/dist/cli/explain-policy.js +12 -2
  35. package/dist/cli/explain-policy.js.map +1 -1
  36. package/dist/cli/export.js +12 -1
  37. package/dist/cli/export.js.map +1 -1
  38. package/dist/cli/index.js +40 -7
  39. package/dist/cli/index.js.map +1 -1
  40. package/dist/cli/init/templates.d.ts +1 -1
  41. package/dist/cli/init/templates.js +65 -1
  42. package/dist/cli/init/templates.js.map +1 -1
  43. package/dist/cli/list.js +6 -0
  44. package/dist/cli/list.js.map +1 -1
  45. package/dist/cli/loader.js +15 -0
  46. package/dist/cli/loader.js.map +1 -1
  47. package/dist/cli/pack/auto-approve-path.d.ts +118 -0
  48. package/dist/cli/pack/auto-approve-path.js +347 -0
  49. package/dist/cli/pack/auto-approve-path.js.map +1 -0
  50. package/dist/cli/pack/hook-codex-pre-tool-use.d.ts +9 -0
  51. package/dist/cli/pack/hook-codex-pre-tool-use.js +129 -10
  52. package/dist/cli/pack/hook-codex-pre-tool-use.js.map +1 -1
  53. package/dist/cli/pack/hook-post-tool-use.d.ts +6 -4
  54. package/dist/cli/pack/hook-post-tool-use.js.map +1 -1
  55. package/dist/cli/pack/hook-pre-tool-use.d.ts +11 -0
  56. package/dist/cli/pack/hook-pre-tool-use.js +90 -23
  57. package/dist/cli/pack/hook-pre-tool-use.js.map +1 -1
  58. package/dist/cli/remove/index.d.ts +12 -0
  59. package/dist/cli/remove/index.js +89 -0
  60. package/dist/cli/remove/index.js.map +1 -1
  61. package/dist/cli/validate/checks.d.ts +48 -0
  62. package/dist/cli/validate/checks.js +312 -0
  63. package/dist/cli/validate/checks.js.map +1 -1
  64. package/dist/cli/validate/index.js +11 -0
  65. package/dist/cli/validate/index.js.map +1 -1
  66. package/dist/policy-packs/builtin/understanding-before-execution/auto-approve.d.ts +136 -0
  67. package/dist/policy-packs/builtin/understanding-before-execution/auto-approve.js +224 -0
  68. package/dist/policy-packs/builtin/understanding-before-execution/auto-approve.js.map +1 -0
  69. package/dist/policy-packs/builtin/understanding-before-execution/index.d.ts +3 -2
  70. package/dist/policy-packs/builtin/understanding-before-execution/index.js +35 -28
  71. package/dist/policy-packs/builtin/understanding-before-execution/index.js.map +1 -1
  72. package/dist/policy-packs/builtin/understanding-before-execution/markers.js +2 -2
  73. package/dist/policy-packs/builtin/understanding-before-execution/markers.js.map +1 -1
  74. package/dist/policy-packs/builtin/understanding-before-execution/measured-permission-modes.d.ts +29 -0
  75. package/dist/policy-packs/builtin/understanding-before-execution/measured-permission-modes.js +82 -0
  76. package/dist/policy-packs/builtin/understanding-before-execution/measured-permission-modes.js.map +1 -0
  77. package/dist/policy-packs/builtin/understanding-before-execution/persisted-reports.d.ts +69 -8
  78. package/dist/policy-packs/builtin/understanding-before-execution/persisted-reports.js +100 -12
  79. package/dist/policy-packs/builtin/understanding-before-execution/persisted-reports.js.map +1 -1
  80. package/dist/policy-packs/builtin/understanding-before-execution/post-tool-use-boundary.js +6 -3
  81. package/dist/policy-packs/builtin/understanding-before-execution/post-tool-use-boundary.js.map +1 -1
  82. package/dist/policy-packs/builtin/understanding-before-execution.d.ts +23 -0
  83. package/dist/policy-packs/builtin/understanding-before-execution.js +36 -9
  84. package/dist/policy-packs/builtin/understanding-before-execution.js.map +1 -1
  85. package/dist/runtime/approval-signing.d.ts +22 -0
  86. package/dist/runtime/approval-signing.js +55 -1
  87. package/dist/runtime/approval-signing.js.map +1 -1
  88. package/dist/runtime/command-normalize.d.ts +112 -0
  89. package/dist/runtime/command-normalize.js +169 -55
  90. package/dist/runtime/command-normalize.js.map +1 -1
  91. package/dist/runtime/deletion-target-resolve.d.ts +28 -0
  92. package/dist/runtime/deletion-target-resolve.js +1055 -0
  93. package/dist/runtime/deletion-target-resolve.js.map +1 -0
  94. package/dist/runtime/index.d.ts +1 -0
  95. package/dist/runtime/index.js +1 -0
  96. package/dist/runtime/index.js.map +1 -1
  97. package/dist/runtime/intercept.js +17 -1
  98. package/dist/runtime/intercept.js.map +1 -1
  99. package/dist/runtime/kubectl-target-parse.d.ts +16 -0
  100. package/dist/runtime/kubectl-target-parse.js +9 -1
  101. package/dist/runtime/kubectl-target-parse.js.map +1 -1
  102. package/dist/runtime/read-only-bash.d.ts +11 -0
  103. package/dist/runtime/read-only-bash.js +288 -7
  104. package/dist/runtime/read-only-bash.js.map +1 -1
  105. package/dist/runtime/risk-classifier.js +14 -1
  106. package/dist/runtime/risk-classifier.js.map +1 -1
  107. package/dist/runtime/when-eval.d.ts +11 -2
  108. package/dist/runtime/when-eval.js +25 -0
  109. package/dist/runtime/when-eval.js.map +1 -1
  110. package/dist/runtime/workflow-policies.d.ts +164 -0
  111. package/dist/runtime/workflow-policies.js +439 -0
  112. package/dist/runtime/workflow-policies.js.map +1 -0
  113. package/dist/schema/index.d.ts +24 -0
  114. package/dist/schema/policies.d.ts +25 -0
  115. package/dist/schema/policies.js +29 -0
  116. package/dist/schema/policies.js.map +1 -1
  117. package/dist/schema/risk.d.ts +6 -0
  118. package/dist/schema/risk.js +80 -0
  119. package/dist/schema/risk.js.map +1 -1
  120. package/package.json +1 -1
package/CHANGELOG.md CHANGED
@@ -7,6 +7,797 @@ and this project adheres to [Semantic Versioning](https://semver.org/).
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.51.0] - 2026-08-27
11
+
12
+ ### Added
13
+
14
+ - **Understanding gate: operator opt-in auto-approval for allowlisted
15
+ permission modes** (agent-tasks `74b4b17d`, slice 1 of the ADR
16
+ `docs/decisions/2026-08-27-ug-auto-mode-approval.md`). A new strict
17
+ pack-config block `auto_approve: { when: [...], require_report: true }`
18
+ (`require_report: false`, unknown keys and an empty `when` are
19
+ `harness validate` errors) lets the `understanding-before-execution`
20
+ PreToolUse hook write the signed SESSION approval marker itself, with
21
+ `approvedBy: auto-mode:claude-code:<mode>`, as the last step of its
22
+ decision order: only after the ordinary marker check, the read-only
23
+ Bash exemption, the recovery commit and the escape `ask` have each
24
+ declined, and only when the payload's `permission_mode` is in `when`,
25
+ the payload `session_id` equals the hook's `$CLAUDE_CODE_SESSION_ID`,
26
+ the signing key already exists (the hook never creates one), no forged
27
+ marker was seen, and the newest report bound to the session is exactly
28
+ `pending` and passes the approve CLI's own validation. The report is
29
+ consumed (flipped to `approved`, so one report mints at most once), the
30
+ audit-only ledger fact `understanding-auto-approved:<sid>` is recorded,
31
+ the `.pending-approval` staging entry is cleared, and the verdict comes
32
+ from re-running the same marker check. Every missing input fails
33
+ closed to today's block; the hook's fail-open infrastructure contract
34
+ is unchanged. New module `src/cli/pack/auto-approve-path.ts`,
35
+ `signingKeyExists` (non-creating) in `src/runtime/approval-signing.ts`,
36
+ `selectNewestStrictSessionReport` (no tolerant fallback, newest strict
37
+ match only) in `persisted-reports.ts`; `validatePersistedReport` and
38
+ `rewriteReportApproved` are exported from the approve CLI and reused,
39
+ not copied. Pinned by the ADR's negative controls and mutation probes
40
+ in `tests/cli/pack-hook-pre-tool-use-auto-approve.test.ts`.
41
+ - **Understanding gate: the opt-in auto-approval path now runs on Codex
42
+ too, closing parity gap 13** (agent-tasks `57058364`, slice 2 of the
43
+ ADR `docs/decisions/2026-08-27-ug-auto-mode-approval.md`; commits
44
+ `cd6a37e`, `9e4ef7b`, `2635ce1`, `3c71b05`, `ab58d84`). `attemptAutoApproval`
45
+ (`src/cli/pack/auto-approve-path.ts`) becomes runtime-agnostic: the
46
+ caller names the harness for the minted marker's
47
+ `approvedBy: auto-mode:<harness>:<mode>` and supplies which
48
+ session-consistency evidence its runtime actually offers: an env
49
+ variable for Claude Code (unchanged) or, for Codex, the payload
50
+ `transcript_path` (Codex exports no session-id environment variable to
51
+ hook processes, measured live), and that check passes only when the
52
+ transcript file's own name carries the session id and the file exists
53
+ on disk. `harness pack hook codex-pre-tool-use` now runs this same
54
+ attempt at the same point in its decision order as the Claude hook,
55
+ after the read-only Bash and recovery-commit exemptions, before
56
+ `.pending-approval` staging (allow only via the marker recheck); it reads
57
+ `permission_mode` and `transcript_path` from the payload and resolves
58
+ tool arguments through the shared `tool_input`/`raw_input` precedence
59
+ (`tool_input` preferred) so the real Codex payload reaches the
60
+ read-only and recovery-commit exemptions too. The block gains an
61
+ explicit `harnesses` list (`claude-code`, `codex`; default Claude Code
62
+ only), so an existing Claude-only opt-in never extends to Codex without
63
+ naming it, and the Codex exemption call site fails closed on a
64
+ `tool_input`/`raw_input` pair that disagrees. A registry of measured
65
+ `(harness, permission_mode, fixture)` triples
66
+ (`src/policy-packs/builtin/understanding-before-execution/measured-permission-modes.ts`)
67
+ ships inside the package; a new `harness validate` check
68
+ (`checkUnderstandingBeforeExecutionAutoApproveMeasured` in
69
+ `src/cli/validate/checks.ts`) errors on any `auto_approve.when` literal
70
+ that no harness was measured emitting, and a sync test binds the
71
+ registry to the checked-in dogfood fixtures in both directions. New
72
+ dogfood capture scripts (`codex-capture.sh`,
73
+ `codex-interactive-capture.sh`) and fixtures under
74
+ `dogfood/ug-auto-mode-signals/payloads/` back the registry; see
75
+ `docs/okf/understanding-gate-auto-mode-signals.md` for the measured
76
+ Codex `permission_mode` vocabulary. A separate opencode probe
77
+ (`dogfood/ug-auto-mode-signals/opencode/`) found no hook-level
78
+ observability of its `--auto` flag in the probed version, so opencode
79
+ stays out of scope for auto-approval; see the same okf doc. 33 new
80
+ Codex hook tests in
81
+ `tests/cli/pack-hook-codex-pre-tool-use-auto-approve.test.ts`.
82
+ - **Dogfood: interactive `bypassPermissions` and subagent hook-payload
83
+ captures** (agent-tasks `74b4b17d`): `dogfood/ug-auto-mode-signals/`
84
+ gains `interactive-capture.sh`, `interactive-ask-probe.sh` and
85
+ `subagent-capture.sh` with redacted fixtures; results and the closed
86
+ "unverified" items are recorded in
87
+ `docs/okf/understanding-gate-auto-mode-signals.md`. A hook
88
+ `permissionDecision: "ask"` in an interactive `bypassPermissions`
89
+ session surfaces a real operator prompt (no auto-allow), so the ADR's
90
+ conditional escape-path `deny` branch is not needed for that mode.
91
+ - **`harness doctor`: auto-approval visibility and settings-drift warnings**
92
+ (agent-tasks `74b4b17d`, the ADR's Option B visibility half). The
93
+ understanding-gate section lists the auto approvals found in
94
+ `harness.generated/.approvals/` by `approvedBy` prefix with the metric
95
+ "auto approvals in the last N sessions" (N default 20, `--recent-sessions
96
+ <n>`), and warns about a `permissions.defaultMode` or a hook entry in
97
+ `.claude/settings.json`, `.claude/settings.local.json` or the user
98
+ settings file that was absent at the last `harness apply` (compared
99
+ against the `.last-apply` snapshot; a file that was never an apply
100
+ target uses an empty baseline). Warnings only, no enforcement. The
101
+ listing runs when the `understanding-before-execution` pack is declared
102
+ and enabled; the drift check additionally requires that `harness apply`
103
+ has run at least once (`harness.generated/` exists).
104
+
105
+ ### Security
106
+
107
+ - **The honest trust model in `src/runtime/approval-signing.ts` names the
108
+ hook as a second marker-minting route** (agent-tasks `74b4b17d`): with
109
+ `auto_approve` opted in, the forgery residual is a disjunction (read
110
+ the key, or make the hook sign for you) rather than the previous
111
+ conjunction. Reach is post-approval only (invoking the hook is a gated
112
+ Bash call before approval), so this is not an escalation over the
113
+ existing post-approval write class; it is named rather than argued
114
+ away. The Codex hook did not run the auto path in this slice; slice 2
115
+ (`57058364`, entry above) wires it and closes
116
+ `docs/okf/codex-adapter-parity-gaps.md` gap 13.
117
+
118
+ ## [0.50.0] - 2026-08-27
119
+
120
+ ### Security
121
+
122
+ - **The understanding gate's persisted-report approval path no longer carries
123
+ gate authority: gate-time approval now flows only through the HMAC-signed
124
+ marker, in both the Claude and the Codex PreToolUse hook**
125
+ (task `7402301d`, the "persisted-report approval path is a separate,
126
+ still-unsigned residual" follow-up that the v0.41.0 marker-signing entry,
127
+ task `f9485cc7`, tracked). Why: both hooks consulted `checkPersistedReport`
128
+ as a SECOND approval source with equal authority ("either source
129
+ approves"), immediately after a forged marker had been rejected, and the
130
+ report was unsigned. Under the very threat model that motivated marker
131
+ signing (a future write primitive the `Edit|Write|Bash` blocker matcher
132
+ does not cover), one unsigned JSON write of `{"approvalStatus":"approved"}`
133
+ into `.understanding-gate/reports/` forged an approval with LESS effort
134
+ than the old marker forgery: the gate-read selection used the tolerant
135
+ sessionId-less fallback (`"any"`, no age limit), so the forged report
136
+ needed neither a session id nor a key read. Two closures were weighed:
137
+ (A) sign the report's approval state at approve time with the existing
138
+ `signMarker` primitive and verify it in the gate, or (B) make the signed
139
+ marker the only gate-time authority and demote the report to evidence.
140
+ (B) shipped. It leaves the smallest attack surface (no second signed
141
+ artefact, no second verification path, no new key consumer, nothing to
142
+ keep in sync when the approve flip and the post-tool-use expiry both
143
+ rewrite the report file), it is fail-closed by construction (the new
144
+ `PersistedReportEvidence` return shape has no `approved` field a hook
145
+ could read an allow decision out of, and `ApprovalSource` lost its
146
+ `"persisted-report"` member), and it leaves the approve flow, the
147
+ post-tool-use expiry, and the approve-time staleness handling (task
148
+ `adc20a8b`) untouched. (A) would have required choosing a canonical hash
149
+ form for a file that the approve flip and the expiry both rewrite, which
150
+ overlaps the C1 staleness cross-check follow-up (task `fa423e9b`); under
151
+ (B) that follow-up becomes an audit-fidelity gap rather than an authority
152
+ gap.
153
+ - Behaviour: `harness pack hook pre-tool-use` and `harness pack hook
154
+ codex-pre-tool-use` block when only the persisted report says approved,
155
+ with a distinct, greppable audit reason in the block reason and on
156
+ stderr, `unsigned persisted-report approval rejected: report <file> has
157
+ approvalStatus=approved ... but the persisted report is evidence, not
158
+ authority`, the counterpart of `forged/unsigned marker rejected`.
159
+ `checkPersistedReport` still runs, for that diagnostic and for the
160
+ parse-error lookup gate only. Shared in the runtime
161
+ (`persisted-reports.ts`), not duplicated per hook.
162
+ - Side effect worth knowing: `approval_lifecycle.max_age` now actually
163
+ expires an approval. Before, a still-approved persisted report kept the
164
+ gate open past `max_age` (pinned by the former "persisted-report source
165
+ wins BEFORE the recovery-commit check" test, now inverted); the
166
+ recovery-git-commit exemption (task `6e888423`) remains the way a bare
167
+ `git commit` passes after expiry.
168
+ - **Back-compat is strict, no migration window**, the same policy as
169
+ `f9485cc7`: an approval that only ever flipped the report (the
170
+ standalone `understanding-gate approve` CLI, or a `harness approve
171
+ understanding` run whose marker write failed) no longer opens the
172
+ harness gate; run `harness approve understanding` once after upgrading
173
+ on a machine with a live approval. The package's own standalone blocker
174
+ for solo users without harness is unaffected.
175
+ - Adversarial audit before closing, pinned as
176
+ `tests/cli/pack-hook-persisted-report-evidence.test.ts`: 22 probe
177
+ classes, each run against both runtimes (44 probes): hand-written
178
+ approved report; approved report next to an unsigned marker; marker
179
+ signed under a foreign key; sessionId-less report; report copied from
180
+ another session; producer-shaped filename with a future timestamp;
181
+ symlinked report; malformed sibling (parse-errors path); case and
182
+ whitespace variants of the status; expired marker; boundary-cleared
183
+ marker with and without a failed report expiry; report written after
184
+ the boundary (race); key rotation after a real approval; task-scoped
185
+ marker for another task; unresolvable generatedDir; the real approve
186
+ flow and a marker without any report (controls); read-only and mutating
187
+ shell commands. Residuals, named and pinned: a VALID marker plus a
188
+ report swapped after approval still allows (the operator did approve
189
+ the session; the marker's `reportContentHash` is not cross-checked at
190
+ gate time, task `fa423e9b`), and a key read plus an uncovered write
191
+ still forges a valid marker (the documented honest trust model of
192
+ `src/runtime/approval-signing.ts`).
193
+
194
+ ### Added
195
+
196
+ - **`workflows:` gets its first slice of runtime enforcement: a `review_subagent` step with `spawn: "required"` followed by a `merge` step now derives a real merge-time gate, closing the v0.7.0 note that runtime enforcement was a follow-up** (task `99f47307`, Slice 1). `src/runtime/workflow-policies.ts#deriveWorkflowGatePolicies` is a pure function `src/cli/loader.ts#loadManifest` calls right after parse: for every workflow whose steps match that shape, and only when `manifest.hooks[]` declares BOTH `require-review-evidence` and `require-review-evidence-bash` (the pair `harness init --template full` already hand-authors, `src/cli/init/templates.ts`), it appends the byte-identical `review-before-merge` / `review-before-merge-bash` policy pair to `manifest.policies`, renamed `workflow:<name>:...` for provenance. Every `loadManifest` caller inherits this with no `harness apply` round-trip: the real `harness policy intercept` entrypoint (`src/cli/policy/intercept.ts`), `harness list policies`, `harness explain[-policy]`, and `harness doctor`. A hand-authored policy on the same trigger surface (event/match/bash_match) + `requires.ledger_tag` that extracts the same template variables and is at least as strong (`enforcement: block`, no `when:`, not `operator_only`) stands in for the derived gate and nothing is derived for that surface; so does a gate an earlier qualifying workflow in the same manifest already derived. A weaker or differently-extracting hand-authored policy does NOT suppress the derivation: both apply, and `harness validate`/`doctor` name the overlap (see review rounds 2 and 3 below). Fail direction: when the two evidence hooks are NOT wired the derivation returns nothing (never an unenforceable policy pointing at a hook that doesn't exist), closed by a new `error`-severity `harness validate` check, `checkWorkflowGateWiring`, so "declared `spawn: required`, gate never wired" cannot pass silently. The same CLI entrypoint runs on both adapters (`harness policy intercept`; `loadManifest` carries no runtime branching), so a Codex install that wires these hooks inherits the derived policies; not separately exercised — `docs/okf/codex-adapter-parity-gaps.md` gap 8 documents partial, de-facto Codex accommodation in the intercept engine (cwd resolution from the Codex sandbox argv), not a verified parity claim for this specific derivation. Scoped: only the `review_subagent (required) -> merge` pairing is enforced; PR-open gating driven by `workflows:` itself (today only reachable via the separate hand-authored `review-subagent-before-pr-create` / `-bash` policies), step-ordering validation, and `when.task_label`/project-based workflow selection remain schema-only and are left to a follow-up slice. `docs/for-agents.md` updated to describe the scoped, hook-dependent, process-gate-not-hostile-agent-safe behavior in place of the old "schema cannot enforce that today" line.
197
+
198
+ **Review round 2** (same task, before this line shipped) reproduced eight
199
+ reviewer findings against the built CLI and fixed six of them:
200
+ - **F1 (correctness, MEDIUM):** dedupe was keyed on trigger surface +
201
+ `requires.ledger_tag` alone, so a hand-authored policy on the identical
202
+ surface with `enforcement: "warn"` (or scoped via `when:`) silently
203
+ suppressed the derived BLOCK gate — a `spawn: "required"` workflow step
204
+ that looked enforced actually degraded to warn-only. Dedupe now only
205
+ skips deriving against a hand-authored policy that is at least as
206
+ strong as the gate it would stand in for (`enforcement: "block"`, no
207
+ `when:`, not `operator_only`); a weaker one no longer suppresses the
208
+ derivation, and a new warning diagnostic
209
+ (`checkWorkflowGateWeakOverlap`) names the overlap so it is not
210
+ silent.
211
+ - **F2 (correctness, MEDIUM):** `apply` (via `loadManifest`) and
212
+ `validate` (via `loadMergedRaw` + `parseManifest`) saw DIFFERENT
213
+ effective policy sets for the identical manifest — a workflow
214
+ requiring the gate, both evidence hooks wired, no hand-authored
215
+ policies, no `grounding-mcp`: `validate` reported "0 errors" while
216
+ `apply --dry-run` refused with "policies declared but grounding-mcp
217
+ not wired". The derivation is now capsuled in one function,
218
+ `withDerivedPolicies`, that both `loadManifest` and `validate` call,
219
+ so the two entrypoints agree.
220
+ - **F3 (correctness, MEDIUM):** `harness doctor` imported checks
221
+ selectively and never wired in `checkWorkflowGateWiring`, so a
222
+ `spawn: "required"` workflow with the merge gate unwired showed green
223
+ under `doctor` while `validate` errored on the identical manifest.
224
+ Doctor's Workflows section now runs the same check (plus F1's
225
+ weak-overlap warning).
226
+ - **F4 (security, MEDIUM, deliberately NOT closed this slice):**
227
+ `mcp__agent-tasks__task_merge` and `mcp__agent-tasks__task_finish`
228
+ (`autoMerge`) still pass the gate uncovered — only
229
+ `mcp__agent-tasks__pull_requests_merge` and `gh pr merge` are gated,
230
+ matching the parity `src/cli/init/templates.ts` already had. A
231
+ follow-up task extends the template pair and this derivation to those
232
+ two verbs together.
233
+ - **F5 (correctness, LOW):** `checkWorkflowGateWiring` (and the runtime
234
+ derivation's own `hasWiredMergeGateHooks`) only checked hook NAME
235
+ presence; a hook declared under the right name but wired to a stale
236
+ `match`/`bash_match` or a `command` that is not `harness policy
237
+ intercept` (`isPolicyInterceptCommand`) is just as unenforced as a
238
+ missing hook. `checkWorkflowGateWiring` now also verifies the actual
239
+ trigger surface + command.
240
+ - **F6 (correctness, LOW):** a workflow with a `merge` step BEFORE its
241
+ `spawn: "required"` review step derives no gate (silent, by design —
242
+ step-ordering validation is the later slice the round-1 entry above
243
+ already scoped out) and previously said nothing about it. A new
244
+ warning (`checkWorkflowMergeBeforeReview`) names the ordering instead.
245
+ - **F7 (maintainability, LOW):** derived policies were indistinguishable
246
+ from hand-authored ones in `harness export`/`list`/`doctor`. A
247
+ `WeakSet`-backed registry (`isDerivedPolicy`) now lets `export` filter
248
+ them out of the emitted manifest and `list`/`doctor` mark them
249
+ "(derived from workflows[])".
250
+ - **F8 (correctness, LOW):** `harness remove hook require-review-evidence`
251
+ silently deleted a hook referenced ONLY by a derived merge gate (no
252
+ hand-authored `policies:` entry named it, so the existing
253
+ reference-check saw nothing). `remove`'s reference check now also
254
+ consults the derived view and refuses the same way hand-authored
255
+ policy references do — though, unlike those, there is no schema-level
256
+ safety net stopping `--force` here, since a derived gate is not a
257
+ static YAML reference the schema can see.
258
+
259
+ **Review round 3** (same task, before this line shipped) fixed the
260
+ remaining findings and closed the CLASS rounds 1 and 2 kept finding
261
+ one member of ("one manifest reader sees the derived policies
262
+ differently from another": round 1 `apply` vs `validate`, round 2 the
263
+ two sides of `diff --since`). Every reader that parses a manifest now
264
+ takes one of two views on purpose, documented as a table in
265
+ `src/runtime/workflow-policies.ts`'s module doc and pinned by a new
266
+ parity suite (`tests/cli/manifest-view-parity.test.ts`) that runs ONE
267
+ on-disk manifest through every reader: the DERIVED view (hand-authored
268
+ plus the workflow gate pair) for enforcement, validation, display, and
269
+ comparisons where both sides are derived (`loadManifest`, `validate`,
270
+ `doctor`, `list`, `describe`, `explain`, `dry-run`, `diff --since` on
271
+ both sides, `add`'s asset gate, the `.last-apply` restart-hint
272
+ comparison); the HAND-AUTHORED view for everything written out
273
+ (`harness export`, the `.last-apply` manifest snapshot, the
274
+ `add`/`remove`/`adopt`/`pack reseed` file rewrites and the schema gate
275
+ in front of them). `withDerivedPolicies` and a new
276
+ `withoutDerivedPolicies` are idempotent and accept either view as
277
+ input, so a reader on the "wrong" side can no longer double-derive or
278
+ strip a hand-authored policy.
279
+ - **F1 (correctness, MEDIUM):** `findWeakGatePolicyOverlaps` re-ran the
280
+ derivation walk WITHOUT its dedupe set, so a surface an equivalent
281
+ hand-authored policy already covered (nothing derived) still warned,
282
+ naming a `workflow:<name>:review-before-merge` policy that did not
283
+ exist (`harness init --template full` plus a qualifying workflow:
284
+ `validate` "0 errors, 1 warning", `doctor` 1 warning, `list policies`
285
+ no `workflow:*` row). The overlap finder and the derivation are now
286
+ two projections of ONE walk (`deriveWorkflowGates`), so they cannot
287
+ disagree about what was derived; the full template plus a qualifying
288
+ workflow yields zero workflow diagnostics.
289
+ - **F2 (correctness, MEDIUM, repeated class):** `harness diff --since
290
+ <ref>` parsed the ref side without the derived policies while the
291
+ working side came from `loadManifest`, so an unchanged manifest with
292
+ a qualifying workflow diffed as `+ policies[workflow:<name>:review-
293
+ before-merge(-bash)]` on every run. Both sides now take the derived
294
+ view (not both stripped): `diff` is an effective-config diff, the
295
+ same reason override layers are merged on both sides, so flipping a
296
+ review step from `spawn: optional` to `required` since the ref shows
297
+ the gate it adds, and an unchanged manifest diffs clean.
298
+ - **F3 (correctness, MEDIUM):** `harness remove hook
299
+ require-review-evidence --force` printed no warning at all: the
300
+ round-2 `derivedGateReferences` was only populated on the dry-run
301
+ path, hard-coded `[]` on the write path, and never rendered by the
302
+ CLI on either. It is now populated on both paths and the CLI prints
303
+ `(forced removal disables the workflows[]-derived merge gate for:
304
+ <workflows>; ...)` on stderr for `--dry-run --force` and for the
305
+ real `--force` write.
306
+ - **F4 (security, LOW):** `trigger.extract` was not part of the dedupe
307
+ key, so a hand-authored block policy on the derived surface that
308
+ extracted `PR_NUMBER` from a wrong path counted as equivalent,
309
+ suppressed the derived gate, and evaluated its own
310
+ `review:${PR_NUMBER}` against an unresolved variable; under
311
+ `risk.degraded_fail_posture: fail_open` the merge went through with
312
+ "template variables unresolved". A differently-extracting policy no
313
+ longer dedupes (both gates apply, fail-safe) and the overlap warning
314
+ names the mismatch (`trigger.extract differs (...)`).
315
+ - **F5 (docs, LOW):** `docs/okf/debug-verb-selection.md` cited
316
+ `docs/for-agents.md` line 249 for the `dry-run` row; that is the
317
+ table separator since the round-2 wording landed, the row is line
318
+ 258. Re-pointed and re-stamped.
319
+ - **F6 (docs, LOW):** the round-1 paragraph above still described the
320
+ original surface-key-only dedupe ("does not double-intercept"),
321
+ contradicting the round-2 F1 bullet; corrected in place.
322
+ - **F7 (tests, LOW):** the `when:`-scoped and `operator_only` weakness
323
+ reasons of the overlap finder were untested; two cases now assert the
324
+ exact `reason` (the `operator_only` branch is unreachable from a
325
+ parsed manifest, which the schema forbids together with `requires:`,
326
+ and is pinned via a hand-built policy object).
327
+ - **F8 (maintainability, LOW):** the duplication pin stays at 111
328
+ (round 2's raise, accepted); this round adds no clone (`doctor`'s
329
+ Workflows section now calls the same `checkWorkflows` aggregate
330
+ `validate` runs instead of re-listing the checks, which also gives
331
+ `doctor` the round-2 `checkWorkflowMergeBeforeReview` warning it was
332
+ missing).
333
+ - **New (correctness, LOW):** a hand-authored policy whose NAME equals
334
+ a derived policy's name is not deduped by name (dedupe keys on
335
+ surface, not name), so the derived view carried two policies with one
336
+ name and every by-name reader (`explain`, `explain-policy`, `audit`,
337
+ `diff`) silently resolved to the hand-authored one. This fires both
338
+ when the hand-authored policy is on a different surface and when it
339
+ is on the same surface but weaker (the latter also reported as an
340
+ overlap by `checkWorkflowGateWeakOverlap`; the two checks are not
341
+ mutually exclusive). `validate`/`doctor` now error on the collision
342
+ (`checkWorkflowDerivedNameCollision`); the runtime still enforces
343
+ both. (Review round 3 follow-up, F3: the message's original "it does
344
+ not intercept the same surface" claim was wrong in the same-surface
345
+ case; corrected, with a same-surface test added.)
346
+ - **F6 follow-up (performance/docs, LOW):** `checkWorkflowGateWeakOverlap`'s
347
+ message now also notes that a deliberately-not-suppressed overlap means
348
+ the same event round-trips the ledger twice (once per policy), so the
349
+ hook's `budget_ms` should be checked against two policies, not one,
350
+ since `requiredHookBudgetMs` does not scale with the policy count.
351
+ - Residuals, named rather than fixed: (a) the `.last-apply` manifest
352
+ snapshot now stores the hand-authored view and the restart-hint
353
+ reader re-derives with the current code, so a snapshot written by a
354
+ release without the derivation compares equal and emits no phantom
355
+ "hooks changed" hint (a derivation-logic change between releases is
356
+ therefore invisible to restart hints, by design: derived policies
357
+ do not touch `settings.json`); (b) `harness export` emits the
358
+ operator-declared manifest without the derived pair, `docs/CLI.md`
359
+ now says so and points at `list policies`; (c) the round-2 F4
360
+ residual stands: `mcp__agent-tasks__task_merge` and
361
+ `mcp__agent-tasks__task_finish` (`autoMerge`) remain ungated until
362
+ the follow-up task extends both the template pair and this
363
+ derivation; (d) `diff --since` derives both sides with the CURRENT
364
+ (working-tree) harness version's derivation logic, since the ref side
365
+ is parsed in-process rather than checked out and re-derived with its
366
+ own code, so a change to the derivation logic itself between harness
367
+ releases does not show up as a diff.
368
+
369
+ - **Risk Gate: a new, environment-INDEPENDENT deletion-target gate closes the
370
+ dev-context gap where `gate-prod-destructive*` only fires once the resolved
371
+ environment is `production`** (task `d03af8f6`). Measured 2026-08-06 (re-
372
+ verified against 0.49.0): on a task branch (`environment: unknown`) an `rm
373
+ -rf` against ANY absolute path — a typo'd path, a stale variable, a
374
+ traversal that walks outside the intended scratch dir — ran unconfirmed,
375
+ because `risk.severity_at_least`/`risk.category_in` fail-close to
376
+ matched=true for an unclassified action, so an unscoped policy would
377
+ instead have gated every unrelated unclassified Bash call in every
378
+ environment (approval-spam, not a deletion-specific gate). New pieces,
379
+ additive alongside the existing production-scoped policies (nothing
380
+ existing changed semantics):
381
+ - `src/runtime/deletion-target-resolve.ts`: a pure, no-nested-quantifier-
382
+ regexes (no ReDoS surface) tokenizer (no shell spawned) that recognizes
383
+ `rm -r*`/`-f*`, `find ... -delete`, and `git clean -f*` across EVERY
384
+ shell segment of a command and statically resolves each target: an
385
+ unexpanded `$VAR`/`~`, a relative path, or a `..`-traversal that
386
+ normalizes outside every declared root is UNRESOLVABLE; an absolute
387
+ path strictly inside a declared `risk.safe_deletion_roots` entry is
388
+ not.
389
+ - `risk.safe_deletion_roots` (new manifest key, schema default `["/tmp",
390
+ "/private/tmp"]` — the two spellings this harness's own scratchpad
391
+ convention can use, since macOS symlinks `/tmp` to `/private/tmp`).
392
+ - `when.action.deletion_target_unresolvable` (new `policy.when:` clause,
393
+ `src/runtime/when-eval.ts`, only the literal `true` is a meaningful
394
+ value): deliberately NOT subject to the "unknown is not safe"
395
+ fail-close the four existing risk/environment clauses share — it reads
396
+ the deletion resolver's own verdict, not the Risk Classifier's
397
+ `classified` state, so it never falls back to matched=true for an
398
+ unrelated unclassified command. This is what lets the new policy below
399
+ be environment-independent without becoming a blanket gate.
400
+ - `gate-dev-unsafe-deletion` (new policy, `harness init --template full` +
401
+ `docs/examples/full-manifest.yaml`, additive alongside
402
+ `gate-prod-destructive`/`-approval`): `require_approval` on
403
+ `action.deletion_target_unresolvable: true`, unblocked via `harness
404
+ approve risk --scope deletion` (its own ledger tag — see the review
405
+ round 2 fixes below).
406
+ - The dead alternation in the shipped `dangerous-shell` classifier's
407
+ `rm\s+-rf\s+(/|/var|/data|/mnt|~)` pattern (the bare `/` branch already
408
+ matches every absolute path, so `/var`/`/data`/`/mnt` never
409
+ independently contribute a match) is documented, not changed — see the
410
+ comment in `docs/examples/full-manifest.yaml`; it only feeds the
411
+ existing production-scoped policies, out of this task's scope.
412
+
413
+ **Review round 2** (same task, before this line shipped) found the
414
+ first cut had five measured gaps against a real transcript corpus.
415
+ Fixed, all additive on top of the pieces above:
416
+ - **Multi-segment recognition (was HIGH):** round 1 inspected only the
417
+ command's FIRST shell segment — 656 of 1215 real deletion commands in
418
+ the operator's own transcript corpus (54%) chained the deletion behind
419
+ `&&`, a newline, or a prior `VAR=value &&` assignment and ran
420
+ completely ungated. The resolver now walks EVERY segment
421
+ (`segmentViewOf`, `src/runtime/command-normalize.ts`) and gates if ANY
422
+ recognized segment is unresolvable.
423
+ - **Cross-tier ledger tag (was HIGH, security):** `gate-dev-unsafe-
424
+ deletion` used to reuse `gate-prod-destructive-approval`'s
425
+ `risk-approved:${SESSION_ID}` tag, so approving one routine dev-context
426
+ `rm -rf dist` silently cleared the production approval gate for the
427
+ rest of the session — measured live (`kubectl delete namespace
428
+ payments` went BLOCKED -> ALLOWED after one unrelated dev approval).
429
+ The dev arm now writes/consults its own
430
+ `risk-approved:deletion:${SESSION_ID}` tag, via a new `harness approve
431
+ risk --scope deletion` option (`src/cli/approve/risk.ts`); the bare
432
+ `harness approve risk` keeps writing only the production tag,
433
+ unchanged.
434
+ - **Wrapper-head coverage (MEDIUM):** `sudo`/`doas`/`command`/`env`/
435
+ `time`/`timeout`/`nice`/`xargs` in front of the deletion verb, and
436
+ `git -C <path> clean -f*`, all now recognize the wrapped verb — a
437
+ bare `xargs rm -rf` (no explicit operand — the target comes from
438
+ stdin) is UNRESOLVABLE, not "not a deletion" the way an ordinary
439
+ operand-less `rm -rf` is.
440
+ - **Root-itself / bare-glob targets (MEDIUM):** `rm -rf /tmp`, `rm -rf
441
+ /tmp/`, and `rm -rf /tmp/*` used to resolve as safe — a target must
442
+ now be STRICTLY deeper than a declared root, and a bare `*`/`**` final
443
+ path segment is never resolvable regardless of the root it sits under.
444
+ - **Obfuscated flags (LOW):** every token is decoded with
445
+ `decodeShellWord` (`src/runtime/shell-word.ts`) before any verb/flag/
446
+ `-delete` comparison, so `find /x $'\x2ddelete'` / `git clean
447
+ $'\x2df'` are recognized.
448
+ - **`safe_deletion_roots` hygiene (LOW):** a bare `/` (or `//`, ...)
449
+ entry is now a schema parse error (it would match every absolute
450
+ path, silently defeating the allowlist); a non-absolute entry or one
451
+ containing `$`/`~` is a new `harness validate` warning
452
+ (`checkSafeDeletionRootsSyntax`, `src/cli/validate/checks.ts`).
453
+ - Deduplicated the tokenizer against `kubectl-target-parse.ts` instead
454
+ of tolerating the round-1 clone pair: `firstSegment` is now exported
455
+ there (pure visibility change) and imported; `check:duplication`'s
456
+ pinned baseline is back at 109 (was raised to 110 in round 1).
457
+
458
+ **Review round 3** (same task): round 2's own hand-rolled recognition —
459
+ a second, independently-drifting copy of the wrapper-peeling and
460
+ segmentation `src/runtime/command-normalize.ts` already ships,
461
+ flag-aware and pinned for the `bash_match` gate — still failed OPEN on
462
+ a new class every round (round 1: first-segment-only; round 2:
463
+ flag-blind wrapper peeling plus bare-`&`/brace/`exec`/`nohup`). Rebuilt
464
+ on top of `command-normalize.ts`'s own machinery instead of a third
465
+ hand-rolled enumeration:
466
+ - **Shared wrapper peeling (structural fix):** the resolver now reuses
467
+ `command-normalize.ts`'s exported `peelWrapperPrefixes` (the same
468
+ loop `canonicalizeSegment` uses for `git`/`gh`/`npm`/`harness`
469
+ trigger recognition) instead of its own narrower copy — closing every
470
+ flag the round-2 peeler missed: `sudo -u <user>`, `sudo -E`, `sudo
471
+ --preserve-env`, `nice -n <n>`, `nice -<n>`, `env -i`, `env -u <VAR>`,
472
+ `timeout -k <n> <duration>`, `timeout --signal=<sig> <duration>`.
473
+ `exec` and `nohup` are now peeled too — added to
474
+ `peelWrapperPrefixes` itself so both this gate and the `bash_match`
475
+ trigger gate benefit. `xargs` (never peeled by `command-normalize
476
+ .ts` — its own argv is not simply "the command to run") stays a
477
+ local concern, but its own flags (`-0`, `-n1`, ...) are now peeled
478
+ too, via the newly-exported `peelGenericFlags`.
479
+ - **Dual-arm segmentation (structural fix):** segments now come from
480
+ BOTH of `command-normalize.ts`'s arms — `segmentViewOf` (primary) AND
481
+ the new `segmentViewOfAmpAware` (the bare-`&` alphabet
482
+ `normalizeCommandAmpAware` already uses for trigger matching) —
483
+ combined additively and de-duplicated by text, closing `echo hi & rm
484
+ -rf /home/x` (a background job the primary alphabet does not split
485
+ on).
486
+ - **`find -exec`/`-execdir` recognition (MEDIUM):** `find /home -exec
487
+ rm -rf {} +` / `find /home -execdir rm {} \;` are now recognized,
488
+ targeting `find`'s own search-path operand (never the `{}`
489
+ placeholder).
490
+ - **Redirection operands (MEDIUM):** a `>`/`>>`/`<`/`<<`/`<<<` token
491
+ (glued or bare, optional leading fd number) is no longer collected as
492
+ a target — `rm -rf /tmp/x >/dev/null 2>&1` no longer reports
493
+ `/dev/null` as an unresolved target.
494
+ - **`find` search-root equality (MEDIUM, premise corrected round 4 —
495
+ see below):** `find`'s own search-root operand EQUALLING a declared
496
+ root now resolves; `rm` keeps the strict-deeper-only rule.
497
+ - **Brace groups and subshells:** `{ rm -rf /home/x; }` (leading `{` /
498
+ trailing `}`/`;` token stripped before the head test) and `(rm -rf
499
+ /home/x)` (trailing `)` stripped from the segment's last token) are
500
+ now recognized.
501
+ - **`safe_deletion_roots` hygiene, widened (LOW):** an entry that
502
+ lexically NORMALIZES to the filesystem root (`/.`, `/./`,
503
+ `/tmp/..` — not only the bare-`/` spelling round 2 caught) is now
504
+ also a schema parse error; `checkSafeDeletionRootsSyntax`
505
+ (`src/cli/validate/checks.ts`) now falls back to
506
+ `DEFAULT_SAFE_DELETION_ROOTS` for a hand-built `Manifest` missing
507
+ `risk`/`risk.safe_deletion_roots` instead of throwing.
508
+ - `gate-dev-unsafe-deletion`'s position in `FULL_TEMPLATE` (after both
509
+ `gate-prod-destructive` and `gate-prod-destructive-approval`) is now
510
+ pinned by a test — `src/runtime/intercept.ts` evaluates policies in
511
+ array order.
512
+ - NOT COVERED, named rather than left implicit (pinned as
513
+ `toBeNull()`): `bash -c '...'`/`sh -c '...'`, `eval "..."`, a script
514
+ FILE the agent writes and executes, `shred`/`rmdir`/`unlink` (real
515
+ deletion verbs outside this task's closed head-token set), and
516
+ `npm run clean` (a script NAME, not a literal deletion verb).
517
+
518
+ **Review round 4** (same task): four more measured gaps against the
519
+ round-3 recognition surface, plus reviewer-flagged output-quality
520
+ issues. Fixed:
521
+ - **`xargs` separated-value flags (HIGH):** round 3's `xargs` flag
522
+ peeling (`peelGenericFlags`, boolean-only) stranded a SEPARATED flag
523
+ value where the verb was expected, so `xargs -I {} rm -rf {}`,
524
+ `xargs -n 1 rm -rf`, `xargs -P 4 rm -rf`, `xargs -a list rm -rf`, and
525
+ `xargs -d '\n' rm -rf` all ran UNGATED (only the glued spellings —
526
+ `-I{}`, `-0`, `-n1` — happened to gate). Replaced with a forward
527
+ scan (bounded by the token count) for the first token that is itself
528
+ a recognized deletion-verb head, skipping `xargs`'s entire flag
529
+ vocabulary uniformly instead of enumerating it.
530
+ - **`find <root> -delete` root-itself premise was FALSE (MEDIUM):**
531
+ round 3 claimed `find <root> -delete` "only ever deletes entries
532
+ strictly inside `<root>`" — measured wrong: a BARE `find /tmp
533
+ -delete` (no test predicate) removes `/tmp` itself too, on both GNU
534
+ findutils and BSD find. Root-equality now resolves ONLY when the
535
+ expression carries at least one non-action test predicate (`-name`,
536
+ `-iname`, `-path`, `-ipath`, `-regex`, `-type`, `-mtime`, `-mmin`,
537
+ `-newer`, `-size`, `-empty`, `-user`, `-perm`, or `-maxdepth`/
538
+ `-mindepth` with a value `>= 1`), so `find /tmp -name '*.log'
539
+ -delete` still resolves but a bare `find /tmp -delete` now gates.
540
+ Corrected the same premise in `src/schema/risk.ts`, `docs/risk-
541
+ gate.md`, and this file.
542
+ - **`&>`/`>&` redirection forms (MEDIUM):** round 3's redirection
543
+ regexes matched only tokens starting with an optional fd number
544
+ followed by `<`/`>`, missing bash's combined stdout+stderr forms
545
+ `&>`/`&>>` (glued) and `>&`/`<&` (bare) — `rm -rf /tmp/x
546
+ &>/dev/null` and `rm -rf /tmp/x >& out` were GATED with the
547
+ redirect target misread as a second, unresolvable target. Both
548
+ regexes now cover the full set.
549
+ - **`stripRedirections` was unpinned in `resolveFind`/
550
+ `resolveGitClean` (MEDIUM, test gap):** added `find /tmp >out -name
551
+ '*.log' -delete` and `git clean -fd /tmp/x >/dev/null` fixtures;
552
+ removing the strip from either resolver now turns both red.
553
+ - **Duplicate/spurious targets (LOW):** `combineVerdicts` now
554
+ de-duplicates verdicts by exact `(verb, targets)` before combining,
555
+ replacing round 3's "accepted cosmetic quirk" — `nohup rm -rf
556
+ /home/x &` and `rm -rf /home/x 2>&1 | tee log` now name their
557
+ target once, not twice. This also fixed a real correctness bug the
558
+ round-3 note did not anticipate: for `rm -rf /home/x & rm -rf
559
+ /home/y`, the primary segmentation arm (which does not split on a
560
+ bare `&`) used to keep parsing past a dropped `&` into the SECOND
561
+ invocation's own tokens, collecting its verb `rm` as a spurious
562
+ literal target; `stripRedirections` now stops (rather than merely
563
+ skipping) at the first bare `&`, so that command no longer lists
564
+ `rm` as a target.
565
+ - **Backtick command substitution added to NOT COVERED (LOW):** `` `rm
566
+ -rf /home/x` `` is pinned alongside the other unparsed-substring
567
+ shapes (`bash -c '...'`, `eval "..."`).
568
+ - **`exec`/`nohup` wrapper enumerations updated (LOW):** `docs/okf/
569
+ policy-engine-producer-wiring.md` and `src/cli/init/templates.ts`'s
570
+ `deny-kill-switch-bypass` comment now list `exec`/`nohup` alongside
571
+ the other peeled wrappers (both were already peeled by
572
+ `peelWrapperPrefixes` since round 3; only the doc/comment
573
+ enumerations were stale). `docs/okf/pause-vs-gate-kill-switch.md`
574
+ gained a corresponding update note and timestamp re-stamp.
575
+ - **`docs/okf/policy-engine-producer-wiring.md` frontmatter
576
+ `description:` quoted (LOW):** the unquoted value's embedded
577
+ `risk.degraded_fail_posture: fail_open` colon-space made the
578
+ frontmatter block unparseable YAML (`npx okf-kit check docs/okf`
579
+ reported `frontmatter-required`); quoting fixes it.
580
+ - **`MAX_NORMALIZE_LENGTH` fallback added to NOT COVERED (LOW):** a
581
+ command past 100,000 characters falls back to the pre-round-2
582
+ single-first-segment contract — a recognized deletion verb in a
583
+ LATER segment of such an oversized command goes unrecognized. Named
584
+ explicitly rather than left implicit.
585
+ - `docs/risk-gate.md`'s "Dev-context deletion gate" section trimmed:
586
+ the round-by-round narrative and the measured BLOCKED->ALLOWED
587
+ incident now live only here (this file) and in the run's own
588
+ review-round notes; the reusable doc states the rule plus a pointer
589
+ to this entry.
590
+
591
+ **Review round 5** (same task): the round-4 root-equality exception for
592
+ `find` (a search-root operand EQUALLING a declared root resolved when
593
+ the expression carried a test predicate) is REMOVED. It had already
594
+ been corrected once, in round 4 — the halt rule fired on a second
595
+ consecutive round finding the same premise wrong, so the exception
596
+ itself was cut rather than patched a third time: every recognized verb
597
+ (`rm`, `find`, `git-clean`) now requires a target STRICTLY deeper than
598
+ a declared root, no exceptions. `find /tmp -name '*.log' -delete` and
599
+ `find /tmp -mindepth 1 -delete` now gate, same as `rm -rf /tmp`; a
600
+ scratch cleanup strictly inside a session directory (e.g. `find
601
+ /private/tmp/claude-<uid>/<session>/x -delete`) is unaffected. Three
602
+ more measured gaps, fixed alongside:
603
+ - **`xargs` replace-string aliasing (MEDIUM, security):** `xargs -I{}
604
+ rm -rf /tmp/{}` and `cat list | xargs -I% rm -rf /tmp/%` used to
605
+ resolve ALLOWED — the placeholder token textually starts with a safe
606
+ root, but its real value is substituted from stdin at runtime, never
607
+ statically known. `findXargsVerbHead`'s forward scan now also
608
+ captures the invocation's own replace-string (the value of `-I`/
609
+ `-i`, glued or separated, default `{}`), and every resolver treats a
610
+ target token containing it as unresolvable, the same as an
611
+ unexpanded `$` token.
612
+ - **`find`'s path-operand collection did not stop at `!`/`(`/`)`
613
+ (LOW):** `findPathOperands` now also stops at `!`, `(`, and `)` (a
614
+ `-`-prefixed token, `-not` included, already stopped it) so a
615
+ negation or grouping operator is never mistaken for — or emitted as
616
+ — a search-root operand.
617
+ - **Glued trailing `&` double-counted a target (LOW):** `rm -rf
618
+ /home/x&` used to yield `targets == ["/home/x&", "/home/x"]` — the
619
+ primary segmentation arm's segment keeps the bare `&` glued to its
620
+ last raw token (it is not one of that arm's boundary characters),
621
+ while the amp-aware arm's matching segment has none, so the two
622
+ arms' verdicts textually differed and `combineVerdicts`'s dedup
623
+ (keyed on exact target text) did not collapse them. A new
624
+ `stripTrailingAmp` (the same treatment `stripTrailingParen` gives a
625
+ subshell's trailing `)`) strips a single trailing bare `&` from a
626
+ segment's last raw token before target collection, so the command
627
+ now yields `["/home/x"]` once.
628
+
629
+ **Review round 6** (same task; escalated after five rounds each found
630
+ a new fail-open on the permissive side of the recognition layer).
631
+ Decision D-023: ANY `xargs`-wrapped deletion is unresolvable, without
632
+ parsing `xargs` at all — `xargs` appends or substitutes stdin-supplied
633
+ operands at runtime, so `echo /home/x | xargs rm -rf /tmp/known`
634
+ deletes `/home/x` while the round-5 module resolved it ALLOWED. The
635
+ round-5 replace-string plumbing (`-I`/`-i` capture, default `{}`) is
636
+ deleted; the bounded forward scan to the first deletion-verb head
637
+ stays, and every resolver returns unresolvable when `xargsWrapped`,
638
+ listing a synthetic `(xargs-supplied target, not statically known)`
639
+ entry plus any explicit operand, none resolved. The round-2 pin
640
+ "`xargs rm -rf /tmp/known` resolves" is flipped to GATED; `xargs ls`/
641
+ `xargs echo`/`xargs -I{} mv {} /trash` stay unrecognized; `xargs echo
642
+ rm -rf /home/x` (which only prints) is gated — an accepted, documented
643
+ over-gate of the forward scan. A path-qualified `/usr/bin/xargs` is
644
+ now recognized too (round 5 matched the bare word only). An
645
+ adversarial audit of 232 shapes against the built module (permissive
646
+ side: quoting, escapes, globs, traversal, substitution, symlink
647
+ spellings, keywords, wrappers; fail-safe side: every legitimate
648
+ inside-root shape) found and closed these CLASSES, each pinned:
649
+ - **Symlink-following spellings (HIGH):** a trailing `/` or `/.` on a
650
+ target follows a symlinked directory into its target (measured on
651
+ macOS: `rm -rf <link>/` removed the link's TARGET directory), and a
652
+ `..` component is resolved PHYSICALLY through a symlinked component
653
+ (`/tmp/<link>/../y` lands under the link's target), so both are now
654
+ unresolvable — the round-1 pin "`rm -rf /tmp/a/../b` resolves" is
655
+ flipped. `find -H`/`-L`/`-follow` make the whole `find` verdict
656
+ unresolvable. Still not visible lexically, named as a ceiling: a
657
+ symlink named without a trailing slash (`rm -rf /tmp/link/y`).
658
+ - **Globs that expand to `..` (HIGH):** `rm -rf /tmp/.*`, `/tmp/..*`,
659
+ `/tmp/.?/x`, `/tmp/.*/foo`, `/tmp/.[.]` resolved ALLOWED; `find
660
+ /tmp/.* -delete` traverses `/tmp/..` = `/`. A path component starting
661
+ with an explicit `.` whose remainder can glob-match `.` is now
662
+ unresolvable (`globComponentCanMatchDotDot`, a minimal fnmatch over
663
+ `*`/`?`/`[...]`); measured on bash 3.2 with and without `dotglob`
664
+ that `*`, `[.][.]`, `.[!.]*`, and `.??*` cannot match `..`, so those
665
+ (and `rm -rf /tmp/x/*.log`) stay resolvable.
666
+ - **Brace expansion, backtick and extglob (MEDIUM):** `rm -rf
667
+ /tmp/{..,x}` expands to `/tmp/..`; `` rm -rf /tmp/`cat<f` `` is a
668
+ single token with a command substitution; `rm -rf /tmp/@(..)/x` is
669
+ cut at the `(` boundary leaving `/tmp/@` as the target. A token
670
+ containing `{` or a backtick is unresolvable (this also subsumes
671
+ the `xargs`/`find` `{}` placeholder), and when the command contains
672
+ an extglob opener a target ending in `?`/`*`/`+`/`@`/`!` is
673
+ unresolvable.
674
+ - **`find -exec` payload operands (HIGH):** `find /tmp/x -exec rm -rf
675
+ /home/y \;` resolved ALLOWED — only the search root was a target.
676
+ The payload's explicit operands (every non-flag token other than the
677
+ exact `{}`, up to `+` or the end of the segment) are now targets;
678
+ `-ok`/`-okdir` are recognized alongside `-exec`/`-execdir`.
679
+ - **`find` continuation after `\;` (HIGH):** `;` is a segment
680
+ boundary, so `find /home/x -exec echo {} \; -exec rm -rf {} \;`
681
+ put the deleting `-exec` in a segment headed by `-exec`, which was
682
+ unrecognized. A `find`-headed segment's search roots are now carried
683
+ (per segmentation arm, in command order) into a directly following
684
+ segment headed by a `find` primitive. This also covers the escaped
685
+ grouped expression `find /home \( -name a -o -name b \) -delete`
686
+ that round 5 reported as unrecognized; the QUOTED `'('` spelling
687
+ stays a pinned residual (the cut lands inside the quoted run). The
688
+ orphaned `\`/`'`/`"` token the cut leaves behind is dropped from
689
+ both the roots and the payload operands.
690
+ - **`git clean` (HIGH):** `git -c clean.requireForce=false clean -d`
691
+ deletes without `-f`, but `command-normalize.ts` canonicalizes it
692
+ to `git clean -d` before the resolver sees it — a `clean
693
+ .requireForce` mention anywhere in the raw command text now counts
694
+ as a force flag (case-insensitive; also covers
695
+ `GIT_CONFIG_PARAMETERS` and a same-command `git config`). `git
696
+ --no-optional-locks clean -fdx /home/y` was unrecognized (an
697
+ un-canonicalized global option sat where `clean` was expected) —
698
+ `git`'s global options are now skipped locally, `-c`/`--config-env`
699
+ with their value. `git clean -f -e /tmp/x` resolved ALLOWED with
700
+ `/tmp/x` mistaken for a pathspec while git cleaned cwd —
701
+ `-e`/`--exclude` now consume their value.
702
+ - **Shell keywords and locally-peeled wrappers (MEDIUM):** `if true;
703
+ then rm -rf /home/x; fi`, `for f in a; do rm -rf /home/x; done`, `!
704
+ rm -rf /home/x`, `f() { rm -rf /home/x; }`, `exec -a name rm -rf
705
+ /home/x` (the shared peeler treats `-a` as boolean, leaving `name`
706
+ as the head), and `busybox`/`toybox rm -rf /home/x` were all
707
+ unrecognized. Leading `if`/`then`/`else`/`elif`/`while`/`until`/
708
+ `do`/`!`/`{`/`)`/`){` tokens are stripped; `exec -a <name>` and the
709
+ two multi-call binaries are peeled locally.
710
+ - **Inert tests and false positives from round 5 (LOW):** the
711
+ oversized-command pin gains a positive assertion (a deletion in the
712
+ FIRST segment past `MAX_NORMALIZE_LENGTH` still gates, the `/tmp`
713
+ variant allows); `( rm -rf /home/x )` reported the lone `)` as a
714
+ second target and `( rm -rf /tmp/x )` was GATED by it — a bare
715
+ `(`/`)` token is dropped during `rm`/`git clean` target collection
716
+ and `findPathOperands`'s unreachable `(`/`)` stop is removed (`!`
717
+ kept); `rm -rf /tmp/x # cleanup` was GATED on `#` and `cleanup` —
718
+ a `#` word now ends the segment, as it does for bash; `rm -rf
719
+ /tmp/x\ y` was GATED as two operands — the tokenizer now honours a
720
+ backslash escape; `find -P /tmp/x -delete` was GATED with `.` as the
721
+ root — `find`'s leading options are skipped. The quote-unaware amp
722
+ arm's duplicate reporting for a quoted literal `&` is noted next to
723
+ the dedup comment as a reporting-only, fail-closed ceiling.
724
+ - NOT COVERED additions, pinned: `env -S '...'`, `find ... -exec sh
725
+ -c '...'`, the quoted-paren grouped `find`, a `case` arm, runners
726
+ outside the wrapper set (`parallel`, `ionice`, `chrt`, `taskset`,
727
+ `caffeinate`, `flock`, `strace`, `ssh`, `docker exec`, `chroot`, `su
728
+ -c`, `watch`), a symlink named without a trailing slash, and a
729
+ `clean.requireForce` override set outside the command text.
730
+
731
+ ### Fixed
732
+
733
+ - **Risk Gate: a narrow, secrets-excluding kubectl read-only verb floor
734
+ fixes the AC5 false positive waived at task `a7eb1a71`** (task
735
+ `da823721`). Once an explicit `--context`/`--namespace` resolves
736
+ `environment: production` (`a7eb1a71`), the pre-existing "unknown is
737
+ not safe" rule made a harmless `kubectl get pods --context prod-eu-1`
738
+ require approval exactly like `kubectl delete namespace payments
739
+ --context prod-eu-1` does. `isReadOnlyKubectlCommand`
740
+ (`src/runtime/read-only-bash.ts`) floors a curated set of read verbs
741
+ (`get`, `describe`, `logs`, `top`, `api-resources`, `api-versions`,
742
+ `version`, `cluster-info`, `explain`, `auth can-i`) to `low` severity
743
+ in `classifyRisk`'s built-in floor (`src/runtime/risk-classifier.ts`),
744
+ UNLESS the command reads `--raw` or the resource argument mentions
745
+ "secret" in any form (`get secret`, `get secrets`, `get
746
+ secret/<name>`, `describe secret`, `-o yaml`/`-o json` on a secret) —
747
+ a prod Secret read stays approval-gated. Any other kubectl subcommand
748
+ stays fail-closed, unchanged. The floor is wired ONLY into the Risk
749
+ Classifier: it is a separate function, not folded into the shared
750
+ `isReadOnlyBashCommand` / `isReadOnlyBashPipeline` the
751
+ understanding-gate PreToolUse blocker and the solution-acceptance
752
+ write-guard also consume, so every kubectl command still requires an
753
+ approved Understanding Report there, unchanged — proven by a real
754
+ end-to-end test (`tests/cli/pack-hook-pre-tool-use.test.ts`), not just
755
+ the classifier unit. See docs/risk-gate.md's "Kubectl read-only verb
756
+ floor (decision record, task `da823721`)" for the full decision, the
757
+ `get all` / `explain secret` sub-decisions, and the blast-radius
758
+ reasoning.
759
+ Round 2 (review): three bypasses of the secrets exclusion were
760
+ measured end-to-end ALLOW and fixed. `-f`/`--filename`/`-k`/`--kustomize`
761
+ on `get`/`describe` now disables the floor (a manifest file or
762
+ kustomization directory can select a Secret/ConfigMap this module
763
+ cannot read). Any token after `kubectl` containing `$` now disables
764
+ the floor (`$VAR`/`${VAR}`/`"$VAR"` leave the resource-type argument
765
+ unresolved in the raw command text). `configmap`/`configmaps`/the
766
+ bare `cm` abbreviation now get the same exclusion as `secret` (a
767
+ common credential store in practice). Also: the `--raw` reject now
768
+ applies to every floored verb, not just `get`; the timing test now
769
+ measures a floored 30-flag run instead of the fail-closed short-circuit
770
+ path; and docs/risk-gate.md's `get all` claim is narrowed to kubectl's
771
+ built-in resources (a CRD can opt into the `all` category via its own
772
+ `spec.names.categories`), citing the runnable `kubectl
773
+ api-resources --categories=all` check rather than an upstream file
774
+ path this task did not run against a live cluster.
775
+ Round 3 (review): brace expansion (`kubectl get s{e..e}cret ...`),
776
+ glob patterns (`get s*`, `get sec[r]et`), and endpoint/identity
777
+ redirection (`--server`/`-s`/`--kubeconfig`/`--token`/`--as`, pre- or
778
+ post-verb) were measured end-to-end ALLOW, the same class of bug as
779
+ round 2's findings recurring a third time on top of a metacharacter-
780
+ by-metacharacter exclusion list. `isReadOnlyKubectlCommand` is
781
+ redesigned around two ALLOWLISTS instead: every token after `kubectl`
782
+ must match a plain-word shape (closing brace expansion, globs,
783
+ quoting, and escaping, and superseding the round-2 `$`-token and
784
+ `decodeShellWord`-decoding checks, both now redundant), and every flag
785
+ token must be drawn from an explicit per-verb read-flag allowlist (or
786
+ a small global-flag allowlist) — `--raw`, `--filename`/
787
+ `-k`/`--kustomize`, and every endpoint/identity flag
788
+ (`--server`/`-s`/`--kubeconfig`/`--token`/`--as`/`--as-group`/
789
+ `--user`/`--cluster`/`--tls-server-name`/`--insecure-skip-tls-verify`)
790
+ are simply absent from every verb's allowlist, closing all three as
791
+ one mechanism instead of three separate checks. `--as`/`--as-group`
792
+ impersonation, floored in rounds 1-2, is now refused (behavior
793
+ change, folded into the endpoint/identity set). See docs/risk-gate.md's
794
+ "Round 3: from metacharacter exclusions to allowlists" for the halt
795
+ rationale and the new residual-risk bullet on the secret/configmap
796
+ exclusion being a two-kind resource-TYPE denylist (not a
797
+ credential-content scan: Pod env literals and Flux
798
+ `HelmRelease`/Argo CD `Application` `values:` blocks stay floored;
799
+ mitigation is an operator-defined classifier pattern).
800
+
10
801
  ## [0.49.0] - 2026-08-26
11
802
 
12
803
  ### Fixed