Releases: LanNguyenSi/harness
Release list
v0.41.0
Security
- The understanding-gate approval marker (and its branch-protection twin) is now HMAC-signed, closing the "existence is enough" filesystem-marker forgery hole — the persisted-report approval path is a separate, still-unsigned residual, see below (task f9485cc7, M10 / Feature idea #7, promised in the
[0.32.0]entry's "cryptographic marker signing... is a tracked follow-up" note). The marker's integrity used to rest entirely on an UNENFORCED invariant — "no configured MCP exposes a filesystem-write primitive" — checked nowhere; one future MCP tool with local file-write would silently reopen it, since its tool name wouldn't match theEdit|Write|Bashblocker matcher, and the marker's contract was "existence is enough" (a bare filesystem-write forged it).harness approve understandingnow writes an HMAC-SHA256 signature over(markerId, approvedAt, approvedBy, reportContentHash)—reportContentHashis the sha256 of the persisted Understanding Report bound to this approval; this is groundwork only, NOT yet enforced at gate-check time (the live cross-check against the currently-selected report is the C1 staleness follow-up, task fa423e9b) — using an operator-side key lazily generated (crypto.randomBytes(32), mode0600) at<generatedDir>/.approval-signing.key.checkApprovalMarker(shared by the session marker, task-scoped markers, andcheckBranchProtectionMarker) now REJECTS a marker with a missing or invalid signature — matched:false, same as no marker, but with a distinctforged/unsigned marker rejecteddiagnostic surfaced through both the Claude and Codex PreToolUse hooks and the branch-protection hook, so an operator/auditor can tell an active forgery attempt (or a pre-signing legacy marker) apart from the routine "never approved" case. BindingmarkerIdinto the signature also means the "copy a marker onto a new session id" admin trick this doc used to describe no longer works — a copied marker fails verification. Back-compat is strict, no migration window: a marker written before this shipped has nosignaturefield and is rejected exactly like a forgery; re-runharness approve understanding(and/orharness approve branch-protection) once after upgrading a machine with a live approval.- What is NOT closed by this task (read before assuming more than it delivers). (1) The persisted-report approval path (
checkPersistedReport, consulted by both PreToolUse hooks with EQUAL authority immediately after a forged marker is correctly rejected) is not signed at all and, under this task's own threat model (a future write primitive theEdit|Write|Bashmatcher doesn't cover), is an equal-or-easier forgery target than the marker used to be: no signing key to read, no sessionId match required (the gate-read path's tolerant fallback is"any", unrestricted by age), just a JSON file withapprovalStatus: "approved"dropped into the reports dir. Tracked as follow-up task7402301d-14bb-4b03-8d88-a2bb8bad8084. (2) Thesolution-acceptancepack's verdict marker remains unsigned (tracked separately,[0.32.0]note). (3) An agent with BOTH arbitrary local file-read (true today, by design — the blocker never gatesRead) AND a write primitive theEdit|Write|Bashmatcher doesn't cover can still forge a validly-signed marker: read the key, compute the HMAC, write the result through the uncovered path — signing does not close that combined case, only the narrower, previously totally-undefended write-only-forgery case, and it makes any attempt materially more expensive and forensically visible. Given the agent and operator share one OS user account, this is deliberate defense-in-depth, not a hard authorization boundary; full write-up insrc/runtime/approval-signing.tsanddocs/policy-packs/understanding-before-execution.md"Marker signing". (4) An interim MCP-catalog write-capability warning atinit/smoke time was considered but did not fall out naturally from this change and is left as a candidate follow-up. - New tests:
tests/runtime/approval-signing.test.ts(key generation/persistence/corruption-recovery/rotation, sign/verify round-trip, tamper + wrong-markerId + missing-field rejections), forgery + tamper-mutation regressions intests/policy-packs/runtime.test.ts,tests/io/read-regular-file-unreadable.test.ts,tests/cli/pack-hook-pre-tool-use.test.ts,tests/cli/pack-hook-codex-pre-tool-use.test.ts, andtests/cli/pack-hook-branch-protection.test.ts.
- What is NOT closed by this task (read before assuming more than it delivers). (1) The persisted-report approval path (
Added
- Warn-only OKF staleness watch on every PR (
.github/workflows/okf-staleness.yml, PR #350): runsokf-kit check(exact pin 0.3.1) againstdocs/okf, surfaces STALE/structural findings as per-doc annotations plus a full job summary, never blocks a merge; tool errors fail red so a broken check cannot look green. Canonical pattern for the five OKF bundle repos; never mark it required in branch protection. harness pack reseed <name>pulls a pack's shippedconfig.ux(andconfig.producers) into an already-installed manifest (task 68b9ad9c). Before this, a deny-message wording fix in the init templates (e.g. the heredoc submission form,agent-tasks/e48e3b45) only reached manifests generated by a freshharness initAFTER the fix shipped:harness applyonly ever projects the manifest OUT tosettings.json, nothing propagated a template fix back INTO an operator's existingpolicy_packs[].config.ux, so an already-installed manifest kept teaching stale wording indefinitely, even after the CLI itself was upgraded.harness doctornow also warns when an enabled pack'sconfig.ux/config.producerstextually diverges from the CLI's shipped default for that pack — compared against the pack's OWN configuredmode(understanding-before-execution'srequired:line varies by mode), not a hardcoded one, so switching modes is never mistaken for drift. Both the warning and the reseed write share one canonical source per pack (defaultUx()/defaultProducers()insrc/policy-packs/builtin/understanding-before-execution.tsandsrc/policy-packs/builtin/branch-protection.ts, wired through the newresolveBuiltinDefaultConfiginsrc/policy-packs/registry.ts), so the check and the fix can never independently drift on what "the shipped template" means; theinitCustom composer (src/cli/init/composer.ts) now reads from the same source instead of carrying its own copy of the literal text.reseedis deliberately explicit-only — never invoked byapply,doctor, or any automatic path — and only ever touchesconfig.ux/config.producers, leaving every other key on the pack entry (mode,approval_lifecycle,permission_profile,min_version, ...) untouched, so an operator's own deliberate ux customisation is never silently clobbered by an upgrade (the same reasoningharness adoptexists for in the opposite direction).--dry-runprints the diff without writing; a pack whoseconfig.uxalready matches the shipped default is a no-op.solution-acceptancehas no registered shipped default (shipsenabled: falsewith noconfig:block in any template) and is reported clearly as such rather than silently reseeded with nothing. A new parity test,tests/cli/init-templates-ux-parity.test.ts, pins FULL_TEMPLATE / SOLO_TEMPLATE / TEAM_TEMPLATE (src/cli/init/templates.ts,src/cli/init/profiles.ts) and the Custom composer'sconfig.ux/config.producersagainstdefaultUx()/defaultProducers(), so a future wording fix landed in a template WITHOUT updating those functions (which would makereseedsilently pull operators back to the stale wording — the same bug class one layer removed) fails the build instead of shipping unnoticed; matching "KEEP IN SYNC" comments were added at each template'sux:/producers:block. New tests:tests/policy-packs/ux-compare.test.ts,tests/policy-packs/ux-drift-check.test.ts,tests/policy-packs/builtin-default-config.test.ts,tests/cli/pack-reseed.test.ts,tests/cli/init-templates-ux-parity.test.ts, plus newdoctorcoverage intests/cli/doctor.test.ts. Docs:docs/policy-packs/understanding-before-execution.md("Refreshingconfig.uxafter a harness upgrade"),docs/policy-packs/branch-protection.md,docs/CLI.md.check:duplication'sMAX_CLONESpin raised 82→86 with a recorded justification (scripts/check-duplication.mjs): the newcli/pack/reseed.tsnecessarily clones the validate/lock/diff/write shape already repeated undeduped acrossadd/remove's CLI andpack add/pack remove.- Codex sessions now get the active-claim tracker and the stay-in-scope reminder too (task cf4cdc93, closes
docs/okf/codex-adapter-parity-gaps.mdgap #3, the last hook-parity gap between the two runtimes). Before this,harness pack hook track-active-claim(writes/clearsharness.generated/active-claimontask_start/task_finish/task_abandonsoharness approve understandingcan auto-resolve--task) andharness pack hook stay-in-scope(soft reminder + audit row on a review-derived follow-up task) were Claude-only: a Codex session could honor an existing task-scoped marker but could never produce the active-claim file itself, and got no stay-in-scope reminder at all.- No new Codex-specific CLI verb needed, unlike the other Codex hooks. Both hook bodies (
src/cli/pack/hook-track-active-claim.ts,src/cli/pack/hook-stay-in-scope.ts) needed no session-id resolution and no shell-command extraction the waycodex-post-tool-use/codex-pre-tool-useneed.harness apply --runtime codexnow emits two more[[hooks.PostToolUse]]groups running the SAME commands (harness pack hook track-active-claim/harness pack hook stay-in-scope) as the Claude branch — the Codex adapter now contributes 6 hooks total, matching the Claude branch's hook roster. - Same two-layer alias fix task a1348c89 established, applied to these two hooks. The existing Claude matchers (`TRACK_ACT...
- No new Codex-specific CLI verb needed, unlike the other Codex hooks. Both hook bodies (
v0.40.0
Headline: harness approve understanding can finally persist the Understanding Report it approves. Attach the report as a quoted heredoc on the command's stdin and the same run parses it, persists it session-bound, and flips it to approved, and the report: ⚠ skipped line that every approval printed is gone, and the audit trail is no longer structurally empty. The gate's escape matcher was rebuilt around a character whitelist after review found a shell-parse divergence that let a backslash-escaped redirect smuggle commands past it. Also: init --template full makes the runtime-reality hook discoverable, CI enforces coverage thresholds, the test suite runs on macOS, and the curated OKF knowledge bundle ships. Re-run npm i -g @lannguyensi/harness to upgrade.
Added
- Report capture on
harness approve understanding(task 61fd36db, PR #337). The command now accepts the Understanding Report as a quoted heredoc on stdin, parses it with the canonical@lannguyensi/understanding-gateparseReport(new dependency,^0.4.5), persists it session-bound withapprovalStatus: "pending"into the reports dir, and lets the existing selection → validation → flip path approve it in the same run. Why this channel and not the Stop hook: the Stop-hook producer fires at END of turn, i.e. after a same-turnapprovehas already looked for a report, and current Claude Code builds no longer reliably persist mid-turn assistant text to the transcript JSONL, so no transcript-based capture (Stop-hook fallback, tier-6 session-id fallback, PreToolUse harvest) can work. The approval command is the only reliable carrier, and as a bonus the operator reads the full report inside the permission prompt before approving. Unparseable stdin degrades loudly (astdin: ⚠line plus a parse-error log in the standalone hook's format, sofindLatestParseErrorsurfaces the reason) but never blocks the approval itself; a parsed-but-hollowgrill_mereport still refuses the marker through the existing validation short-circuit; incomplete piped input (timeout, stream error, size cap) is refused rather than captured, so a truncated report can never be persisted and approved as if it were whole. The deny message, theinitprofiles/templates/composerux.runtexts, anddocs/okf/understanding-gate-lockout-recovery.mdall teach the heredoc form. - The runtime-reality PreToolUse hook is discoverable from
harness init --template full(task 9f10267e, PRs #333, #334).grep runtime-reality src/cli/init/used to return zero hits, so operators only found the hook throughdocs/runtime-reality-hook.md. FULL_TEMPLATE now carries a fully commented-outhooks[]entry with placeholder env values and a docs pointer: enable-in-place, but not an active default, because the hook is host-coupled and withoutRUNTIME_REALITY_KEYWORD, an expectations file, andRUNTIME_REALITY_PROBE_CMDit degrades to a silent allow that looks like protection. A parity test assertsparseManifest(FULL_TEMPLATE).hookscontains no active runtime-reality entry and that the commented block still exists; mutation-verified (uncommenting the block turns the guard red). - Curated OKF knowledge bundle for harness (PR #336): eight cross-file concept docs under
docs/okf/capturing semantics no single source file states: gate fail-posture matrix, evidence-ledger trust boundary, version-sensitive producer wiring, whole-manifest validation scope, understanding-gate lockout recovery, pause-vs-gate-disable kill switches, Codex adapter parity gaps, and debug verb selection. Every claim verified against sources atf3c1727;okf-kit check --strictclean.
Changed
- The understanding-gate escape matcher moved to
src/cli/pack/approve-escape.ts(PR #337) and is now shared by the hook rather than defined inside it. Beyond the bareharness approve …line it accepts exactly one extra shape: a clean command part plus one single-quoted heredoc, terminated by the first line exactly equal to the delimiter (mirroring shell semantics), with nothing but whitespace after it. Single-line behavior is byte-for-byte unchanged. - CI enforces the coverage thresholds it declares (PR #331). The Test step runs
npm run test:covinstead ofnpm test, so the 90/90/90/75 lines/functions/statements/branches thresholds invitest.config.tsgate merges instead of silently rotting. Master clears the gate (statements 91.69%, branches 82.57%, functions 93.46%, lines 93.41%); verified the gate trips by locally raising the lines threshold to 100.
Fixed
- SECURITY: a backslash-escaped redirect could smuggle commands past the understanding gate (PR #337, found by review before release).
harness approve understanding \<<'UR'was accepted as a report heredoc by the escape matcher, but bash reads\<as a literal<followed by a file redirect: no heredoc exists and the "body" lines execute as ordinary shell commands. Reproduced live in bash. The matcher would then emitpermissionDecision: "ask"on a command visually near-identical to the legitimate one, and under aBash(harness approve understanding:*)allowlist it would have auto-approved the smuggled command with no prompt at all. The heredoc command part is now held to a character whitelist (/^[A-Za-z0-9_\s,./=:@~-]*$/) instead of a metacharacter blacklist, so no quoting or escaping trick can change how the shell tokenizes the line. Mutation-verified: removing the whitelist fails exactly the three new exploit tests. Negative tests cover\<<,foo\<<,\\<<, quote-obscured intros,<(id),>(cmd), unquoted/double-quoted/<<-delimiters, second redirects, trailing commands, early terminators, unterminated bodies, and CR-in-head. addLedgerFactcould lose the tail of a failing MCP server's stderr (task 5839b59e, PR #329). stderr was captured in the child'sexithandler, but Node does not guarantee the stderrdataevent fires beforeexit, so the surfaced reason could collapse to(no stderr). Capture onclose(fires only after all stdio pipes drain), mirroringledger-client.ts'squeryLedgerByTag. Also adds a subprocess E2E case asserting the real block/deny envelope fromhook-pre-tool-use.ts, which was previously only exercised on the allow path.- The test suite runs on macOS (PR #335): four Darwin-only failure clusters (14 tests), with Linux CI staying green. Tests hardcoding
/bin/true(absent on macOS) use/usr/bin/true;locateGitContext/repoRelativePathresolve paths to their physical form before relativizing against git's physical work-tree root, which fixes real symlinked-path usage (macOS/var→/private/var), not just the tests. - Two fixed-window test flakes replaced with readiness handshakes (PRs #330, #332). The smoke SIGKILL-escalation test spawns its trap child directly and waits for the trap to be installed before handing it to
runSmoke, so the runner's timeout window only starts once SIGTERM is guaranteed to be swallowed. The io lock concurrency test polls for worker A'sA:lock-acquiredmarker instead of assuming a fixed 50ms head start, which under CPU contention let worker B win the first-acquire race and flip the[A,B]assertion.
v0.39.0
Headline: the 2026-07-01 deep-review remediation ships — enforcement no longer degrades under load, the grounding: section finally does something, and apply --merge stops eating operator edits. The PreToolUse gate pools one grounding-mcp session per intercept (a hook timeout no longer silently fail-opens enforcement), grounding.evidence_ledger.path now actually configures the grounding-mcp entry, apply --merge preserves operator-added mcpServers with provenance, the Codex understanding-gate reaches approval-lifecycle parity with Claude Code, diff --since stops reporting override layers as phantom history, and CI gains two architecture fitness gates plus new validate lints (git-ignored OW knob, block policies without declared producers). Re-run npm i -g @lannguyensi/harness to upgrade.
Added
- Two architecture fitness functions now run in CI (task 19e293c6, harness-review-2026-07-01).
npm run check:boundaries(dependency-cruiser 18.0.0,.dependency-cruiser.cjs) pins the layering schema → policies → runtime → policy-packs → cli and fails on any new reverse import; running it for the first time surfaced four pre-existing shared-util edges (policies/duration,policies/extract,runtime/ledger-record,runtime/expand-homeimported from below), which are grandfathered per target file and documented in the config with a pointer to the structural-concentration follow-up (f86b2425).npm run check:duplication(jscpd 5.0.11 viascripts/check-duplication.mjs) pins the clone count insrc/at the post-extraction baseline of 82: a percentage threshold cannot see a single new ~16-line copy, an absolute count can — raising the pin requires an in-PR justification. Negative-control verified: a deliberate reverse import failscheck:boundaries(exit 1), and the pre-extraction tree (85 clones) failscheck:duplication. - The
grounding:manifest section is wired:grounding.evidence_ledger.pathnow configures the grounding-mcp entry (task 129e1b94, harness-review-2026-07-01).applyprojects the path as theEVIDENCE_LEDGER_DBenv on the enabled grounding-mcp entry (the exact variable its ledger-bridge reads),~-expanded to an absolute path; an operator-declared env override wins.adoptmirrors the same synthesis so the apply→adopt round-trip stays drift-free, anddoctorgains a Grounding section that checks the EFFECTIVE ledger path for writability and warns when an override diverges from the manifest value.session.*,retention_days, andpolicies_sourcestay RESERVED with explicit status comments (no consumer exists yet). - Trust-model tripwire for custom
blockpolicies, plus avalidatewarning on undeclared producers (task 43b107f2, harness-review-2026-07-01). writing-custom-policies.md gains a fourth "Read this first" tripwire spelling out that whoever can write the ledger can open arequires:/ledger_taggate: block policies are advisory against the very agent they gate, and an enforcing gate needs a non-agent evidence actor. NewcheckPolicySelfAttestationinvalidatewarns when ablockpolicy declares noproducers:at all (require_approvalexcluded with a recorded rationale); the recipes and templates now model the pattern, closing the agent-writablerisk-overridegap on the FULL_TEMPLATE'sgate-prod-destructive. Engine hardening is tracked as follow-up task 078ec604. harness validate/harness doctornow warn when the solution-acceptance pack is enabled but the OW knob path is git-ignored (task 24f6ceb9, ow-review-2026-07-01). The orchestrator-workflow arm reads repo state (.ai/solution-acceptance.jsonplus run completeness under.ai/runs/); a repo that ignores.ai/wholesale cannot commit its enforcement posture, so fresh clones and git worktrees silently skip the OW arm under the defaultautoknob. The shared check (checkSolutionAcceptanceKnobIgnored) probesgit check-ignoreagainst the process cwd, stays silent outside a git repository and indoctor --shallowruns (no spawns), and lands in the same Policy Packs section as the producer deadlock checks. Negative control: a repo that ignores only.ai/runs/produces no warning.
Changed
parseConfigUxexists once (task 19e293c6). The four byte-identical copies in hook-pre-tool-use, hook-codex-pre-tool-use, hook-branch-protection, and hook-solution-acceptance (the CHANGELOG had flagged the third copy for extraction; the fourth landed anyway because no scan existed) are now one shared helper insrc/cli/pack/hook-bootstrap.ts, parameterized by the hook's stderr label. The per-hook warning output is byte-identical to the pre-extraction strings, pinned by a test per label.- The harness repo itself now commits its OW enforcement posture (task 24f6ceb9; operator-approved revision of the earlier ".ai/ fully gitignored" decision, which was about run noise).
.gitignorenarrows.ai/to.ai/runs/;.ai/workflow/(kit templates + manifest) and.ai/solution-acceptance.json(committed as"orchestratorWorkflow": "on") are now tracked, so the solution-acceptance OW arm enforces in every checkout, including fresh clones and worktrees.docs/policy-packs/solution-acceptance.mdgains a "Repo state and gitignore" section documenting the convention and the honest residual (run-to-change binding is agent-grounding067bede3). - The symlink-rejecting gate-marker read exists once (task f86b2425 slice 1).
checkApprovalMarker(understanding-before-execution) andreadVerdict(solution-acceptance) now sharereadRegularFileRejectingSymlinkinsrc/io/(5-kind result; every deny detail and the exists-but-unreadable-still-satisfies semantic byte-identical). Future defensive fixes (lstat/read race, ENOTDIR handling) have a single home.
Fixed
- First run on a fresh machine no longer dead-ends (task 24ec07a6, harness-review-2026-07-01). Every read verb (
doctor,describe,validate,list,explain, ...) previously failed with a baremanifest not found: <path>when noharness.yamlexists; the base-manifest miss now appendsNo harness.yaml on this machine yet: run harness init --interactive (or harness init --template solo) to create one(same message shape, same exit 66; a missing override LAYER keeps the old message, since "run init" would be wrong advice for a mid-read race). Also fixed two doc-drift items the same review flagged: docs/for-agents.md's${REPO}row claimed "basename of cwd" (the code resolves the basename of the git worktree ROOT) and its${BRANCH}row promised a(detached)placeholder that has never existed (detached HEAD substitutes the empty string); docs/init-interactive.md's Custom-flow pack list was missingbranch-protectionand now notes thatsolution-acceptanceis deliberately not wizard-selectable. - A manifest with the wrong
versionnow gets upgrade guidance instead of a bare zod literal error (task 50a94127, harness-review-2026-07-01).version: 2producesthis CLI supports manifest version 1; your manifest declares version 2. A newer manifest needs a newer CLI: re-run npm i -g @lannguyensi/harness ...on every parseManifest surface (validate,doctor,describe, ...); a manifest missing the key gets the distinctmissing manifest version: add version: 1variant instead of wrong upgrade advice. Message wording only: issue codes, paths, error type, and exit codes are unchanged. Also corrected two dishonest schema comments:risk:was still annotated "no runtime surface reads them yet" in schema/index.ts and risk.ts althoughrisk.classifiers[]has been live since Phase 7 (classifyRisk on every PreToolUse +when.risk.*in when-eval); and the genuinely inert keysmemory.scopes/memory.retention.broken_refsnow carry explicitSTATUS: INERTdisclosure comments (validated, read by no consumer) instead of looking like live configuration. apply --mergeno longer clobbers operator-addedmcpServersentries — and manifest-removed/disabled servers still leave the target (task 059b669c, harness-review-2026-07-01; operator decision: deep-merge over warn-only). The merge is now per server name insidemcpServers, with provenance from.last-apply: names the manifest declares come from the generated output; a server the operator hand-added directly to the target settings.json survives (kept N operator-added mcpServer(s) (...)in the apply summary); a server harness wrote on a previous apply that the current manifest no longer emits (deleted, orenabled: false) is dropped (dropped N manifest-removed mcpServer(s) (...)), soenabled: falseremains an effective kill switch on--mergetargets. Without provenance (no.last-applyyet, a record without a settings.json entry, or a corrupt record) unknown names are conservatively preserved.hooksstays wholesale-owned (no stable per-entry identity in the settings shape; hand-added hooks belong in the manifest viaharness adopt). The merge remains idempotent, and a malformed (non-object)mcpServerson either side falls back to the old wholesale replace.adoptno longer drops hooktimeoutor MCPmin_version/version_commandon the round-trip (task 059b669c).parseSettingsHooksnow captures a settings hook'stimeout(positive integers only) andbuildHookEntrycarries it intobudget_ms— 1:1 with apply's projection, so adopt→apply round-trips the value losslessly for newly adopted hooks (a timeout-only hand-edit to an already-declared hook is deliberately ignored:timeoutis not part of the drift key, because hooks adopt add-only and a timeout-only difference would otherwise create a duplicate entry — pinned by a test).buildMcpEntrynow also carries forwardmin_version/version_commandfrom the existing manifest entry on a replace-modified drift (previously onlyhealthandenabled: false, despite the comment claiming full manifest-only-field preservation; the comment now enumerates...
v0.38.0
Headline: the Risk Gate now flags fail-closed unclassified matches across the audit trail, and validate / doctor lint risk policies that forgot to scope by environment.name. When a risk-gate policy fires only because the action was unclassified (the "unknown is not safe" rule), harness audit (table and --json) and harness explain --trace now mark the decision, so an operator reviewing a deny can tell a real critical-severity match from a fail-closed one. A new lint, shared by harness validate and harness doctor, warns when a when: block gates on risk.severity_at_least / risk.category_in / action.reversible without an environment.name scope (those clauses match every unclassified command in every environment). Re-run npm i -g @lannguyensi/harness to upgrade.
Added
harness validatenow warns on risk-gate policies without anenvironment.namescope (M7). A policy withrisk.severity_at_least,risk.category_in, oraction.reversiblein itswhen:block but noenvironment.nameclause fires on every unclassified command in every environment (the "unknown is not safe" fail-close rule).validateemits aseverity: "warning"diagnostic atpolicies[<index>]pointing atdocs/risk-gate.md. The negative control (same clauses plusenvironment.name) produces no warning.harness doctordelegates to the same shared check (checkPolicyRiskWithoutEnvScope), so its risk-gate health section reaches parity withvalidateand now also warns onaction.reversible-unscoped policies; doctor previously excludedaction.reversiblebased on an incorrect assumption that its clause matchedfalseon an unclassified action.- Audit record and block-time deny message now flag a fail-closed unclassified match (M7). When a risk-gate policy fires because the action was unclassified rather than a genuine classification hit,
PolicyDecision.whenUnclassifiedFallbackis set totrue. The field is serialised into thepolicy_decisionledger row and surfaces on three CLI paths:harness audit(table) annotates the reason column with[unclassified-fallback];harness audit --jsonincludes thewhenUnclassifiedFallback: truefield on each decision object;harness explain <policy> --trace --jsonincludeswhenUnclassifiedFallback: truein the JSON trace projection. The non-ux block-time deny message appends(matched via the fail-closed unclassified rule, not a real risk classification)before the hint suffix so the agent-facing message names the cause. Policies with aux:block are not altered at the agent-facing surface; the flag still rides the audit record and is visible inharness auditandexplain --trace.
v0.37.0
Headline: harness doctor learns the solution-acceptance deadlock checks, plus a sessionId-namespace fix to ledger-deny hints and clearer intercept / approve-understanding diagnostics. harness doctor now reports the two solution-acceptance misconfigurations that deadlock the completion-gate (parity with harness validate), ledger-gate deny hints name which sessionId namespace to record the unblocking entry under, the understanding-gate admits read-only Bash pipelines for post-task CI polls, and harness validate now hard-errors on a solution-acceptance pack with no reachable producer. Re-run npm i -g @lannguyensi/harness to upgrade.
Added
harness doctornow surfaces the solution-acceptance producer/dir deadlock findings (task 08ccfe87). The two misconfigurations thatharness validatealready catches are now also reported byharness doctorin the Policy Packs section: condition #1 (grounding-mcp absent fromtools.mcp) is reported as an error, condition #2 (relativeSOLUTION_VERDICT_DIRin grounding-mcp env) as a warning.checkSolutionAcceptanceProduceris the single source of truth for both checks; no logic is duplicated.
Changed
- Ledger-gate deny "to satisfy" hints now name the sessionId namespace (task cdc60d56, discovery 2026-06-24). The hint previously showed the session id value but not which namespace it is; an entry written under the agent-tasks task UUID never satisfies a harness runtime gate, which keys off the runtime session id (the 2026-05-17 incident on PRs #174/#175: a first attempt wrote under the task UUID and was rejected, the second under the session id and passed). The format is now
... To satisfy: record an evidence-ledger entry containing \`, under this runtime session's id `` (not the agent-tasks task UUID).` Naming an identity is not a producer verb, so the deny path stays neutral on producer (agent-tasks/88ca4bb3). harness validatenow errors (was a warning) when thesolution-acceptancepolicy pack is enabled butgrounding-mcpis not wired undertools.mcp(task e3af6388). Without a reachablesolution_evaluateproducer the completion-gate can never see a verdict and deadlocks on a permanent deny, so this is a hard misconfiguration rather than a soft warning. A relativeSOLUTION_VERDICT_DIR(the other producer condition) stays a warning. Operator impact: a manifest withsolution-acceptanceenabled butgrounding-mcpabsent now makesharness validateexit 1 where it previously exited 0.
Fixed
- Surface a previously-swallowed audit-write failure in the runtime policy intercept (task 6b8e53cc).
intercept()caught and silently discarded an unexpected throw fromledger.record(), leavingharness audit/explain --traceblind with no signal. The catch now writes aharness runtime intercept: audit-write failed for <policy>: <error>diagnostic to stderr while preserving the fail-open contract (the gate decision is still applied; the stdout deny-JSON is untouched). This is a defense-in-depth backstop for an unexpectedrecord()throw; the common{ok:false}write-failure path (connection refused / spawn fail / timeout) was already surfaced one layer down atrealLedgerClient.record. harness approve understandingnow names the cause when a manifest-load failure degrades the ledger write (task 6b8e53cc, M8). The manifest load wrapped the ledger-write path in a barecatch {}, so an unparseable / missing manifest left the operator withledger: ok falseand the generic reasonmanifest unreadable; skipped ledger write— no diagnosis. The captured loader error is now interpolated into the reason (manifest unreadable (<cause>); skipped ledger write); the report flip still runs (fail-open preserved).- The understanding-gate PreToolUse hooks now allow a read-only Bash pipeline (task 1d024fff, friction #36/#69/#72). A post-task CI poll like
gh pr checks 123 | headwas blocked because the read-only classifier hard-refused any|, so confirming CI right aftertask_finishforced a fresh Understanding Report. A newisReadOnlyBashPipeline(used only by the Claude and Codex understanding-gate hooks) admits a single-|pipeline when every stage independently classifies read-only, while still refusing;,&,&&,||,|&, redirection, and command substitution. A pipeline of read-only stages cannot write, so this widens nothing the gate must stop. The strictisReadOnlyBashCommandis unchanged for its other consumers (the Risk Classifier read-only floor and the solution-acceptance write-guard). Scope: this is the read-only fix only; post-task writes (e.g.gh pr comment) remain gated by design.
v0.36.0
Headline: a doctor cleanup mode for rogue ledgers, plus gate-admission and pause-sentinel fixes. harness doctor gains an opt-in --rm-rogue-ledgers mode, the read-only Bash classifier re-admits sort, tree, and file behind precise write-flag guards (they were over-blocked), and the runtime-reality gate now honors the pause sentinel like every other gate. Re-run npm i -g @lannguyensi/harness to upgrade.
Added
harness doctor --rm-rogue-ledgersopt-in cleanup mode (PR #296). Doctor can now remove rogue evidence ledgers it finds, gated behind an explicit flag so a baredoctorrun stays read-only.
Security
- Validate
sessionIdbefore joining it into the session-export transcript path (PR #294).session-exportjoinedsessionIdinto the transcript path with no validation; arejectMalformedSessionIdguard (rejecting blank values, path separators, and..) now runs at the path-construction choke point.
Fixed
- Re-admit
sort,tree, andfilein the read-only Bash classifier behind precise write-flag guards (PR #292). These commands were over-blocked; they are now admitted unless invoked with a write-producing flag (for examplesort -o). - Honor the pause sentinel in the runtime-reality gate (PR #291). The runtime-reality checker now respects the pause sentinel like every other gate instead of firing while the harness is paused.
Changed
v0.35.0
Headline: the Tier-1 discovery follow-up. The five HIGH findings from the 2026-06-10 discovery audit are fixed: a defense-in-depth gap in the approval-marker path, the long-standing harness add whole-manifest footgun, a solution-acceptance verdict-dir mismatch, a policy-degradation footgun that apply did not catch, and an integration suite that never ran in CI. Operator action: harness apply now refuses a manifest that declares policies: without wiring grounding-mcp under tools.mcp (previously it applied and the policies silently degraded to warn-mode at runtime); wire grounding-mcp or remove the policies. Re-run npm i -g @lannguyensi/harness to upgrade.
Security
- Validate
sessionIdbefore joining it into approval-marker paths (task 96178e12, discovery H5, PR #284).approvalMarkerPathForjoinedsessionIdinto the.approvals/path with no validation, unlike the task-marker twin. A newrejectMalformedSessionIdguard (rejecting blank, path separators, and..) is applied at the single path-construction choke point, and the gate read path (checkApprovalMarker) fails closed (blocks, returns no match) instead of throwing out of the hook on a malformed value.
Changed
harness addasset gate is scoped to the entry being added (task 57ea5f5b, discovery H1, recurring footgun).addranrunAssetChecksover the whole proposed manifest and blocked on any error, so an unrelated pre-existing problem (for example a missing required CLI frominit --template full) sank an otherwise-fine hooks-only add. The gate now blocks only on asset errors introduced or newly caused by the added entry (a baseline diff against the original manifest); pre-existing unrelated errors are surfaced as a non-blocking warning pointing atharness validate.harness validatestays whole-manifest.harness applyfails loud on policies without grounding-mcp (task 09120efb, discovery H3, PR #288).checkPolicyGroundingMcpran only invalidate(a warning). An operator who ranapplywithoutvalidatecould deploy policies that silently degrade to warn-mode at runtime.applynow runs the same check in its gate phase and fails closed with a message naming the degradation and the fix. See the operator action above.- CI runs the integration suite (task 6791ba98, discovery H7, PR #286).
tests/integration/operator-state-isolation.test.tswas gated behindHARNESS_INTEGRATION_TESTS=1that CI never set, so the operator-state-isolation acceptance ran nowhere. A dedicated CI step now runsnpm run test:integrationon push and PR.
Fixed
- Project
SOLUTION_VERDICT_DIRinto the solution-acceptance hook (task d4395979, discovery H2, PR #287). The completion-gate hook (consumer) read the verdict marker the grounding-mcp server (producer) writes, but harness did not project a manifest-declaredtools.mcp[grounding-mcp].env.SOLUTION_VERDICT_DIRinto the hook, so a non-default override split producer and consumer onto different dirs and the gate could never see a verdict.applynow projects the override onto both solution-acceptance hook commands, mirroring theUNDERSTANDING_GATE_REPORT_DIRpattern. Thevalidatewarning is corrected: an absolute override is now handled silently, and only a relative override (which cannot be reconciled across working directories) warns.
v0.34.0
Headline: the discovery release. A live-reproduced gate-integrity bug is closed: harness approve understanding could silently bind a fresh session to a weeks-old leftover report when the producer Stop hook failed (finding C1 of the 2026-06-10 discovery audit); the tolerant fallback now rejects stale sessionId-less candidates, prints loud adoption warnings, and orders reports by creation time instead of mtime. Around it: non-TTY-safe confirmations on apply and adopt (with apply --yes), validate --json, a new harness gc retention cleanup, an uninstall that finally sees the ~/.harness/ state root and now removes migrated state it previously left behind, and docs synced to reality. Operator action: none required unless you piped confirmation prompts (echo yes | harness apply --overwrite-drift now refuses; use --yes). Re-run npm i -g @lannguyensi/harness to upgrade.
Added
-
harness gc: retention-based cleanup of gate state (task 38943a05, harness-discovery M3). Nothing ever deleted terminal understanding-gate reports, parse-error logs, or approval markers of dead sessions; the reports dir on the originating install accumulated 103 files in under a month, and stale leftovers were the raw material of the C1 stale-adoption bug. The new verb ages out artifacts older than a retention window (default 30 days,--retention-days <n>): terminal-status (approved/expired) reports by their createdAt, parse-error logs and approval markers by mtime. Pending reports are never touched regardless of age, and only the enumerated harness-owned dirs are considered; the evidence ledger and solution-acceptance verdict dirs stay producer-owned. Dry-run by default,--applydeletes; per-file deletion failures are surfaced loudly and fail the command. -
apply --yes, non-TTY guards on confirmation prompts,validate --json(task 0f901128, harness-discovery H4 + H6). Theapply --overwrite-driftandadoptconfirmation prompts read stdin via readline with no TTY check, so a triggered confirmation in CI or an agent-driven shell blocked forever waiting for input that never comes. Both default prompts now refuse loudly under a non-TTY stdin and name the escape hatch;applygains--yes(skip the overwrite-drift confirmation, as if the operator typedyes;adoptalready had it). Interactive TTY behavior is unchanged.harness validategains--json, emitting{ diagnostics, errorCount, warningCount }on stdout with exit-code semantics unchanged, closing the gap withdescribe/doctor/listfor CI and agent pipelines. (Discovery note: the H4/H6 write-up claimed--yeswas missing everywhere;adopt --yesandapply --jsonalready existed. The real gaps wereapply --yes, the two TTY guards, andvalidate --json.) Breaking workaround: piping the confirmation (echo yes | harness apply --overwrite-drift) previously worked and now refuses, since piped stdin is exactly the non-TTY case the guard exists for; use--yesinstead.
Fixed
-
uninstallwas blind to the~/.harness/state root and left.understanding-gate/behind (task 38943a05, harness-discovery M2). Uninstall hardcoded~/.claude/as the only root, so on installs migrated bymigrate-home(v0.24.0) it found no manifest, lock, orharness.generated/to remove and tore down essentially nothing but settings.json entries. It now resolves the state root through the shared resolver (~/.harness/, legacy fallback,HARNESS_HOMEenv;--state <path>override), inventories and removes.understanding-gate/(reports, parse-errors, hypotheses) alongside manifest/lock/generated, and prints both roots when they differ. An explicit--homewithout--statekeeps the historic single-directory contract. Also fixed: two raw NUL bytes embedded in template literals made the source file binary to grep/file tooling; they are now\u0000escapes (behavior identical). -
Docs synced to the shipped binary (task 27adde6d, harness-discovery M9 + L1). README and
docs/CLI.mdclaimedv0.30.0(three releases behind); CLI.md now tracksv0.33.0and gains the verbs shipped since:approve branch-protection,pack hook solution-acceptance[-writeguard],pack hook runtime-reality, plus the previously undocumentedpack hook stay-in-scope. Corrected along the way: the Hook-entrypoints table documented a top-levelharness hook ...namespace that does not exist (the runtime entrypoints live underharness pack hook ...;harness add hookis the unrelated manifest mutation). Newdocs/policy-packs/solution-acceptance.mddocuments the pack end-to-end, including theSOLUTION_VERDICT_DIR/SOLUTION_VERDICT_IDenv knobs that previously existed only in CHANGELOG entries;writing-custom-policies.mdnow names three builtin packs. The stalebranch-protection.tsheader comment claiming the full template does not wire the pack (it shipsenabled: true) is fixed.
Security
approve understandingno longer adopts stale sessionId-less reports via the tolerant fallback (task adc20a8b, harness-discovery C1, friction-log #67). Live repro 2026-06-10: with the live session's report never persisted (a silent Stop-hook producer failure, tracked separately in agent-grounding),harness approve understandingadopted the only candidate on disk, a 17-day-old sessionId-less pending report, validated it, stamped the fresh sessionId onto it, and printed only "passed structural checks". Three changes close this: (1) the tolerant fallback rejects sessionId-less candidates older than 15 minutes (TOLERANT_FALLBACK_MAX_AGE_MS); the rejection is its own error path naming the file, its createdAt, and its age, and pointing atparse-errors/, since a stale-only state usually means the producer failed to persist the fresh report. (2) When a fallback adoption does happen, the CLI prints the adopted report's createdAt and age with a verify-this warning instead of flipping it silently. (3) Report listing now orders by creation time (JSONcreatedAt, then filename ISO prefix, mtime as last resort) instead of mtime, which the approval rewrite itself bumps, so selection stays deterministic under rewritten files. The strict sessionId match and the gate-read / expiry paths (tolerantFallback: "any", no age limit) are unchanged.
v0.33.0
Headline: a security-driven release that closes two gate-bypasses. A read-only-bash classifier hole let command rm -rf / and env FOO=bar rm -rf / run as "read-only" past the hard Understanding Gate, and the branch-protection override could be self-blessed by an agent-writable ledger ACK. Both are now closed: command runners recurse-classify their nested argv, and the branch-protection override is an operator-only canonical marker (new harness approve branch-protection verb), mirroring the understanding gate. Also bundled: a vitest CVE bump, clearer gate-block messages surfaced by dogfooding, and a SOLUTION_VERDICT_ID knob for solo sessions. Operator action: none required, back-compat. Re-run npm i -g @lannguyensi/harness to upgrade.
Added
solution-acceptance:SOLUTION_VERDICT_IDenv knob for solo / non-agent-tasks sessions (task 01435583, PR #272): the completion-gate derived the verdict id solely from the agent-tasksactive-claim, so a session that never callstask_startwas permanently blocked with "no active-claim". It now falls back to aSOLUTION_VERDICT_IDenv var when no claim is present. Resolution order is active-claim first, thenSOLUTION_VERDICT_ID, then fail-closed, so a claimed session's id stays authoritative and cannot be redirected by the env (a sessionId fallback is still intentionally absent). The env value is validated as a safe single path segment; a malformed value fails closed. Set it to the same id you pass tomcp__agent-grounding__solution_evaluate({ id }).
Changed
- Gate-block messages are clearer about preflight ordering, mode-aware wording, and the approve escape hatch (PR #274), surfaced by dogfooding the gates.
preflight-before-investigation/preflight-before-pushux.requirednow note thatharness preflightis itself gated by the Understanding Gate, so the "Run: harness preflight" remedy no longer dead-ends when the report is not yet approved. A newunderstandingApprovalRequirement(mode)helper derives the understandingux.requiredphrase from the mode (onlystrictsays "a human-approved Understanding Report";fast_confirm/grill_mestay "an approved Understanding Report"); no output change for currentgrill_meprofiles.approveEscapeHint()appends a targeted hint when a blocked Bash command starts withharness approvebut trips the (unchanged) metachar guard, telling the agent to re-run it bare; the understandingux.runline gains the same "(bare, no pipes, chaining, or redirection)" guidance.
Security
-
Command runners
env/commandno longer bypass the hard gate (HIGH audit finding, PR #271). The read-only-bash classifier listedenvandcommandinSIMPLE_READ_ONLY_BINS, so the gate treatedcommand rm -rf /tmp/xandenv FOO=bar rm -rf /as read-only and allowed them without an approved Understanding Report, a hard-gate bypass acrosshook-pre-tool-use,hook-codex-pre-tool-use, andhook-solution-acceptance-writeguard. Both binaries are command runners (their argv is itself a nested command); they are removed from the simple set and given a find-style special case that strips the runner's own leading flags/assignments and recurse-classifies the residual underlying command. Bare and lookup-only forms (env,env -u X,command -v node) stay read-only;env -S/--split-stringfails closed since it re-parses a string into a fresh argv that defeats whitespace tokenization. -
branch-protection override is now an operator-only canonical marker (MEDIUM audit finding #39, PR #275). The override was satisfied by any
branch-protection-ackledger entry, which an agent can self-write viamcp__agent-grounding__ledger_add, letting it bless its own protected-branch edit. The agent-writable ledger ACK is replaced with an operator-only canonical marker file underharness.generated/.approvals/branch-protection-<sessionId>, mirroring the understanding gate (writeApprovalMarker/checkApprovalMarker). A new operator verbharness approve branch-protectionwrites the marker and records thebranch-protection-ackledger row as a best-effort audit echo only. The blocker now reads the marker as the canonical override; the ledger tag alone no longer opens the gate. -
vitest bumped to
^4.1.4(CVE-2026-47429 / GHSA-5xrq-8626-4rwp, PR #273). vitest < 4.1.0 lets the UI server read and execute arbitrary files. vitest is a devDependency; the lockfile is regenerated.
v0.32.0
Headline: harness ships a new opt-in solution-acceptance policy pack that makes task completion EARNED from a real preflight run rather than self-attested. The producer (@lannguyensi/grounding-mcp >= 0.3.2 solution_evaluate) records a HEAD-pinned verdict from a real preflight run --json; this pack gates the task-finishing tools (agent-tasks completion verbs + git push / gh pr merge) on a ready verdict at the current HEAD, and adds an anti-forgery write-guard so the agent cannot hand-write the verdict marker. Operator action: none required, back-compat. The pack is opt-in (harness pack add solution-acceptance, or flip the disabled exemplar in the full template); it needs grounding-mcp under tools.mcp and the preflight binary on PATH, and harness validate warns if you enable it without the producer. Re-run npm i -g @lannguyensi/harness to upgrade.
Added
solution-acceptancebuiltin policy pack (task cc43c7a4, PR #269): the consumer half of the "Verifier-gated Done" gate. Twoblocking: hardPreToolUse hooks:completion-gate(harness pack hook solution-acceptance): deniestask_finish/task_submit_pr/task_merge/pull_requests_mergeandgit push/gh pr mergeunless a ready solution-acceptance verdict exists at the current git HEAD for the active-claim task. Fail-closed (missing / not-ready / HEAD-drift / unresolvable-HEAD / no-active-claim all block); the verdict id is the active-claim task id, never the session id.write-guard(harness pack hook solution-acceptance-writeguard): the anti-forgery closure. Relocating the verdict dir is not sufficient (the understanding-gate allows all Bash post-approval), so this hook denies the agent's enumerated writes into the verdict dir (Bash redirects /tee/mv/cp/ln/ interpreter writes that reference it, including glob-obscured spellings andchmod/chattron the dir, plus Write/Edit/MultiEdit/NotebookEdit whose target lands inside it). The only legitimate writer is the producer.- harness is a pure consumer: it reimplements the marker read + gate decision locally (no
grounding-mcpruntime dependency) and reads the producer-default verdict dir~/.local/state/agent-grounding/solution-verdicts(SOLUTION_VERDICT_DIRoverride). The gate decision isready && head === HEADonly;confidenceis informational (parity with the producer'ssolution_gate). A golden-fixture test pins the consumer field-for-field to a real 0.3.2 marker. harness validate/harness doctorwarn when the pack is enabled butgrounding-mcpis absent fromtools.mcp(the producer would be unreachable and the gate would deadlock) or declares a non-defaultSOLUTION_VERDICT_DIR. Added to the full init template as a disabled, discoverable exemplar (no No-Op default).- Anti-forgery scope is v1-honest: it closes the enumerated-write-path residual, not arbitrary same-uid forgery. Cryptographic marker signing (which also closes glob-every-segment and interpreter runtime-path-construction spellings) is a tracked follow-up.