@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
|
@@ -7,10 +7,10 @@ import { openLedgerSession, } from "../../policies/index.js";
|
|
|
7
7
|
import * as fs from "node:fs";
|
|
8
8
|
import * as os from "node:os";
|
|
9
9
|
import * as path from "node:path";
|
|
10
|
-
import { intercept, recordPolicyDecisionOnSession, resolveGitContext, resolveKubeContext, } from "../../runtime/index.js";
|
|
10
|
+
import { buildActionEnvelope, intercept, isBlockingDecision, recordPolicyDecisionOnSession, resolveEnvironment, resolveGitContext, resolveKubeContext, sanitizeEnvelopeReason, } from "../../runtime/index.js";
|
|
11
11
|
import { resolveGeneratedDir, writePendingApproval } from "../../runtime/pending-approval.js";
|
|
12
12
|
import { parseBashPrefix } from "../../runtime/bash-prefix-parse.js";
|
|
13
|
-
import { MAX_NORMALIZE_LENGTH, normalizeCommand, normalizeCommandAmpAware, segmentViewOf, } from "../../runtime/command-normalize.js";
|
|
13
|
+
import { MAX_NORMALIZE_LENGTH, normalizeCommand, normalizeCommandAmpAware, normalizeCommandQuoteAware, segmentViewOf, } from "../../runtime/command-normalize.js";
|
|
14
14
|
import { extractShellCommand, SHELL_ALIASES } from "../../runtime/tool-name-aliases.js";
|
|
15
15
|
import { loadManifest } from "../loader.js";
|
|
16
16
|
import { checkPauseFromLoader } from "../pause-check.js";
|
|
@@ -52,7 +52,19 @@ function hookSuffix(hookName) {
|
|
|
52
52
|
* policy name so concurrent fires (rare but possible) stay readable.
|
|
53
53
|
*/
|
|
54
54
|
function formatDecisionDiagnostic(decision, hookName) {
|
|
55
|
-
|
|
55
|
+
// The deny-degraded suffix names the fail_open opt-out ON PURPOSE and
|
|
56
|
+
// ONLY here: stderr is the operator's surface. The agent-facing deny
|
|
57
|
+
// envelope deliberately omits it (see the deny-degraded branch in
|
|
58
|
+
// `runtime/intercept.ts` — a deny that includes its own disable recipe
|
|
59
|
+
// is not a gate).
|
|
60
|
+
// Both degraded arms are cause-neutral: the degraded family has five
|
|
61
|
+
// causes and only one of them is the ledger; the true cause follows
|
|
62
|
+
// on the block's own `reason:` line (review 2026-08-08, rounds 3+5).
|
|
63
|
+
const header = `harness policy intercept${hookSuffix(hookName)}: ${decision.policyName}: ${decision.outcome}${decision.outcome === "warn-degraded"
|
|
64
|
+
? " (evidence could not be evaluated; non-blocking per warn tier or fail_open opt-out)"
|
|
65
|
+
: decision.outcome === "deny-degraded"
|
|
66
|
+
? " (evidence could not be evaluated; failing closed per enforcement tier; operator opt-out: risk.degraded_fail_posture: fail_open)"
|
|
67
|
+
: ""}`;
|
|
56
68
|
const lines = [header];
|
|
57
69
|
lines.push(` ledger_tag: ${decision.ledgerTag}`);
|
|
58
70
|
if (decision.requiresEval !== undefined) {
|
|
@@ -141,6 +153,48 @@ function resolvePolicyCwd(event, codexCommandCwd) {
|
|
|
141
153
|
}
|
|
142
154
|
return process.cwd();
|
|
143
155
|
}
|
|
156
|
+
// Mirrors `ENV_PRECEDENCE` in `../../runtime/environment-resolver.ts`
|
|
157
|
+
// (most-dangerous-first). Kept as a local, small, duplicate table
|
|
158
|
+
// instead of importing that module's private const, so this file stays
|
|
159
|
+
// a read-only consumer of `resolveEnvironment`'s public API rather than
|
|
160
|
+
// reaching into its internals. `unknown` is deliberately ranked lowest:
|
|
161
|
+
// resolving to no signal at all must never look like an "upgrade" over
|
|
162
|
+
// a branch that DID fire a resolver.
|
|
163
|
+
const ENV_RANK = {
|
|
164
|
+
production: 0,
|
|
165
|
+
staging: 1,
|
|
166
|
+
dev: 2,
|
|
167
|
+
local: 3,
|
|
168
|
+
unknown: 4,
|
|
169
|
+
};
|
|
170
|
+
/**
|
|
171
|
+
* Branch-switch, upgrade-only merge (task 341e024b): a leading `git
|
|
172
|
+
* switch <branch>` / `git checkout <branch>` names the branch the REST
|
|
173
|
+
* of the command actually runs against, the same way a leading `cd
|
|
174
|
+
* <path>` names a different working directory (see `resolverGit`'s own
|
|
175
|
+
* comment at the call site). Unlike that `cd` merge — which fully
|
|
176
|
+
* REPLACES the git context and can move the resolved environment in
|
|
177
|
+
* EITHER direction (G5, pre-existing, out of scope for this task) —
|
|
178
|
+
* this merge is deliberately asymmetric: it only ever pushes the
|
|
179
|
+
* resolved environment to something MORE dangerous, never less.
|
|
180
|
+
*
|
|
181
|
+
* Both the base git context and the switch-target candidate (same repo
|
|
182
|
+
* / sha, only `branch` differs) are run through the SAME
|
|
183
|
+
* `resolveEnvironment` call with IDENTICAL env / kube inputs, so any
|
|
184
|
+
* difference in the result is attributable to the branch alone. The
|
|
185
|
+
* more dangerous of the two (`ENV_RANK` order) wins; a switch AWAY from
|
|
186
|
+
* a production branch never downgrades — when the candidate resolves to
|
|
187
|
+
* something equally or less dangerous, `baseGit` is returned unchanged.
|
|
188
|
+
*/
|
|
189
|
+
function applyBranchSwitchUpgrade(event, manifest, cwd, baseGit, branchTarget, inputs, user, host, now) {
|
|
190
|
+
if (branchTarget === null || branchTarget === baseGit.branch)
|
|
191
|
+
return baseGit;
|
|
192
|
+
const candidateGit = { ...baseGit, branch: branchTarget };
|
|
193
|
+
const effectiveNow = now ?? new Date();
|
|
194
|
+
const baseResolution = resolveEnvironment(buildActionEnvelope(event, { cwd, git: baseGit, user, host, now: effectiveNow }), manifest.environments.resolvers, inputs);
|
|
195
|
+
const candidateResolution = resolveEnvironment(buildActionEnvelope(event, { cwd, git: candidateGit, user, host, now: effectiveNow }), manifest.environments.resolvers, inputs);
|
|
196
|
+
return ENV_RANK[candidateResolution.name] < ENV_RANK[baseResolution.name] ? candidateGit : baseGit;
|
|
197
|
+
}
|
|
144
198
|
/**
|
|
145
199
|
* Real grounding-mcp-backed ledger client. Exported for testing: the
|
|
146
200
|
* `record` adapter's failure-surfacing path is exercised against a
|
|
@@ -165,6 +219,38 @@ export function realLedgerClient(server, opts) {
|
|
|
165
219
|
const stderr = opts.stderr ?? process.stderr;
|
|
166
220
|
let session = null;
|
|
167
221
|
const summaryCache = new Map();
|
|
222
|
+
// One fresh-session retry for audit writes per client instance,
|
|
223
|
+
// reserved for `deny-degraded` decisions (task f1aea826, AC4; review
|
|
224
|
+
// 2026-08-08 tightened both the reservation and the budget). The
|
|
225
|
+
// pooled session carries a session-wide timeout latch: a single slow
|
|
226
|
+
// QUERY (the very condition that produces a blocking `deny-degraded`)
|
|
227
|
+
// short-circuits every later call on the same session, including the
|
|
228
|
+
// audit write for that decision — so the one row that most needs to
|
|
229
|
+
// land was guaranteed to be lost. A fresh subprocess lands it whenever
|
|
230
|
+
// grounding-mcp itself is healthy (the measured 2026-08-06 case: only
|
|
231
|
+
// the artificial 1ms query budget latched; follow-up calls answered
|
|
232
|
+
// fine).
|
|
233
|
+
//
|
|
234
|
+
// Bounds, stated precisely because the record runs on the deny path
|
|
235
|
+
// BEFORE the envelope is emitted and any stall risks the outer
|
|
236
|
+
// hook-timeout-is-allow contract: the retry is (a) reserved for
|
|
237
|
+
// decisions whose outcome is `deny-degraded` — an allow/warn row
|
|
238
|
+
// failing first must not consume it — (b) taken at most once per
|
|
239
|
+
// client instance, and (c) run on a session whose OWN timeout is
|
|
240
|
+
// `auditRetryTimeoutMs(timeoutMs)` = max(250ms, timeoutMs/4). Its two
|
|
241
|
+
// calls (initialize + ledger_add) therefore add at most
|
|
242
|
+
// 2 * max(250ms, timeoutMs/4) on top of the query's timeoutMs — that
|
|
243
|
+
// is <=timeoutMs/2 (total <=1.5x) once timeoutMs >= 1s, while below
|
|
244
|
+
// 1s the 250ms floor dominates and the ADD is bounded at 500ms
|
|
245
|
+
// absolute (review 2026-08-08 round 2: at the audit's 1-100ms probe
|
|
246
|
+
// settings that is a multiple of the query budget, but a trivial
|
|
247
|
+
// absolute cost). The fresh session replaces the latched one, so
|
|
248
|
+
// later audit writes in the same invocation inherit the working
|
|
249
|
+
// transport (and its tighter budget). If the retry also fails, the
|
|
250
|
+
// loud-stderr contract stands (audit degradation never blocks,
|
|
251
|
+
// docs/okf matrix).
|
|
252
|
+
const retryTimeoutMs = auditRetryTimeoutMs(timeoutMs);
|
|
253
|
+
let recordRetryUsed = false;
|
|
168
254
|
const getSession = () => (session ??= openLedgerSession({
|
|
169
255
|
mcpCommand: command,
|
|
170
256
|
...(env && { mcpEnv: env }),
|
|
@@ -175,9 +261,12 @@ export function realLedgerClient(server, opts) {
|
|
|
175
261
|
// Cache the Promise (not the value) so concurrent queries for the
|
|
176
262
|
// same session dedupe onto one in-flight round-trip. A degraded
|
|
177
263
|
// result is cached too — deliberately: a first-query timeout
|
|
178
|
-
// degrades ALL K policies
|
|
179
|
-
//
|
|
180
|
-
//
|
|
264
|
+
// degrades ALL K policies off the one cached round-trip instead of
|
|
265
|
+
// retrying per policy, which would reintroduce the fan-out. (What
|
|
266
|
+
// a degraded evaluation MEANS is tier-dependent since task
|
|
267
|
+
// f1aea826: warn policies stay non-blocking `warn-degraded`,
|
|
268
|
+
// block/require_approval fail closed as `deny-degraded`.)
|
|
269
|
+
// Staleness: our own
|
|
181
270
|
// `record` rows are filtered out by the evaluator by design; an
|
|
182
271
|
// EXTERNAL writer landing evidence mid-loop would make a later
|
|
183
272
|
// policy deny where a fresh query would allow — a fail-closed,
|
|
@@ -191,7 +280,24 @@ export function realLedgerClient(server, opts) {
|
|
|
191
280
|
return cached;
|
|
192
281
|
},
|
|
193
282
|
async record(decision, sessionId) {
|
|
194
|
-
|
|
283
|
+
let result = await recordPolicyDecisionOnSession(getSession(), decision, sessionId);
|
|
284
|
+
if (!result.ok &&
|
|
285
|
+
decision.outcome === "deny-degraded" &&
|
|
286
|
+
!recordRetryUsed) {
|
|
287
|
+
// Latched-session recovery (bounds documented at retryTimeoutMs
|
|
288
|
+
// above): drop the pooled session and retry ONCE over a fresh
|
|
289
|
+
// subprocess with the tighter retry budget. Queries are
|
|
290
|
+
// unaffected — their per-session summary promise is already
|
|
291
|
+
// cached by the time any record runs.
|
|
292
|
+
recordRetryUsed = true;
|
|
293
|
+
session?.dispose();
|
|
294
|
+
session = openLedgerSession({
|
|
295
|
+
mcpCommand: command,
|
|
296
|
+
...(env && { mcpEnv: env }),
|
|
297
|
+
timeoutMs: retryTimeoutMs,
|
|
298
|
+
});
|
|
299
|
+
result = await recordPolicyDecisionOnSession(session, decision, sessionId);
|
|
300
|
+
}
|
|
195
301
|
// A failed audit-write must not block the tool — the decision is
|
|
196
302
|
// still applied — but it must not be silent either: an unrecorded
|
|
197
303
|
// decision is invisible to `harness audit` / `explain --trace`.
|
|
@@ -206,13 +312,178 @@ export function realLedgerClient(server, opts) {
|
|
|
206
312
|
},
|
|
207
313
|
};
|
|
208
314
|
}
|
|
209
|
-
|
|
315
|
+
/**
|
|
316
|
+
* Per-call budget of the audit-retry session (task f1aea826, review
|
|
317
|
+
* round 2): a quarter of the ledger timeout with a 250ms floor (a fresh
|
|
318
|
+
* node subprocess must cold-start + initialize within it). Exported as a
|
|
319
|
+
* pure function so the FORMULA itself is pinned by a unit test — the
|
|
320
|
+
* round-1 finding this bounds was precisely that an unbounded retry
|
|
321
|
+
* reused the full budget, and nothing would go red if that regressed.
|
|
322
|
+
*/
|
|
323
|
+
export function auditRetryTimeoutMs(timeoutMs) {
|
|
324
|
+
return Math.max(250, Math.floor(timeoutMs / 4));
|
|
325
|
+
}
|
|
326
|
+
/**
|
|
327
|
+
* Recognise a hook `command` string as an invocation of `harness policy
|
|
328
|
+
* intercept`'s subcommand, regardless of how the leading interpreter /
|
|
329
|
+
* binary token is spelled: a bare `harness policy intercept`, a node-path
|
|
330
|
+
* form (`node /abs/path/dist/cli/index.js policy intercept`, e.g. a local
|
|
331
|
+
* dev build), an `npx` form (`npx harness policy intercept`), a leading
|
|
332
|
+
* inline env-var assignment (`FOO=bar harness policy intercept` — the
|
|
333
|
+
* same shape `understanding-before-execution.ts`'s `wrap()` helper
|
|
334
|
+
* prefixes pack-contributed commands with), or a trailing flag
|
|
335
|
+
* (`harness policy intercept --hook <name>`, the tag
|
|
336
|
+
* `generate-codex-config.ts#commandWithHookTag` appends).
|
|
337
|
+
*
|
|
338
|
+
* Every OTHER call site that needs to recognise this subcommand in this
|
|
339
|
+
* codebase today (`generate-settings.ts#hookTimeoutSeconds`,
|
|
340
|
+
* `generate-codex-config.ts#commandWithHookTag`) still compares
|
|
341
|
+
* `command.trim() === "harness policy intercept"` VERBATIM — deliberately
|
|
342
|
+
* left unchanged (task d20a7e0c scope: the hook-timeout projection
|
|
343
|
+
* contract those two own is out of scope). For THEM a missed match only
|
|
344
|
+
* changes a floor/flag on a command harness itself emits byte-for-byte,
|
|
345
|
+
* so the single canonical spelling is enough. This matcher exists for a
|
|
346
|
+
* caller (the validate/doctor hook-budget-vs-ledger-timeout guard, task
|
|
347
|
+
* d20a7e0c) where under-recognition is a false NEGATIVE on a security
|
|
348
|
+
* check against an OPERATOR-authored `manifest.hooks[]` entry, which is
|
|
349
|
+
* under no obligation to spell the command exactly the way harness's own
|
|
350
|
+
* generators do.
|
|
351
|
+
*
|
|
352
|
+
* Deliberately narrow, not a shell parser: matches the two literal words
|
|
353
|
+
* `policy` and `intercept` as adjacent, whitespace-separated tokens,
|
|
354
|
+
* preceded by start-of-string / whitespace / a path separator (so
|
|
355
|
+
* `mypolicy intercept` inside one longer token cannot match) and followed
|
|
356
|
+
* by whitespace, end-of-string, or a semicolon/quote character (`;`, `"`,
|
|
357
|
+
* `'`) immediately abutting the word — the shape a chained shell command
|
|
358
|
+
* (`harness policy intercept; echo done`) or an embedded `sh -c
|
|
359
|
+
* "harness policy intercept"` wrapper produces, where nothing whitespace
|
|
360
|
+
* separates `intercept` from the next token (review 2026-08-09, fix round
|
|
361
|
+
* 1, finding 3). The quote arm accepts more than it strictly should: a
|
|
362
|
+
* quote glued directly onto the word (`intercept'or'`) also matches, even
|
|
363
|
+
* though a shell concatenates that into a different, longer identifier
|
|
364
|
+
* (`interceptor`). This is a deliberate, harmless OVER-flag — at worst a
|
|
365
|
+
* spurious validate/doctor diagnostic on an exotic command, never a missed
|
|
366
|
+
* real hook — accepted because the same quote boundary is load-bearing for
|
|
367
|
+
* the legitimate `sh -c "...intercept"` wrapper above. Still not
|
|
368
|
+
* exhaustive: a trailing `)`, `|`, `&`, or
|
|
369
|
+
* backtick immediately abutting the word (e.g. `$(harness policy
|
|
370
|
+
* intercept)`) is a KNOWN, deliberately-unclosed gap — widening further
|
|
371
|
+
* risks matching inside an unrelated longer token with no whitespace
|
|
372
|
+
* boundary at all, and the shapes above already cover the realistic
|
|
373
|
+
* manifest-authoring patterns this matcher exists for. A false negative
|
|
374
|
+
* here degrades to the pre-existing verbatim behaviour (the hook is
|
|
375
|
+
* simply not classified as ledger-consulting, so the margin check has
|
|
376
|
+
* nothing to say about it) — not a runtime bypass; the dispatch this
|
|
377
|
+
* check advises about is unaffected either way.
|
|
378
|
+
*/
|
|
379
|
+
export function isPolicyInterceptCommand(command) {
|
|
380
|
+
return /(?:^|[\s/\\])policy\s+intercept(?:\s|$|[;"'])/.test(command);
|
|
381
|
+
}
|
|
382
|
+
/**
|
|
383
|
+
* Minimum `budget_ms` a blocking, ledger-consulting hook needs to clear
|
|
384
|
+
* the worst-case wall time `realLedgerClient` (below) can spend before it
|
|
385
|
+
* writes its own fail-closed decision — derived from that function's OWN
|
|
386
|
+
* two round-trip shapes, not a measured-then-eyeballed constant. Both
|
|
387
|
+
* halves are pinned against the real client in
|
|
388
|
+
* tests/e2e/policy-intercept.test.ts ("hook-budget-ledger-margin
|
|
389
|
+
* derivation (task d20a7e0c)").
|
|
390
|
+
*
|
|
391
|
+
* SCOPE, stated precisely (review 2026-08-09, fix round 1): this is the
|
|
392
|
+
* worst case on the PURE-TIMEOUT HANG SHAPE — the shape where the
|
|
393
|
+
* `query()` call that degrades does so by tripping `LedgerSession`'s own
|
|
394
|
+
* session-wide `timedOut` latch (src/policies/ledger-client.ts), which is
|
|
395
|
+
* what lets the retry sequence below start from an INSTANT first
|
|
396
|
+
* `record()` attempt. A query() that instead degrades via a NON-timeout
|
|
397
|
+
* path — `ledger_summary` coming back a genuine JSON-RPC error, an
|
|
398
|
+
* unparseable payload, or a payload missing the `entries` shape
|
|
399
|
+
* (`querySummary`'s non-timeout `degraded` branches in
|
|
400
|
+
* src/policies/ledger-client.ts) — never sets that latch, so it falls
|
|
401
|
+
* OUTSIDE this bound. See the KNOWN RESIDUAL paragraph at the end of this
|
|
402
|
+
* comment; do not read this function, or the `checkHookBudgetLedgerMargin`
|
|
403
|
+
* diagnostic it backs, as a blanket worst-case guarantee.
|
|
404
|
+
*
|
|
405
|
+
* `T` = the ledger's own per-call budget (`health.timeout_ms`, defaulted
|
|
406
|
+
* to 5000 exactly like `realLedgerClient` itself). `R` =
|
|
407
|
+
* `auditRetryTimeoutMs(T)`, the audit-retry session's own per-call budget.
|
|
408
|
+
*
|
|
409
|
+
* 1. query() — up to 2×T. `LedgerSession.querySummary`
|
|
410
|
+
* (src/policies/ledger-client.ts) performs `ensureInitialized()`
|
|
411
|
+
* (the `initialize` handshake, its own T-bounded wait) and, only
|
|
412
|
+
* once THAT resolves, the actual `ledger_summary` `tools/call` (a
|
|
413
|
+
* SEPARATE T-bounded wait). A ledger that is merely SLOW rather than
|
|
414
|
+
* hard-down (the threat class docs/risk-gate.md and this file's own
|
|
415
|
+
* `deny-degraded` diagnostics already name, distinct from an
|
|
416
|
+
* instantly-refusing one) can answer `initialize` just under T and
|
|
417
|
+
* still time out on the following `ledger_summary` — the case a
|
|
418
|
+
* naive "1×T" assumption undercounts.
|
|
419
|
+
*
|
|
420
|
+
* 2. the deny-degraded audit retry — up to 3×R. On the pure-timeout
|
|
421
|
+
* hang shape, the pooled session's `timedOut` latch (tripped by the
|
|
422
|
+
* query()-side timeout itself) makes the FIRST `record()` attempt on
|
|
423
|
+
* it instant (see `realLedgerClient`'s own doc comment above). The
|
|
424
|
+
* ONE reserved retry then opens a FRESH session budgeted at R and
|
|
425
|
+
* calls `recordPolicyDecisionOnSession` (src/runtime/ledger-
|
|
426
|
+
* record.ts), which can spend up to three independent R-bounded
|
|
427
|
+
* round-trips before it can report success or give up: the fresh
|
|
428
|
+
* session's own `initialize` handshake, the first `ledger_add`
|
|
429
|
+
* attempt at `type: "policy_decision"`, and — only reached when that
|
|
430
|
+
* attempt comes back a genuine JSON-RPC error rather than a
|
|
431
|
+
* transport timeout (an older/incompatible grounding-mcp rejecting
|
|
432
|
+
* the newer type) — the legacy `type: "fact"` fallback `ledger_add`.
|
|
433
|
+
*
|
|
434
|
+
* query() must complete (or degrade) before any policy decision — and so
|
|
435
|
+
* any record() — is computed (`intercept()`'s per-policy loop in
|
|
436
|
+
* src/runtime/intercept.ts awaits `evaluateOnePolicy`, which calls
|
|
437
|
+
* `ledger.query`, before it calls `ledger.record`), and the retry only
|
|
438
|
+
* starts after the first record() attempt on the pooled session already
|
|
439
|
+
* failed, so the two stages are SEQUENTIAL within one hook invocation:
|
|
440
|
+
* worst case ON THE PURE-TIMEOUT HANG SHAPE = 2T + 3R.
|
|
441
|
+
*
|
|
442
|
+
* KNOWN RESIDUAL (review 2026-08-09, fix round 1): when `query()` instead
|
|
443
|
+
* degrades via one of the NON-timeout branches named in the SCOPE
|
|
444
|
+
* paragraph above, the pooled session's `timedOut` latch is never set, so
|
|
445
|
+
* `record()`'s own first attempt is a REAL round-trip too — up to T for
|
|
446
|
+
* the `type: "policy_decision"` `ledger_add` attempt, plus another up to
|
|
447
|
+
* T for the legacy `type: "fact"` fallback if that attempt is itself
|
|
448
|
+
* rejected non-timeout — BEFORE the 3R retry sequence even starts. Worst
|
|
449
|
+
* case on this "error-then-hang" shape is therefore up to 4T + 3R, not
|
|
450
|
+
* 2T + 3R: 23750ms at the shipped T=5000 default, against this
|
|
451
|
+
* function's 13750ms. This formula is DELIBERATELY NOT raised to
|
|
452
|
+
* 4T + 3R: doing so would fail `checkHookBudgetLedgerMargin` against the
|
|
453
|
+
* shipped, reviewer-confirmed-clean fresh-init budget of 15000ms
|
|
454
|
+
* (src/cli/init/templates.ts, task 7bf47554/#414 — out of scope here) at
|
|
455
|
+
* the same T=5000 default, since 15000 < 23750. The guard this function
|
|
456
|
+
* backs GUARANTEES delivery of the fail-closed verdict on the
|
|
457
|
+
* pure-timeout hang shape, but NOT on this error-then-hang shape.
|
|
458
|
+
* Unlike an earlier draft of this note, this residual is NOT confined to
|
|
459
|
+
* one runtime projection: Claude Code and Codex both convert `budget_ms`
|
|
460
|
+
* into the outer hook-kill seconds via the identical `hookTimeoutSeconds`
|
|
461
|
+
* helper (src/cli/apply/generate-settings.ts, shared by
|
|
462
|
+
* generate-codex-config.ts) since task 7bf47554/#414, so a manifest at
|
|
463
|
+
* the shipped defaults is equally exposed on both — this was checked
|
|
464
|
+
* against both generators' current source, not assumed.
|
|
465
|
+
*/
|
|
466
|
+
export function requiredHookBudgetMs(ledgerTimeoutMs) {
|
|
467
|
+
return 2 * ledgerTimeoutMs + 3 * auditRetryTimeoutMs(ledgerTimeoutMs);
|
|
468
|
+
}
|
|
469
|
+
function degradedLedgerClient(reason, stderr, hookName) {
|
|
210
470
|
return {
|
|
211
471
|
async query() {
|
|
212
472
|
return { kind: "degraded", reason };
|
|
213
473
|
},
|
|
214
|
-
async record() {
|
|
215
|
-
|
|
474
|
+
async record(decision) {
|
|
475
|
+
// No transport exists, so no audit row can ever land. Stay silent
|
|
476
|
+
// for non-blocking rows (a manifest without grounding-mcp would
|
|
477
|
+
// otherwise emit one line per policy per tool event), but a
|
|
478
|
+
// BLOCKING decision with no audit row must not also be invisible
|
|
479
|
+
// (review 2026-08-08: previously a deny-degraded produced neither
|
|
480
|
+
// a row nor an audit-write-failed line on this path). Blocking-ness
|
|
481
|
+
// is the runtime's own isBlockingDecision — round 2 caught a third
|
|
482
|
+
// hand-rolled copy of that predicate here.
|
|
483
|
+
if (isBlockingDecision(decision)) {
|
|
484
|
+
stderr.write(`harness policy intercept${hookSuffix(hookName)}: blocking decision for ` +
|
|
485
|
+
`${decision.policyName} has NO audit row (${reason})\n`);
|
|
486
|
+
}
|
|
216
487
|
},
|
|
217
488
|
};
|
|
218
489
|
}
|
|
@@ -274,7 +545,7 @@ export async function runInterceptCli(opts = {}) {
|
|
|
274
545
|
const server = findGroundingMcp(manifest);
|
|
275
546
|
ledger = server
|
|
276
547
|
? realLedgerClient(server, opts)
|
|
277
|
-
: degradedLedgerClient("grounding-mcp not declared in manifest");
|
|
548
|
+
: degradedLedgerClient("grounding-mcp not declared in manifest", stderr, opts.hookName);
|
|
278
549
|
}
|
|
279
550
|
// For the SESSION_ID builtin we keep the empty-string fallback rather
|
|
280
551
|
// than routing through resolveSessionId (which lands on "default").
|
|
@@ -454,18 +725,32 @@ export async function runInterceptCli(opts = {}) {
|
|
|
454
725
|
const ampNormalizedCommandThunk = bashCommand === null
|
|
455
726
|
? undefined
|
|
456
727
|
: () => (ampNormalizedCommandCache ??= normalizeCommandAmpAware(bashCommand));
|
|
728
|
+
// Memoised thunk for the quote-aware THIRD normalisation pass (task
|
|
729
|
+
// cf3dff51) — the SAME shape as `ampNormalizedCommandThunk` immediately
|
|
730
|
+
// above, for the same reason: `policyMatchesEvent`'s fourth arm calls
|
|
731
|
+
// this only when a policy's regex has already missed the raw command,
|
|
732
|
+
// `normalizedCommand`, AND the amp-aware pass, so most events never
|
|
733
|
+
// reach it at all; computing it eagerly here would pay its
|
|
734
|
+
// segmentation cost on the common path for every Bash call regardless
|
|
735
|
+
// of whether any policy ends up needing it.
|
|
736
|
+
let quoteNormalizedCommandCache;
|
|
737
|
+
const quoteNormalizedCommandThunk = bashCommand === null
|
|
738
|
+
? undefined
|
|
739
|
+
: () => (quoteNormalizedCommandCache ??= normalizeCommandQuoteAware(bashCommand));
|
|
457
740
|
// Above `MAX_NORMALIZE_LENGTH`, `normalizeCommand` skips normalisation
|
|
458
741
|
// entirely and `truncated` comes back `true`. Raw matching still
|
|
459
742
|
// applies regardless (`policyMatchesEvent`'s raw-OR-normalised-OR-amp-
|
|
460
|
-
// normalised construction), so an oversized command
|
|
461
|
-
// ADDITIONAL normalised-form coverage — but that skip
|
|
462
|
-
// silent, so one stderr line reports it here, keeping the
|
|
463
|
-
// pure and I/O-free. This one line also covers the
|
|
464
|
-
// pass (`normalizeCommandAmpAware`, task `aabbad63`,
|
|
465
|
-
// F6)
|
|
466
|
-
//
|
|
743
|
+
// normalised-OR-quote-normalised construction), so an oversized command
|
|
744
|
+
// only loses the ADDITIONAL normalised-form coverage — but that skip
|
|
745
|
+
// must not be silent, so one stderr line reports it here, keeping the
|
|
746
|
+
// module itself pure and I/O-free. This one line also covers the
|
|
747
|
+
// amp-aware SECOND pass (`normalizeCommandAmpAware`, task `aabbad63`,
|
|
748
|
+
// fix round 1 finding F6) and the quote-aware THIRD pass
|
|
749
|
+
// (`normalizeCommandQuoteAware`, task `cf3dff51`): both share the
|
|
750
|
+
// identical `MAX_NORMALIZE_LENGTH` bound over the identical
|
|
751
|
+
// `bashCommand`, so neither one's own `truncated` can ever disagree
|
|
467
752
|
// with `normalizedCommand`'s for this call — no separate stderr line
|
|
468
|
-
// names
|
|
753
|
+
// names either pass's own skip, nor does one need to.
|
|
469
754
|
if (normalizedCommand?.truncated === true) {
|
|
470
755
|
stderr.write(`harness policy intercept${hookSuffix(opts.hookName)}: Bash command exceeds ${MAX_NORMALIZE_LENGTH} chars; normalised-form matching skipped for this call (raw match only)\n`);
|
|
471
756
|
}
|
|
@@ -488,21 +773,23 @@ export async function runInterceptCli(opts = {}) {
|
|
|
488
773
|
namespace: opts.kubeNamespace ?? "",
|
|
489
774
|
}
|
|
490
775
|
: resolveKubeContext();
|
|
491
|
-
//
|
|
492
|
-
//
|
|
493
|
-
// `
|
|
494
|
-
//
|
|
495
|
-
//
|
|
496
|
-
//
|
|
497
|
-
//
|
|
498
|
-
//
|
|
499
|
-
// the
|
|
500
|
-
//
|
|
501
|
-
//
|
|
502
|
-
//
|
|
503
|
-
//
|
|
504
|
-
//
|
|
505
|
-
// `
|
|
776
|
+
// Three POSIX Bash idioms (`VAR=value command`, `cd <path> &&
|
|
777
|
+
// command`, and — since task 341e024b — `git switch|checkout
|
|
778
|
+
// <branch> && command`) were invisible to the resolver before: the
|
|
779
|
+
// env resolver read `process.env` and the branch resolver read
|
|
780
|
+
// `.git/HEAD` under the hook's starting cwd, so a prod signal
|
|
781
|
+
// smuggled through any of the three idioms silently passed the
|
|
782
|
+
// gate. Parse the leading prefix once from the Bash command and
|
|
783
|
+
// merge the result into the resolver inputs only. ${CWD} always
|
|
784
|
+
// names the hook's own cwd; ${REPO}/${BRANCH} (`cwdGitContext`
|
|
785
|
+
// above, feeding `builtins`) are cwd-only too — see the comment
|
|
786
|
+
// above `cwdGitContext`'s declaration. The Risk Gate resolver's git
|
|
787
|
+
// base, `resolverGit` below, is its own independent narrow parse: it
|
|
788
|
+
// recognises only a leading `cd`, never `git -C` / `--work-tree` /
|
|
789
|
+
// `--git-dir` / `env -C`. Bottom line: `resolverGit` must never read
|
|
790
|
+
// anything other than `cwdGitContext`, or the leading-`cd` target
|
|
791
|
+
// within THIS same narrow parse — this feeds `when:`-clause risk
|
|
792
|
+
// classification, not ledger-tag namespacing.
|
|
506
793
|
//
|
|
507
794
|
// G5 (review round 2, 2026-07-27): that leading-`cd` parse is itself
|
|
508
795
|
// still a live declassification lever, PRE-EXISTING and NOT changed
|
|
@@ -519,7 +806,13 @@ export async function runInterceptCli(opts = {}) {
|
|
|
519
806
|
// branch, as in the example above) — `resolverGit` trusts whichever
|
|
520
807
|
// of the two the command happens to name, with no independent check
|
|
521
808
|
// that the `cd` target is the repo the destructive command actually
|
|
522
|
-
// runs against.
|
|
809
|
+
// runs against. The `git switch`/`checkout` merge added below
|
|
810
|
+
// (`gitForRisk`, via `applyBranchSwitchUpgrade`) deliberately does
|
|
811
|
+
// NOT share this bidirectional-risk shape: it is a SEPARATE,
|
|
812
|
+
// upgrade-only step layered on top of `resolverGit`, not a change to
|
|
813
|
+
// `resolverGit` itself — a switch away from a production branch can
|
|
814
|
+
// never downgrade what `resolverGit` (cwd- or cd-based) already
|
|
815
|
+
// resolved.
|
|
523
816
|
const bashPrefix = event.tool_name === "Bash"
|
|
524
817
|
? (() => {
|
|
525
818
|
const cmd = readBashCommand(event.tool_input);
|
|
@@ -545,11 +838,18 @@ export async function runInterceptCli(opts = {}) {
|
|
|
545
838
|
// win over process.env (matches POSIX `VAR=value cmd` semantics).
|
|
546
839
|
return { ...base, ...bashPrefix.inlineEnv };
|
|
547
840
|
})();
|
|
841
|
+
const riskUser = safeOs(() => os.userInfo().username);
|
|
842
|
+
const riskHost = safeOs(() => os.hostname());
|
|
843
|
+
// A leading `git switch`/`checkout <branch>` (task 341e024b) is
|
|
844
|
+
// merged on top of `resolverGit` above — upgrade-only, see
|
|
845
|
+
// `applyBranchSwitchUpgrade`'s own doc comment for why this one is
|
|
846
|
+
// NOT a straight replacement the way the `cd` merge is.
|
|
847
|
+
const gitForRisk = applyBranchSwitchUpgrade(event, manifest, cwd, resolverGit, bashPrefix?.branchTarget ?? null, { env: resolverEnv, kubeContext: kube.context, kubeNamespace: kube.namespace }, riskUser, riskHost, opts.now);
|
|
548
848
|
riskContext = {
|
|
549
|
-
git:
|
|
849
|
+
git: gitForRisk,
|
|
550
850
|
cwd,
|
|
551
|
-
user:
|
|
552
|
-
host:
|
|
851
|
+
user: riskUser,
|
|
852
|
+
host: riskHost,
|
|
553
853
|
env: resolverEnv,
|
|
554
854
|
kubeContext: kube.context,
|
|
555
855
|
kubeNamespace: kube.namespace,
|
|
@@ -569,6 +869,7 @@ export async function runInterceptCli(opts = {}) {
|
|
|
569
869
|
...(riskContext && { riskContext }),
|
|
570
870
|
...(normalizedCommand && { normalizedCommand }),
|
|
571
871
|
...(ampNormalizedCommandThunk && { ampNormalizedCommandThunk }),
|
|
872
|
+
...(quoteNormalizedCommandThunk && { quoteNormalizedCommandThunk }),
|
|
572
873
|
...(commandSegmentsThunk && { commandSegmentsThunk }),
|
|
573
874
|
...(process.env.HARNESS_REPO !== undefined && { repoOverridden: true }),
|
|
574
875
|
...(process.env.HARNESS_BRANCH !== undefined && { branchOverridden: true }),
|
|
@@ -586,14 +887,18 @@ export async function runInterceptCli(opts = {}) {
|
|
|
586
887
|
// the producer side knows the live session id (it just received it on
|
|
587
888
|
// the hook event), but `harness approve risk` runs from the operator's
|
|
588
889
|
// `!`-shell where `$CLAUDE_SESSION_ID` is unset, so it has to read the
|
|
589
|
-
// marker. `deny` decisions are deliberately not
|
|
590
|
-
// risk` cannot unblock a
|
|
591
|
-
//
|
|
592
|
-
//
|
|
593
|
-
//
|
|
594
|
-
//
|
|
595
|
-
//
|
|
596
|
-
//
|
|
890
|
+
// marker. `deny` and `deny-degraded` decisions are deliberately not
|
|
891
|
+
// staged: `harness approve risk` cannot unblock either (a deny is a
|
|
892
|
+
// real verdict, a deny-degraded means the evidence source is
|
|
893
|
+
// unreadable, so no approval tag could even be read), and writing a
|
|
894
|
+
// marker the verb cannot act on would just lie about the
|
|
895
|
+
// recoverability of the block. The "first blocking decision" check
|
|
896
|
+
// (rather than `.some()`) matters when two `when:`-bearing policies
|
|
897
|
+
// fire on the same event: the runtime's `intercept()` picks the first
|
|
898
|
+
// blocking decision (its `isBlockingDecision`, imported here so the
|
|
899
|
+
// two surfaces cannot drift — review 2026-08-08), so a blocking-first
|
|
900
|
+
// / `require_approval`-second order produces an unrecoverable block;
|
|
901
|
+
// we mustn't stage in that case either.
|
|
597
902
|
//
|
|
598
903
|
// Best-effort: a staging-write failure must never escalate a gate block
|
|
599
904
|
// into a thrown hook error. `resolveGeneratedDir` is skipped (along
|
|
@@ -601,8 +906,7 @@ export async function runInterceptCli(opts = {}) {
|
|
|
601
906
|
// exercise the policy logic with injected manifests and no on-disk
|
|
602
907
|
// path, and the test path is also the only place an empty
|
|
603
908
|
// `eventSessionId` is interesting.
|
|
604
|
-
const firstBlocking = result.decisions.find(
|
|
605
|
-
d.outcome === "require_approval");
|
|
909
|
+
const firstBlocking = result.decisions.find(isBlockingDecision);
|
|
606
910
|
if (result.blockJson &&
|
|
607
911
|
eventSessionId !== undefined &&
|
|
608
912
|
eventSessionId.length > 0 &&
|
|
@@ -637,6 +941,33 @@ export async function runInterceptCli(opts = {}) {
|
|
|
637
941
|
if (result.decisions.length === 0 && manifest.policies.length > 0) {
|
|
638
942
|
stderr.write(formatNoMatchHint(event, manifest, opts.hookName));
|
|
639
943
|
}
|
|
944
|
+
// Degraded-deny operator hint. Default-verbosity, ONE line per event:
|
|
945
|
+
// the envelope deliberately tells the agent only to involve the
|
|
946
|
+
// operator, and the full diagnostic (which names the fail_open
|
|
947
|
+
// opt-out) is verbose-only — without this line an operator whose
|
|
948
|
+
// fleet wedges on a degraded ledger has no default-verbosity pointer
|
|
949
|
+
// at all (review 2026-08-08, round 2). Two deliberate properties:
|
|
950
|
+
// - It carries the decision's OWN reason (sanitised: it can embed
|
|
951
|
+
// grounding-mcp subprocess output) instead of asserting a ledger
|
|
952
|
+
// fault — deny-degraded has five causes and only one of them is
|
|
953
|
+
// the ledger; a hardcoded "ledger unreadable" sent operators of
|
|
954
|
+
// the other four down a false doctor-path (round 3, medium).
|
|
955
|
+
// - It does NOT name the opt-out knob: hook stderr can be surfaced
|
|
956
|
+
// to the model in some harness configurations, so the default
|
|
957
|
+
// channel points at the operator docs and the verbose diagnostic
|
|
958
|
+
// instead of carrying the disable recipe (deviation from round
|
|
959
|
+
// 2's suggested fix, recorded in the run's 03-decisions.md).
|
|
960
|
+
// Suppressed under --verbose, where formatDecisionDiagnostic already
|
|
961
|
+
// prints the same reason with more context.
|
|
962
|
+
const firstDegradedDeny = result.decisions.find((d) => d.outcome === "deny-degraded");
|
|
963
|
+
if (firstDegradedDeny && !verbose) {
|
|
964
|
+
stderr.write(`harness policy intercept${hookSuffix(opts.hookName)}: ${firstDegradedDeny.policyName}: ` +
|
|
965
|
+
`deny-degraded (evidence could not be evaluated: ` +
|
|
966
|
+
`${sanitizeEnvelopeReason(firstDegradedDeny.reason)}); failing closed. ` +
|
|
967
|
+
`Operator recovery: check the reason above, then harness doctor if it names ` +
|
|
968
|
+
`the ledger; details and the availability opt-out: docs/risk-gate.md ` +
|
|
969
|
+
`("Degraded mode") or rerun with HARNESS_POLICY_VERBOSE=1\n`);
|
|
970
|
+
}
|
|
640
971
|
if (verbose) {
|
|
641
972
|
for (const decision of result.decisions) {
|
|
642
973
|
if (decision.outcome === "allow")
|