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