@lannguyensi/harness 0.43.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 +1248 -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 +34 -13
- 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 +62 -7
- 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 +136 -24
- 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 +44 -26
- 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 +538 -79
- 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-runtime.d.ts +90 -10
- package/dist/policy-packs/builtin/post-merge-gate-runtime.js +112 -16
- package/dist/policy-packs/builtin/post-merge-gate-runtime.js.map +1 -1
- package/dist/policy-packs/builtin/post-merge-gate.js +84 -31
- package/dist/policy-packs/builtin/post-merge-gate.js.map +1 -1
- package/dist/policy-packs/builtin/solution-acceptance-runtime.d.ts +152 -8
- package/dist/policy-packs/builtin/solution-acceptance-runtime.js +260 -13
- 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 +429 -4
- package/dist/runtime/command-normalize.js +1146 -67
- 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 +247 -4
- package/dist/runtime/intercept.js +553 -61
- 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 +356 -26
- package/dist/runtime/read-only-bash.js.map +1 -1
- package/dist/runtime/shell-word.d.ts +21 -0
- package/dist/runtime/shell-word.js +242 -0
- package/dist/runtime/shell-word.js.map +1 -0
- 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 +5 -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
|
@@ -5,13 +5,16 @@
|
|
|
5
5
|
// the decisions and the Claude Code deny-JSON (or null when all allow).
|
|
6
6
|
// Side effects (stdin, stdout, ledger I/O) live in the thin CLI entrypoint
|
|
7
7
|
// that wraps this.
|
|
8
|
+
import * as fs from "node:fs";
|
|
9
|
+
import * as path from "node:path";
|
|
8
10
|
import { evaluateExtract, evaluateRequires, parseDurationSeconds, substituteTemplate, } from "../policies/index.js";
|
|
9
11
|
import { renderProducers } from "../policies/producers.js";
|
|
10
12
|
import { buildActionEnvelope } from "./action-envelope.js";
|
|
11
13
|
import { renderAgentFacing } from "./agent-facing.js";
|
|
12
|
-
import { normalizeCommand } from "./command-normalize.js";
|
|
14
|
+
import { normalizeCommand, normalizeCommandAmpAware, normalizeCommandQuoteAware, segmentViewOf, } from "./command-normalize.js";
|
|
13
15
|
import { resolveEnvironment, } from "./environment-resolver.js";
|
|
14
|
-
import {
|
|
16
|
+
import { resolveGitContext } from "./git-context.js";
|
|
17
|
+
import { POLICY_DECISION_TYPE } from "../io/ledger-record.js";
|
|
15
18
|
import { classifyRisk } from "./risk-classifier.js";
|
|
16
19
|
import { resolveSessionId } from "./session-id.js";
|
|
17
20
|
import { expandToolNameAliases, extractShellCommand, } from "./tool-name-aliases.js";
|
|
@@ -57,8 +60,22 @@ function enrichEnvelope(manifest, event, riskContext, now) {
|
|
|
57
60
|
* tests), which fall back to computing it lazily right here — correct
|
|
58
61
|
* either way, since `normalizeCommand` is a pure function of the
|
|
59
62
|
* command string alone.
|
|
63
|
+
*
|
|
64
|
+
* `ampNormalizedCommandThunk` (task aabbad63) is the SAME
|
|
65
|
+
* resolved-by-the-wrapper pattern for the ampersand-aware SECOND
|
|
66
|
+
* normalisation pass, but threaded as a memoised THUNK rather than a
|
|
67
|
+
* precomputed value — see `InterceptOptions.ampNormalizedCommandThunk`
|
|
68
|
+
* for why laziness matters here specifically. Omitted callers fall back
|
|
69
|
+
* to calling `normalizeCommandAmpAware` directly, same shape as the
|
|
70
|
+
* `precomputedNormalizedCommand` fallback above.
|
|
71
|
+
*
|
|
72
|
+
* `quoteNormalizedCommandThunk` (task cf3dff51) is the SAME thunk pattern
|
|
73
|
+
* again, for the quote-aware THIRD normalisation pass — see
|
|
74
|
+
* `InterceptOptions.quoteNormalizedCommandThunk`. Omitted callers fall
|
|
75
|
+
* back to calling `normalizeCommandQuoteAware` directly, same shape as
|
|
76
|
+
* the two fallbacks above.
|
|
60
77
|
*/
|
|
61
|
-
export function policyMatchesEvent(policy, event, precomputedNormalizedCommand) {
|
|
78
|
+
export function policyMatchesEvent(policy, event, precomputedNormalizedCommand, ampNormalizedCommandThunk, quoteNormalizedCommandThunk) {
|
|
62
79
|
if (policy.trigger.event !== event.hook_event_name)
|
|
63
80
|
return false;
|
|
64
81
|
if (policy.trigger.match !== undefined) {
|
|
@@ -80,23 +97,43 @@ export function policyMatchesEvent(policy, event, precomputedNormalizedCommand)
|
|
|
80
97
|
catch {
|
|
81
98
|
return false;
|
|
82
99
|
}
|
|
83
|
-
// Raw-OR-normalised (D-003, run
|
|
84
|
-
// resolution
|
|
85
|
-
//
|
|
86
|
-
//
|
|
87
|
-
//
|
|
88
|
-
//
|
|
89
|
-
//
|
|
90
|
-
//
|
|
91
|
-
//
|
|
92
|
-
//
|
|
93
|
-
//
|
|
94
|
-
//
|
|
95
|
-
//
|
|
100
|
+
// Raw-OR-normalised-OR-amp-normalised-OR-quote-normalised (D-003, run
|
|
101
|
+
// 2026-07-27-gate-target-repo-resolution; third arm added task
|
|
102
|
+
// aabbad63; fourth arm added task cf3dff51): test the RAW command
|
|
103
|
+
// first — cheap, and byte-identical to the pre-fix behaviour — then,
|
|
104
|
+
// only if that fails, the primary NORMALISED command (wrapper
|
|
105
|
+
// prefixes peeled, git global options dropped, whitespace collapsed,
|
|
106
|
+
// BOUNDARY_RE segmentation) — then, only if THAT also fails, the
|
|
107
|
+
// ampersand-aware second pass (AMP_BOUNDARY_RE segmentation, closing
|
|
108
|
+
// the bare-`&` family BOUNDARY_RE cannot see: `A=x&env -C /tmp git
|
|
109
|
+
// status`, `echo hi & nice git status`) — then, only if THAT also
|
|
110
|
+
// fails, the quote-aware third pass (BOUNDARY_RE's own alphabet, but
|
|
111
|
+
// quote-tracking, closing a shell-boundary character sitting INSIDE a
|
|
112
|
+
// quoted assignment value: `VAR='a; b' git push origin master`).
|
|
113
|
+
// Strictly additive at every step: a command that matched today keeps
|
|
114
|
+
// matching via the raw test alone; the primary normalised form can
|
|
115
|
+
// only ADD a match (env/nice/command wrappers, extra git global
|
|
116
|
+
// options, doubled whitespace); the amp-aware form can only add a
|
|
117
|
+
// FURTHER match on top of those two; the quote-aware form can only
|
|
118
|
+
// add a FOURTH match on top of all three, never remove one any of the
|
|
119
|
+
// others already found. Replacing the matcher input instead of OR-ing
|
|
120
|
+
// it in risks silently REMOVING an existing match if some pass ever
|
|
121
|
+
// mangles a shape — the fail-open direction for a gate — so this
|
|
122
|
+
// stays additive rather than a substitution at every arm.
|
|
96
123
|
if (!re.test(command)) {
|
|
97
124
|
const { normalized } = precomputedNormalizedCommand ?? normalizeCommand(command);
|
|
98
|
-
if (!re.test(normalized))
|
|
99
|
-
|
|
125
|
+
if (!re.test(normalized)) {
|
|
126
|
+
const amp = ampNormalizedCommandThunk
|
|
127
|
+
? ampNormalizedCommandThunk()
|
|
128
|
+
: normalizeCommandAmpAware(command);
|
|
129
|
+
if (!re.test(amp.normalized)) {
|
|
130
|
+
const quoted = quoteNormalizedCommandThunk
|
|
131
|
+
? quoteNormalizedCommandThunk()
|
|
132
|
+
: normalizeCommandQuoteAware(command);
|
|
133
|
+
if (!re.test(quoted.normalized))
|
|
134
|
+
return false;
|
|
135
|
+
}
|
|
136
|
+
}
|
|
100
137
|
}
|
|
101
138
|
}
|
|
102
139
|
return true;
|
|
@@ -131,13 +168,79 @@ function outcomeForFailedRequires(enforcement) {
|
|
|
131
168
|
* `requires:` (an operator runs `harness approve risk`); once the
|
|
132
169
|
* tag is on record the requires evaluation passes and the outcome
|
|
133
170
|
* is `allow` instead.
|
|
171
|
+
* - `deny-degraded` aborts (task f1aea826): it is only ever produced
|
|
172
|
+
* for `block` / `require_approval` policies under the default
|
|
173
|
+
* `preserve_enforcement` posture (see `degradedOutcome`), so its
|
|
174
|
+
* presence alone means "an incident-preventing gate could not read
|
|
175
|
+
* its evidence" — fail closed.
|
|
134
176
|
* - `allow` / `warn` / `warn-degraded` never abort.
|
|
177
|
+
*
|
|
178
|
+
* Exported for the CLI wrapper's pending-approval staging
|
|
179
|
+
* (`src/cli/policy/intercept.ts`), which must agree with the runtime on
|
|
180
|
+
* WHICH decision is the first blocking one — a hand-rolled copy there
|
|
181
|
+
* drifted when `deny-degraded` was added (review 2026-08-08, low
|
|
182
|
+
* finding). Keep this the single definition.
|
|
135
183
|
*/
|
|
136
|
-
function isBlockingDecision(d) {
|
|
184
|
+
export function isBlockingDecision(d) {
|
|
137
185
|
if (d.outcome === "deny")
|
|
138
186
|
return d.enforcement === "block";
|
|
187
|
+
if (d.outcome === "deny-degraded")
|
|
188
|
+
return true;
|
|
139
189
|
return d.outcome === "require_approval";
|
|
140
190
|
}
|
|
191
|
+
/**
|
|
192
|
+
* Outcome for a policy whose `requires` could NOT be evaluated at all —
|
|
193
|
+
* degraded/thrown ledger query, unresolved template variables, invalid
|
|
194
|
+
* `within`, thrown `evaluateRequires`, or the defensive
|
|
195
|
+
* schema-invariant branch. The fail posture is derived from the
|
|
196
|
+
* policy's own `enforcement:` (task f1aea826):
|
|
197
|
+
*
|
|
198
|
+
* warn → `warn-degraded` (availability first,
|
|
199
|
+
* unchanged: advisory friction never
|
|
200
|
+
* bricks the session)
|
|
201
|
+
* block / require_approval → `deny-degraded` (fail closed: a gate
|
|
202
|
+
* against an irreversible incident must
|
|
203
|
+
* not open because its evidence source
|
|
204
|
+
* is unreadable)
|
|
205
|
+
*
|
|
206
|
+
* The manifest-level opt-out `risk.degraded_fail_posture: fail_open`
|
|
207
|
+
* restores the pre-0.45 availability-first mapping (`warn-degraded`
|
|
208
|
+
* for every tier). The hand-built-manifest case (`options.manifest.risk`
|
|
209
|
+
* absent, only possible past `harness validate`) defaults to the
|
|
210
|
+
* fail-closed posture, matching the schema default.
|
|
211
|
+
*/
|
|
212
|
+
function degradedOutcome(policy, manifest) {
|
|
213
|
+
if (policy.enforcement === "warn")
|
|
214
|
+
return "warn-degraded";
|
|
215
|
+
const posture = manifest.risk?.degraded_fail_posture ?? "preserve_enforcement";
|
|
216
|
+
return posture === "fail_open" ? "warn-degraded" : "deny-degraded";
|
|
217
|
+
}
|
|
218
|
+
/**
|
|
219
|
+
* Control characters (C0 range plus DEL), built via fromCharCode so the
|
|
220
|
+
* source file itself stays free of raw control bytes. Matches what the
|
|
221
|
+
* envelope sanitiser collapses to a single space.
|
|
222
|
+
*/
|
|
223
|
+
const ENVELOPE_CONTROL_CHARS = new RegExp(`[${String.fromCharCode(0)}-${String.fromCharCode(31)}${String.fromCharCode(127)}]+`, "g");
|
|
224
|
+
/**
|
|
225
|
+
* Bound and clean a transport-level reason before it is interpolated
|
|
226
|
+
* into the agent-facing deny-degraded envelope. The string can embed
|
|
227
|
+
* output captured from the grounding-mcp SUBPROCESS (`exitDiagnostic`
|
|
228
|
+
* in `src/policies/ledger-client.ts` appends the child's last stderr
|
|
229
|
+
* line), i.e. untrusted content that previously only reached stderr
|
|
230
|
+
* and the audit ledger, never model-visible text. The full, untouched
|
|
231
|
+
* string still goes to the audit row and the verbose diagnostic; only
|
|
232
|
+
* the envelope interpolation and the default-verbosity operator hint
|
|
233
|
+
* are bounded.
|
|
234
|
+
*
|
|
235
|
+
* Exported for the CLI wrapper's deny-degraded operator hint (review
|
|
236
|
+
* 2026-08-08, round 3; default-verbosity only, suppressed under
|
|
237
|
+
* verbose), which interpolates the same untrusted reason into its
|
|
238
|
+
* one-line stderr surface.
|
|
239
|
+
*/
|
|
240
|
+
export function sanitizeEnvelopeReason(reason) {
|
|
241
|
+
const stripped = reason.replace(ENVELOPE_CONTROL_CHARS, " ");
|
|
242
|
+
return stripped.length > 200 ? `${stripped.slice(0, 200)}...` : stripped;
|
|
243
|
+
}
|
|
141
244
|
/**
|
|
142
245
|
* Placeholder `ledgerTag` recorded on an `operator_only` decision (and on
|
|
143
246
|
* the defensive schema-invariant-violated branch below). Both outcomes
|
|
@@ -178,21 +281,23 @@ async function evaluateOnePolicy(policy, options) {
|
|
|
178
281
|
// Defensive branch only, for a manifest that bypassed
|
|
179
282
|
// `harness validate` (hand-built Policy object, stale cached parse).
|
|
180
283
|
//
|
|
181
|
-
// Deliberately
|
|
182
|
-
//
|
|
183
|
-
//
|
|
184
|
-
//
|
|
185
|
-
//
|
|
186
|
-
//
|
|
187
|
-
//
|
|
188
|
-
//
|
|
189
|
-
//
|
|
190
|
-
//
|
|
191
|
-
//
|
|
284
|
+
// Deliberately NOT a plain `deny`: this is the SAME "could not
|
|
285
|
+
// decide" family as the other degraded returns in this function
|
|
286
|
+
// (unresolved template variables, a degraded ledger query, an
|
|
287
|
+
// invalid `within`, a throwing `evaluateRequires`) — the contract
|
|
288
|
+
// for all of them is "the evaluator could not form a real verdict",
|
|
289
|
+
// routed through `degradedOutcome` so the policy's own enforcement
|
|
290
|
+
// decides the fail posture (task f1aea826): `warn` stays the
|
|
291
|
+
// non-blocking `warn-degraded`, `block`/`require_approval` fail
|
|
292
|
+
// closed as `deny-degraded`. Failing this branch as plain `deny`
|
|
293
|
+
// would treat an internal schema-invariant violation as if it were
|
|
294
|
+
// a deliberate `operator_only: true` policy authored by the
|
|
295
|
+
// operator, which it is not — `deny-degraded` keeps the two
|
|
296
|
+
// observably distinct in every audit row and envelope.
|
|
192
297
|
return {
|
|
193
298
|
policyName: policy.name,
|
|
194
299
|
enforcement: policy.enforcement,
|
|
195
|
-
outcome:
|
|
300
|
+
outcome: degradedOutcome(policy, options.manifest),
|
|
196
301
|
reason: "policy declares neither requires: nor operator_only: true (schema invariant violated)",
|
|
197
302
|
extractValues: extract.values,
|
|
198
303
|
ledgerTag: NO_LEDGER_TAG,
|
|
@@ -209,7 +314,7 @@ async function evaluateOnePolicy(policy, options) {
|
|
|
209
314
|
return {
|
|
210
315
|
policyName: policy.name,
|
|
211
316
|
enforcement: policy.enforcement,
|
|
212
|
-
outcome:
|
|
317
|
+
outcome: degradedOutcome(policy, options.manifest),
|
|
213
318
|
reason: `template variables unresolved: ${unresolved.join(", ")}`,
|
|
214
319
|
extractValues: extract.values,
|
|
215
320
|
ledgerTag,
|
|
@@ -231,7 +336,7 @@ async function evaluateOnePolicy(policy, options) {
|
|
|
231
336
|
return {
|
|
232
337
|
policyName: policy.name,
|
|
233
338
|
enforcement: policy.enforcement,
|
|
234
|
-
outcome:
|
|
339
|
+
outcome: degradedOutcome(policy, options.manifest),
|
|
235
340
|
reason: queryResult.reason,
|
|
236
341
|
extractValues: extract.values,
|
|
237
342
|
ledgerTag,
|
|
@@ -248,7 +353,7 @@ async function evaluateOnePolicy(policy, options) {
|
|
|
248
353
|
return {
|
|
249
354
|
policyName: policy.name,
|
|
250
355
|
enforcement: policy.enforcement,
|
|
251
|
-
outcome:
|
|
356
|
+
outcome: degradedOutcome(policy, options.manifest),
|
|
252
357
|
reason: `invalid within: ${requires.within}`,
|
|
253
358
|
extractValues: extract.values,
|
|
254
359
|
ledgerTag,
|
|
@@ -272,7 +377,7 @@ async function evaluateOnePolicy(policy, options) {
|
|
|
272
377
|
return {
|
|
273
378
|
policyName: policy.name,
|
|
274
379
|
enforcement: policy.enforcement,
|
|
275
|
-
outcome:
|
|
380
|
+
outcome: degradedOutcome(policy, options.manifest),
|
|
276
381
|
reason: `requires eval threw: ${err.message}`,
|
|
277
382
|
extractValues: extract.values,
|
|
278
383
|
ledgerTag,
|
|
@@ -323,6 +428,325 @@ function filterEntriesByTag(entries, tag) {
|
|
|
323
428
|
(e.content.includes(tag) ||
|
|
324
429
|
(e.source !== undefined && e.source.includes(tag))));
|
|
325
430
|
}
|
|
431
|
+
/**
|
|
432
|
+
* Which of a policy's own trigger-satisfying segments name a target — the
|
|
433
|
+
* attribution sibling of `policyMatchesEvent` (task `98ad072f`, T-003).
|
|
434
|
+
* Re-tests the policy's OWN `bash_match` regex against each segment's
|
|
435
|
+
* `text` in isolation: every shipped `bash_match` alternation includes a
|
|
436
|
+
* `^` branch, so testing it against one segment's canonicalised text
|
|
437
|
+
* alone (no leading boundary character — see `CommandSegment.text`'s own
|
|
438
|
+
* doc comment) is well-defined. Returns every segment that matches on its
|
|
439
|
+
* own — usually one, occasionally several (D-004: a decoy read and a cwd
|
|
440
|
+
* read chained in one command can each independently satisfy the SAME
|
|
441
|
+
* trigger).
|
|
442
|
+
*
|
|
443
|
+
* Never changes WHETHER a policy matches — `policyMatchesEvent` already
|
|
444
|
+
* decided that from the WHOLE command (raw-or-normalised-or-amp-
|
|
445
|
+
* normalised), unchanged by this function. This only narrows down WHICH
|
|
446
|
+
* segment(s), if any, are individually responsible for the match, so
|
|
447
|
+
* `intercept()` below knows which segment's target — if any — to resolve
|
|
448
|
+
* the `${REPO}`/`${BRANCH}`/`currentHeadSha` builtins from instead of the
|
|
449
|
+
* event's own cwd. `[]` when the policy has no `bash_match` trigger (an
|
|
450
|
+
* MCP-tool-name-triggered policy — no segment concept applies), when its
|
|
451
|
+
* regex is malformed (mirrors `policyMatchesEvent`'s own defensive
|
|
452
|
+
* `try/catch`), or when the whole-command match came ONLY from the
|
|
453
|
+
* ampersand-aware third arm: `segments` here is always built from the
|
|
454
|
+
* PRIMARY (`BOUNDARY_RE`) segmentation, which — by construction — cannot
|
|
455
|
+
* itself contain the bare-`&` split the amp arm relies on, so an amp-
|
|
456
|
+
* only match finds no individually-matching segment and this returns
|
|
457
|
+
* `[]` (D-003: cwd builtins, identical to shipped).
|
|
458
|
+
*/
|
|
459
|
+
export function attributeTriggerSegments(policy, segments) {
|
|
460
|
+
if (policy.trigger.bash_match === undefined)
|
|
461
|
+
return [];
|
|
462
|
+
let re;
|
|
463
|
+
try {
|
|
464
|
+
re = new RegExp(policy.trigger.bash_match);
|
|
465
|
+
}
|
|
466
|
+
catch {
|
|
467
|
+
return [];
|
|
468
|
+
}
|
|
469
|
+
return segments.filter((seg) => re.test(seg.text));
|
|
470
|
+
}
|
|
471
|
+
/**
|
|
472
|
+
* Does a policy's `requires:` reference the per-repo `${REPO}`/`${BRANCH}`
|
|
473
|
+
* builtins, or ask for `at_head`? Only these policies pay the per-policy
|
|
474
|
+
* attribution cost below — every other matching policy keeps the plain,
|
|
475
|
+
* per-event cwd builtins `options.builtins` already carries, byte-
|
|
476
|
+
* identical to before this task. D-005: `at_head` is included even when
|
|
477
|
+
* `ledger_tag` itself doesn't reference `${REPO}`/`${BRANCH}`, because
|
|
478
|
+
* `at_head` compares against `currentHeadSha`, which this task resolves
|
|
479
|
+
* from the SAME per-policy context — splitting the two would check
|
|
480
|
+
* `at_head` against the cwd's HEAD while `${REPO}` (if present elsewhere)
|
|
481
|
+
* named a different repo, the worse inconsistency D-005 rejects.
|
|
482
|
+
*/
|
|
483
|
+
function usesPerRepoBuiltins(policy) {
|
|
484
|
+
const requires = policy.requires;
|
|
485
|
+
if (requires === undefined)
|
|
486
|
+
return false;
|
|
487
|
+
return (requires.at_head === true ||
|
|
488
|
+
requires.ledger_tag.includes("${REPO}") ||
|
|
489
|
+
requires.ledger_tag.includes("${BRANCH}"));
|
|
490
|
+
}
|
|
491
|
+
/**
|
|
492
|
+
* Bound on the number of DISTINCT attributed contexts one policy is
|
|
493
|
+
* evaluated against for one event (D-013, fix round, run
|
|
494
|
+
* 2026-08-02-per-repo-gate-scoping-redesign). Each distinct context costs
|
|
495
|
+
* one ledger query and one audit write in `intercept()`'s evaluation loop
|
|
496
|
+
* below; on a manifest with several per-repo-builtins policies (the
|
|
497
|
+
* shipped `FULL_TEMPLATE` has four), an event naming K distinct targets
|
|
498
|
+
* amplifies to 4K queries/writes, unbounded by `MAX_NORMALIZE_LENGTH` —
|
|
499
|
+
* measured 200/200 at K=200 by reviewer 2, on a hook budget whose timeout
|
|
500
|
+
* is ALLOW (same class as the 07-27 quadratic hot-path fail-open: a slow
|
|
501
|
+
* enough event silently passes). `resolveAttributedContexts` returns a
|
|
502
|
+
* `"bounded"` result instead of a `contexts` array once a policy's DISTINCT
|
|
503
|
+
* targets would exceed this constant; `intercept()` denies that policy
|
|
504
|
+
* directly (naming the ambiguity) without querying the ledger for any of
|
|
505
|
+
* them, rather than silently evaluating all of them.
|
|
506
|
+
*/
|
|
507
|
+
export const MAX_ATTRIBUTED_CONTEXTS = 4;
|
|
508
|
+
/**
|
|
509
|
+
* Realpath a path for identity comparison, never throwing (D-012, fix
|
|
510
|
+
* round, run 2026-08-02-per-repo-gate-scoping-redesign). Falls back to the
|
|
511
|
+
* LEXICAL path unchanged when the target does not exist or is otherwise
|
|
512
|
+
* unreadable (`fs.realpathSync.native` throws `ENOENT` for a path this
|
|
513
|
+
* module was never guaranteed to have on disk, and this function must
|
|
514
|
+
* never throw — same fail-safe posture as `resolveGitContext` itself,
|
|
515
|
+
* which returns empty strings rather than throwing on a bad path).
|
|
516
|
+
* `.native` (not the plain JS `fs.realpathSync`) resolves via the OS
|
|
517
|
+
* syscall directly — cheaper, and avoids Node's own pure-JS symlink-loop
|
|
518
|
+
* bookkeeping for a value this function only uses for an identity
|
|
519
|
+
* comparison, never for a filesystem walk of its own.
|
|
520
|
+
*/
|
|
521
|
+
function realpathOrSelf(p) {
|
|
522
|
+
try {
|
|
523
|
+
return fs.realpathSync.native(p);
|
|
524
|
+
}
|
|
525
|
+
catch {
|
|
526
|
+
return p;
|
|
527
|
+
}
|
|
528
|
+
}
|
|
529
|
+
/**
|
|
530
|
+
* Resolve the distinct `${REPO}`/`${BRANCH}`/`currentHeadSha` contexts a
|
|
531
|
+
* `usesPerRepoBuiltins` policy must be evaluated against (task `98ad072f`,
|
|
532
|
+
* T-003, `01-plan.md` Proposed Approach items 2-4). The engine trusts a
|
|
533
|
+
* trigger-satisfying segment's `effectiveTarget` UNIFORMLY as the
|
|
534
|
+
* directory that segment's own invocation genuinely runs in (bash
|
|
535
|
+
* semantics) — orchestrator decision D-010, 2026-08-02: an earlier
|
|
536
|
+
* revision of this function additionally distrusted an inherited target
|
|
537
|
+
* whenever a DIFFERENT invocation intervened between the `cd` and the
|
|
538
|
+
* satisfying segment — REJECTED: that coupling has no basis in bash's own
|
|
539
|
+
* semantics, is dodgeable by inserting any harmless read between the `cd`
|
|
540
|
+
* and the gated verb, and carried its own unverified `cd`-recognition
|
|
541
|
+
* gap. The `cd <B> && git log && git push` shape this was meant to guard
|
|
542
|
+
* is not a regression at all under this design — `git push` genuinely
|
|
543
|
+
* runs inside B — see `tests/runtime/intercept-cli.test.ts`'s "leading-cd
|
|
544
|
+
* is now a deliverable" block.
|
|
545
|
+
*
|
|
546
|
+
* D-021 (UNIVERSAL-ADDITIVE, operator decision, fix round, run
|
|
547
|
+
* 2026-08-02-per-repo-gate-scoping-redesign): attribution is uniformly
|
|
548
|
+
* ADDITIVE, never REPLACE. Every satisfying segment with an attributable
|
|
549
|
+
* target — `seg.effectiveTarget !== null`, whether it came from the
|
|
550
|
+
* segment's OWN explicit repo-relocating flag (`-C`/`env -C`/`--git-dir`;
|
|
551
|
+
* `--work-tree` is excluded from ever producing a target at all, D-017)
|
|
552
|
+
* or was INHERITED from a preceding `cd` — demands the cwd context AND
|
|
553
|
+
* that target's own context, side by side; `seg.ownTarget` is not
|
|
554
|
+
* consulted for this decision. Any unsatisfied context blocks the whole
|
|
555
|
+
* event (the existing per-context evaluation machinery below is
|
|
556
|
+
* unchanged). `seg.effectiveTarget === null` (fully unattributable —
|
|
557
|
+
* D-003) still adds ONLY the cwd context — there is no foreign target to
|
|
558
|
+
* be additive WITH.
|
|
559
|
+
*
|
|
560
|
+
* Demanding cwd unconditionally makes the engine structurally immune to
|
|
561
|
+
* misattribution of the foreign target: no gap in `command-normalize.ts`'s
|
|
562
|
+
* static, string-only model of shell control flow (subshells, pipes,
|
|
563
|
+
* `cd -`, a later `cd`, an unmodeled git flag composition) can ever make a
|
|
564
|
+
* gate WEAKER than the shipped, cwd-only engine, because cwd's own demand
|
|
565
|
+
* is never dropped — only added to. This replaces an earlier REPLACE-for-
|
|
566
|
+
* own-target design (D-011) that trusted a statically extracted own
|
|
567
|
+
* target as proof of "this one invocation operates there"; four
|
|
568
|
+
* independent review passes each measured a live bypass of that
|
|
569
|
+
* invariant against the shipped binary (a forged `.git/HEAD` directory
|
|
570
|
+
* reached through, respectively, a non-persisting `cd`, `--work-tree`,
|
|
571
|
+
* more than one repo-relocating flag, and a tilde-valued flag not counted
|
|
572
|
+
* by the multi-flag guard) — see `03-decisions.md` D-011/D-017/D-018/
|
|
573
|
+
* D-019/D-020/D-021 for the full history. The static-analysis-proves-it
|
|
574
|
+
* invariant those bypasses each disproved is not carried forward here.
|
|
575
|
+
* Cost accepted by the operator: a legitimate `git -C B` from cwd A now
|
|
576
|
+
* demands BOTH A's and B's context, where the pre-D-021 engine demanded
|
|
577
|
+
* only B's.
|
|
578
|
+
*
|
|
579
|
+
* D-012: a target reached through a symlink resolves to its REAL
|
|
580
|
+
* (realpath'd) repository identity, not the symlink's own lexical
|
|
581
|
+
* basename — `resolveGitContext` derives `repo` from the basename of
|
|
582
|
+
* wherever it's pointed, and never itself realpaths, so an agent could
|
|
583
|
+
* otherwise pick the demanded repo identity by naming a symlink.
|
|
584
|
+
*
|
|
585
|
+
* D-013: distinct contexts are bounded at `MAX_ATTRIBUTED_CONTEXTS`; see
|
|
586
|
+
* that constant's own comment.
|
|
587
|
+
*
|
|
588
|
+
* D-015: the cwd context is deduped by its OWN `[REPO, BRANCH, sha]`
|
|
589
|
+
* signature (not the literal string `"cwd"`), so a foreign path that
|
|
590
|
+
* resolves to the SAME repository identity as cwd (e.g. a subdirectory of
|
|
591
|
+
* the cwd repo reached via an explicit `-C`) collapses into the cwd
|
|
592
|
+
* context instead of producing a spurious duplicate decision/audit write.
|
|
593
|
+
* `repoOverridden` / `branchOverridden` (from `InterceptOptions`, both
|
|
594
|
+
* default `false`) keep an operator's `HARNESS_REPO`/`HARNESS_BRANCH`
|
|
595
|
+
* override intact in an attributed foreign context instead of letting the
|
|
596
|
+
* target repo's own identity silently overwrite it — independently per
|
|
597
|
+
* field. `satisfying` is a `Set` (not `Array.includes` per segment) for
|
|
598
|
+
* O(1) membership tests instead of O(n) per segment.
|
|
599
|
+
*
|
|
600
|
+
* A policy with no attributable foreign target (no `bash_match`, no
|
|
601
|
+
* individually-matching segment, only an amp-arm-only match, an
|
|
602
|
+
* unattributable composition, a target resolving to the SAME repository
|
|
603
|
+
* identity as cwd, or a target outside any git repo — D-003) is evaluated
|
|
604
|
+
* EXACTLY as it is today, with the EXACT SAME `options.builtins` object
|
|
605
|
+
* reference (no clone) when nothing attributed, keeping that the
|
|
606
|
+
* byte-identical common case.
|
|
607
|
+
*
|
|
608
|
+
* `resolveGitContextMemo` is a per-`intercept()`-call cache keyed by the
|
|
609
|
+
* REALPATH'D absolute path (never module-level state — no cross-event
|
|
610
|
+
* caching), so several policies (or several satisfying segments) naming
|
|
611
|
+
* the same foreign path within one event pay the `fs` cost once, not
|
|
612
|
+
* once per policy per segment.
|
|
613
|
+
*/
|
|
614
|
+
function resolveAttributedContexts(policy, segments, cwdBuiltins, cwdCurrentHeadSha, resolveGitContextMemo, repoOverridden, branchOverridden) {
|
|
615
|
+
const cwdContext = { builtins: cwdBuiltins, currentHeadSha: cwdCurrentHeadSha };
|
|
616
|
+
const satisfying = new Set(attributeTriggerSegments(policy, segments));
|
|
617
|
+
if (satisfying.size === 0)
|
|
618
|
+
return { kind: "contexts", contexts: [cwdContext] };
|
|
619
|
+
const cwdSignature = [cwdBuiltins.REPO, cwdBuiltins.BRANCH, cwdCurrentHeadSha ?? ""].join("|");
|
|
620
|
+
const seenSignatures = new Set();
|
|
621
|
+
const contexts = [];
|
|
622
|
+
const addCwdOnce = () => {
|
|
623
|
+
if (seenSignatures.has(cwdSignature))
|
|
624
|
+
return;
|
|
625
|
+
seenSignatures.add(cwdSignature);
|
|
626
|
+
contexts.push(cwdContext);
|
|
627
|
+
};
|
|
628
|
+
const cwdReal = realpathOrSelf(cwdBuiltins.CWD);
|
|
629
|
+
for (const seg of segments) {
|
|
630
|
+
if (!satisfying.has(seg))
|
|
631
|
+
continue;
|
|
632
|
+
if (seg.effectiveTarget === null) {
|
|
633
|
+
// D-003: fully unattributable — cwd only, no foreign target to be
|
|
634
|
+
// additive with.
|
|
635
|
+
addCwdOnce();
|
|
636
|
+
continue;
|
|
637
|
+
}
|
|
638
|
+
// D-021 (UNIVERSAL-ADDITIVE, operator decision after the four-pass
|
|
639
|
+
// halt — see this function's own doc comment): the cwd context is
|
|
640
|
+
// demanded UNCONDITIONALLY here, regardless of whether the target came
|
|
641
|
+
// from the segment's own explicit flag or was inherited from a
|
|
642
|
+
// preceding `cd`. `seg.ownTarget` is no longer read for this decision.
|
|
643
|
+
addCwdOnce();
|
|
644
|
+
const resolvedLexical = path.resolve(cwdBuiltins.CWD, seg.effectiveTarget);
|
|
645
|
+
const resolved = realpathOrSelf(resolvedLexical);
|
|
646
|
+
if (resolved === cwdReal) {
|
|
647
|
+
addCwdOnce();
|
|
648
|
+
continue;
|
|
649
|
+
}
|
|
650
|
+
let gitCtx = resolveGitContextMemo.get(resolved);
|
|
651
|
+
if (gitCtx === undefined) {
|
|
652
|
+
gitCtx = resolveGitContext(resolved);
|
|
653
|
+
resolveGitContextMemo.set(resolved, gitCtx);
|
|
654
|
+
}
|
|
655
|
+
if (gitCtx.repo.length === 0) {
|
|
656
|
+
// D-003: outside any repo → cwd fallback, not a distinct context.
|
|
657
|
+
addCwdOnce();
|
|
658
|
+
continue;
|
|
659
|
+
}
|
|
660
|
+
const signature = [gitCtx.repo, gitCtx.branch, gitCtx.sha].join("|");
|
|
661
|
+
if (signature === cwdSignature) {
|
|
662
|
+
// D-015: a foreign target that resolves to cwd's own REAL identity
|
|
663
|
+
// (e.g. a subdirectory of the cwd repo reached via `-C`, a
|
|
664
|
+
// different literal path than `cwdBuiltins.CWD` itself but the SAME
|
|
665
|
+
// `.git`) collapses into the cwd context regardless of segment
|
|
666
|
+
// order — not just when a bare cwd-reading segment happened to add
|
|
667
|
+
// it first.
|
|
668
|
+
addCwdOnce();
|
|
669
|
+
continue;
|
|
670
|
+
}
|
|
671
|
+
if (seenSignatures.has(signature))
|
|
672
|
+
continue;
|
|
673
|
+
if (contexts.length >= MAX_ATTRIBUTED_CONTEXTS) {
|
|
674
|
+
// D-013: fail CLOSED — do not evaluate any of them, name the
|
|
675
|
+
// ambiguity instead. `contexts.length + 1` names "at least this
|
|
676
|
+
// many distinct targets", the count observed before bailing, not
|
|
677
|
+
// necessarily the final total (the loop stops here).
|
|
678
|
+
return { kind: "bounded", distinctCount: contexts.length + 1 };
|
|
679
|
+
}
|
|
680
|
+
seenSignatures.add(signature);
|
|
681
|
+
contexts.push({
|
|
682
|
+
builtins: {
|
|
683
|
+
...cwdBuiltins,
|
|
684
|
+
REPO: repoOverridden ? cwdBuiltins.REPO : gitCtx.repo,
|
|
685
|
+
BRANCH: branchOverridden ? cwdBuiltins.BRANCH : gitCtx.branch,
|
|
686
|
+
},
|
|
687
|
+
currentHeadSha: gitCtx.sha.length > 0 ? gitCtx.sha : undefined,
|
|
688
|
+
});
|
|
689
|
+
}
|
|
690
|
+
// Defensive fallback: every satisfying segment existed but somehow none
|
|
691
|
+
// was added above (should not happen — every branch above adds either
|
|
692
|
+
// the cwd context or a foreign one). Never leave a matched, per-repo-
|
|
693
|
+
// builtins policy with zero contexts to evaluate against.
|
|
694
|
+
return { kind: "contexts", contexts: contexts.length > 0 ? contexts : [cwdContext] };
|
|
695
|
+
}
|
|
696
|
+
/**
|
|
697
|
+
* Lazily resolve the event's per-segment view for attribution, computed
|
|
698
|
+
* at most once per `intercept()` call (the caller, `intercept()` below,
|
|
699
|
+
* memoises the RESULT in its own `segmentsForAttribution` local — this
|
|
700
|
+
* function itself is called at most once per call already, but stays
|
|
701
|
+
* side-effect-free so that remains true regardless of caller changes).
|
|
702
|
+
* Prefers `options.commandSegmentsThunk` (D-015 fix round: the deferred,
|
|
703
|
+
* compute-only-if-needed seam — see its own doc comment), then the eager
|
|
704
|
+
* `options.commandSegments`, then falls back to computing it directly
|
|
705
|
+
* from the event's own command for standalone callers/tests that inject
|
|
706
|
+
* neither. `null` (truncated — see `segmentViewOf`) and "no Bash command
|
|
707
|
+
* on this event" both collapse to `[]`, the same "nothing to attribute,
|
|
708
|
+
* cwd builtins" shape as a policy with no `bash_match` at all.
|
|
709
|
+
*/
|
|
710
|
+
function resolveCommandSegments(options) {
|
|
711
|
+
if (options.commandSegmentsThunk !== undefined)
|
|
712
|
+
return options.commandSegmentsThunk() ?? [];
|
|
713
|
+
if (options.commandSegments !== undefined)
|
|
714
|
+
return options.commandSegments ?? [];
|
|
715
|
+
const command = extractShellCommand(options.event);
|
|
716
|
+
if (command === null)
|
|
717
|
+
return [];
|
|
718
|
+
return segmentViewOf(command) ?? [];
|
|
719
|
+
}
|
|
720
|
+
/**
|
|
721
|
+
* Synthesise the single decision `intercept()` records for a policy whose
|
|
722
|
+
* distinct attributed contexts exceeded `MAX_ATTRIBUTED_CONTEXTS` (D-013).
|
|
723
|
+
* Routes through `outcomeForFailedRequires` — the SAME enforcement-to-
|
|
724
|
+
* outcome mapping every other "could not safely evaluate" branch in
|
|
725
|
+
* `evaluateOnePolicy` uses — rather than a hardcoded outcome, so a
|
|
726
|
+
* `block`-enforcement policy (the security-critical case: preflight/push/
|
|
727
|
+
* merge gates) genuinely denies, while a `warn`-enforcement policy warns
|
|
728
|
+
* instead of hard-blocking, consistent with how every other failed-
|
|
729
|
+
* evaluation branch in this module already respects the policy's own
|
|
730
|
+
* declared enforcement. No ledger query, no template substitution against
|
|
731
|
+
* a resolved `ledger_tag` (there is no single context to substitute one
|
|
732
|
+
* against) — `extractValues` is still computed from `trigger.extract`
|
|
733
|
+
* against the CWD builtins (cheap, pure, matches the `operator_only`
|
|
734
|
+
* short-circuit's own reasoning) so a `ux:`/`producers:` block on this
|
|
735
|
+
* policy can still render.
|
|
736
|
+
*/
|
|
737
|
+
function boundedContextsDecision(policy, event, distinctCount, cwdBuiltins, evaluatedAt) {
|
|
738
|
+
const extract = evaluateExtract(policy.trigger.extract ?? {}, buildEventContext(event), cwdBuiltins);
|
|
739
|
+
return {
|
|
740
|
+
policyName: policy.name,
|
|
741
|
+
enforcement: policy.enforcement,
|
|
742
|
+
outcome: outcomeForFailedRequires(policy.enforcement),
|
|
743
|
+
reason: `ambiguous: this command names at least ${distinctCount} distinct repository targets for this policy, ` +
|
|
744
|
+
`exceeding the ${MAX_ATTRIBUTED_CONTEXTS}-context bound — refusing to evaluate all of them`,
|
|
745
|
+
extractValues: extract.values,
|
|
746
|
+
ledgerTag: "(bounded: too many distinct attributed targets — no context queried)",
|
|
747
|
+
evaluatedAt,
|
|
748
|
+
};
|
|
749
|
+
}
|
|
326
750
|
export async function intercept(options) {
|
|
327
751
|
const { manifest, event } = options;
|
|
328
752
|
// The Risk Gate is active only when some policy declares a `when:`
|
|
@@ -351,8 +775,9 @@ export async function intercept(options) {
|
|
|
351
775
|
const whenFallbackMap = new Map();
|
|
352
776
|
const matching = [];
|
|
353
777
|
for (const p of manifest.policies) {
|
|
354
|
-
if (!policyMatchesEvent(p, event, options.normalizedCommand))
|
|
778
|
+
if (!policyMatchesEvent(p, event, options.normalizedCommand, options.ampNormalizedCommandThunk, options.quoteNormalizedCommandThunk)) {
|
|
355
779
|
continue;
|
|
780
|
+
}
|
|
356
781
|
if (p.when === undefined) {
|
|
357
782
|
matching.push(p);
|
|
358
783
|
continue;
|
|
@@ -364,34 +789,73 @@ export async function intercept(options) {
|
|
|
364
789
|
matching.push(p);
|
|
365
790
|
}
|
|
366
791
|
}
|
|
792
|
+
// Per-policy attribution (task `98ad072f`, T-003): `segmentsForAttribution`
|
|
793
|
+
// is resolved at most once, lazily, only if some matching policy actually
|
|
794
|
+
// uses `${REPO}`/`${BRANCH}`/`at_head` — a manifest with none of those
|
|
795
|
+
// (every Phase 4/5/6-only manifest) never computes it.
|
|
796
|
+
// `resolveGitContextMemo` is this call's own, non-module-level cache
|
|
797
|
+
// (task constraint: no eager/global filesystem work) — see
|
|
798
|
+
// `resolveAttributedContexts`'s own comment.
|
|
799
|
+
let segmentsForAttribution;
|
|
800
|
+
const resolveGitContextMemo = new Map();
|
|
367
801
|
const decisions = [];
|
|
368
802
|
for (const policy of matching) {
|
|
369
|
-
const
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
803
|
+
const attributed = usesPerRepoBuiltins(policy)
|
|
804
|
+
? resolveAttributedContexts(policy, (segmentsForAttribution ??= resolveCommandSegments(options)), options.builtins, options.currentHeadSha, resolveGitContextMemo, options.repoOverridden === true, options.branchOverridden === true)
|
|
805
|
+
: { kind: "contexts", contexts: [{ builtins: options.builtins, currentHeadSha: options.currentHeadSha }] };
|
|
806
|
+
if (attributed.kind === "bounded") {
|
|
807
|
+
// D-013: fail CLOSED without querying the ledger for any of the
|
|
808
|
+
// (too many) distinct targets — one synthetic decision, one audit
|
|
809
|
+
// write, then move on to the next policy. Ledger-query count for
|
|
810
|
+
// THIS policy stays at zero regardless of how many distinct targets
|
|
811
|
+
// the command actually names, instead of scaling with them.
|
|
812
|
+
const evaluatedAt = (options.now ?? new Date()).toISOString();
|
|
813
|
+
const decision = boundedContextsDecision(policy, event, attributed.distinctCount, options.builtins, evaluatedAt);
|
|
814
|
+
decisions.push(decision);
|
|
815
|
+
try {
|
|
816
|
+
await options.ledger.record(decision, resolveSessionId(event.session_id));
|
|
383
817
|
}
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
818
|
+
catch (err) {
|
|
819
|
+
(options.stderr ?? process.stderr).write(`harness runtime intercept: audit-write failed for ${decision.policyName}: ${err instanceof Error ? err.message : String(err)}\n`);
|
|
820
|
+
}
|
|
821
|
+
continue;
|
|
388
822
|
}
|
|
389
|
-
|
|
390
|
-
//
|
|
391
|
-
//
|
|
392
|
-
//
|
|
393
|
-
//
|
|
394
|
-
|
|
823
|
+
for (const context of attributed.contexts) {
|
|
824
|
+
// Same object reference as `options` on the (overwhelmingly common)
|
|
825
|
+
// single-cwd-context path — no clone, byte-identical to the
|
|
826
|
+
// pre-attribution call shape. Only a genuinely foreign context
|
|
827
|
+
// builds a shallow override.
|
|
828
|
+
const contextOptions = context.builtins === options.builtins &&
|
|
829
|
+
context.currentHeadSha === options.currentHeadSha
|
|
830
|
+
? options
|
|
831
|
+
: { ...options, builtins: context.builtins, currentHeadSha: context.currentHeadSha };
|
|
832
|
+
const base = await evaluateOnePolicy(policy, contextOptions);
|
|
833
|
+
// Attach the per-event Risk Gate verdicts so `harness audit` /
|
|
834
|
+
// `explain --trace` can replay the classification + environment
|
|
835
|
+
// that the `when:` match was made against. Also carry the
|
|
836
|
+
// unclassifiedFallback flag (M7) when the when: evaluation set it
|
|
837
|
+
// to true; leave the field absent otherwise so decisions from
|
|
838
|
+
// manifests without a `when:` policy stay byte-identical.
|
|
839
|
+
const whenFallback = whenFallbackMap.get(policy.name);
|
|
840
|
+
const decision = enriched
|
|
841
|
+
? {
|
|
842
|
+
...base,
|
|
843
|
+
risk: enriched.risk,
|
|
844
|
+
environment: enriched.environment,
|
|
845
|
+
...(whenFallback === true ? { whenUnclassifiedFallback: true } : {}),
|
|
846
|
+
}
|
|
847
|
+
: base;
|
|
848
|
+
decisions.push(decision);
|
|
849
|
+
try {
|
|
850
|
+
await options.ledger.record(decision, resolveSessionId(event.session_id));
|
|
851
|
+
}
|
|
852
|
+
catch (err) {
|
|
853
|
+
// Audit-write failure must not block; the decision is still applied.
|
|
854
|
+
// Surface the failure to stderr so a persistently-failing recorder
|
|
855
|
+
// does not silently leave `harness audit` / `explain --trace` blind.
|
|
856
|
+
// Goes to stderr to keep the stdout deny-JSON contract intact.
|
|
857
|
+
(options.stderr ?? process.stderr).write(`harness runtime intercept: audit-write failed for ${decision.policyName}: ${err instanceof Error ? err.message : String(err)}\n`);
|
|
858
|
+
}
|
|
395
859
|
}
|
|
396
860
|
}
|
|
397
861
|
// First blocking decision wins the envelope. `deny` and
|
|
@@ -438,7 +902,35 @@ export async function intercept(options) {
|
|
|
438
902
|
// `run:` is the canonical remedy surface and rendering both would
|
|
439
903
|
// give the agent two different command suggestions.
|
|
440
904
|
let reasonText;
|
|
441
|
-
if (
|
|
905
|
+
if (blocking.outcome === "deny-degraded") {
|
|
906
|
+
// Degraded-specific envelope (task f1aea826). Deliberately takes
|
|
907
|
+
// precedence over the policy's `ux:` surface: the operator-curated
|
|
908
|
+
// ux text describes the MISSING-evidence case ("run the producer,
|
|
909
|
+
// then retry"), which is actively misleading here — the evidence
|
|
910
|
+
// could not be READ, so producing the tag cannot unblock anything
|
|
911
|
+
// until the ledger is reachable again. The envelope names the real
|
|
912
|
+
// cause (the transport-level reason, sanitised — it embeds output
|
|
913
|
+
// from the grounding-mcp subprocess) and the posture, so nobody
|
|
914
|
+
// debugs a phantom missing tag.
|
|
915
|
+
//
|
|
916
|
+
// The `risk.degraded_fail_posture: fail_open` opt-out is
|
|
917
|
+
// deliberately NOT named here (review 2026-08-08, high finding):
|
|
918
|
+
// this text is fed back to the very agent that was just blocked,
|
|
919
|
+
// and nothing prevents that agent from editing the manifest — a
|
|
920
|
+
// deny that includes its own disable recipe is not a gate. The
|
|
921
|
+
// opt-out lives on operator surfaces only: the stderr diagnostic
|
|
922
|
+
// (`formatDecisionDiagnostic`), docs/risk-gate.md, and the OKF
|
|
923
|
+
// fail-posture matrix. Same reasoning as the producer-neutrality
|
|
924
|
+
// norm above and the operator-only framing of `harness pause`.
|
|
925
|
+
reasonText =
|
|
926
|
+
`${blocking.policyName}: required evidence could not be read ` +
|
|
927
|
+
`(evidence ledger degraded: ${sanitizeEnvelopeReason(blocking.reason)}). ` +
|
|
928
|
+
`This ${blocking.enforcement} policy fails closed while its evidence ` +
|
|
929
|
+
`source is unreadable; producing the required tag will not unblock it ` +
|
|
930
|
+
`until the ledger is reachable again. Ask your operator to check ` +
|
|
931
|
+
`grounding-mcp (harness doctor), then retry. Session: ${sessionId}.`;
|
|
932
|
+
}
|
|
933
|
+
else if (blockingPolicy?.ux) {
|
|
442
934
|
// The ux surface is operator-curated plain language. The
|
|
443
935
|
// unclassifiedFallback flag rides the audit record (recorded above
|
|
444
936
|
// and surfaced by `harness audit` and `explain --trace`) so operators
|