@lannguyensi/harness 0.44.0 → 0.45.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 +1213 -0
- package/dist/cli/adopt/derive.d.ts +16 -7
- package/dist/cli/adopt/derive.js +19 -2
- package/dist/cli/adopt/derive.js.map +1 -1
- package/dist/cli/adopt/index.js +18 -3
- package/dist/cli/adopt/index.js.map +1 -1
- package/dist/cli/apply/apply.d.ts +1 -0
- package/dist/cli/apply/apply.js +62 -6
- package/dist/cli/apply/apply.js.map +1 -1
- package/dist/cli/apply/generate-codex-config.js +5 -6
- package/dist/cli/apply/generate-codex-config.js.map +1 -1
- package/dist/cli/apply/generate-opencode-config.d.ts +58 -0
- package/dist/cli/apply/generate-opencode-config.js +210 -0
- package/dist/cli/apply/generate-opencode-config.js.map +1 -0
- package/dist/cli/apply/generate-settings.d.ts +90 -32
- package/dist/cli/apply/generate-settings.js +133 -10
- package/dist/cli/apply/generate-settings.js.map +1 -1
- package/dist/cli/apply/index.d.ts +2 -1
- package/dist/cli/apply/index.js +2 -1
- package/dist/cli/apply/index.js.map +1 -1
- package/dist/cli/approve/stdin-report.d.ts +15 -0
- package/dist/cli/approve/stdin-report.js +1 -1
- package/dist/cli/approve/stdin-report.js.map +1 -1
- package/dist/cli/approve/understanding.d.ts +75 -0
- package/dist/cli/approve/understanding.js +150 -2
- package/dist/cli/approve/understanding.js.map +1 -1
- package/dist/cli/audit.d.ts +1 -1
- package/dist/cli/audit.js +3 -2
- package/dist/cli/audit.js.map +1 -1
- package/dist/cli/doctor/claude-mcp.d.ts +19 -1
- package/dist/cli/doctor/claude-mcp.js +36 -10
- package/dist/cli/doctor/claude-mcp.js.map +1 -1
- package/dist/cli/doctor/codex.d.ts +3 -1
- package/dist/cli/doctor/codex.js +6 -9
- package/dist/cli/doctor/codex.js.map +1 -1
- package/dist/cli/doctor/format.js +94 -8
- package/dist/cli/doctor/format.js.map +1 -1
- package/dist/cli/doctor/index.d.ts +23 -0
- package/dist/cli/doctor/index.js +106 -2
- package/dist/cli/doctor/index.js.map +1 -1
- package/dist/cli/doctor/opencode.d.ts +24 -0
- package/dist/cli/doctor/opencode.js +107 -0
- package/dist/cli/doctor/opencode.js.map +1 -0
- package/dist/cli/doctor/target-checks.d.ts +11 -0
- package/dist/cli/doctor/target-checks.js +25 -0
- package/dist/cli/doctor/target-checks.js.map +1 -0
- package/dist/cli/doctor/toolchain-parity.d.ts +63 -0
- package/dist/cli/doctor/toolchain-parity.js +154 -0
- package/dist/cli/doctor/toolchain-parity.js.map +1 -0
- package/dist/cli/doctor/types.d.ts +85 -1
- package/dist/cli/doctor/types.js +1 -1
- package/dist/cli/doctor/types.js.map +1 -1
- package/dist/cli/doctor/understanding-mode-env.d.ts +39 -0
- package/dist/cli/doctor/understanding-mode-env.js +83 -0
- package/dist/cli/doctor/understanding-mode-env.js.map +1 -0
- package/dist/cli/dry-run.js +32 -17
- package/dist/cli/dry-run.js.map +1 -1
- package/dist/cli/explain.d.ts +1 -1
- package/dist/cli/explain.js +1 -1
- package/dist/cli/explain.js.map +1 -1
- package/dist/cli/index.js +58 -4
- package/dist/cli/index.js.map +1 -1
- package/dist/cli/init/composer.js +36 -13
- package/dist/cli/init/composer.js.map +1 -1
- package/dist/cli/init/interactive.d.ts +7 -4
- package/dist/cli/init/interactive.js +79 -27
- package/dist/cli/init/interactive.js.map +1 -1
- package/dist/cli/init/profiles.d.ts +2 -2
- package/dist/cli/init/profiles.js +32 -1
- package/dist/cli/init/profiles.js.map +1 -1
- package/dist/cli/init/templates.d.ts +2 -1
- package/dist/cli/init/templates.js +130 -20
- package/dist/cli/init/templates.js.map +1 -1
- package/dist/cli/pack/approve-escape.js +69 -8
- package/dist/cli/pack/approve-escape.js.map +1 -1
- package/dist/cli/pack/hook-branch-protection.js +1 -1
- package/dist/cli/pack/hook-branch-protection.js.map +1 -1
- package/dist/cli/pack/hook-codex-pre-tool-use.js +27 -1
- package/dist/cli/pack/hook-codex-pre-tool-use.js.map +1 -1
- package/dist/cli/pack/hook-post-merge-gate.js +1 -1
- package/dist/cli/pack/hook-post-merge-gate.js.map +1 -1
- package/dist/cli/pack/hook-pre-tool-use.js +91 -4
- package/dist/cli/pack/hook-pre-tool-use.js.map +1 -1
- package/dist/cli/pack/hook-solution-acceptance.js +19 -2
- package/dist/cli/pack/hook-solution-acceptance.js.map +1 -1
- package/dist/cli/pack/understanding-report-schema-hint.d.ts +13 -0
- package/dist/cli/pack/understanding-report-schema-hint.js +52 -3
- package/dist/cli/pack/understanding-report-schema-hint.js.map +1 -1
- package/dist/cli/pause/index.js +1 -1
- package/dist/cli/pause/index.js.map +1 -1
- package/dist/cli/policy/intercept.d.ts +148 -0
- package/dist/cli/policy/intercept.js +379 -48
- package/dist/cli/policy/intercept.js.map +1 -1
- package/dist/cli/session-start/index.d.ts +12 -3
- package/dist/cli/session-start/index.js +67 -11
- package/dist/cli/session-start/index.js.map +1 -1
- package/dist/cli/session-start/stale-base-check.d.ts +71 -0
- package/dist/cli/session-start/stale-base-check.js +379 -0
- package/dist/cli/session-start/stale-base-check.js.map +1 -0
- package/dist/cli/session-start/toolchain-parity.d.ts +142 -8
- package/dist/cli/session-start/toolchain-parity.js +244 -36
- package/dist/cli/session-start/toolchain-parity.js.map +1 -1
- package/dist/cli/smoke/assertions.d.ts +9 -2
- package/dist/cli/smoke/assertions.js +9 -2
- package/dist/cli/smoke/assertions.js.map +1 -1
- package/dist/cli/uninstall/index.js +18 -2
- package/dist/cli/uninstall/index.js.map +1 -1
- package/dist/cli/validate/checks.d.ts +3 -1
- package/dist/cli/validate/checks.js +298 -3
- package/dist/cli/validate/checks.js.map +1 -1
- package/dist/io/claude-mcp.d.ts +122 -1
- package/dist/io/claude-mcp.js +214 -12
- package/dist/io/claude-mcp.js.map +1 -1
- package/dist/io/duration.js.map +1 -0
- package/dist/io/expand-home.js.map +1 -0
- package/dist/io/extract.js.map +1 -0
- package/dist/io/harness-lock.js +1 -1
- package/dist/io/harness-lock.js.map +1 -1
- package/dist/{runtime → io}/ledger-record.d.ts +1 -1
- package/dist/io/ledger-record.js.map +1 -0
- package/dist/policies/index.d.ts +2 -2
- package/dist/policies/index.js +2 -2
- package/dist/policies/index.js.map +1 -1
- package/dist/policies/ledger-client.js +10 -6
- package/dist/policies/ledger-client.js.map +1 -1
- package/dist/policies/producers.js +1 -1
- package/dist/policies/producers.js.map +1 -1
- package/dist/policies/requires.js +2 -2
- package/dist/policies/requires.js.map +1 -1
- package/dist/policy-packs/builtin/branch-protection.js +28 -3
- package/dist/policy-packs/builtin/branch-protection.js.map +1 -1
- package/dist/policy-packs/builtin/post-merge-gate.js +45 -9
- package/dist/policy-packs/builtin/post-merge-gate.js.map +1 -1
- package/dist/policy-packs/builtin/solution-acceptance-runtime.d.ts +139 -8
- package/dist/policy-packs/builtin/solution-acceptance-runtime.js +246 -12
- package/dist/policy-packs/builtin/solution-acceptance-runtime.js.map +1 -1
- package/dist/policy-packs/builtin/solution-acceptance.js +17 -3
- package/dist/policy-packs/builtin/solution-acceptance.js.map +1 -1
- package/dist/policy-packs/builtin/understanding-before-execution/active-claim.d.ts +25 -0
- package/dist/policy-packs/builtin/understanding-before-execution/active-claim.js +89 -0
- package/dist/policy-packs/builtin/understanding-before-execution/active-claim.js.map +1 -0
- package/dist/policy-packs/builtin/understanding-before-execution/index.d.ts +13 -0
- package/dist/policy-packs/builtin/understanding-before-execution/index.js +39 -0
- package/dist/policy-packs/builtin/understanding-before-execution/index.js.map +1 -0
- package/dist/policy-packs/builtin/understanding-before-execution/ledger.d.ts +25 -0
- package/dist/policy-packs/builtin/understanding-before-execution/ledger.js +56 -0
- package/dist/policy-packs/builtin/understanding-before-execution/ledger.js.map +1 -0
- package/dist/policy-packs/builtin/understanding-before-execution/lifecycle.d.ts +20 -0
- package/dist/policy-packs/builtin/understanding-before-execution/lifecycle.js +79 -0
- package/dist/policy-packs/builtin/understanding-before-execution/lifecycle.js.map +1 -0
- package/dist/policy-packs/builtin/understanding-before-execution/markers.d.ts +103 -0
- package/dist/policy-packs/builtin/understanding-before-execution/markers.js +203 -0
- package/dist/policy-packs/builtin/understanding-before-execution/markers.js.map +1 -0
- package/dist/policy-packs/builtin/understanding-before-execution/persisted-reports.d.ts +177 -0
- package/dist/policy-packs/builtin/understanding-before-execution/persisted-reports.js +309 -0
- package/dist/policy-packs/builtin/understanding-before-execution/persisted-reports.js.map +1 -0
- package/dist/policy-packs/builtin/understanding-before-execution/post-tool-use-boundary.d.ts +106 -0
- package/dist/policy-packs/builtin/understanding-before-execution/post-tool-use-boundary.js +209 -0
- package/dist/policy-packs/builtin/understanding-before-execution/post-tool-use-boundary.js.map +1 -0
- package/dist/policy-packs/builtin/understanding-before-execution/task-markers.d.ts +78 -0
- package/dist/policy-packs/builtin/understanding-before-execution/task-markers.js +185 -0
- package/dist/policy-packs/builtin/understanding-before-execution/task-markers.js.map +1 -0
- package/dist/policy-packs/builtin/understanding-before-execution-runtime.d.ts +1 -536
- package/dist/policy-packs/builtin/understanding-before-execution-runtime.js +9 -1095
- package/dist/policy-packs/builtin/understanding-before-execution-runtime.js.map +1 -1
- package/dist/policy-packs/builtin/understanding-before-execution.d.ts +109 -1
- package/dist/policy-packs/builtin/understanding-before-execution.js +183 -15
- package/dist/policy-packs/builtin/understanding-before-execution.js.map +1 -1
- package/dist/policy-packs/registry.d.ts +11 -0
- package/dist/policy-packs/registry.js +1 -1
- package/dist/policy-packs/registry.js.map +1 -1
- package/dist/policy-packs/runtime.d.ts +1 -1
- package/dist/policy-packs/runtime.js +10 -1
- package/dist/policy-packs/runtime.js.map +1 -1
- package/dist/runtime/agent-facing.js +1 -1
- package/dist/runtime/agent-facing.js.map +1 -1
- package/dist/runtime/bash-match-facts.d.ts +9 -7
- package/dist/runtime/bash-match-facts.js +9 -7
- package/dist/runtime/bash-match-facts.js.map +1 -1
- package/dist/runtime/bash-match-registry.d.ts +75 -38
- package/dist/runtime/bash-match-registry.js +279 -232
- package/dist/runtime/bash-match-registry.js.map +1 -1
- package/dist/runtime/bash-prefix-parse.d.ts +7 -0
- package/dist/runtime/bash-prefix-parse.js +205 -17
- 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 +411 -38
- package/dist/runtime/command-normalize.js.map +1 -1
- package/dist/runtime/hermetic-spawn-guard.js +6 -0
- package/dist/runtime/hermetic-spawn-guard.js.map +1 -1
- package/dist/runtime/index.d.ts +2 -2
- package/dist/runtime/index.js +2 -2
- package/dist/runtime/index.js.map +1 -1
- package/dist/runtime/intercept.d.ts +78 -3
- package/dist/runtime/intercept.js +151 -39
- package/dist/runtime/intercept.js.map +1 -1
- package/dist/runtime/ledger-add.js +1 -1
- package/dist/runtime/ledger-add.js.map +1 -1
- package/dist/runtime/read-only-bash.js +303 -24
- package/dist/runtime/read-only-bash.js.map +1 -1
- package/dist/schema/doctor.d.ts +25 -0
- package/dist/schema/doctor.js +23 -0
- package/dist/schema/doctor.js.map +1 -0
- package/dist/schema/extract.js +1 -1
- package/dist/schema/extract.js.map +1 -1
- package/dist/schema/index.d.ts +76 -0
- package/dist/schema/index.js +9 -0
- package/dist/schema/index.js.map +1 -1
- package/dist/schema/requires.js +2 -2
- package/dist/schema/requires.js.map +1 -1
- package/dist/schema/risk.d.ts +7 -0
- package/dist/schema/risk.js +23 -0
- package/dist/schema/risk.js.map +1 -1
- package/dist/schema/stale-base-check.d.ts +18 -0
- package/dist/schema/stale-base-check.js +37 -0
- package/dist/schema/stale-base-check.js.map +1 -0
- package/dist/schema/toolchain-parity.d.ts +3 -0
- package/dist/schema/toolchain-parity.js +13 -0
- package/dist/schema/toolchain-parity.js.map +1 -1
- package/package.json +3 -2
- package/dist/policies/duration.js.map +0 -1
- package/dist/policies/extract.js.map +0 -1
- package/dist/runtime/expand-home.js.map +0 -1
- package/dist/runtime/ledger-record.js.map +0 -1
- /package/dist/{policies → io}/duration.d.ts +0 -0
- /package/dist/{policies → io}/duration.js +0 -0
- /package/dist/{runtime → io}/expand-home.d.ts +0 -0
- /package/dist/{runtime → io}/expand-home.js +0 -0
- /package/dist/{policies → io}/extract.d.ts +0 -0
- /package/dist/{policies → io}/extract.js +0 -0
- /package/dist/{runtime → io}/ledger-record.js +0 -0
package/CHANGELOG.md
CHANGED
|
@@ -7,6 +7,1219 @@ and this project adheres to [Semantic Versioning](https://semver.org/).
|
|
|
7
7
|
|
|
8
8
|
## [Unreleased]
|
|
9
9
|
|
|
10
|
+
## [0.45.0] - 2026-08-19
|
|
11
|
+
|
|
12
|
+
### Internal
|
|
13
|
+
|
|
14
|
+
- The 4 grandfathered reverse-import exemptions (duration, extract,
|
|
15
|
+
expand-home, ledger-record) recorded as known debt in 0.44.0 are closed:
|
|
16
|
+
the utils moved to `src/io/` and the dependency-cruiser exemptions are
|
|
17
|
+
deleted (slice 4 of the structural-concentration work, task 61a37b25).
|
|
18
|
+
`src/io` stays deliberately unconstrained; a follow-up considers an
|
|
19
|
+
io-no-upward-imports rule.
|
|
20
|
+
- `src/policy-packs/builtin/understanding-before-execution-runtime.ts`
|
|
21
|
+
(formerly a single monolith) is now a 9-line re-export shim over 7
|
|
22
|
+
concern-scoped siblings under `understanding-before-execution/`
|
|
23
|
+
(`ledger.ts`, `markers.ts`, `persisted-reports.ts`, `lifecycle.ts`,
|
|
24
|
+
`task-markers.ts`, `post-tool-use-boundary.ts`, `active-claim.ts`),
|
|
25
|
+
composed by `index.ts` (slice 2 of the structural-concentration work,
|
|
26
|
+
task `348a4d42`). Pure move: every existing import of the old path
|
|
27
|
+
(hook-pre-tool-use.ts, hook-codex-pre-tool-use.ts,
|
|
28
|
+
cli/approve/understanding.ts, tests, etc.) keeps working unchanged.
|
|
29
|
+
`safeJsonParse` (module-private in the monolith) is now exported from
|
|
30
|
+
`persisted-reports.ts` so `markers.ts` can import it cross-file, but is
|
|
31
|
+
deliberately NOT re-exported from the shim's public surface (pinned by
|
|
32
|
+
`tests/policy-packs/ube-export-surface.test.ts`); a follow-up considers
|
|
33
|
+
moving it into `src/io/`.
|
|
34
|
+
- `approval_lifecycle.expire_on_bash_match`'s shipped `SOLO_TEMPLATE` /
|
|
35
|
+
`TEAM_TEMPLATE` / `FULL_TEMPLATE` regexes stay `^`-anchored
|
|
36
|
+
(`^gh pr (merge|close)\b`, `^git push origin (master|main)\b`) — no
|
|
37
|
+
functional change from the last release (task `fb80b5bb`). A same-task
|
|
38
|
+
first attempt widened both patterns to `\b`-scoped/un-anchored to close
|
|
39
|
+
a measured fail-open gap (a boundary command behind `cd <dir> &&`, an
|
|
40
|
+
env-var prefix, a subshell, or an inserted flag never expired the
|
|
41
|
+
marker); a follow-up review measured that widening end-to-end against
|
|
42
|
+
the real PostToolUse hook and found it made things worse: the
|
|
43
|
+
`harness approve understanding <<'RPT' ... RPT` flow became
|
|
44
|
+
self-revoking (a report body that legitimately quotes a boundary
|
|
45
|
+
command as part of the plan expired its own freshly-written marker,
|
|
46
|
+
an operator-only recovery), plus at least 8 everyday false positives
|
|
47
|
+
observed during the round-2 review probe (corpus not retained;
|
|
48
|
+
`grep`/`echo`/commit-message text) and at least 20 residual fail-open
|
|
49
|
+
forms still observed uncaught in that same probe. A scoped
|
|
50
|
+
`isEscapeCommand` exemption for the
|
|
51
|
+
approve-heredoc was considered and rejected: it would import a
|
|
52
|
+
documented divergence class (4 prior halts) into a new consumer.
|
|
53
|
+
Reverted `src/cli/init/profiles.ts`, `src/cli/init/templates.ts`,
|
|
54
|
+
`docs/examples/full-manifest.yaml`, and regenerated
|
|
55
|
+
`docs/examples/full-manifest.expected.yaml` (verified byte-for-byte
|
|
56
|
+
against the real `describe()` output) back to the shipped patterns.
|
|
57
|
+
New docs: "expire_on_bash_match: start-anchored, with a documented
|
|
58
|
+
fail-open limitation" in
|
|
59
|
+
`docs/policy-packs/understanding-before-execution.md`, naming the full
|
|
60
|
+
set of known fail-open forms (including `git push --force/-u origin
|
|
61
|
+
main`, `git push origin HEAD:main`, `git -c x=y push`, `gh --repo o/r
|
|
62
|
+
pr merge`, whitespace variants), the safety-net role of `max_age`
|
|
63
|
+
(`1h` SOLO / `4h` TEAM+FULL), and a separate known gap: the interactive
|
|
64
|
+
custom profile (`harness init --interactive`, `composeCustom()` in
|
|
65
|
+
`src/cli/init/composer.ts`) sets `expire_on_tool_match` and `max_age`
|
|
66
|
+
but never sets `expire_on_bash_match` at all — tracked as a follow-up
|
|
67
|
+
(task `90eae119-cb77-4941-975c-7d2930e685d8`), not fixed here. Updated
|
|
68
|
+
`tests/cli/init-full-template-pins.test.ts`:
|
|
69
|
+
restored the "expire_on_bash_match is a separate anchored family,
|
|
70
|
+
untouched" guard's original `^`-anchored assertions and added a
|
|
71
|
+
trailing-`\b` structural check that the pre-fb80b5bb version of this
|
|
72
|
+
test never had (a round-2 mutation probe proved its absence would go
|
|
73
|
+
undetected); added an "anchored-pattern behavior" describe block
|
|
74
|
+
pinning positive matches, the documented known-miss forms, and
|
|
75
|
+
negative false-positive-avoidance forms (including the approve-heredoc
|
|
76
|
+
shape) against the actual shipped regexes; hoisted the `bashMatchers`
|
|
77
|
+
helper (duplicated across two describe blocks during this task's first
|
|
78
|
+
attempt on this branch — master carried only one definition) to module
|
|
79
|
+
scope. Mutation-verified: removing the `^` anchor from a pattern turns
|
|
80
|
+
both the structural guard and the false-positive-avoidance pins red;
|
|
81
|
+
removing only the trailing `\b` turns solely the new end-anchor check
|
|
82
|
+
red — both restored to green afterward. Round 2b: the "separate
|
|
83
|
+
anchored family, untouched" guard gained a whole-value
|
|
84
|
+
`toEqual([...])` pin over the two literals (the pre-existing
|
|
85
|
+
per-pattern shape checks stayed green under a widening such as
|
|
86
|
+
`(master|main)` -> `(master|main|develop)`); the known-miss `it.each`
|
|
87
|
+
array gained the 4 forms the docs already named but the array did not
|
|
88
|
+
yet pin (`git -c user.name=x push origin main`, `git push origin
|
|
89
|
+
HEAD:main`, `gh --repo owner/repo pr merge 42`, doubled whitespace);
|
|
90
|
+
and `tests/cli/init-composer.test.ts` gained a pin for the
|
|
91
|
+
interactive-composer gap named above (task
|
|
92
|
+
`90eae119-cb77-4941-975c-7d2930e685d8`).
|
|
93
|
+
|
|
94
|
+
### Added
|
|
95
|
+
|
|
96
|
+
- `generateSettingsWithWarnings` (`src/cli/apply/generate-settings.ts`)
|
|
97
|
+
gains `projectSigningKeyEnv`, projecting the harness's own approval-
|
|
98
|
+
signing key path (`<generatedDir>/.approval-signing.key`,
|
|
99
|
+
`signingKeyPathFor`) as the `SOLUTION_VERDICT_SIGNING_KEY` env on the
|
|
100
|
+
`tools.mcp[grounding-mcp]` entry, exactly mirroring the existing
|
|
101
|
+
`EVIDENCE_LEDGER_DB` / `projectGroundingEnv` projection (task
|
|
102
|
+
`03a917fd`/H1, agent-grounding `9b6c4beb` comment 2, Option 2): only
|
|
103
|
+
the fully-resolved, absolute key PATH is projected, never the key
|
|
104
|
+
bytes; an operator-declared `env.SOLUTION_VERDICT_SIGNING_KEY` on the
|
|
105
|
+
entry wins; no projection fires without a grounding-mcp entry or
|
|
106
|
+
without the new `GenerateSettingsExtras.generatedDir` passed in
|
|
107
|
+
(there is no safe default to guess). Wiring the real, resolved
|
|
108
|
+
`generatedDir` through `harness apply`'s and `harness init`'s call
|
|
109
|
+
sites (task `03a917fd`/H1b) is now live: `generate-opencode-config.ts`'s
|
|
110
|
+
`generateOpencodeConfig` gained the matching `generatedDir` extra and
|
|
111
|
+
`apply.ts`'s `buildExpectedFiles` threads its own resolved
|
|
112
|
+
`generatedDir` into both that call and its `generateSettingsWithWarnings`
|
|
113
|
+
call; `init/interactive.ts`'s `loadDesiredMcpServers` — the function
|
|
114
|
+
that feeds the REAL `claude mcp` CLI registration (`ensureMcpServers`)
|
|
115
|
+
— now also projects `SOLUTION_VERDICT_SIGNING_KEY`, mirroring
|
|
116
|
+
`EVIDENCE_LEDGER_DB` there; `adopt/derive.ts`'s `manifestMcpProjection`
|
|
117
|
+
gained the matching manifest-side mirror so an apply→adopt cycle does
|
|
118
|
+
not report phantom drift on grounding-mcp for this key either. One
|
|
119
|
+
caveat carried over from `EVIDENCE_LEDGER_DB`/T-002: settings.json's
|
|
120
|
+
`mcpServers` block (and therefore `apply.ts`'s
|
|
121
|
+
`generateSettingsWithWarnings` call specifically) is dead at runtime —
|
|
122
|
+
Claude Code's real MCP registration goes exclusively through the
|
|
123
|
+
`claude mcp` CLI path in `interactive.ts`, so the settings.json branch's
|
|
124
|
+
`generatedDir` threading is wired for symmetry/future-proofing but has
|
|
125
|
+
no independently observable effect today.
|
|
126
|
+
- Review round H1 fix-round (task `03a917fd`): `generate-settings.ts`
|
|
127
|
+
gains `buildDesiredMcpServers(manifest, {homeDir, generatedDir})`, a
|
|
128
|
+
single choke-point wrapper around `buildMcpServers` ->
|
|
129
|
+
`projectGroundingEnv` -> `projectSigningKeyEnv`. Every producer of the
|
|
130
|
+
Claude-Code-shaped MCP server-spec map — `harness apply`'s
|
|
131
|
+
settings.json generation, `init --interactive`'s `claude mcp` CLI
|
|
132
|
+
wiring, `harness doctor`'s registration-health check, and the opencode
|
|
133
|
+
config generator — now calls this one function instead of hand-rolling
|
|
134
|
+
the same three-call sequence, closing the gap where doctor's "not
|
|
135
|
+
registered, run this `claude mcp add-json ...`" hint for grounding-mcp
|
|
136
|
+
silently omitted `SOLUTION_VERDICT_SIGNING_KEY` (3 of the 4 producers
|
|
137
|
+
called `projectSigningKeyEnv`, doctor did not). `projectSigningKeyEnv`
|
|
138
|
+
also normalizes a relative or literal-tilde `generatedDir`
|
|
139
|
+
(`expandHome` + `path.resolve`, mirroring `groundingLedgerEnvValue`'s
|
|
140
|
+
idiom for `EVIDENCE_LEDGER_DB`) into an absolute path instead of
|
|
141
|
+
silently under-projecting. `docs/ARCHITECTURE.md`'s `grounding:`
|
|
142
|
+
wiring-status note and `GenerateSettingsResult.mcpServers`'s doc
|
|
143
|
+
comment now name both projections.
|
|
144
|
+
- `harness doctor` gains an on-demand toolchain-parity section (task
|
|
145
|
+
`13919613`, commit `16d47941`), the doctor companion to `harness session-start
|
|
146
|
+
toolchain-parity`: reuses that command's Collector/Comparator core
|
|
147
|
+
(`collectLocalSnapshot`, `compareToPeer`, `parseSnapshotJson`,
|
|
148
|
+
`formatSnapshotAge`, the real collectors) verbatim — no comparison
|
|
149
|
+
logic is duplicated — to compare the current live toolchain against
|
|
150
|
+
whatever peer snapshots already exist in `machine_state_dir`, without
|
|
151
|
+
ever writing a snapshot file or a ledger fact. Present only when
|
|
152
|
+
`toolchain_parity.enabled: true` (omitted otherwise), following the
|
|
153
|
+
same optional-section convention as `npmGlobalBin`/the claude-code MCP
|
|
154
|
+
registration check. `--shallow` skips the live collection (no
|
|
155
|
+
`node`/`npm` spawn), mirroring those same checks. Drift always rolls
|
|
156
|
+
into `warningCount`, never `errorCount` — advisory only, not a gate,
|
|
157
|
+
same contract as the SessionStart producer. Documented in
|
|
158
|
+
`docs/CLI.md`.
|
|
159
|
+
- `harness session-start toolchain-parity` hardening round (task
|
|
160
|
+
`c1b5ade5`, follow-up to the initial 690fba7c drop): (1) a lossy
|
|
161
|
+
`sanitizeProfileName` advisory — a configured `profile` that is not
|
|
162
|
+
filename-safe now logs a warning, plus a stronger WARNING when the
|
|
163
|
+
sanitized filename collides with an existing snapshot for a DIFFERENT
|
|
164
|
+
profile (this run's write is about to overwrite it); (2) defensive
|
|
165
|
+
try/catch around the `resolveSession` and `writeLedger` call sites — a
|
|
166
|
+
throw/rejection from either now degrades to a note + exit 0 instead of
|
|
167
|
+
an unhandled throw/rejection, same as every other failure path in this
|
|
168
|
+
producer (deliberately NOT applied to `collectLocalSnapshot`, which the
|
|
169
|
+
hermetic-spawn-guard contract requires to propagate uncaught); (3) a
|
|
170
|
+
new optional `toolchain_parity.stale_after_days` manifest knob
|
|
171
|
+
(`src/schema/toolchain-parity.ts`, default off) — a peer snapshot older
|
|
172
|
+
than the threshold gets an extra advisory note without being counted
|
|
173
|
+
as drift. Advisory character is unchanged throughout: every PRODUCTION
|
|
174
|
+
path exits 0 (an injected collector that throws propagates by the
|
|
175
|
+
hermetic-spawn-guard contract, corrected wording — see Round 2b below),
|
|
176
|
+
and `ok`/drift comparator semantics are untouched. Also
|
|
177
|
+
corrects the `toolchain-parity` hook's `budget_ms: 10000` comment in
|
|
178
|
+
`src/cli/init/templates.ts`, which previously described only the
|
|
179
|
+
parallel node/npm collection and missed that the ledger write runs
|
|
180
|
+
SEQUENTIALLY after with its own ~5s default timeout floor (worst case
|
|
181
|
+
~9s, not the "comfortable" sub-5s headroom the old comment claimed).
|
|
182
|
+
**Round 2 (review fix-round, same task):** (a) the collision-warning
|
|
183
|
+
note and the stale-peer note both interpolated an untrusted,
|
|
184
|
+
cross-machine-synced `profile` field into the stderr stream
|
|
185
|
+
unsanitized — a crafted profile string (embedded newline plus
|
|
186
|
+
note-prefix-shaped text) could forge a standalone parity line;
|
|
187
|
+
both sites now wrap the value in `sanitizeProfileName(...)`, same as
|
|
188
|
+
the pre-existing filename-label site (the two "ok against"/"drift:N
|
|
189
|
+
against" sibling notes stay unsanitized — a known, separate gap,
|
|
190
|
+
scoped out of this round); (b) the collision check was one-sided — it
|
|
191
|
+
only ran on the lossy machine's own run, so a filename-safe machine
|
|
192
|
+
silently overwrote a lossy peer's snapshot with no signal and that
|
|
193
|
+
peer silently dropping out of `peersCompared`; the existing-file read
|
|
194
|
+
now runs on every invocation, and only the "not filename-safe" note
|
|
195
|
+
stays scoped to the lossy case; (c) a session resolver that throws
|
|
196
|
+
while the stdin event DID carry a `session_id` used to still report
|
|
197
|
+
`sessionSource: "stdin"` even though `sessionId` had actually fallen
|
|
198
|
+
back to `"default"` — misrepresenting the ledger fact's provenance and
|
|
199
|
+
suppressing the AC5 "resolved to default" warning; a `resolverThrew`
|
|
200
|
+
flag now short-circuits `sessionSource` to `"default"` whenever the
|
|
201
|
+
catch fires; (d) `stale_after_days` gained `.finite()` — `.positive()`
|
|
202
|
+
alone accepted `Infinity` (reachable via YAML `.inf`), silently turning
|
|
203
|
+
the staleness check into a permanent no-op. The try/catch this round
|
|
204
|
+
removes was around `resolveManifestLedgerWriter`: unlike the two
|
|
205
|
+
real I/O/spawn-adjacent seams it sat next to (`resolveSession`,
|
|
206
|
+
`writeLedger`), that call has no throw path at all — it is a pure
|
|
207
|
+
function over an already-validated `manifest` (a plain array `.find`
|
|
208
|
+
plus a string split underneath) — so the catch was dead, untested code
|
|
209
|
+
(round-1 review finding) and is removed rather than built out into a
|
|
210
|
+
fake seam; the two genuine I/O sites stay caught.
|
|
211
|
+
**Round 2b (second review fix-round, same task, `c1b5ade5`):** R2's
|
|
212
|
+
review, re-run against the R2 fix, proved the log-injection class was
|
|
213
|
+
still open across roughly ten call sites in three vector families — R2
|
|
214
|
+
had sanitized only the collision-warning and stale-peer notes, leaving
|
|
215
|
+
every other note() call reachable by untrusted content: the "ok
|
|
216
|
+
against"/"drift:N against" notes (raw `comparison.peerProfile`, never
|
|
217
|
+
wrapped by R2 at all), every individual drift message from
|
|
218
|
+
`compareToPeer` (untrusted `peer.node`/`owKitVersion`/npm/mcp names,
|
|
219
|
+
reproduced against a real forged `parity:unparseable-peer:INJECTED` tag),
|
|
220
|
+
and two of the three peer-file-read notes (the corrupt-file and
|
|
221
|
+
unreadable-file lines used a raw `fileName`, while only the third,
|
|
222
|
+
`peerLabel`, was sanitized). Fix: moved the CR/LF strip to the shared
|
|
223
|
+
`note()` closure itself — `msg.replace(/[\r\n]/g, " ")`, once, before
|
|
224
|
+
every stderr write — closing the whole class at its one choke point
|
|
225
|
+
instead of requiring every present and future call site to remember its
|
|
226
|
+
own sanitization. With injection protection centralized in `note()`, the
|
|
227
|
+
three R2-added `sanitizeProfileName(...)` display wraps (collision note,
|
|
228
|
+
unparseable-peer `peerLabel`, stale-peer note) are reverted back to the
|
|
229
|
+
raw field: `sanitizeProfileName` is a FILENAME escaper (documented at
|
|
230
|
+
its definition) that also lossily mangles a legitimate non-ASCII profile
|
|
231
|
+
name, so reusing it for display was both redundant once `note()`
|
|
232
|
+
protects the line and a correctness regression — the stale-peer note's
|
|
233
|
+
sanitized spelling could differ from the same peer's raw spelling on the
|
|
234
|
+
"ok against" line two lines above it, within the SAME run. `note()`
|
|
235
|
+
itself is now the only site responsible for log-injection safety; the
|
|
236
|
+
filename path (`ownFileName`/`writeOwnSnapshot`) is unaffected and still
|
|
237
|
+
goes through `sanitizeProfileName`. Also, three unrelated LOW findings
|
|
238
|
+
from the same review pass: (a) the collision-warning note now appends a
|
|
239
|
+
one-line remediation hint ("...set a distinct `toolchain_parity.profile`
|
|
240
|
+
on one of the machines"); (b) the collision pre-read's previously-bare
|
|
241
|
+
`catch {}` is narrowed to re-note any error whose `code` is not `ENOENT`
|
|
242
|
+
(a genuinely missing file is still silent — EACCES/EISDIR/etc. is now a
|
|
243
|
+
one-line note that the collision check silently did not run, rather than
|
|
244
|
+
vanishing into the same branch as "no existing file"); (c) the ledger
|
|
245
|
+
write's `result.reason ?? "unknown error"` fallback is kept, with a
|
|
246
|
+
comment explaining why (`AddLedgerFactResult`'s `!ok` arm types `reason`
|
|
247
|
+
as non-optional, so the fallback is unreachable through any type-checked
|
|
248
|
+
caller — but unlike the pure `resolveManifestLedgerWriter` call the R2
|
|
249
|
+
catch-removal above targeted, `writeLedger` is an injectable *public*
|
|
250
|
+
seam a JS-level or otherwise untyped caller can reach with a malformed
|
|
251
|
+
return value at runtime, so this one stays). New/updated tests: a
|
|
252
|
+
note()-level pin (an arbitrary thrown message with an embedded newline
|
|
253
|
+
produces no second stderr line), dedicated regressions for the three
|
|
254
|
+
reviewer-reproduced vectors (hostile `peer.profile`, hostile `peer.node`
|
|
255
|
+
in a drift message, hostile peer filename with an embedded newline —
|
|
256
|
+
each asserted against the exact greppable prefix it tried to forge), a
|
|
257
|
+
negative control pinning that the collision check does NOT fire when a
|
|
258
|
+
machine's own profile matches its own existing snapshot (guards a
|
|
259
|
+
"warn-always" mutation), an EISDIR case for the narrowed catch, and a
|
|
260
|
+
label-coherence test showing one peer's name renders identically on the
|
|
261
|
+
"ok against" and stale-peer lines in the same run.
|
|
262
|
+
|
|
263
|
+
### Security
|
|
264
|
+
|
|
265
|
+
- No functional change: the known fail-open limitation of
|
|
266
|
+
`approval_lifecycle.expire_on_bash_match` is now documented — see the
|
|
267
|
+
Internal entry above (task `fb80b5bb`) and
|
|
268
|
+
`docs/policy-packs/understanding-before-execution.md`.
|
|
269
|
+
- **SECURITY: `harness session-start toolchain-parity`'s stderr log-injection
|
|
270
|
+
class is closed, across every call site, not just the two R2 patched**
|
|
271
|
+
(task `c1b5ade5` R2b). The machine-state directory is populated
|
|
272
|
+
cross-machine by `agent-memory-sync`, so a peer's snapshot content
|
|
273
|
+
(`profile`, `node`, `owKitVersion`, npm/mcp package names) and even a
|
|
274
|
+
peer's raw filename are all attacker-influenced input; a crafted value
|
|
275
|
+
containing an embedded newline plus note-prefix-shaped text could forge
|
|
276
|
+
a standalone, fully fake `harness session-start toolchain-parity: ...`
|
|
277
|
+
line in stderr — including the exact greppable
|
|
278
|
+
`parity:unparseable-peer:<label>` / `toolchain-parity:ok` /
|
|
279
|
+
`toolchain-parity:drift:<n>` tags an operator or `harness audit` would
|
|
280
|
+
grep for. R2 (same task, prior round) sanitized only 2 of the roughly 10
|
|
281
|
+
reachable sites (the collision-warning and stale-peer notes); this round
|
|
282
|
+
closes the rest — the "ok against"/"drift:N against" notes, every
|
|
283
|
+
individual `compareToPeer` drift message, and the two previously-raw
|
|
284
|
+
peer-file-read notes — by stripping CR/LF once, in the shared `note()`
|
|
285
|
+
closure all of these funnel through, rather than continuing to require a
|
|
286
|
+
wrap at each call site. The pre-existing 876/878-area peer-unreadable and
|
|
287
|
+
peer-corrupt notes (raw `fileName`) are covered by this same fix; there
|
|
288
|
+
is no remaining open site in this class and no follow-up is needed.
|
|
289
|
+
- **The `solution-acceptance` policy pack's verdict marker is now HMAC-signed, reusing the SAME `signMarker` / `verifyMarkerSignature` primitive the understanding-gate approval marker (and its branch-protection twin) shipped in v0.41.0 (task `f9485cc7`) — this is the "solution-acceptance's verdict marker remains unsigned" residual that entry's own follow-up note tracked separately** (task `c7c3f606`, pattern + exemplar). `evaluateGate` (`src/policy-packs/builtin/solution-acceptance-runtime.ts`) now verifies the verdict's signature BEFORE trusting `ready`/`head` at all: the signed payload mirrors the approval marker's `(approvedAt, approvedBy, reportContentHash)` shape onto the verdict's own fields — `timestamp` plays `approvedAt`, `source` plays `approvedBy`, and a sha256 content hash of `head`/`ready`/`confidence`/`blockers` plays `reportContentHash` — so tampering ANY of those fields post-signing invalidates the signature, not just editing `signature` itself. `harness pack hook solution-acceptance` REJECTS a verdict with a missing or invalid signature: `allowed:false`, same as no verdict at all, but with a distinct `forged/unsigned solution-acceptance verdict rejected` reason (new `GateResult.forged` field) so an operator/auditor can tell an active forgery attempt (or a not-yet-signing producer) apart from the routine "no verdict yet" / "not ready yet" cases. **Back-compat is strict, no migration window**, the SAME trade-off f9485cc7 made: a verdict with no `signature` field is rejected exactly like a forgery.
|
|
290
|
+
- **Honest residual (read before assuming more than it delivers): this is CONSUMER-SIDE ONLY.** Unlike the understanding-gate marker, harness does not WRITE the solution-acceptance verdict — the producer is `@lannguyensi/grounding-mcp`, a separate package/repo. This task shipped the pattern + exemplar (`signVerdict` / `verifyVerdictSignature`, exported from `solution-acceptance-runtime.ts`, are the reusable pair a producer-side change mirrors) but **no currently-released grounding-mcp version signs its output**. Concretely, until a matching producer release ships (a tracked cross-repo follow-up): every verdict this consumer reads is "unsigned" and the completion-gate denies it UNIVERSALLY, even a perfectly legitimate `ready:true` verdict at the correct HEAD; re-running `solution_evaluate` does not recover from this (the new verdict is unsigned too); `harness pause`, or temporarily disabling the pack, is the operator's interim recourse. grounding-mcp's own `solution_gate` does not yet enforce this signature either — only this harness consumer does, until the producer-side change closes that asymmetry. `docs/policy-packs/solution-acceptance.md` "Marker signing" spells this out in full. The persisted-report approval path (task `7402301d`) and the write-guard's glob-every-segment / interpreter-runtime-path-construction residual are UNCHANGED by this task and remain separately tracked.
|
|
291
|
+
- New tests: `tests/policy-packs/solution-acceptance-runtime.test.ts` (sign/verify round-trip, forged/unsigned rejection distinct from no-marker, hand-written-marker-without-key forgery regression, one-tampered-signature-byte regression, tampering `ready` without touching `signature`, cross-id signature-replay rejection, signing-key-I/O-failure-not-classified-as-forged, and the golden-fixture tests updated to assert the real UNSIGNED 0.3.2/0.5.0 producer markers are now rejected while a locally-signed copy of the same content still gates correctly) and `tests/cli/pack-hook-solution-acceptance.test.ts` (the same forgery/tamper regressions exercised through the real hook entrypoint).
|
|
292
|
+
- **The `solution-acceptance` policy pack's verdict marker is now HMAC-signed, reusing the SAME `signMarker` / `verifyMarkerSignature` primitive the understanding-gate approval marker (and its branch-protection twin) shipped in v0.41.0 (task `f9485cc7`) — this is the "solution-acceptance's verdict marker remains unsigned" residual that entry's own follow-up note tracked separately** (task `c7c3f606`, pattern + exemplar). `evaluateGate` (`src/policy-packs/builtin/solution-acceptance-runtime.ts`) now verifies the verdict's signature BEFORE trusting `ready`/`head` at all: the signed payload mirrors the approval marker's `(approvedAt, approvedBy, reportContentHash)` shape onto the verdict's own fields — `timestamp` plays `approvedAt`, `source` plays `approvedBy`, and a sha256 content hash of `head`/`ready`/`confidence`/`blockers` plays `reportContentHash` — so tampering ANY of those fields post-signing invalidates the signature, not just editing `signature` itself. `harness pack hook solution-acceptance` REJECTS a verdict with a missing or invalid signature: `allowed:false`, same as no verdict at all, but with a distinct `forged/unsigned solution-acceptance verdict rejected` reason (new `GateResult.forged` field) so an operator/auditor can tell an active forgery attempt (or a not-yet-signing producer) apart from the routine "no verdict yet" / "not ready yet" cases. **Back-compat is strict, no migration window**, the SAME strict no-grace-period POLICY f9485cc7 made: a verdict with no `signature` field is rejected exactly like a forgery. (The RECOVERY shape differs, though — see the honest-residual bullet below.)
|
|
293
|
+
- **Fix-round-2 (review R1 HIGH): cross-id replay via a verbatim file copy is now closed.** `verifyVerdictSignature` previously derived the HMAC markerId from `verdict.id` — a field read back out of the marker BODY, which an attacker controls simply by copying a validly-signed verdict file onto a DIFFERENT id's marker path (the body's `id` travels with the copy, unchanged). `evaluateGate` never compared `verdict.id` against the id it was actually checking, so a verbatim copy of one task's ready verdict onto a second task's path passed verification and ALLOWED the second task to complete. `verifyVerdictSignature` now takes the CALLER's id explicitly and derives the markerId from it, matching the load-bearing invariant `approval-signing.ts` already documents ("verification always recomputes the HMAC using the id the caller is checking, NOT one read back out of the marker body"); `evaluateGate` additionally rejects outright when `verdict.id !== id` (belt-and-braces — the signed payload does not cover `id` itself, so this catches a post-signing edit to that one field that the markerId fix alone would not).
|
|
294
|
+
- **Fix-round-2 (review R1 LOW): the hook's STDERR diagnostic now consumes `GateResult.forged`.** `harness pack hook solution-acceptance` appends a short `[audit: forged/unsigned verdict marker rejected]` tag to its operator-facing diagnostic line specifically when `forged: true`, mirroring the `markerForgedNote` audit-tag pattern already used by `hook-pre-tool-use.ts` / `hook-branch-protection.ts` — previously `GateResult.forged` was computed but never read by any production code path.
|
|
295
|
+
- **Fix-round-2 (review R1 LOW): a verdict missing `timestamp` or `source` is no longer misclassified as forged.** Those two fields map onto `verifyMarkerSignature`'s `approvedAt` / `approvedBy`; a blank one is a legitimately malformed marker (e.g. a producer bug), not necessarily an active forgery attempt. `evaluateGate` now carves the `"missing approvedAt"` / `"missing approvedBy"` verification reasons out of the generic forged bucket — fail-closed (`allowed: false`) either way, but `forged: false`, the same shape as the existing signing-key-unavailable carve-out.
|
|
296
|
+
- **Fix-round-2b (review R2 MED, audit-fidelity): the missing-timestamp/missing-source carve-out above is now scoped to verdicts with NO signature at all.** As shipped, the carve-out fired on the verification REASON string alone, regardless of whether the verdict actually carried `alg`/`signature` fields — so a forger who mutated an otherwise VALIDLY-SIGNED verdict to blank its `timestamp` (or `source`) suppressed the `forged: true` audit tag entirely: `verifyMarkerSignature` checks `approvedAt`/`approvedBy` for blankness BEFORE ever comparing the signature, so the blanked-but-signed marker hit the same `"missing approvedAt"` reason as a genuinely never-signed one and inherited its `forged: false` classification. The block itself still held either way (`allowed: false` throughout — this was never a bypass), but the audit trail lied about what happened. `evaluateGate` now additionally requires `verdict.signature === undefined && verdict.alg === undefined` before granting the carve-out; a verdict that carries `alg`/`signature` but still hits the blank-field reason falls through to the generic `forged: true` bucket instead, since a signed field reading blank only happens via post-signing tampering. The genuinely-unsigned case (no `alg`/`signature` fields, blank `timestamp`/`source`) keeps its `forged: false` classification unchanged — that one remains a defensible "unsigned legacy marker" read, not an attack signal.
|
|
297
|
+
- **Honest residual (read before assuming more than it delivers): this is CONSUMER-SIDE ONLY.** Unlike the understanding-gate marker, harness does not WRITE the solution-acceptance verdict — the producer is `@lannguyensi/grounding-mcp`, a separate package/repo. This task shipped the pattern + exemplar (`signVerdict` / `verifyVerdictSignature`, exported from `solution-acceptance-runtime.ts`, are the reusable pair a producer-side change mirrors) but **no currently-released grounding-mcp version signs its output**. Concretely, until a matching producer release ships (a tracked cross-repo follow-up): every verdict this consumer reads is "unsigned" and the completion-gate denies it UNIVERSALLY, even a perfectly legitimate `ready:true` verdict at the correct HEAD; re-running `solution_evaluate` does not recover from this (the new verdict is unsigned too); `harness pause`, or temporarily disabling the pack, is the operator's interim recourse. grounding-mcp's own `solution_gate` does not yet enforce this signature either — only this harness consumer does, until the producer-side change closes that asymmetry. `docs/policy-packs/solution-acceptance.md` "Marker signing" spells this out in full. **Unlike f9485cc7's residual, there is no operator-side command that resolves this one**: f9485cc7's producer and consumer live in this one repo, so a denied operator has a local one-command fix (`harness approve understanding` / `harness approve branch-protection`); here the producer is a separate package on its own release cadence, so nothing an operator runs today makes a verdict pass until that producer release ships. The persisted-report approval path (task `7402301d`) and the write-guard's glob-every-segment / interpreter-runtime-path-construction residual are UNCHANGED by this task and remain separately tracked.
|
|
298
|
+
- New tests: `tests/policy-packs/solution-acceptance-runtime.test.ts` (sign/verify round-trip, forged/unsigned rejection distinct from no-marker, hand-written-marker-without-key forgery regression, one-tampered-signature-byte regression, tampering `ready` without touching `signature`, relabeled-body signature-replay rejection, a VERBATIM file-copy cross-id replay regression, an isolated `verdict.id !== id` pin independent of the markerId fix, a sanitize-collision regression (`"a:b"` vs `"a_b"` colliding on one file path), the two missing-timestamp/missing-source not-forged carve-outs (now exercised via a genuinely UNSIGNED marker), signing-key-I/O-failure-not-classified-as-forged, the golden-fixture tests updated to assert the real UNSIGNED 0.3.2/0.5.0 producer markers are now rejected while a locally-signed copy of the same content still gates correctly, and — fix-round-2b — a SIGNED-verdict-with-blanked-timestamp/source regression pinning `forged: true` (audit finding A8) plus a minimal-hand-written-marker negative control pinning `forged: false` (audit finding A7)) and `tests/cli/pack-hook-solution-acceptance.test.ts` (the same forgery/tamper/cross-id-replay regressions and the forged-audit-tag diagnostic, exercised through the real hook entrypoint, plus — fix-round-2b — the SIGNED-and-blanked-timestamp forged-tag regression, the `verdict.id !== id` cross-id-mismatch-with-a-still-valid-signature regression, and forged-tag negative controls on the not-ready and stale-head denial paths).
|
|
299
|
+
- **SECURITY (LOW): `isEscapeCommand`'s top-level `command.trim()`
|
|
300
|
+
(`src/cli/pack/approve-escape.ts`) stripped a TRAILING report-heredoc line
|
|
301
|
+
made only of a non-bash-blank whitespace codepoint (e.g. NBSP, U+2028)
|
|
302
|
+
before the 623640a5 `after.every(/^[ \t]*$/)` post-terminator check ever
|
|
303
|
+
saw it, making that check unreachable through the public API for exactly
|
|
304
|
+
the shape it exists to reject** (task `508a2d81`, a residual the
|
|
305
|
+
623640a5 fold self-disclosed). PATH-stub verified against real bash: such
|
|
306
|
+
a line is not inert — bash attempts to execute it as a real command.
|
|
307
|
+
Fix: the top-level trim now strips only TAB, SPACE, LF, and CR from the
|
|
308
|
+
string's boundaries (never the full JS `\s`/Unicode-whitespace class), so
|
|
309
|
+
every other `\s` codepoint survives to the after-check.
|
|
310
|
+
- **SECURITY: the agent-facing deny envelope of the built-in `gate-prod-destructive`
|
|
311
|
+
Risk Gate policy named the session-wide kill switch (`harness pause --for
|
|
312
|
+
<duration>` / `harness resume`) as a way to unblock a denied critical
|
|
313
|
+
production mutation** (task `70781cf6`). `ux.run` is the exact "what to do
|
|
314
|
+
next" text the blocked agent reads (`renderAgentFacing`,
|
|
315
|
+
`src/runtime/intercept.ts`); handing that same agent a recipe for silencing
|
|
316
|
+
every gate in the session is a full bypass instruction addressed to the
|
|
317
|
+
party who was just blocked, not to the operator who alone can act on it.
|
|
318
|
+
Removed the kill-switch line from `gate-prod-destructive`'s `ux.run` in both
|
|
319
|
+
`src/cli/init/templates.ts` (`FULL_TEMPLATE`) and
|
|
320
|
+
`docs/examples/full-manifest.yaml`; the two remaining `run:` entries (choose
|
|
321
|
+
a non-destructive alternative, or ask the operator for a deliberate `harness
|
|
322
|
+
approve risk --force` override) stay unchanged and fully actionable. The
|
|
323
|
+
operator-facing recovery path is not lost: `harness pause`'s own refusal
|
|
324
|
+
message already explains the correct route (a terminal genuinely outside the
|
|
325
|
+
agent session). While auditing every other shipped `ux.run` line for the
|
|
326
|
+
same class of leak, the `deny-kill-switch-bypass` and
|
|
327
|
+
`deny-pause-sentinel-forgery` policies' `run:` text also named `pause`/
|
|
328
|
+
`resume` as something the operator could be asked to do; reworded both to
|
|
329
|
+
point back at the commands already named in their `cannot`/`required` text
|
|
330
|
+
instead of repeating the verbs in the actionable `run:` list.
|
|
331
|
+
`deny-session-env-strip`'s `run:` line did not name either verb and is
|
|
332
|
+
unchanged. Fix round 1 (Finding 3, reviewer-flagged): reworded
|
|
333
|
+
`deny-pause-sentinel-forgery`'s `run:` line again — its first rewording's
|
|
334
|
+
"Ask the OPERATOR to do that themselves" had an ambiguous antecedent (the
|
|
335
|
+
nearest noun phrase was the just-forbidden sentinel write, not the
|
|
336
|
+
operator's actual recovery action); it now points explicitly at "the
|
|
337
|
+
operator-only command named under Required" instead, without
|
|
338
|
+
reintroducing `pause`/`resume` into the `run:` text itself.
|
|
339
|
+
|
|
340
|
+
New/updated tests: `tests/cli/init-no-agent-facing-pause-hint.test.ts` is
|
|
341
|
+
a repo-wide guard over every agent-facing `ux.run` line the init/describe
|
|
342
|
+
surface can emit: `manifest.policies[].ux.run` AND
|
|
343
|
+
`manifest.policy_packs[].config.ux.run` (understanding-before-execution,
|
|
344
|
+
branch-protection, post-merge-gate) across `FULL_TEMPLATE`,
|
|
345
|
+
`SOLO_TEMPLATE`, `TEAM_TEMPLATE`, and `docs/examples/full-manifest.yaml`,
|
|
346
|
+
plus the Custom composer (`composeCustom()` in
|
|
347
|
+
`src/cli/init/composer.ts`, `harness init --interactive`'s Custom
|
|
348
|
+
profile) with every composable pack and policy selected. Fix round 1
|
|
349
|
+
(Finding 1, reviewer-flagged as a MEDIUM coverage gap) added the
|
|
350
|
+
`policy_packs`/composer coverage and the `harness gate disable`/`harness
|
|
351
|
+
gate enable` sibling-verb check — the original guard only walked
|
|
352
|
+
`manifest.policies[].ux.run`, so a kill-switch hint reworded into a
|
|
353
|
+
policy pack's `ux.run` (or into the Custom composer's own copy) would
|
|
354
|
+
have passed both this guard and the pre-existing parity tests
|
|
355
|
+
unnoticed. Each scanned source also now asserts it found at least one
|
|
356
|
+
`ux.run` line (Finding 2): before the round-1 fix, `SOLO_TEMPLATE` has
|
|
357
|
+
zero top-level `policies:` entries, so that arm silently scanned zero
|
|
358
|
+
lines under the original narrower guard and could not have caught
|
|
359
|
+
anything. A "see also" citation to
|
|
360
|
+
`docs/okf/pause-vs-gate-kill-switch.md` stays allow-listed as
|
|
361
|
+
non-actionable, and a dedicated assertion pins that
|
|
362
|
+
`gate-prod-destructive`'s `ux.run` has exactly its two legitimate
|
|
363
|
+
entries left. Regenerated `docs/examples/full-manifest.expected.yaml`
|
|
364
|
+
(the `harness describe` golden fixture) to match. Coverage note
|
|
365
|
+
(Finding 4, reviewer-flagged as inaccurate): the existing
|
|
366
|
+
`tests/cli/init-full-template-parity.test.ts` is the test that
|
|
367
|
+
deep-equality-checks `FULL_TEMPLATE` against
|
|
368
|
+
`docs/examples/full-manifest.yaml` (including each policy_pack's
|
|
369
|
+
`config`, so its `ux.run` too); `tests/cli/init-templates-ux-parity.test.ts`
|
|
370
|
+
is a separate drift guard that instead pins `FULL_TEMPLATE` /
|
|
371
|
+
`SOLO_TEMPLATE` / `TEAM_TEMPLATE` / the Custom composer's policy_pack
|
|
372
|
+
`ux` against the canonical `defaultUx()` functions in
|
|
373
|
+
`src/policy-packs/builtin/*.ts`, not against `full-manifest.yaml`.
|
|
374
|
+
|
|
375
|
+
- **SECURITY: `harness apply`'s generated Claude Code `settings.json` hook
|
|
376
|
+
`timeout` was emitted in the manifest's `budget_ms` unit unconverted, so
|
|
377
|
+
every Claude-hook kill-timer was 1000x too large** (task `7bf47554`).
|
|
378
|
+
Claude Code's settings.json hook `timeout` is documented in SECONDS
|
|
379
|
+
("Seconds before canceling",
|
|
380
|
+
https://code.claude.com/docs/en/hooks); the manifest's `budget_ms` is
|
|
381
|
+
milliseconds. `toSettingsCommand` (`src/cli/apply/generate-settings.ts`)
|
|
382
|
+
emitted `timeout: h.budget_ms` with no unit conversion, so a template
|
|
383
|
+
budget of 1000-2000ms silently became a Claude Code timeout of
|
|
384
|
+
1000-2000 SECONDS (16-33 minutes) instead of 1-2 seconds, meaning
|
|
385
|
+
hard-blocking enforcement hooks (e.g. `harness policy intercept`) could
|
|
386
|
+
hang for tens of minutes before Claude Code cancelled them, rather than
|
|
387
|
+
failing fast. The sibling Codex projection
|
|
388
|
+
(`src/cli/apply/generate-codex-config.ts`) already converted correctly
|
|
389
|
+
(`Math.ceil(budget_ms / 1000)` with a policy-intercept floor); the
|
|
390
|
+
Claude Code projection now shares that exact formula via a single
|
|
391
|
+
extracted helper, `hookTimeoutSeconds` (`generate-settings.ts`), so the
|
|
392
|
+
two runtime projections cannot diverge on this again. `harness adopt`'s
|
|
393
|
+
reverse projection (`src/cli/adopt/index.ts`'s `buildHookEntry`), which
|
|
394
|
+
read a live settings.json `timeout` back into the manifest's
|
|
395
|
+
`budget_ms`, is fixed symmetrically (`* 1000`) so the adopt<->apply
|
|
396
|
+
round-trip stays lossless; `timeout` remains deliberately excluded from
|
|
397
|
+
the adopt drift key, so the ms/seconds rounding asymmetry a non-1000-
|
|
398
|
+
multiple `budget_ms` introduces (e.g. 1500ms -> 2s -> 2000ms) does not
|
|
399
|
+
register as phantom drift or duplicate-adopt a hook. New/updated tests:
|
|
400
|
+
`tests/cli/apply/generate-settings.test.ts` pins the corrected
|
|
401
|
+
conversion (including the 2s/1s floors) and asserts both runtime
|
|
402
|
+
projections emit the identical seconds value for the same hook;
|
|
403
|
+
`tests/cli/adopt.test.ts` adds an explicit apply-then-adopt round-trip
|
|
404
|
+
test for the 1500ms rounding-asymmetry case, asserting zero drift.
|
|
405
|
+
|
|
406
|
+
**Fix round 2, same task: the corrected 1-2s outer timeout above was
|
|
407
|
+
ITSELF a fail-open regression, not purely a safety fix.** Review found
|
|
408
|
+
that `harness policy intercept`'s blocking, ledger-consulting hooks —
|
|
409
|
+
every `requires:`-based policy (the require-*-evidence / risk-gate
|
|
410
|
+
policies) AND, via the engine's unconditional per-decision audit write
|
|
411
|
+
(`intercept()`'s `options.ledger.record(...)` call, run for every
|
|
412
|
+
matched policy including the pure pattern-deny `operator_only` kill
|
|
413
|
+
switches), every other blocking `harness policy intercept` hook too —
|
|
414
|
+
can legitimately need up to the grounding-mcp server's own
|
|
415
|
+
`health.timeout_ms` (5000ms by default) for a single ledger query, plus
|
|
416
|
+
a further deny-degraded audit-write retry bounded by
|
|
417
|
+
`auditRetryTimeoutMs` (`src/cli/policy/intercept.ts`), for a measured
|
|
418
|
+
worst case of ~10.8-13.75s. The now-CORRECT 1-2s Claude Code outer
|
|
419
|
+
timeout sits well UNDER that: on a merely slow or hung ledger (not a
|
|
420
|
+
clean, fast failure), Claude Code kills the `harness policy intercept`
|
|
421
|
+
subprocess before its `deny`/`deny-degraded` JSON reaches stdout, and a
|
|
422
|
+
killed PreToolUse hook is treated as ALLOW — silently turning a
|
|
423
|
+
correctly-computed fail-closed verdict into an unintended fail-open one.
|
|
424
|
+
The same shape applies to the three builtin policy-pack blockers
|
|
425
|
+
(`branch-protection`, `understanding-before-execution`,
|
|
426
|
+
`post-merge-gate`), whose own ledger probes are bounded by the identical
|
|
427
|
+
`health.timeout_ms` with effectively zero margin against their own
|
|
428
|
+
budget_ms=5000 outer timeout. Fixed by raising every blocking (`blocking:
|
|
429
|
+
"hard"`), ledger-consulting hook's `budget_ms` to 15000 (a 15s outer
|
|
430
|
+
timeout) in `src/cli/init/templates.ts` (FULL_TEMPLATE's eleven
|
|
431
|
+
`harness policy intercept` hooks, uniformly — deliberately including the
|
|
432
|
+
three pattern-deny kill-switch hooks, both because their audit write
|
|
433
|
+
waits on the same ledger round-trip and because `generate-settings.ts`'s
|
|
434
|
+
`buildGroups` dedupes same-matcher hooks by `(command, timeout)`, so a
|
|
435
|
+
non-uniform budget would split one Claude Code invocation per Bash call
|
|
436
|
+
into several redundant ones), `docs/examples/full-manifest.yaml` /
|
|
437
|
+
`.expected.yaml` (the one hook there that actually routes through
|
|
438
|
+
`harness policy intercept`), and the three builtin policy packs
|
|
439
|
+
(`src/policy-packs/builtin/branch-protection.ts`,
|
|
440
|
+
`understanding-before-execution.ts` — both the Claude and Codex
|
|
441
|
+
variants — `post-merge-gate.ts`). 15000ms clears the measured
|
|
442
|
+
~10.8-13.75s worst case with margin. Trade-off, stated plainly: a gated
|
|
443
|
+
action can now visibly freeze for up to ~15s when the evidence ledger is
|
|
444
|
+
down or hanging, instead of failing fast (and silently open) at 1-2s —
|
|
445
|
+
this IS the intended fail-closed posture the Risk Gate's
|
|
446
|
+
`degraded_fail_posture: preserve_enforcement` default (task `f1aea826`)
|
|
447
|
+
already commits to; the pre-existing 1-2s budget just never gave that
|
|
448
|
+
posture enough time to actually land. New test:
|
|
449
|
+
`tests/runtime/hook-budget-ledger-margin.test.ts` pins, against the real
|
|
450
|
+
shipped `FULL_TEMPLATE` and the real policy-pack `resolve()` output
|
|
451
|
+
(not a hardcoded hook-name list), that every blocking ledger-consulting
|
|
452
|
+
hook's outer timeout clears `health.timeout_ms` plus the deny-degraded
|
|
453
|
+
audit-retry margin — a lightweight stand-in for the fuller drift-guard
|
|
454
|
+
task `d20a7e0c` is tracking.
|
|
455
|
+
|
|
456
|
+
**Fix round 2 continued, same task: the raise above was itself
|
|
457
|
+
incomplete.** Review found two more `harness init` surfaces still
|
|
458
|
+
shipping the identical blocking `harness policy intercept` hooks at
|
|
459
|
+
their pre-fix 1000-2000ms budgets — the same fail-open gap, reachable
|
|
460
|
+
through a different init path than `--template full`: the
|
|
461
|
+
`harness init --interactive` Custom composer's `HOOK_FOR_POLICY` table
|
|
462
|
+
(`src/cli/init/composer.ts`, five hook rows backing its six reference
|
|
463
|
+
policies) and the `team` profile template's `require-review-evidence`
|
|
464
|
+
hook (`src/cli/init/profiles.ts`'s `TEAM_TEMPLATE`). Raised both to the
|
|
465
|
+
same uniform 15000ms. The `solo` profile template ships no blocking
|
|
466
|
+
`harness policy intercept` hook at all (no agent-tasks-coupled or
|
|
467
|
+
Bash-triggered evidence policy in that profile), so there is nothing to
|
|
468
|
+
raise there; a new negative-control test pins that this is by design,
|
|
469
|
+
not an oversight. With this, the "every blocking ledger-consulting
|
|
470
|
+
hook" framing above is now accurate across every manifest-emitting
|
|
471
|
+
`harness init` surface, not only `FULL_TEMPLATE`. Also strengthened
|
|
472
|
+
`tests/runtime/hook-budget-ledger-margin.test.ts`: it now asserts the
|
|
473
|
+
same margin invariant against the Custom composer's full policy
|
|
474
|
+
selection and `TEAM_TEMPLATE` too (mutation-probe confirmed: reverting
|
|
475
|
+
either surface's budget back to its pre-fix value turns exactly that
|
|
476
|
+
surface's new assertion red, while every other assertion — including
|
|
477
|
+
the other surface's — stays green), asserts the `SOLO_TEMPLATE`
|
|
478
|
+
negative control, and adds a hard 15000ms floor assertion alongside the
|
|
479
|
+
existing formula-derived margin check. The formula alone
|
|
480
|
+
(`health.timeout_ms` + 2x `auditRetryTimeoutMs`, ~8s for the shipped
|
|
481
|
+
5000ms grounding-mcp timeout) understates the measured ~10.8-13.75s
|
|
482
|
+
subprocess wall-time worst case, so a partial regression into
|
|
483
|
+
[8000ms, 13000ms) would still pass a formula-only check; the new hard
|
|
484
|
+
floor (pinned to the same ~10.8-13.75s measurement this task's budget
|
|
485
|
+
note above and `src/cli/init/templates.ts` both cite) closes that gap,
|
|
486
|
+
with its own control test pinning the discrimination at a 10000ms
|
|
487
|
+
probe value.
|
|
488
|
+
|
|
489
|
+
- **SECURITY: `harness validate` / `harness doctor` now ENFORCE the
|
|
490
|
+
hook-budget-vs-ledger-timeout margin task `7bf47554` above only pinned
|
|
491
|
+
by hand against the shipped defaults** (task `d20a7e0c`). Everything
|
|
492
|
+
`7bf47554` fixed was a one-time, hardcoded raise of specific budgets on
|
|
493
|
+
specific manifest-emitting surfaces (`FULL_TEMPLATE`, the Custom
|
|
494
|
+
composer, `TEAM_TEMPLATE`, three builtin policy packs); nothing stopped
|
|
495
|
+
an aged manifest, a hand-authored `harness.yaml`, or a manifest that
|
|
496
|
+
simply raises `tools.mcp.grounding-mcp.health.timeout_ms` from silently
|
|
497
|
+
regressing back into the fail-open gap — a merely SLOW (not even
|
|
498
|
+
hard-down) evidence ledger getting a blocking `harness policy
|
|
499
|
+
intercept` / policy-pack hook killed by the runtime's outer hook
|
|
500
|
+
timeout before its fail-closed `deny`/`deny-degraded` JSON reaches
|
|
501
|
+
stdout, which both Claude Code and Codex then read as an unintended
|
|
502
|
+
ALLOW.
|
|
503
|
+
|
|
504
|
+
Two fixes:
|
|
505
|
+
|
|
506
|
+
1. **Corrected margin formula.** `7bf47554`'s own hardcoded 15000ms
|
|
507
|
+
floor existed because its formula (`health.timeout_ms` +
|
|
508
|
+
2x`auditRetryTimeoutMs`, ~7500ms at the shipped 5000ms default)
|
|
509
|
+
UNDERSTATED the measured ~10.8-13.75s real-world worst case. Re-
|
|
510
|
+
deriving from `realLedgerClient`'s actual two round-trip shapes
|
|
511
|
+
(`src/cli/policy/intercept.ts`) explains the gap and closes it
|
|
512
|
+
properly: `query()` can cost up to **2x** `health.timeout_ms` (a
|
|
513
|
+
slow-but-alive server can independently near-max BOTH the
|
|
514
|
+
`initialize` handshake and the following `ledger_summary` call
|
|
515
|
+
before the second one finally times out — not just 1x), and the
|
|
516
|
+
deny-degraded audit retry can cost up to **3x**
|
|
517
|
+
`auditRetryTimeoutMs` (the fresh retry session's own `initialize`,
|
|
518
|
+
the first `ledger_add` attempt, and — only when that attempt is
|
|
519
|
+
rejected outright by an older/incompatible grounding-mcp rather
|
|
520
|
+
than timing out — the legacy `type: "fact"` fallback `ledger_add`).
|
|
521
|
+
The corrected formula, `requiredHookBudgetMs(T) = 2T +
|
|
522
|
+
3*auditRetryTimeoutMs(T)` (new export, `src/cli/policy/
|
|
523
|
+
intercept.ts`), evaluates to 13750ms at the shipped 5000ms default —
|
|
524
|
+
within `7bf47554`'s own cited ~10.8-13.75s measurement range, unlike
|
|
525
|
+
the ~7500ms the old formula gave. Both round-trip shapes are
|
|
526
|
+
measured independently against the REAL `realLedgerClient` /
|
|
527
|
+
`recordPolicyDecisionOnSession` (not just asserted) in
|
|
528
|
+
`tests/e2e/policy-intercept.test.ts`'s new "hook-budget-ledger-margin
|
|
529
|
+
derivation" suite, via a fake grounding-mcp subprocess that can
|
|
530
|
+
independently delay `initialize`, `ledger_summary`, and
|
|
531
|
+
`ledger_add`, and can force the legacy fact-type fallback.
|
|
532
|
+
|
|
533
|
+
2. **Durable, generic validate/doctor guard.** New
|
|
534
|
+
`checkHookBudgetLedgerMargin` (`src/cli/validate/checks.ts`, wired
|
|
535
|
+
into both `harness validate` and `harness doctor`) checks the
|
|
536
|
+
corrected formula against an ARBITRARY manifest's OWN
|
|
537
|
+
`tools.mcp[grounding-mcp].health.timeout_ms` — not the shipped
|
|
538
|
+
5000ms default — and against every ENABLED `policy_packs[]` entry's
|
|
539
|
+
resolved hooks via the shared `resolveBuiltin` registry lookup, for
|
|
540
|
+
every runtime `harness apply` can target, instead of
|
|
541
|
+
`7bf47554`'s test hand-importing three specific pack modules. A pack
|
|
542
|
+
whose blocker gates on a filesystem verdict marker rather than a
|
|
543
|
+
live ledger round-trip (`solution-acceptance` /
|
|
544
|
+
`solution-acceptance-writeguard`) is correctly excluded — flagging
|
|
545
|
+
it would be a false positive. An under-budgeted hook is reported as
|
|
546
|
+
an `error` naming both the hook's own `budget_ms` and the ledger's
|
|
547
|
+
`health.timeout_ms` (plus the derived requirement), so e.g. an
|
|
548
|
+
operator who raises `health.timeout_ms` without raising a
|
|
549
|
+
pack-shipped hook's fixed `budget_ms` in lockstep is now caught at
|
|
550
|
+
lint time instead of silently reopening the gap. Also fixes the
|
|
551
|
+
matcher this check (and, incidentally,
|
|
552
|
+
`tests/runtime/hook-budget-ledger-margin.test.ts`'s own
|
|
553
|
+
`blockingInterceptHooks`) uses to recognise a `harness policy
|
|
554
|
+
intercept` hook: the new `isPolicyInterceptCommand`
|
|
555
|
+
(`src/cli/policy/intercept.ts`) matches the subcommand tokens
|
|
556
|
+
regardless of a leading interpreter/env-var prefix (`node
|
|
557
|
+
/path/to/dist/cli/index.js policy intercept`, `npx harness policy
|
|
558
|
+
intercept`, `FOO=bar harness policy intercept`) or a trailing flag
|
|
559
|
+
(`harness policy intercept --hook <name>`), instead of the verbatim
|
|
560
|
+
`command.trim() === "harness policy intercept"` compare every other
|
|
561
|
+
call site in this codebase still (deliberately, out of this task's
|
|
562
|
+
scope) uses — an operator-authored `manifest.hooks[]` entry is under
|
|
563
|
+
no obligation to spell the command exactly the way harness's own
|
|
564
|
+
generators do, and under-recognition here is a false NEGATIVE on a
|
|
565
|
+
security check. Fix round 1 (review 2026-08-09) additionally widens
|
|
566
|
+
`isPolicyInterceptCommand`'s TRAILING boundary to also accept a
|
|
567
|
+
semicolon or quote character glued directly onto the subcommand word
|
|
568
|
+
with no whitespace (`harness policy intercept; echo done`, `sh -c
|
|
569
|
+
"harness policy intercept"`), which the whitespace-or-end-only
|
|
570
|
+
boundary shipped in this task's first commit missed; a trailing `)`,
|
|
571
|
+
`|`, `&`, or backtick immediately abutting the word remains a known,
|
|
572
|
+
deliberately-unclosed gap (documented in the function's own doc
|
|
573
|
+
comment) rather than a runtime bypass — a missed match here only
|
|
574
|
+
means the hook is not classified as ledger-consulting for THIS
|
|
575
|
+
check, never a change to what the hook actually does at runtime.
|
|
576
|
+
|
|
577
|
+
**Fix round 1 (review 2026-08-09): two corrections to this task's own
|
|
578
|
+
first commit, made in the name of not overclaiming what the guard
|
|
579
|
+
above actually guarantees.**
|
|
580
|
+
|
|
581
|
+
- **The `2T + 3R` formula's "worst case" claim is scoped to the
|
|
582
|
+
pure-timeout hang shape, not every degradation shape.** The formula
|
|
583
|
+
(and the diagnostic message above) previously read as an unqualified
|
|
584
|
+
worst-case bound. It is not one: `query()`'s `2T` half assumes the
|
|
585
|
+
pooled `LedgerSession`'s session-wide `timedOut` latch trips (making
|
|
586
|
+
the FIRST `record()` attempt instant, per `realLedgerClient`'s own
|
|
587
|
+
doc comment) — true only when `query()` degrades via an actual
|
|
588
|
+
timeout. A `query()` that instead degrades via a NON-timeout path
|
|
589
|
+
(`ledger_summary` returning a genuine JSON-RPC error, an unparseable
|
|
590
|
+
payload, or a payload missing the `entries` shape —
|
|
591
|
+
`LedgerSession.querySummary`'s non-timeout `degraded` branches,
|
|
592
|
+
`src/policies/ledger-client.ts`) never sets that latch, so the first
|
|
593
|
+
`record()` attempt afterwards is a REAL round-trip too — up to
|
|
594
|
+
another `2T` (the `type: "policy_decision"` attempt plus a possible
|
|
595
|
+
legacy `type: "fact"` fallback) BEFORE the `3R` retry sequence even
|
|
596
|
+
starts. Worst case on this "error-then-hang" shape is therefore up to
|
|
597
|
+
`4T + 3R` — 23750ms at the shipped `T=5000` default, against this
|
|
598
|
+
guard's `13750ms` floor. The formula is DELIBERATELY NOT raised to
|
|
599
|
+
`4T + 3R`: doing so would fail `checkHookBudgetLedgerMargin` against
|
|
600
|
+
the shipped, reviewer-confirmed-clean fresh-init budget of `15000ms`
|
|
601
|
+
(task `7bf47554`/#414, out of this task's scope) at the same
|
|
602
|
+
`T=5000` default, since `15000 < 23750`. Instead, `requiredHookBudgetMs`'s
|
|
603
|
+
doc comment (`src/cli/policy/intercept.ts`) and the
|
|
604
|
+
`checkHookBudgetLedgerMargin` diagnostic message now both name this
|
|
605
|
+
residual explicitly as a KNOWN, un-closed gap rather than implying a
|
|
606
|
+
guarantee neither actually gives — the guard backs delivery of the
|
|
607
|
+
fail-closed verdict on the pure-timeout hang shape only. This
|
|
608
|
+
residual is NOT confined to one runtime projection: Claude Code and
|
|
609
|
+
Codex both convert `budget_ms` into the outer hook-kill seconds via
|
|
610
|
+
the identical `hookTimeoutSeconds` helper
|
|
611
|
+
(`src/cli/apply/generate-settings.ts`, shared by
|
|
612
|
+
`generate-codex-config.ts`) since task `7bf47554`/#414, so a manifest
|
|
613
|
+
at the shipped defaults is equally exposed on both — checked against
|
|
614
|
+
both generators' current source for this note, not assumed.
|
|
615
|
+
|
|
616
|
+
- **The diagnostic message no longer attributes a `3× deny-degraded
|
|
617
|
+
audit-retry budget` to hooks that never run one.** The four
|
|
618
|
+
ledger-consulting pack blockers (`branch-protection`,
|
|
619
|
+
`understanding-before-execution`'s `pre-tool-use` /
|
|
620
|
+
`codex-pre-tool-use`, `post-merge-gate`) only ever call
|
|
621
|
+
`queryLedgerByTag` (open session, one `querySummary`, dispose) — they
|
|
622
|
+
never call `ledger_add`, so they have no deny-degraded audit-retry
|
|
623
|
+
step of their own; their real worst case is bounded at up to
|
|
624
|
+
`2×timeout_ms` for the query alone, not `2T + 3R`. The shared
|
|
625
|
+
`${required}ms` floor is kept identical across direct
|
|
626
|
+
`harness policy intercept` hooks and these pack blockers on purpose
|
|
627
|
+
(a deliberately conservative carryover, not a separately-derived and
|
|
628
|
+
separately-tested per-kind bound), but the message text explaining
|
|
629
|
+
WHY now differs per hook kind instead of asserting a retry sequence
|
|
630
|
+
that does not exist for the pack blockers. `post-merge-gate`
|
|
631
|
+
specifically already fails OPEN (allow) on a degraded or unreachable
|
|
632
|
+
ledger by its own design (`src/cli/pack/hook-post-merge-gate.ts`'s
|
|
633
|
+
"Fail posture: OPEN" note, and its own `budget_ms: 15000` comment in
|
|
634
|
+
`src/policy-packs/builtin/post-merge-gate.ts` already said as much)
|
|
635
|
+
— a hook killed by the outer timeout there reaches the same allow
|
|
636
|
+
outcome its own degraded-handling would already choose, so this
|
|
637
|
+
check is kept for it only to stop a merely-slow-but-alive ledger from
|
|
638
|
+
needlessly stalling the gate, not because a fail-closed verdict is at
|
|
639
|
+
risk. `src/cli/validate/checks.ts` now tags each collected hook with
|
|
640
|
+
its ledger-traffic shape (`isPolicyInterceptHook`,
|
|
641
|
+
`isFailOpenOnDegraded`) to pick the right explanatory text; the
|
|
642
|
+
numeric threshold, hook selection, and severity are all unchanged.
|
|
643
|
+
|
|
644
|
+
`tests/runtime/hook-budget-ledger-margin.test.ts`'s original
|
|
645
|
+
hand-imported per-surface assertions are updated to source their margin
|
|
646
|
+
bound from the corrected formula (previously understated) and gain a
|
|
647
|
+
new describe block running the generic `checkHookBudgetLedgerMargin`
|
|
648
|
+
guard against the very same shipped manifests, closing the loop the
|
|
649
|
+
file's own comment named as future work. New/updated tests:
|
|
650
|
+
`tests/cli/validate.test.ts` (fixture pairs for AC1/AC2 plus the
|
|
651
|
+
matcher-fix, pack-genericity, and negative-control cases, plus fix
|
|
652
|
+
round 1's semicolon-chained and quote-wrapped matcher cases),
|
|
653
|
+
`tests/cli/doctor.test.ts` (doctor-surface parity + `format()`
|
|
654
|
+
rendering), `tests/runtime/hook-budget-ledger-margin.test.ts`,
|
|
655
|
+
`tests/e2e/policy-intercept.test.ts`.
|
|
656
|
+
|
|
657
|
+
- **SECURITY: the operator-approval escape matcher (`isEscapeCommand`,
|
|
658
|
+
`src/cli/pack/approve-escape.ts`) used JS's generic `\s` whitespace
|
|
659
|
+
class where bash's actual lexical blank set was meant, letting a
|
|
660
|
+
non-bash-blank codepoint move a report heredoc's real delimiter word
|
|
661
|
+
(as bash reads it) away from the word the matcher extracts** (task
|
|
662
|
+
`623640a5`). Ground-truthed against GNU bash with a PATH-stub harness:
|
|
663
|
+
of the 25 codepoints JS's `\s` class matches, only TAB and SPACE are
|
|
664
|
+
bash lexical blanks; the other 23 (NBSP U+00A0, LF, CR, VT, FF, and 18
|
|
665
|
+
further Unicode space-separator/line-separator/BOM codepoints) glue
|
|
666
|
+
onto the adjacent token instead of being stripped as an insignificant
|
|
667
|
+
separator. Measured exploit shape (the 2026-08-04 halt that opened this
|
|
668
|
+
task): a non-breaking space right after a report heredoc's `<<'X'`
|
|
669
|
+
intro closing quote was accepted by the matcher's `\s*$` as
|
|
670
|
+
insignificant trailing whitespace, so the matcher extracted delimiter
|
|
671
|
+
`X`; bash instead glues the NBSP onto the quoted word, so its real
|
|
672
|
+
delimiter is `X`+NBSP, and the body line `X`+NBSP (matching the
|
|
673
|
+
matcher's delimiter, not bash's) closes the heredoc one line early:
|
|
674
|
+
the following body line then runs as a real top-level command
|
|
675
|
+
(`git push origin master` in the measured repro) instead of staying
|
|
676
|
+
inert report text. Fix: every regex in the module that means "a
|
|
677
|
+
separator bash will actually treat as blank here" (the heredoc-intro
|
|
678
|
+
trailing/leading whitespace, the `harness`/`approve` separator, the
|
|
679
|
+
heredoc command-part whitelist, the command-part right-trim, and the
|
|
680
|
+
post-terminator blank-line check) now uses `[ \t]`, never `\s`.
|
|
681
|
+
Behavior change operators should know about: a `harness approve`
|
|
682
|
+
heredoc carrying a non-bash-blank codepoint in one of those positions
|
|
683
|
+
used to be accepted as a clean escape and routed to the normal
|
|
684
|
+
interactive ASK permission prompt; it is now DENIED outright (fails
|
|
685
|
+
closed to the ordinary understanding-gate block), since ASK implied a
|
|
686
|
+
false equivalence to the safe heredoc shape. The PreToolUse
|
|
687
|
+
discoverability hint (`approveEscapeHint`,
|
|
688
|
+
`src/cli/pack/hook-pre-tool-use.ts`) now also names the offending
|
|
689
|
+
codepoint (e.g. "contains U+00A0 ... where an ordinary space or tab was
|
|
690
|
+
expected") when a blocked command carries one, so an agent that emitted
|
|
691
|
+
invisible whitespace gets a reason instead of silently retrying the
|
|
692
|
+
same bytes. New regression tests: `tests/cli/pack-approve-escape.test.ts`
|
|
693
|
+
(the full 25/23 codepoint enumeration measured against the live JS `\s`
|
|
694
|
+
class, the NBSP incident itself, and a mechanical guard, module source
|
|
695
|
+
with comments/strings stripped, asserting no future regex in the
|
|
696
|
+
module can reintroduce a bare `\s` token, mutation-verified to actually
|
|
697
|
+
redden); `tests/cli/pack-hook-pre-tool-use.test.ts` (the hint still
|
|
698
|
+
surfaces for the newly-blocked shapes, now naming the codepoint; a
|
|
699
|
+
negative control confirms an ordinary metacharacter block names no
|
|
700
|
+
codepoint).
|
|
701
|
+
|
|
702
|
+
- **SECURITY: `harness doctor` now detects template-policy drift, an
|
|
703
|
+
installed manifest that lacks a shipped `operator_only` kill-switch
|
|
704
|
+
security policy** (task `adf037c1`). Measured 2026-08-06: a 0.44.0
|
|
705
|
+
machine whose `~/.harness/harness.yaml` predated the kill-switch
|
|
706
|
+
defenses (`deny-kill-switch-bypass`, `deny-session-env-strip`,
|
|
707
|
+
`deny-pause-sentinel-forgery`) had the documented `harness pause`
|
|
708
|
+
bypass live as ALLOW, with nothing surfacing the gap: `harness apply`
|
|
709
|
+
never retroactively adds newly-shipped default policies to an
|
|
710
|
+
already-materialized manifest, so security policies introduced after
|
|
711
|
+
install reach only fresh installs. Doctor's new "Template drift
|
|
712
|
+
(shipped security policies)" section compares the installed manifest
|
|
713
|
+
against `shippedOperatorOnlyPolicyNames()` (the `operator_only` set
|
|
714
|
+
parsed from `FULL_TEMPLATE` itself, kept honest by the full-template
|
|
715
|
+
parity test), so it can never drift from what `harness init --template
|
|
716
|
+
full` writes and automatically extends when a new operator_only policy
|
|
717
|
+
is added. Each missing policy is an ERROR (doctor exits non-zero, the
|
|
718
|
+
standard CI/dogfood signal), naming the policy and the rehydration
|
|
719
|
+
path. A same-named-but-DOWNGRADED policy (present but no longer
|
|
720
|
+
`operator_only: true`, e.g. the historical `requires.ledger_tag` shape
|
|
721
|
+
a ledger write can satisfy, or `enforcement: warn`) is flagged as a
|
|
722
|
+
distinct error too: name-presence alone would pass a bypassable
|
|
723
|
+
kill-switch as no-drift, the exact aged-manifest class this check
|
|
724
|
+
targets. A stale/typo'd `doctor.ignore_template_drift` entry matching
|
|
725
|
+
no shipped name is surfaced as a warning so a policy rename cannot
|
|
726
|
+
silently strand an opt-out. Scope is the `operator_only` security floor regardless of profile
|
|
727
|
+
(a solo/team install missing kill-switch protection is flagged too;
|
|
728
|
+
that is the security core, per operator decision 2026-08-08), and it
|
|
729
|
+
does NOT nag about full-only convenience policies a smaller profile
|
|
730
|
+
never carried. Deliberate opt-out: `doctor.ignore_template_drift:
|
|
731
|
+
[<policy-name>, ...]` in the manifest, a NEW minimal `doctor:` schema
|
|
732
|
+
section that silences only the doctor report line and changes no
|
|
733
|
+
enforcement. This is deliberately NOT a `policies[].enabled: false`
|
|
734
|
+
flag: the runtime policy engine has no such concept, so an operator
|
|
735
|
+
setting it would believe a policy disabled while it still fired (a
|
|
736
|
+
footgun the CLI help + docs already implied but no code backed). The
|
|
737
|
+
original AC's two-severity split (operator_only error vs cosmetic
|
|
738
|
+
warning) was consciously narrowed to a single operator_only error tier
|
|
739
|
+
per the same operator scope decision, since cosmetic policies are no
|
|
740
|
+
longer compared. Shared `checkTemplatePolicyDrift` in
|
|
741
|
+
`src/cli/validate/checks.ts` (doctor-only surface today; validate is
|
|
742
|
+
about internal consistency, not external-reference comparison, so it is
|
|
743
|
+
intentionally not wired to fail `harness validate`/`apply`).
|
|
744
|
+
docs/okf/pause-vs-gate-kill-switch.md documents the drift check and the
|
|
745
|
+
opt-out; fixtures cover missing/present/opt-out/partial, and a parity
|
|
746
|
+
test pins the shipped set so a template rename reddens rather than
|
|
747
|
+
silently shrinking the check.
|
|
748
|
+
|
|
749
|
+
- **SECURITY: read-only Bash classifier no longer treats mutating
|
|
750
|
+
argument forms of allowlisted git subcommands as read-only** (task
|
|
751
|
+
`9d1fff1b`). `GIT_READ_ONLY_SUBS` (`src/runtime/read-only-bash.ts`)
|
|
752
|
+
was a bare-form allowlist of subcommand NAMES; measured against real
|
|
753
|
+
git 2.50.1, six argument forms of those names mutate while classifying
|
|
754
|
+
read-only: `git branch -D <b>` (deletes a branch), `git tag <name>`
|
|
755
|
+
(creates a tag), `git remote set-url` (rewrites config), `git fetch
|
|
756
|
+
<url> <refspec>` (writes an arbitrary local ref), `git diff
|
|
757
|
+
--output=<path>` (creates/overwrites a file; `log`/`show` accept the
|
|
758
|
+
same option), and `git reflog expire`/`delete` (destroys reflog
|
|
759
|
+
entries). All four consumers of the classifier — the risk-classifier
|
|
760
|
+
read-only severity floor, both PreToolUse understanding-gate hooks, and
|
|
761
|
+
the solution-acceptance write-guard — funnel through the same
|
|
762
|
+
`classifyTokens`, so all four are corrected by the single fix and none
|
|
763
|
+
needed a consumer-side change (measured: the full pack + risk-classifier
|
|
764
|
+
suites, 602 tests, unchanged). The `bin === "git"` branch now applies a
|
|
765
|
+
per-subcommand POSITIVE argument-form check (`isReadOnlyGitInvocation`):
|
|
766
|
+
branch/tag are read-only only with no non-flag operand (every mutation
|
|
767
|
+
needs the branch/tag name; branch also denies the glued no-operand
|
|
768
|
+
writes like `--set-upstream-to=`); remote only for the `show`/`get-url`
|
|
769
|
+
verbs or bare `-v`; fetch only for bare or a single remote/URL (a
|
|
770
|
+
second positional refspec blocks — the escalation to an arbitrary
|
|
771
|
+
local ref); reflog only for bare/`show`/flag forms (expire/delete/drop
|
|
772
|
+
are always the first arg); everything after a bare `--` is folded into
|
|
773
|
+
the positional view. Two write/exec vectors reach git's SHARED option
|
|
774
|
+
parser and transport layer and are therefore forfeited GLOBALLY, above
|
|
775
|
+
the per-subcommand switch, for every allowlisted git subcommand (review
|
|
776
|
+
round 1, measured against git 2.50.1): `--output=<path>` creates a file
|
|
777
|
+
at option-parse time — measured writing on `rev-list`/`shortlog`/`blame`
|
|
778
|
+
as well as diff/log/show — and `--upload-pack=`/`--exec=`/`--receive-pack=`
|
|
779
|
+
plus `ext::`/`fd::` positional transports run an operator-named binary
|
|
780
|
+
locally (measured `git fetch --upload-pack=<script> <repo>` executing
|
|
781
|
+
the script). The choice is positive-shape, not a write-flag
|
|
782
|
+
denylist, so a git argument this floor has not reasoned about
|
|
783
|
+
over-blocks (annoying) instead of laundering a write (unsafe) — the
|
|
784
|
+
same fail-closed stance as the existing `npm audit` guard, and
|
|
785
|
+
deliberately NOT the enumerate-every-exception shape whose repeated
|
|
786
|
+
failure halted the neighbouring task 5b5d1022. Literal verb/flag
|
|
787
|
+
comparisons use the repo's raw-or-decoded form (task fdee7d0f) so a
|
|
788
|
+
quoted spelling (`git remote se"t-url"`) fails the positive match and
|
|
789
|
+
blocks rather than slipping through. Explicitly out of scope (separate
|
|
790
|
+
tasks): global-option / path-qualified git (`git -C <d>`, 5b5d1022),
|
|
791
|
+
GNU long-option abbreviations (dd055c1d), and env-var exec equivalents.
|
|
792
|
+
negative tests pin the mutating forms (reproduced against git 2.50.1),
|
|
793
|
+
positive controls pin the bare forms plus the read corpus (`rev-parse
|
|
794
|
+
--git-dir`, `ls-files -c`, `log -c HEAD`, `show -c HEAD`, `diff --stat`,
|
|
795
|
+
`status --short`, and single-positional `fetch`/`ls-remote` from a URL);
|
|
796
|
+
a mutation probe confirms reverting to the bare-name allowlist reddens
|
|
797
|
+
the negative suite, and a per-arm probe confirms each raw-or-decoded
|
|
798
|
+
decode arm is load-bearing (deleting it reddens a quoted no-positional
|
|
799
|
+
write form). The over-blocked read forms (`git tag -l 'v*'`,
|
|
800
|
+
`git branch --contains HEAD`, separated fetch flag values, ...) are the
|
|
801
|
+
accepted conservative cost, documented in `docs/risk-gate.md`.
|
|
802
|
+
|
|
803
|
+
- **SECURITY: block/require_approval policies now fail CLOSED when their
|
|
804
|
+
evidence source is degraded, instead of silently mapping to the
|
|
805
|
+
never-blocking `warn-degraded`** (task `f1aea826`). Measured 2026-08-06 on
|
|
806
|
+
0.44.0 (isolated probe home, `git push` event without its preflight tag):
|
|
807
|
+
a healthy ledger denied, every broken-ledger shape (ENOTDIR, corrupt file,
|
|
808
|
+
directory) denied, but `--ledger-timeout` at 1/25/50/100 ms flipped the
|
|
809
|
+
same event to **ALLOW**, because the timeout path mapped to
|
|
810
|
+
`warn-degraded`, which `isBlockingDecision` never treats as blocking.
|
|
811
|
+
`evaluateOnePolicy`'s degraded family (ledger timeout/spawn failure,
|
|
812
|
+
unresolved template variables, invalid `within`, thrown evaluator,
|
|
813
|
+
schema-invariant branch) now routes through a tier-aware
|
|
814
|
+
`degradedOutcome`: `warn` policies keep the non-blocking `warn-degraded`
|
|
815
|
+
(availability first, unchanged), `block`/`require_approval` policies
|
|
816
|
+
produce the NEW blocking `deny-degraded` outcome. The `deny-degraded`
|
|
817
|
+
envelope deliberately bypasses the policy's `ux:` surface and names the
|
|
818
|
+
degraded cause (sanitised: the transport reason embeds subprocess
|
|
819
|
+
output, so the envelope interpolation is bounded to 200 chars with
|
|
820
|
+
control characters stripped) plus an operator-facing recovery path, so
|
|
821
|
+
nobody debugs a phantom missing tag on an unreadable ledger. The
|
|
822
|
+
fail_open opt-out is deliberately ABSENT from that agent-facing text
|
|
823
|
+
(review finding: a deny that includes its own disable recipe is not a
|
|
824
|
+
gate, and nothing prevents the blocked agent from editing the
|
|
825
|
+
manifest); it is named on the operator surfaces instead: the verbose
|
|
826
|
+
stderr diagnostic, docs/risk-gate.md, and the OKF fail-posture matrix.
|
|
827
|
+
**ACCEPTED SEMANTIC CHANGE
|
|
828
|
+
an upgrading operator should know about:** a degraded ledger (including
|
|
829
|
+
"grounding-mcp not declared in manifest" under block-tier policies) now
|
|
830
|
+
DENIES where it silently allowed; the explicit manifest opt-out
|
|
831
|
+
`risk.degraded_fail_posture: fail_open` (schema default
|
|
832
|
+
`preserve_enforcement`, `src/schema/risk.ts`) restores the pre-0.45
|
|
833
|
+
availability-first mapping for every tier, and the operator-only
|
|
834
|
+
`harness pause` kill switch remains the deadlock escape (it is honoured
|
|
835
|
+
BEFORE manifest load); the shipped init template documents the knob as
|
|
836
|
+
a COMMENTED-OUT line on purpose (the schema is strict, so an emitted
|
|
837
|
+
key would make the manifest unreadable by a pre-0.45 binary, and a
|
|
838
|
+
manifest load failure is allow at the hook layer: on a mixed-version
|
|
839
|
+
fleet that would turn a downgrade into a silent full fail-open). Audit
|
|
840
|
+
completeness rides along, best-effort and precisely bounded: the pooled
|
|
841
|
+
ledger session's timeout latch previously guaranteed that exactly the
|
|
842
|
+
degraded deny lost its own audit row (every post-timeout call on that
|
|
843
|
+
session short-circuits), so `realLedgerClient.record` now retries once
|
|
844
|
+
per invocation over a fresh session, RESERVED for `deny-degraded` rows
|
|
845
|
+
(an allow/warn row failing first cannot consume it) and capped at
|
|
846
|
+
max(250ms, timeoutMs/4) per call — two calls, so the added stall is at
|
|
847
|
+
most half a timeout once the ledger timeout is >=1s, and bounded at
|
|
848
|
+
500ms absolute below that, where the floor dominates (the formula is
|
|
849
|
+
pinned as the exported `auditRetryTimeoutMs`); a longer or unreserved
|
|
850
|
+
retry would stall the deny path toward the outer hook budget, where a
|
|
851
|
+
hook timeout is conventionally allow. With grounding-mcp absent from the manifest
|
|
852
|
+
altogether there is no transport at all: a BLOCKING decision on that
|
|
853
|
+
path now emits a dedicated "has NO audit row" stderr line instead of
|
|
854
|
+
the previous silent no-op. `isBlockingDecision` is now exported and
|
|
855
|
+
shared with the CLI wrapper's pending-approval staging, which had a
|
|
856
|
+
drifted hand-rolled copy. New outcome threaded through every consumer:
|
|
857
|
+
`harness audit` `VALID_OUTCOMES`, `explain`/`audit` `--outcome` filters,
|
|
858
|
+
the verbose stderr diagnostic (which names the outcome, the fail-closed
|
|
859
|
+
posture and the operator opt-out, cause-neutrally — the true cause
|
|
860
|
+
follows on its own reason line), a NEW
|
|
861
|
+
default-verbosity one-line stderr operator hint per degraded-denied
|
|
862
|
+
event (carrying the decision's OWN sanitised reason, since
|
|
863
|
+
deny-degraded has five causes and only one of them is the ledger — a
|
|
864
|
+
hardcoded ledger-fault wording sent operators of the other four to a
|
|
865
|
+
green `harness doctor`; pointing at doctor and the docs, deliberately
|
|
866
|
+
without the opt-out literal on the default channel, and suppressed
|
|
867
|
+
under verbose where the full diagnostic supersedes it), and the
|
|
868
|
+
`checkPolicyGroundingMcp` validate/apply warning, whose pre-0.45
|
|
869
|
+
"every policy will fire in degraded warn-mode" wording described the
|
|
870
|
+
inverse of the new block-tier behaviour and is now tier-aware and
|
|
871
|
+
test-pinned; `smoke` assertions classify the new outcome as deny via
|
|
872
|
+
the unchanged envelope path. A stale-contract drift guard now runs in
|
|
873
|
+
the suite: `tests/no-stale-fail-posture-claims.test.ts` fails if the
|
|
874
|
+
pre-0.45 phrasings OR the round-4-enumerated restatement shapes
|
|
875
|
+
("ledger degradation → warn-degraded" untiered, "only ever blocks if
|
|
876
|
+
grounding-mcp", the untiered fail-open framing) reappear under `src/`
|
|
877
|
+
or `docs/` outside a per-phrase allowlist with freshness checks and
|
|
878
|
+
one positive control per needle. Honest bound, stated in the guard
|
|
879
|
+
itself: it is a literal/pattern grep pinning the mechanically
|
|
880
|
+
greppable phrasings the review rounds found, not a semantic checker —
|
|
881
|
+
wrong-modality claims were fixed by hand and are not pinned, and a
|
|
882
|
+
future paraphrase avoiding all needles still needs eyes (four review rounds
|
|
883
|
+
each found a fresh such surface; docs quoting message literals
|
|
884
|
+
verbatim was the recurring root cause, so the updated docs now
|
|
885
|
+
describe messages instead of quoting them); the same sweep updated
|
|
886
|
+
docs/writing-custom-policies.md,
|
|
887
|
+
docs/CLI.md, docs/examples/pandora-bootstrap.md, the OKF
|
|
888
|
+
producer-wiring bundle, the ledger-client module header, the init
|
|
889
|
+
composer's wizard advisory, and marked the superseded ROADMAP Phase 4
|
|
890
|
+
acceptance bullets. NOT closed here, named so no
|
|
891
|
+
doc overclaims: the OUTER hook-budget layer (a hook exceeding its
|
|
892
|
+
`budget_ms` is allow by harness contract) stays fail-open and is not
|
|
893
|
+
reachable from this repo's schema; keep hook budgets comfortably above
|
|
894
|
+
the ledger timeout (the shipped template's 1000-2000ms budgets vs the
|
|
895
|
+
5000ms grounding-mcp health timeout mean the HANG shape can still blow
|
|
896
|
+
the hook budget before the deny lands; a validate/doctor consistency
|
|
897
|
+
check is filed as a follow-up task), and malformed event JSON or a
|
|
898
|
+
failed manifest load remain allow-with-stderr at the CLI wrapper.
|
|
899
|
+
docs/risk-gate.md "Degraded mode",
|
|
900
|
+
docs/okf/gate-fail-posture-matrix.md,
|
|
901
|
+
docs/okf/policy-engine-producer-wiring.md and
|
|
902
|
+
docs/okf/debug-verb-selection.md rewritten to the new contract;
|
|
903
|
+
the two pre-0.45 pins asserting warn-degraded-never-blocks for block
|
|
904
|
+
tier were rewritten deliberately alongside (unit + e2e), plus new
|
|
905
|
+
fixtures: require_approval+timeout blocks, warn+timeout allows,
|
|
906
|
+
fail_open opt-out restores the old mapping end-to-end through the real
|
|
907
|
+
manifest parse, genuine-timeout e2e (hanging fake grounding-mcp), an
|
|
908
|
+
AC4 e2e pinning exactly TWO subprocess spawns with the `deny-degraded`
|
|
909
|
+
row landed in the ledger despite the latch, retry allocation (a warn
|
|
910
|
+
row failing first does not consume the deny row's retry),
|
|
911
|
+
envelope-over-ux precedence, envelope sanitisation, the opt-out's
|
|
912
|
+
absence from the envelope and presence on stderr, the no-transport
|
|
913
|
+
"has NO audit row" line, and `--outcome deny-degraded` accepted by
|
|
914
|
+
audit/explain filters.
|
|
915
|
+
|
|
916
|
+
- nanoid bumped 3.3.16 → 3.3.18 via `npm audit fix` (lockfile-only, no source
|
|
917
|
+
change), closing GHSA-2v37-7h3g-55p8 / CVE-2026-67213 (High, CVSS 8.2:
|
|
918
|
+
`customAlphabet`/`customRandom` loop indefinitely when `size` is 0; affected
|
|
919
|
+
range < 3.3.17). Dev-only transitive (vitest → vite → postcss → nanoid);
|
|
920
|
+
postcss's declared `^3.3.16` already admits the fix, so no `package.json`
|
|
921
|
+
change and no override introduced (the same shape as the 0.42.0 entry that
|
|
922
|
+
carried nanoid 3.3.12 → 3.3.16 for the postcss advisory). harness source
|
|
923
|
+
never calls nanoid, so the advisory's mechanism is not reachable from the
|
|
924
|
+
shipped CLI; the practical effect is `npm audit` back to 0 findings and this
|
|
925
|
+
repo's own preflight npm-audit check unblocked. Note the structural residue
|
|
926
|
+
the 0.42.0 entry already accepted: postcss's range floor (3.3.16) is below
|
|
927
|
+
the patched 3.3.17, so only the lockfile pin holds the fix (conscious
|
|
928
|
+
no-override decision, unchanged). (task `b81e8e09`)
|
|
929
|
+
|
|
930
|
+
- **SECURITY: the Risk Gate's environment resolver ignored a `git
|
|
931
|
+
switch`/`checkout <branch>` at the head of a Bash command, so a
|
|
932
|
+
same-command branch hop onto a production branch ran under the
|
|
933
|
+
PRE-switch (often `unknown`) label** (task `341e024b`). Measured
|
|
934
|
+
2026-08-06 (isolated probe home, harness 0.44.0, cwd on a non-production
|
|
935
|
+
branch): `git switch main && rm -rf <path>` and the `git checkout main`
|
|
936
|
+
equivalent both classified `critical` severity / `unknown` environment /
|
|
937
|
+
**ALLOW**, while the comparison `cd <repo-on-main> && rm -rf <path>` form
|
|
938
|
+
already denied — the branch resolver only ever read `.git/HEAD` at the
|
|
939
|
+
hook's cwd (or, since the `cd`-target fix below, a leading `cd <path>`'s
|
|
940
|
+
target), never a `git switch`/`checkout` argument. Alltagsrelevant: the
|
|
941
|
+
post-merge convention of switching back to the default branch before
|
|
942
|
+
continuing work is exactly this shape (`git switch main && rm -rf
|
|
943
|
+
node_modules && npm ci`), so it ran ungated while the identical work with
|
|
944
|
+
a leading `cd` did not.
|
|
945
|
+
|
|
946
|
+
`src/runtime/bash-prefix-parse.ts`'s `parseBashPrefix` now also extracts
|
|
947
|
+
a `branchTarget` from a leading `git [-C <path>] (switch|checkout)
|
|
948
|
+
<branch> [&&|;]`, mirroring the existing `cdTarget` extraction (a new
|
|
949
|
+
`consumeLeadingGitSwitch`, sharing the same command-head-only, two-pass,
|
|
950
|
+
no-shell-simulation discipline the module's `cdTarget`/`inlineEnv`
|
|
951
|
+
parsing already has — task `1a8a103d`). Deliberately conservative,
|
|
952
|
+
unlike `cdTarget`'s bidirectional risk (which can raise OR lower the
|
|
953
|
+
resolved environment depending on the target, a pre-existing, documented
|
|
954
|
+
asymmetry — G5, task `98ad072f`/2026-07-27 — untouched by this task):
|
|
955
|
+
`git checkout -- <path>` (a file restore, not a branch change) and an
|
|
956
|
+
unresolvable `$VAR`/`${VAR}` branch argument set NO branch signal — never
|
|
957
|
+
guessed. `src/cli/policy/intercept.ts`'s new `applyBranchSwitchUpgrade`
|
|
958
|
+
merges the parsed branch candidate against the existing `resolverGit`
|
|
959
|
+
(cwd- or cd-target-based) UPGRADE-ONLY: both are run through the same
|
|
960
|
+
`resolveEnvironment` call with identical env/kube inputs, and the
|
|
961
|
+
switch-target branch is only adopted when it resolves to a MORE
|
|
962
|
+
dangerous environment than the base; a switch AWAY from a production
|
|
963
|
+
branch never downgrades an already-production classification.
|
|
964
|
+
|
|
965
|
+
5 new end-to-end fixtures in `tests/runtime/intercept-cli-bash-prefix.test.ts`
|
|
966
|
+
pin the acceptance criteria (`git switch main`/`git checkout main` from
|
|
967
|
+
a non-prod cwd now deny; `git switch <non-prod-branch>` from a repo
|
|
968
|
+
already on `main` still denies — no downgrade; `git checkout --
|
|
969
|
+
<path>` and `git switch $BRANCH` set no signal and stay allow), plus
|
|
970
|
+
unit coverage for the parser itself in
|
|
971
|
+
`tests/runtime/bash-prefix-parse.test.ts` (slashed branch names, the
|
|
972
|
+
optional `-C <path>` skip, the flag/variable/missing-separator
|
|
973
|
+
exclusions, and — falling out of the existing two-pass loop for free —
|
|
974
|
+
a leading `cd <path> && git switch <branch>` chain resolving both
|
|
975
|
+
candidates). Existing `cdTarget`/inline-env fixtures are unchanged and
|
|
976
|
+
green. `docs/risk-gate.md`'s "Environment resolvers" section documents
|
|
977
|
+
the new upgrade-only branch signal alongside the pre-existing
|
|
978
|
+
bidirectional `cd` one.
|
|
979
|
+
|
|
980
|
+
Fix round 1 (reviewer-flagged, `accept_with_notes`): the upgrade-only
|
|
981
|
+
merge itself was verified sound (no downgrade path), but two gaps
|
|
982
|
+
remained. **MEDIUM (security):** a QUOTED branch name bypassed the
|
|
983
|
+
gate entirely — `consumeLeadingGitSwitch` read the branch token
|
|
984
|
+
INCLUDING the quote characters, so `git switch "main" && rm -rf <path>`
|
|
985
|
+
extracted `branchTarget` as the literal 6-character string `"main"`,
|
|
986
|
+
which never matches a `branch_patterns` entry like `main`, so no
|
|
987
|
+
upgrade fired and the destructive command ran ungated — identical in
|
|
988
|
+
effect to the pre-fix unquoted case above. The sibling `cd`-target
|
|
989
|
+
parser already stripped quotes (`skipPathToken`/`consumeLeadingCd`);
|
|
990
|
+
this was an inconsistency, and the module doc's given reason for
|
|
991
|
+
skipping a quoted branch form ("real git branch names cannot contain
|
|
992
|
+
whitespace, so this covers slashed names without needing a quoted
|
|
993
|
+
form") was itself wrong — a whitespace-free branch name can still be
|
|
994
|
+
quoted by the operator. `consumeLeadingGitSwitch` now reads a single-
|
|
995
|
+
or double-quoted branch token the same way `skipPathToken` reads a
|
|
996
|
+
quoted path: literal content, quotes stripped. A `$`-containing
|
|
997
|
+
DOUBLE-quoted branch argument is still left unresolved (real bash
|
|
998
|
+
interpolates inside double quotes, consistent with the existing
|
|
999
|
+
unquoted-`$VAR` rule); a SINGLE-quoted one is always literal, since
|
|
1000
|
+
single quotes never interpolate in real bash. New e2e fixtures in
|
|
1001
|
+
`tests/runtime/intercept-cli-bash-prefix.test.ts` pin `git switch
|
|
1002
|
+
"main"` / `git switch 'main'` / `git checkout "main"` from a non-prod
|
|
1003
|
+
cwd now denying, a quoted switch away from `main` still not
|
|
1004
|
+
downgrading, and a double-quoted `"$BRANCH"` still not guessed; new
|
|
1005
|
+
parser-unit coverage in `tests/runtime/bash-prefix-parse.test.ts`
|
|
1006
|
+
covers the quote-stripping itself, the double-quote-`$` exclusion, the
|
|
1007
|
+
single-quote-`$` literal case, unterminated/empty quoted literals, and
|
|
1008
|
+
the missing-separator rule for a quoted branch. Mutation-probed:
|
|
1009
|
+
disabling the quote-stripping branch turns exactly the 12 new e2e/unit
|
|
1010
|
+
assertions red.
|
|
1011
|
+
|
|
1012
|
+
**LOW (maintainability):** `ENV_RANK` in `src/cli/policy/intercept.ts`
|
|
1013
|
+
is a hand-maintained copy of `ENV_PRECEDENCE` in
|
|
1014
|
+
`src/runtime/environment-resolver.ts`; the `Record<MatchableEnvironment,
|
|
1015
|
+
number>` type catches an added/removed environment but not a REORDER,
|
|
1016
|
+
which could silently invert the upgrade-only direction. A new drift
|
|
1017
|
+
guard, `tests/runtime/intercept-env-rank-drift-guard.test.ts`, measures
|
|
1018
|
+
`ENV_PRECEDENCE`'s real order live via `resolveEnvironment` (four
|
|
1019
|
+
resolvers, one per environment, each on a different signal kind so all
|
|
1020
|
+
four can fire on one probe envelope at once; the conflict winner reveals
|
|
1021
|
+
the true order by elimination) and, for every adjacent pair in that
|
|
1022
|
+
measured order, round-trips `runInterceptCli` to confirm
|
|
1023
|
+
`applyBranchSwitchUpgrade`'s `ENV_RANK`-driven behavior agrees.
|
|
1024
|
+
Mutation-probed: swapping two adjacent entries in `ENV_PRECEDENCE`
|
|
1025
|
+
(without touching `ENV_RANK`) turns exactly the affected pair's two
|
|
1026
|
+
assertions red.
|
|
1027
|
+
|
|
1028
|
+
**LOW, documentation only (not built):** a chained double switch (`git
|
|
1029
|
+
switch dev && git switch main && rm ...`) resolves `branchTarget` as
|
|
1030
|
+
`dev` (first-switch-wins), missing `main`. Multi-switch parsing was
|
|
1031
|
+
deliberately NOT added — it would widen the parser's surface toward the
|
|
1032
|
+
false-positive class task `dbc6d303` already measured. The module doc
|
|
1033
|
+
on `consumeLeadingGitSwitch` and `docs/risk-gate.md` now state this
|
|
1034
|
+
limit explicitly so it is not over-trusted.
|
|
1035
|
+
|
|
1036
|
+
### Changed
|
|
1037
|
+
|
|
1038
|
+
- Audited `src/runtime/recovery-git-commit.ts`'s three JS `\s`-based
|
|
1039
|
+
tokenizers (the `git\s+commit` anchor and `tokenize`'s leading-skip and
|
|
1040
|
+
token-continuation checks) against the same JS-`\s`-vs-bash-blank
|
|
1041
|
+
divergence class `623640a5` closed elsewhere — reviewer residual from
|
|
1042
|
+
that task. Measured against real bash and git (PATH-stub): unlike
|
|
1043
|
+
`623640a5`, this module's over-split is safe by construction, not a
|
|
1044
|
+
fix. No production change; the safe direction is pinned in
|
|
1045
|
+
`tests/runtime/recovery-git-commit.test.ts` with live bash+git evidence
|
|
1046
|
+
for every call site plus a hermetic pin across all 23 non-bash-blank
|
|
1047
|
+
`\s` codepoints. (task `822a508d`)
|
|
1048
|
+
|
|
1049
|
+
### Fixed
|
|
1050
|
+
|
|
1051
|
+
- **`init`'s MCP registration could mutate/read the WRONG `claude` CLI registry file under a non-default home, and treated an "already registered" outcome as a hard failure that blocked the settings.json migration** (task `fb3e4dce`, two reviewer findings on run `2026-07-18-init-mcp-wiring-claude-code`). Finding 2: `realClaudeMcpExec` (`src/io/claude-mcp.ts`) spawned the real `claude` CLI with no explicit `env`, so it inherited `process.env` verbatim — identical to `ensureMcpServers`'s own drift-read directory only when that directory happened to be the OS default (`~/.claude.json`). A non-default `homeDir`/`registryPath` override (e.g. `harness init --home`) made the live CLI spawn mutate/read a DIFFERENT registry file than the one `ensureMcpServers` had just compared `desired` against. Fixed by threading a new `configDir` through every verb wrapper to `exec`'s new third argument; `realClaudeMcpExec` now sets `CLAUDE_CONFIG_DIR` in the spawned child's env from it. **Correction (review round 2, task `fb3e4dce`): the original fix set `configDir = path.dirname(registryPath)` unconditionally, which on the DEFAULT resolution path is `~` itself (not the CLI's own actual default `CLAUDE_CONFIG_DIR` of `~/.claude`), so it silently redirected every OTHER config-dir-relative path the CLI resolves (e.g. registry backups landing in `<HOME>/backups` instead of `<HOME>/.claude/backups`) — `resolveMcpConfigDirOverride` (`src/io/claude-mcp.ts`) now emits `configDir` only when the resolution actually used a `CLAUDE_CONFIG_DIR`/`homeDir`/`registryPath` override, leaving the pure default path's real spawn with unmodified `process.env` inheritance; the same helper now also backs `uninstall`'s `claude mcp remove` call (`src/cli/uninstall/index.ts`), which previously threaded no `configDir` at all under a non-default `--home`.** A `claude mcp get` verification that merely timed out or hit a missing CLI (inconclusive, not a positive "absent") no longer overrides a File-verified already-exists match on its own (`addAndVerifyAlreadyExists`); the wizard's post-registration message now distinguishes freshly `add-json`-registered servers ("registered N ...") from verified-already-exists ones ("confirmed N ... already registered"), instead of reporting both as "registered". Finding 3: `ensureMcpServers` reported an `add-json` "already exists" outcome exactly like any other failure, even when the desired server genuinely IS registered (e.g. a registration this run's own registry-file read raced ahead of/missed) — the wizard's `allOk` gate then blocked the dead-`settings.json`-`mcpServers`-block migration even though the target state already held. `addAndVerifyAlreadyExists` now confirms an "already exists" outcome via `claude mcp get` + a fresh re-read of the same registry file (empirically verified: `claude mcp get`'s stdout is prose and does not expose the registered command/args, so the spec comparison uses the file, not `get`'s own output) — a verified, spec-matching registration is surfaced as `EnsureServerResult.verifiedAlreadyExists.matches: true`, which `wireClaudeMcp`'s `allOk` check now treats as success; an unverified or spec-mismatched one preserves the prior conservative behavior unchanged. New coverage in `tests/io/claude-mcp.test.ts` (configDir threaded to every verb across an add/replace/get sequence for a non-default home; matching-spec, mismatched-spec, and get-can't-find-it already-exists variants; a plain "added" outcome never calls `get`) and `tests/cli/init-interactive.test.ts` (end-to-end: a verified-matching already-exists still runs the settings.json migration and prints no failure warning; a verified-mismatched one skips migration and prints the same warning as before this fix). Review round 2 adds: a real `claude mcp add-json` spawn (a throwaway shim binary, never the operator's real CLI/registry) pinning that an explicit `configDir` wins over the parent process's own `CLAUDE_CONFIG_DIR` and that omitting it inherits `process.env` verbatim; pure-function coverage of `resolveMcpConfigDirOverride`'s default-vs-override decision; a `claude mcp remove` configDir-alignment pin for `uninstall` under both an explicit `--home` and the pure default; a timed-out-`get`-still-checks-the-file case; and verb-order plus message-wording pins on the two existing already-exists wizard tests.
|
|
1052
|
+
- **The session-start preflight not-ready fail-log's default directory (`~/.harness/logs`) is now repo-wide test-isolation-safe instead of suite-local** (task `80f49922`, reviewer finding on `a48b9729`). `defaultFailLogDir()` called `os.homedir()` directly, so the throw-on-real-home-dir guard in `resolvePaths()` (`loader.ts:45-64`, already pinned by `tests/integration/operator-state-isolation.test.ts`) never covered this seam — only a suite-local `vi.mock("node:os")` pin in `preflight.test.ts` did, protecting the one caller that suite happened to know about, not any future test or child process. `defaultFailLogDir` now resolves via `resolvePaths()` (same idiom as `defaultReportsDir`'s caller in `approve/understanding.ts`), so a caller that forgets to inject `homeDir`/`configPath` now fails at the guard instead of silently writing into the operator's real home. Side effect fix: the fail-log dir now also respects `$HARNESS_HOME` and the legacy `~/.claude/` fallback instead of hardcoding `~/.harness/` regardless of the rest of the harness state's actual location (a pre-existing divergence from task `31fbf856`) — EXCEPT when `configPath` is injected without `homeDir`: `configPath` overrides the manifest path directly, so the log dir becomes `dirname(configPath)/logs` and does not route through home-dir resolution (or the `$HARNESS_HOME`/legacy tiers) at all in that case. The not-ready branch now catches only the guard's throw (`persistFailLog`'s own write failure already never throws, so it stays outside that try/catch) and folds it into the same `{ ok: false, reason }` shape a fail-log write failure already produces, so the SessionStart producer still never throws. New coverage in `tests/integration/operator-state-isolation.test.ts` (no `HARNESS_INTEGRATION_TESTS` opt-in, no subprocess spawn), all exercising `runSessionStartPreflight` directly: a no-injection negative control (asserts the guard blocks the write and the real `~/.harness/logs/` is byte-for-byte unchanged); a hermetic `homeDir`-injection case and a hermetic `configPath`-injection case (neither mocks `os` nor needs `HARNESS_ALLOW_REAL_GENERATED_DIR`, since an injected option bypasses the guard outright); a `$HARNESS_HOME` case (needs the `ALLOW_REAL` escape hatch to reach that precedence tier since neither `homeDir` nor `configPath` is injected, secured with the same real-`~/.harness/logs/`-unchanged assertion as the negative control); and a legacy `~/.claude/` fallback case, the only one of the five that mocks `node:os` — scoped to itself via `vi.doMock` + a dynamic re-import — because that precedence tier reads `os.homedir()` directly and has no LoaderOptions seam to override it. `preflight.test.ts`'s suite-local negative-control test is updated to assert the guard blocks the write; its `vi.mock("node:os")` pin stays in place as a second line of defense for every OTHER `os.homedir()` consumer in that suite (it no longer protects this one seam, which now fails closed independently of it).
|
|
1053
|
+
- **Two not-ready preflight fail-logs written in the same millisecond practically never overwrite each other anymore** (task `a48b9729`). The fail-log filename's ISO timestamp has millisecond resolution, so a second persist within the same ms silently clobbered the first; filenames now carry a 4-hex random suffix (`preflight-<repo>-<timestamp>-<hex4>.json`), leaving a 1-in-65536 same-millisecond residual collision chance rather than a guarantee. Alongside (test-only): the preflight suite pins `os.homedir()` to a throwaway tmp home so a not-ready test that forgets to inject `logDir` can never write into the operator's real `~/.harness/logs` (with a negative-control test), plus rotation boundary tests (exactly 20 → no eviction; 21 → exactly the oldest evicted), hostile-repo-basename containment tests, and a multi-failing-checks cap/join pin.
|
|
1054
|
+
- **`harness doctor`'s dead-`settings.json`-`mcpServers`-block check now honors `CLAUDE_CONFIG_DIR`** (task `f13c6fbd`, #423). The check read a fixed `<home>/.claude/settings.json`, so with a config-dir override it inspected a file Claude Code does not read. Resolution now mirrors `resolveClaudeUserRegistryPath`'s precedence (non-empty `CLAUDE_CONFIG_DIR` wins), the warning names the resolved path, and doctor gained an `envOverride` test knob so the dead-block tests stay hermetic against the operator's real environment. (This entry was itself backfilled by the first run of the new changelog-coverage gate below.)
|
|
1055
|
+
- **`harness dry-run`'s `bash_match` prediction under-predicted the quoted-shell-boundary family (`VAR='a; b' git push origin master`) that `harness policy intercept` already blocks** (task `f561e44c`, follow-up to `cf3dff51`). Task `cf3dff51` wired `normalizeCommandQuoteAware` into `policyMatchesEvent` (`src/runtime/intercept.ts`) as a fourth matching arm but, scoped narrowly to that one wiring point, deliberately left `dry-run`'s own independently-copied matcher (`src/cli/dry-run.ts`) untouched — reintroducing the debug-verb/runtime parity gap that file's own comment (and `docs/okf/debug-verb-selection.md`) has warned about since task `ea8becf5`. `policyMatchesTool` now tries the same fourth, quote-aware OR-arm, literally duplicated to mirror the amp-aware arm's own precedent (no shared-helper seam exists for this OR-chain yet). Debug-verb-only fix, no gate touched; `dry-run` was under-predicting, the fail-safe direction. New parity fixture: `tests/cli/dry-run.test.ts` asserts `dry-run` and `policyMatchesEvent` agree, entry for entry, across the 12 `cf3dff51` target spellings (`;`, `|`, `&&`, `(`, and a literal newline in both spaced/unspaced forms, on `git push` and the operator-only kill switch).
|
|
1056
|
+
- **`understanding-before-execution`'s `config.mode` (harness.yaml) was config-drift: it drove prose only, never enforcement** (task `5d73d78d`; fix landed in two rounds, the second a review fix-round closing three HIGH findings against the first). Live repro in an isolated home: `config.mode: grill_me`, an agent report with no `mode:` metadata field, `harness approve understanding` (stdin heredoc) → output read `validation: ✓ fast_confirm report passed structural checks` and persisted `"mode": "fast_confirm"`, silently skipping the `derivedTodos` / `acceptanceCriteria` / `priorArt` sections `grill_me` requires. Root cause, two independent sites: (1) the Claude-runtime `UserPromptSubmit`/`Stop` hooks are bins shipped by `@lannguyensi/understanding-gate` (`understanding-gate-claude-hook` / `understanding-gate-claude-stop`), invoked bare — no `UNDERSTANDING_GATE_MODE` env var — so the package's own env-then-marker-then-`fast_confirm`-default resolver always fell through to its default; (2) `persistStdinReport`'s gap-fill for a report with no declared mode hardcoded the literal `"fast_confirm"`.
|
|
1057
|
+
- Fix: mode resolution is now TWO functions with different contracts (`src/policy-packs/builtin/understanding-before-execution.ts`), not one. `resolveModeFromConfig()` — `config.mode` > default (`grill_me`), **never the env var** — drives the GENERATION path: `resolve()`/`buildHooks` (what `harness apply` bakes into `settings.json` + `instructions.md`) and `registry.ts`'s `resolveBuiltinDefaultConfig` (`harness doctor`'s UX-drift comparison, `harness pack reseed`). `resolveMode()` — `UNDERSTANDING_GATE_MODE` env > `config.mode` > default — is reserved for LIVE runtime consumers that resolve at invocation time with the real process environment available: `approve/understanding.ts`'s stdin-report gap-fill, and the Codex `UserPromptSubmit` injector (`harness pack hook codex-user-prompt-submit`). The first round shipped a single env-aware resolver for BOTH roles, which meant an operator's ambient `UNDERSTANDING_GATE_MODE` (exported for an unrelated one-off override, or left over from a previous shell) would silently override `config.mode` in the GENERATED artefact itself — persisting, frozen, until the next `apply`, and making `doctor`/`reseed` report drift that had nothing to do with `harness.yaml`. `buildHooks` still bakes the (now config-only) resolved mode into the two npm-backed Claude bin commands via the same env var (`wrapMode`), but omits the prefix entirely when the resolved mode already coerces to the package's own default (`fast_confirm`) — baking it unconditionally (the first round's behavior) made the package's own in-prompt `/grill` / "grill me" per-prompt escalation marker permanently dead, since the package's own resolver checks its env var before ever looking at the marker.
|
|
1058
|
+
- Fix: `harness approve understanding`'s stdin-report path now genuinely REFUSES the approval marker when the submitted report is rejected under the resolved mode (e.g. a `fast_confirm`-shaped report submitted against a `grill_me`-configured host) and no report matching this session id exists — no marker, no ledger tag, no report flip, unless `--force` (mirroring the pre-existing `priorArt`-validation short-circuit). The first round resolved the correct mode but a rejected stdin submission with no fallback report still fell through to a `{ skipped: true }` validation outcome (not `ok: false`), so the marker was written anyway — a config-drift-shaped hole reopened one call deeper than the one this task closes. A second review round (verification round 2) closed a further gap in the same guard: a sessionId-null pending report ADOPTED via the tolerant fallback (not a strict sessionId match) does not count as "matching this session id" either, so a rejected submission can no longer silently ride on an unrelated leftover report's already-valid content and get stamped with the live session's id.
|
|
1059
|
+
- `strict` has no upstream equivalent (the package's own mode type is two-valued); `toPackageMode()` coerces it to `grill_me` — the closest available rigor — only at that interop boundary, not inside harness's own `strict` semantics.
|
|
1060
|
+
- **Behavior change, corrected blast-radius statement (the first round's was inaccurate — it implied only an EXPLICITLY-configured non-`fast_confirm` host was affected):** the mode actually enforced now matches `config.mode` in all three cases — `config.mode: fast_confirm` (was already effectively `fast_confirm`, unaffected save for the marker-liveness fix above), `config.mode: grill_me` / `strict` (now genuinely enforced, was silently `fast_confirm`), **and no `config:` block / no `mode:` key at all** (now genuinely enforced at `grill_me`, the package default — this last case is the WIDEST blast radius of the three and was NOT called out by the first round's changelog entry: every unconfigured host is affected, not only hosts with an explicit setting). Previously-accepted reports missing `derivedTodos` / `acceptanceCriteria` / `priorArt` are now rejected until the agent supplies them, for any host in the latter two buckets.
|
|
1061
|
+
- New/updated coverage: `tests/policy-packs/resolve-mode.test.ts` (both resolvers' contracts, LOW-8/LOW-9 warning + normalisation fixes), `tests/policy-packs/expand.test.ts` and `tests/cli/apply/apply.test.ts` (generation-path env-independence, the omitted-prefix case), `tests/cli/approve-understanding.test.ts` and `tests/cli/approve-stdin-report.test.ts` (mode gap-fill end to end, the stdin-rejection marker-refusal enforcement + its `--force` escape hatch, the disabled-pack LOW-10 case), and a new suite-wide `tests/_helpers/scrub-ambient-env.ts` setup file (MEDIUM-5: an ambient `UNDERSTANDING_GATE_MODE` in the operator/CI shell no longer leaks into tests that don't explicitly set it).
|
|
1062
|
+
- **`harness approve understanding` could persist a self-contradictory report: `approvalStatus: "approved"` alongside a stale `expiredAt` timestamp** (task `646b7b6a`, reviewer finding on `agent-grounding` `5120938c`, the harness-side twin of that repo's `withApprovalStatus` fix in PR #173). `rewriteReportApproved` (`src/cli/approve/understanding.ts`) rewrote `approvalStatus` to `"approved"` in place without clearing `expiredAt`; since `@lannguyensi/understanding-gate` 0.4.11 declared `expiredAt` in the report schema, the field survived the rewrite whenever an operator approved a report that `expirePersistedReport` (`understanding-before-execution-runtime.ts`) had previously expired. Not a gate bypass — enforcement only reads `approvalStatus` — but a data-integrity defect in the persisted audit record. Fix: `rewriteReportApproved` now `delete`s `expiredAt` from the parsed report before serializing, mirroring the package's own `withApprovalStatus` idiom. New test in `tests/cli/approve-understanding.test.ts` covers approving a report fixtured exactly as `expirePersistedReport` writes one (`approvalStatus: "expired"` + ISO `expiredAt`), asserting the key is absent after approval.
|
|
1063
|
+
|
|
1064
|
+
### Added
|
|
1065
|
+
|
|
1066
|
+
- **Release-pipeline guards: CI now fails fast when a commit since the last
|
|
1067
|
+
tag has no `[Unreleased]` changelog entry, and release.yml aborts instead
|
|
1068
|
+
of shipping an empty release body** (task `3ed3d333`). New
|
|
1069
|
+
`scripts/check-changelog-coverage.mjs` (`npm run
|
|
1070
|
+
check:changelog-coverage`, wired before Build in ci.yml in the
|
|
1071
|
+
check-no-only idiom) compares `git log <last-tag>..HEAD` against the
|
|
1072
|
+
`[Unreleased]` section: a commit counts as covered when the section cites
|
|
1073
|
+
one of its mechanical link tokens (an 8-hex task id from the commit
|
|
1074
|
+
message, the squash-merge `#PR` number, a GHSA advisory id, or the
|
|
1075
|
+
commit's own SHA); commits typed `test`/`docs`/`ci`/`style`/`refactor`/
|
|
1076
|
+
`chore` are skipped by a convention documented — with its limits — in the
|
|
1077
|
+
script header. Verified against the historical failure: a checkout of
|
|
1078
|
+
`5b7a36f` (pre-0.42.0) goes red with 14 uncovered commits (including the
|
|
1079
|
+
postcss HIGH advisory close), while this branch is green; the gate's
|
|
1080
|
+
first live run also caught the missing `f13c6fbd` doctor entry backfilled
|
|
1081
|
+
under Fixed above. release.yml additionally hard-stops after the awk
|
|
1082
|
+
extraction when `release_notes.md` came out empty or whitespace-only
|
|
1083
|
+
(the "tag pushed before converting [Unreleased]" failure mode), naming
|
|
1084
|
+
the missing `## [<version>]` section in the error.
|
|
1085
|
+
- The `toolchain-parity` ledger fact now carries unparseable peer snapshots
|
|
1086
|
+
instead of silently absorbing them into a shrunken comparison (task
|
|
1087
|
+
`690fba7c`, consumer hardening motivated by the agent-memory `06d09cde`
|
|
1088
|
+
incident where a corrupted `machine-state/<profile>.json` — inline git
|
|
1089
|
+
conflict markers, not valid JSON — made the drift count quietly fall from
|
|
1090
|
+
`drift:22` to `drift:5` with no warning). A peer file that fails
|
|
1091
|
+
`parseSnapshotJson` now also logs a `parity:unparseable-peer:<file-derived
|
|
1092
|
+
label>` stderr line, and the run's ledger fact gains a
|
|
1093
|
+
`:unparseable-peer:<n>` suffix (e.g. `toolchain-parity:drift:7:unparseable-peer:2`)
|
|
1094
|
+
whenever one or more peer files were skipped this way, so an unparseable
|
|
1095
|
+
peer never makes `drift:N` look like a complete comparison. (Peers whose
|
|
1096
|
+
file cannot be READ at all are a separate branch scoped to task
|
|
1097
|
+
`c1b5ade5`; the absence of the suffix alone therefore does not prove
|
|
1098
|
+
completeness.) The file-derived label is sanitized before interpolation —
|
|
1099
|
+
peer filenames arrive cross-machine via sync and are untrusted, so a
|
|
1100
|
+
crafted name cannot forge a standalone parity line in stderr — and falls
|
|
1101
|
+
back to the raw filename when stripping `.json` would leave it empty. The
|
|
1102
|
+
existing "peer snapshot `<file>` is corrupt" warn line is unchanged;
|
|
1103
|
+
valid-only peer sets keep the exact pre-existing `toolchain-parity:ok` /
|
|
1104
|
+
`toolchain-parity:drift:<n>` format (no behaviour change). Four new tests
|
|
1105
|
+
(negative control, corrupt-peer-plus-drift, multi-peer aggregation
|
|
1106
|
+
pinning `:unparseable-peer:2`, hostile-filename sanitization) plus two
|
|
1107
|
+
existing cases extended.
|
|
1108
|
+
- Both PreToolUse hooks (Claude and Codex) now tell a blocked agent WHICH
|
|
1109
|
+
Understanding Report sections were malformed on their last attempt: a
|
|
1110
|
+
`(list)` heading that was present but held prose instead of markdown
|
|
1111
|
+
list items, with an agent-facing sentence naming each section as
|
|
1112
|
+
`<Display Name> (<key>)` (e.g. `Prior Art (priorArt)`), paired with
|
|
1113
|
+
`renderReportSchemaHint`'s existing display-name vocabulary rather than
|
|
1114
|
+
the bare camelCase key the parser itself uses (task `823837fd`, review
|
|
1115
|
+
Fix 3). The sentence is only appended when the session has NO persisted
|
|
1116
|
+
report at all, never when a fixed report is merely pending operator
|
|
1117
|
+
approval (review Fix 1), and is defined once and shared by both hooks
|
|
1118
|
+
via `renderMalformedSectionsNotice` (`src/cli/approve/understanding.ts`,
|
|
1119
|
+
review Fix 4) instead of two byte-identical copies.
|
|
1120
|
+
- `harness approve understanding`'s one-line parse-error summary
|
|
1121
|
+
(`findLatestParseError`) likewise names malformed sections when the
|
|
1122
|
+
operator-facing reason is not already sourced from the package's own
|
|
1123
|
+
`message` field, which annotates them inline since
|
|
1124
|
+
`@lannguyensi/understanding-gate` 0.4.10 (review Fix 5).
|
|
1125
|
+
- `@lannguyensi/understanding-gate` lockfile refreshed to 0.4.10 (the
|
|
1126
|
+
`malformedSections` `ParseError` field this surfacing depends on);
|
|
1127
|
+
the declared range stays `^0.4.9`.
|
|
1128
|
+
- **`harness doctor` now warns (advisory, never an error) when
|
|
1129
|
+
`UNDERSTANDING_GATE_MODE` is set in the operator's shell environment and
|
|
1130
|
+
diverges from `policy_packs[understanding-before-execution].config.mode`**
|
|
1131
|
+
(task `24abdecb`, reviewer finding on the `5d73d78d` Batch 11
|
|
1132
|
+
verification round). After that task's fix, only the LIVE runtime path
|
|
1133
|
+
(`resolveMode` in `approve/understanding.ts`'s stdin-report gap-fill, and
|
|
1134
|
+
the Codex `UserPromptSubmit` injector) reads the env var at all — the
|
|
1135
|
+
GENERATION path (`resolve()`/`buildHooks`, what `harness apply` bakes
|
|
1136
|
+
into settings.json) never has and still doesn't. An operator who carries
|
|
1137
|
+
`UNDERSTANDING_GATE_MODE` in their shell profile therefore silently
|
|
1138
|
+
downgrades live enforcement relative to what `harness.yaml` declares,
|
|
1139
|
+
with no signal anywhere that the two have diverged — live-verified:
|
|
1140
|
+
`harness approve understanding` accepted a `fast_confirm`-shaped report
|
|
1141
|
+
against a `grill_me`-configured manifest with no warning. New
|
|
1142
|
+
`checkUnderstandingModeEnvDivergence` (`src/cli/doctor/understanding-mode-env.ts`)
|
|
1143
|
+
is a pure manifest+env check, gated on the pack being declared and
|
|
1144
|
+
enabled, normalising the env value the same way `resolveMode` does
|
|
1145
|
+
(trim + lowercase) so an env value the live resolver would itself reject
|
|
1146
|
+
is correctly treated as non-divergent; the advisory names both the env
|
|
1147
|
+
value and the resolved `config.mode` and renders in doctor's
|
|
1148
|
+
`Environment` section (mirroring the existing npm-global-bin-off-PATH
|
|
1149
|
+
advisory there). `harness doctor`'s existing `envOverride` test-injection
|
|
1150
|
+
knob is reused rather than adding a second one. New coverage in
|
|
1151
|
+
`tests/cli/doctor-understanding-mode-env.test.ts`: the pure function's
|
|
1152
|
+
unset/empty/agreeing/invalid/undeclared/disabled no-advisory cases, the
|
|
1153
|
+
diverging-value case (naming both values), and a `doctor()`-level
|
|
1154
|
+
integration pass through `format()` asserting the rendered `Environment`
|
|
1155
|
+
section and `warningCount`.
|
|
1156
|
+
- **`harness apply --runtime opencode`: a third policy-pack adapter
|
|
1157
|
+
runtime alongside `claude-code` and `codex`** (task `f34eb233`).
|
|
1158
|
+
New `src/cli/apply/generate-opencode-config.ts` emits
|
|
1159
|
+
`harness.generated/opencode/opencode.json` in place of settings.json,
|
|
1160
|
+
carrying an ADR-style header documenting the adapter-mapping decisions
|
|
1161
|
+
verified against opencode's published config JSON Schema and docs:
|
|
1162
|
+
`tools.mcp[]` IS projected (opencode's native `mcp` block, reusing
|
|
1163
|
+
`generate-settings.ts`'s `buildMcpServers` / `projectGroundingEnv` so
|
|
1164
|
+
the grounding-mcp `EVIDENCE_LEDGER_DB` wiring and the literal-tilde
|
|
1165
|
+
warning stay identical across every runtime); manifest `hooks[]` and
|
|
1166
|
+
`memory.router` are NOT projected (opencode has no declarative
|
|
1167
|
+
hook/event field at all, only a JS/TS plugin API — out of this task's
|
|
1168
|
+
scope), and opencode's native `permission` block is deferred to a
|
|
1169
|
+
follow-up (v1 keeps parity with the codex adapter's own deferred
|
|
1170
|
+
sandbox-profile mapping), both cases surfacing a warning instead of a
|
|
1171
|
+
silent drop. Output is byte-stable across repeat applies and harness
|
|
1172
|
+
never merges into, or writes, any operator-owned opencode config file
|
|
1173
|
+
(`~/.config/opencode/`, a project `opencode.json`, or `$OPENCODE_CONFIG`)
|
|
1174
|
+
— the generated artefact's own banner explains how an operator wires it
|
|
1175
|
+
in. `--target <path>` is rejected with `--runtime opencode`, symmetric
|
|
1176
|
+
to the existing `--runtime codex` rejection (both wire Claude Code's
|
|
1177
|
+
settings.json). `harness doctor --target opencode`
|
|
1178
|
+
(`src/cli/doctor/opencode.ts`) checks the generated artefact's
|
|
1179
|
+
presence + banner and that every projected MCP server's command
|
|
1180
|
+
resolves on PATH; `countStatusDiagnostics` is factored out of
|
|
1181
|
+
`doctor/codex.ts`'s `countCodexDiagnostics` so both target modules
|
|
1182
|
+
share one tally loop instead of two copies. New coverage:
|
|
1183
|
+
`tests/cli/apply/generate-opencode-config.test.ts`,
|
|
1184
|
+
`tests/cli/apply/apply-opencode-runtime.test.ts`,
|
|
1185
|
+
`tests/cli/doctor-opencode.test.ts`.
|
|
1186
|
+
- **`harness apply --runtime opencode` review fix-round** (task
|
|
1187
|
+
`f34eb233`): addresses five findings from the batch18 review of the
|
|
1188
|
+
opencode adapter above. HIGH: `branch-protection` /
|
|
1189
|
+
`understanding-before-execution` / `solution-acceptance` /
|
|
1190
|
+
`post-merge-gate`'s `buildInstructions()` each branched only on
|
|
1191
|
+
`runtime === "codex"`, so opencode silently fell through to the
|
|
1192
|
+
claude-code `else` branch and its `instructions.md` falsely claimed
|
|
1193
|
+
hooks were "wired into the harness-managed settings.json" even though
|
|
1194
|
+
nothing is wired for opencode (no settings.json exists, and
|
|
1195
|
+
`packExpansion.hooks` is never projected into the opencode artefact).
|
|
1196
|
+
Each pack's instructions now mark opencode UNSUPPORTED/not-wired,
|
|
1197
|
+
mirroring `post-merge-gate`'s existing codex marker;
|
|
1198
|
+
`post-merge-gate`'s pack-level apply warning widened from
|
|
1199
|
+
`runtime === "codex"` to `runtime !== "claude-code"` so opencode gets
|
|
1200
|
+
it too; `apply.ts`'s stale "pack instructions.md are runtime-agnostic"
|
|
1201
|
+
comment corrected. MED: added a golden `toBe()` test over
|
|
1202
|
+
`generateOpencodeConfig`'s full rendered output (a manifest with mcp +
|
|
1203
|
+
hooks + a policy-pack-shaped hook + memory.router, deterministic
|
|
1204
|
+
`homeDir`) — the prior substring-only assertions missed a
|
|
1205
|
+
deleted/garbled line inside the generator's static HEADER banner.
|
|
1206
|
+
`src/cli/init/interactive.ts`'s three stale "disabled until f34eb233
|
|
1207
|
+
lands" comments/messages reworded: the adapter has shipped, wizard
|
|
1208
|
+
wiring is tracked separately as installer v1.1 task `c5287b80`;
|
|
1209
|
+
`docs/for-agents.md`'s CLI cheat sheet gains the missing
|
|
1210
|
+
`doctor --target opencode` row. LOW: a `tools.mcp[]` entry declared
|
|
1211
|
+
`enabled: false` is now projected into the opencode `mcp` block as an
|
|
1212
|
+
explicit `{"enabled": false}` marker instead of being omitted --
|
|
1213
|
+
opencode MERGES multiple config sources, so an omitted key does not
|
|
1214
|
+
override an active declaration for the same server name elsewhere,
|
|
1215
|
+
while opencode does accept a bare `{"enabled": false}` entry;
|
|
1216
|
+
`harness doctor --target opencode`'s MCP command-resolution check
|
|
1217
|
+
skips the PATH lookup for such a marker instead of reading its missing
|
|
1218
|
+
`.command`. `countStatusDiagnostics` and its status type moved out of
|
|
1219
|
+
`doctor/codex.ts` into new `doctor/target-checks.ts` (re-exported from
|
|
1220
|
+
`codex.ts` for compatibility) -- `codex.ts` was not a natural home for
|
|
1221
|
+
a target-agnostic tally helper other adapters also depend on.
|
|
1222
|
+
|
|
10
1223
|
## [0.44.0] - 2026-08-03
|
|
11
1224
|
|
|
12
1225
|
### Security
|