@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.
- package/CHANGELOG.md +825 -0
- package/dist/cli/add/index.js +15 -3
- package/dist/cli/add/index.js.map +1 -1
- package/dist/cli/apply/apply.js +19 -3
- package/dist/cli/apply/apply.js.map +1 -1
- package/dist/cli/approve/risk.d.ts +28 -2
- package/dist/cli/approve/risk.js +14 -4
- package/dist/cli/approve/risk.js.map +1 -1
- package/dist/cli/diff/index.js +22 -1
- package/dist/cli/diff/index.js.map +1 -1
- package/dist/cli/doctor/format.js +29 -2
- package/dist/cli/doctor/format.js.map +1 -1
- package/dist/cli/doctor/index.js +62 -3
- package/dist/cli/doctor/index.js.map +1 -1
- package/dist/cli/doctor/types.d.ts +57 -0
- package/dist/cli/explain-policy.d.ts +4 -1
- package/dist/cli/explain-policy.js +12 -2
- package/dist/cli/explain-policy.js.map +1 -1
- package/dist/cli/export.js +12 -1
- package/dist/cli/export.js.map +1 -1
- package/dist/cli/index.js +29 -7
- package/dist/cli/index.js.map +1 -1
- package/dist/cli/init/templates.d.ts +34 -1
- package/dist/cli/init/templates.js +137 -3
- package/dist/cli/init/templates.js.map +1 -1
- package/dist/cli/list.js +6 -0
- package/dist/cli/list.js.map +1 -1
- package/dist/cli/loader.js +15 -0
- package/dist/cli/loader.js.map +1 -1
- package/dist/cli/pack/hook-codex-pre-tool-use.js +11 -6
- package/dist/cli/pack/hook-codex-pre-tool-use.js.map +1 -1
- package/dist/cli/pack/hook-post-tool-use.d.ts +6 -4
- package/dist/cli/pack/hook-post-tool-use.js.map +1 -1
- package/dist/cli/pack/hook-pre-tool-use.js +23 -22
- package/dist/cli/pack/hook-pre-tool-use.js.map +1 -1
- package/dist/cli/policy/intercept.js +59 -9
- package/dist/cli/policy/intercept.js.map +1 -1
- package/dist/cli/remove/index.d.ts +12 -0
- package/dist/cli/remove/index.js +89 -0
- package/dist/cli/remove/index.js.map +1 -1
- package/dist/cli/validate/checks.d.ts +49 -0
- package/dist/cli/validate/checks.js +416 -9
- package/dist/cli/validate/checks.js.map +1 -1
- package/dist/cli/validate/index.js +11 -0
- package/dist/cli/validate/index.js.map +1 -1
- package/dist/policy-packs/builtin/understanding-before-execution/index.d.ts +2 -2
- package/dist/policy-packs/builtin/understanding-before-execution/index.js +33 -27
- package/dist/policy-packs/builtin/understanding-before-execution/index.js.map +1 -1
- package/dist/policy-packs/builtin/understanding-before-execution/markers.js +2 -2
- package/dist/policy-packs/builtin/understanding-before-execution/markers.js.map +1 -1
- package/dist/policy-packs/builtin/understanding-before-execution/persisted-reports.d.ts +46 -8
- package/dist/policy-packs/builtin/understanding-before-execution/persisted-reports.js +71 -12
- package/dist/policy-packs/builtin/understanding-before-execution/persisted-reports.js.map +1 -1
- package/dist/policy-packs/builtin/understanding-before-execution/post-tool-use-boundary.js +6 -3
- package/dist/policy-packs/builtin/understanding-before-execution/post-tool-use-boundary.js.map +1 -1
- package/dist/policy-packs/builtin/understanding-before-execution.js +9 -8
- package/dist/policy-packs/builtin/understanding-before-execution.js.map +1 -1
- package/dist/runtime/bash-prefix-parse.d.ts +12 -1
- package/dist/runtime/bash-prefix-parse.js +2 -2
- package/dist/runtime/bash-prefix-parse.js.map +1 -1
- package/dist/runtime/command-normalize.d.ts +112 -0
- package/dist/runtime/command-normalize.js +169 -55
- package/dist/runtime/command-normalize.js.map +1 -1
- package/dist/runtime/deletion-target-resolve.d.ts +28 -0
- package/dist/runtime/deletion-target-resolve.js +1055 -0
- package/dist/runtime/deletion-target-resolve.js.map +1 -0
- package/dist/runtime/index.d.ts +1 -0
- package/dist/runtime/index.js +1 -0
- package/dist/runtime/index.js.map +1 -1
- package/dist/runtime/intercept.js +17 -1
- package/dist/runtime/intercept.js.map +1 -1
- package/dist/runtime/kubectl-target-parse.d.ts +31 -0
- package/dist/runtime/kubectl-target-parse.js +249 -0
- package/dist/runtime/kubectl-target-parse.js.map +1 -0
- package/dist/runtime/read-only-bash.d.ts +11 -0
- package/dist/runtime/read-only-bash.js +288 -7
- package/dist/runtime/read-only-bash.js.map +1 -1
- package/dist/runtime/risk-classifier.js +14 -1
- package/dist/runtime/risk-classifier.js.map +1 -1
- package/dist/runtime/when-eval.d.ts +11 -2
- package/dist/runtime/when-eval.js +25 -0
- package/dist/runtime/when-eval.js.map +1 -1
- package/dist/runtime/workflow-policies.d.ts +164 -0
- package/dist/runtime/workflow-policies.js +439 -0
- package/dist/runtime/workflow-policies.js.map +1 -0
- package/dist/schema/index.d.ts +24 -0
- package/dist/schema/policies.d.ts +25 -0
- package/dist/schema/policies.js +29 -0
- package/dist/schema/policies.js.map +1 -1
- package/dist/schema/risk.d.ts +6 -0
- package/dist/schema/risk.js +80 -0
- package/dist/schema/risk.js.map +1 -1
- 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
|