@lannguyensi/harness 0.30.1 → 0.32.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.
Files changed (53) hide show
  1. package/CHANGELOG.md +31 -0
  2. package/README.md +1 -0
  3. package/dist/cli/index.js +59 -0
  4. package/dist/cli/index.js.map +1 -1
  5. package/dist/cli/init/templates.d.ts +1 -1
  6. package/dist/cli/init/templates.js +15 -0
  7. package/dist/cli/init/templates.js.map +1 -1
  8. package/dist/cli/pack/hook-branch-protection.js +2 -1
  9. package/dist/cli/pack/hook-branch-protection.js.map +1 -1
  10. package/dist/cli/pack/hook-codex-pre-tool-use.js +4 -3
  11. package/dist/cli/pack/hook-codex-pre-tool-use.js.map +1 -1
  12. package/dist/cli/pack/hook-codex-stop.js +1 -0
  13. package/dist/cli/pack/hook-codex-stop.js.map +1 -1
  14. package/dist/cli/pack/hook-post-tool-use.js +1 -0
  15. package/dist/cli/pack/hook-post-tool-use.js.map +1 -1
  16. package/dist/cli/pack/hook-pre-tool-use.js +3 -2
  17. package/dist/cli/pack/hook-pre-tool-use.js.map +1 -1
  18. package/dist/cli/pack/hook-runtime-reality.d.ts +50 -0
  19. package/dist/cli/pack/hook-runtime-reality.js +160 -0
  20. package/dist/cli/pack/hook-runtime-reality.js.map +1 -0
  21. package/dist/cli/pack/hook-solution-acceptance-writeguard.d.ts +26 -0
  22. package/dist/cli/pack/hook-solution-acceptance-writeguard.js +187 -0
  23. package/dist/cli/pack/hook-solution-acceptance-writeguard.js.map +1 -0
  24. package/dist/cli/pack/hook-solution-acceptance.d.ts +26 -0
  25. package/dist/cli/pack/hook-solution-acceptance.js +237 -0
  26. package/dist/cli/pack/hook-solution-acceptance.js.map +1 -0
  27. package/dist/cli/pause/index.d.ts +17 -3
  28. package/dist/cli/pause/index.js +30 -6
  29. package/dist/cli/pause/index.js.map +1 -1
  30. package/dist/cli/policy/intercept.js +7 -1
  31. package/dist/cli/policy/intercept.js.map +1 -1
  32. package/dist/cli/session-start/branch-check.js +4 -2
  33. package/dist/cli/session-start/branch-check.js.map +1 -1
  34. package/dist/cli/session-start/index.js +4 -2
  35. package/dist/cli/session-start/index.js.map +1 -1
  36. package/dist/cli/validate/checks.js +38 -0
  37. package/dist/cli/validate/checks.js.map +1 -1
  38. package/dist/policy-packs/builtin/solution-acceptance-runtime.d.ts +119 -0
  39. package/dist/policy-packs/builtin/solution-acceptance-runtime.js +289 -0
  40. package/dist/policy-packs/builtin/solution-acceptance-runtime.js.map +1 -0
  41. package/dist/policy-packs/builtin/solution-acceptance.d.ts +44 -0
  42. package/dist/policy-packs/builtin/solution-acceptance.js +185 -0
  43. package/dist/policy-packs/builtin/solution-acceptance.js.map +1 -0
  44. package/dist/policy-packs/registry.d.ts +1 -1
  45. package/dist/policy-packs/registry.js +10 -0
  46. package/dist/policy-packs/registry.js.map +1 -1
  47. package/dist/runtime/risk-classifier.js +64 -0
  48. package/dist/runtime/risk-classifier.js.map +1 -1
  49. package/dist/runtime/session-id.d.ts +4 -3
  50. package/dist/runtime/session-id.js +17 -5
  51. package/dist/runtime/session-id.js.map +1 -1
  52. package/package.json +3 -1
  53. package/scripts/runtime-reality-docker-probe.mjs +71 -0
@@ -0,0 +1,160 @@
1
+ // Phase 1 Schritt 3 (harness side) — `harness pack hook runtime-reality` runtime verb.
2
+ //
3
+ // PreToolUse drift gate. Wires @lannguyensi/runtime-reality-checker as a
4
+ // real, blocking PreToolUse hook by composing its PURE policy handler
5
+ // with a host-coupled subprocess probe.
6
+ //
7
+ // Why this verb exists at all: the package's own bin
8
+ // (`runtime-reality-policy-pre-tool-use`) ships with `probe: null`, so it
9
+ // always degrades to allow — it can detect a trigger but never compare
10
+ // against actual runtime state. The probe is deliberately left to the
11
+ // harness side so the agent-grounding repo stays free of host-coupling
12
+ // (see agent-grounding/docs/policy-runtime-reality.md "Actuals probe").
13
+ // This verb is that harness-side half: it spawns the operator-configured
14
+ // `RUNTIME_REALITY_PROBE_CMD`, parses its JSON `ActualProcessState[]`, and
15
+ // injects it into the package handler.
16
+ //
17
+ // Failure mode mirrors the package and the understanding-gate pack hook:
18
+ // every load / parse / probe error degrades to ALLOW (exit 0). The only
19
+ // deny path is a probe that actually produced actuals showing critical
20
+ // drift (or the operator's explicit `RUNTIME_REALITY_*_BLOCK` escalations,
21
+ // which the package handler owns). A misconfigured probe must never
22
+ // tarpit the session.
23
+ import { execFileSync } from "node:child_process";
24
+ import { handlePolicyPreToolUse, loadExpectations, } from "@lannguyensi/runtime-reality-checker/policy";
25
+ /** Hard ceiling on a single probe invocation. The hook's own budget_ms
26
+ * (default 30s) is the outer bound; keep the probe well inside it so a
27
+ * hung `docker ps` degrades to allow rather than blowing the hook
28
+ * budget. */
29
+ const PROBE_TIMEOUT_MS = 10_000;
30
+ /** docker ps on a busy host can emit a lot of lines; 4 MiB is generous. */
31
+ const PROBE_MAX_BUFFER = 4 * 1024 * 1024;
32
+ /**
33
+ * Validate that the probe's stdout parses to an `ActualProcessState[]`.
34
+ * The package handler treats a thrown probe as "probe failed" and applies
35
+ * the fail-open / `PROBE_FAIL_BLOCK` policy, so throwing on malformed
36
+ * output is the correct signal — not silently returning `[]` (which would
37
+ * read as "nothing is running" and manufacture phantom critical drift).
38
+ */
39
+ export function parseProbeOutput(raw) {
40
+ const trimmed = raw.trim();
41
+ if (!trimmed) {
42
+ throw new Error("probe produced empty output");
43
+ }
44
+ let parsed;
45
+ try {
46
+ parsed = JSON.parse(trimmed);
47
+ }
48
+ catch (err) {
49
+ throw new Error(`probe output is not valid JSON: ${String(err)}`);
50
+ }
51
+ if (!Array.isArray(parsed)) {
52
+ throw new Error("probe output is not a JSON array of ActualProcessState");
53
+ }
54
+ return parsed.map((item, i) => {
55
+ if (typeof item !== "object" || item === null) {
56
+ throw new Error(`probe output[${i}] is not an object`);
57
+ }
58
+ const rec = item;
59
+ if (typeof rec.name !== "string") {
60
+ throw new Error(`probe output[${i}].name must be a string`);
61
+ }
62
+ if (typeof rec.running !== "boolean") {
63
+ throw new Error(`probe output[${i}].running must be a boolean`);
64
+ }
65
+ // startup_mode / port are optional and forwarded as-is; the package's
66
+ // runRealityCheck only reads them when the expectation declares them.
67
+ return rec;
68
+ });
69
+ }
70
+ /**
71
+ * Build a synchronous probe from an operator-configured command string,
72
+ * or `null` when none is set (handler then takes the no-probe degrade
73
+ * path). The command runs via `sh -c` so a full command line with args
74
+ * works. The resolved keyword is passed on the env (RUNTIME_REALITY_KEYWORD)
75
+ * so a probe script can scope its output; it is deliberately NOT appended
76
+ * as a positional arg, which would surprise commands that don't ignore
77
+ * extra args (e.g. `cat file`, `printf`).
78
+ */
79
+ export function buildSubprocessProbe(probeCmd, env) {
80
+ const cmd = probeCmd?.trim();
81
+ if (!cmd)
82
+ return null;
83
+ return ({ keyword }) => {
84
+ const stdout = execFileSync("sh", ["-c", cmd], {
85
+ encoding: "utf8",
86
+ timeout: PROBE_TIMEOUT_MS,
87
+ maxBuffer: PROBE_MAX_BUFFER,
88
+ env: { ...env, RUNTIME_REALITY_KEYWORD: keyword },
89
+ });
90
+ return parseProbeOutput(stdout);
91
+ };
92
+ }
93
+ const DEFAULT_DEPS = {
94
+ loadExpectations,
95
+ buildProbe: buildSubprocessProbe,
96
+ };
97
+ /**
98
+ * Pure composition: given the raw PreToolUse stdin and an environment,
99
+ * build the probe and run the package handler. Injectable deps keep this
100
+ * unit-testable without spawning real subprocesses.
101
+ */
102
+ export function runRuntimeRealityHook(rawStdin, env, deps = DEFAULT_DEPS) {
103
+ const probe = deps.buildProbe(env.RUNTIME_REALITY_PROBE_CMD, env);
104
+ return handlePolicyPreToolUse(rawStdin, env, {
105
+ loadExpectations: deps.loadExpectations,
106
+ probe,
107
+ });
108
+ }
109
+ async function readStdin(stream) {
110
+ if (stream.isTTY)
111
+ return "";
112
+ const chunks = [];
113
+ for await (const chunk of stream) {
114
+ chunks.push(chunk);
115
+ }
116
+ return Buffer.concat(chunks).toString("utf8");
117
+ }
118
+ function allowResult(reason) {
119
+ return { stdout: "", stderr: "", exitCode: 0, decision: { kind: "skip", reason } };
120
+ }
121
+ /**
122
+ * CLI entrypoint for `harness pack hook runtime-reality`. Reads the
123
+ * PreToolUse event JSON on stdin, runs the drift check, writes the
124
+ * hookSpecificOutput envelope (deny) / stderr message, and RETURNS the
125
+ * handler result. It deliberately does NOT call `process.exit`: the
126
+ * caller in src/cli/index.ts throws `HarnessExitError` on a nonzero
127
+ * exit code, which lets `main.ts` exit only after the promise resolves
128
+ * so the deny envelope on stdout fully flushes to the pipe first. A
129
+ * synchronous `process.exit` here could truncate that envelope and
130
+ * silently defeat the block. Any unexpected failure resolves to ALLOW.
131
+ */
132
+ export async function runPackHookRuntimeRealityCli(opts = {}) {
133
+ const stdin = opts.stdin ?? process.stdin;
134
+ const stdout = opts.stdout ?? process.stdout;
135
+ const stderr = opts.stderr ?? process.stderr;
136
+ let raw = "";
137
+ try {
138
+ raw = await readStdin(stdin);
139
+ }
140
+ catch {
141
+ return allowResult("stdin read failed, degraded to allow");
142
+ }
143
+ let result;
144
+ try {
145
+ result = runRuntimeRealityHook(raw, process.env);
146
+ }
147
+ catch (err) {
148
+ // Defense in depth: the handler already degrades internally, but a
149
+ // bug in probe construction must not crash the hook.
150
+ const message = `runtime-reality hook failed silently: ${String(err)}\n`;
151
+ stderr.write(message);
152
+ return allowResult("hook construction failed, degraded to allow");
153
+ }
154
+ if (result.stdout)
155
+ stdout.write(result.stdout);
156
+ if (result.stderr)
157
+ stderr.write(result.stderr);
158
+ return result;
159
+ }
160
+ //# sourceMappingURL=hook-runtime-reality.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"hook-runtime-reality.js","sourceRoot":"","sources":["../../../src/cli/pack/hook-runtime-reality.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,26 @@
1
+ import type { LoaderOptions } from "../loader.js";
2
+ export interface PackHookSolutionAcceptanceWriteguardOptions extends LoaderOptions {
3
+ stdin?: NodeJS.ReadableStream;
4
+ stdout?: NodeJS.WritableStream;
5
+ stderr?: NodeJS.WritableStream;
6
+ /** Override cwd resolution (test injection). */
7
+ cwd?: string;
8
+ /** Override the verdict directory (test injection; default = producer default). */
9
+ verdictDir?: string;
10
+ }
11
+ export interface PackHookSolutionAcceptanceWriteguardResult {
12
+ exitCode: number;
13
+ blocked: boolean;
14
+ diagnostic: string;
15
+ }
16
+ interface Decision {
17
+ blocked: boolean;
18
+ reason: string;
19
+ }
20
+ /**
21
+ * Pure write-guard decision for a tool event. Exported for direct unit
22
+ * testing of the full forge-attempt matrix without spinning up the CLI.
23
+ */
24
+ export declare function evaluateWriteGuard(toolName: string, toolInput: unknown, dir: string, cwd: string): Decision;
25
+ export declare function runPackHookSolutionAcceptanceWriteguardCli(opts?: PackHookSolutionAcceptanceWriteguardOptions): Promise<PackHookSolutionAcceptanceWriteguardResult>;
26
+ export {};
@@ -0,0 +1,187 @@
1
+ // `harness pack hook solution-acceptance-writeguard` — PreToolUse
2
+ // anti-forgery write-guard for the `solution-acceptance` policy pack.
3
+ //
4
+ // This is the load-bearing closure of the gate. The completion-gate alone
5
+ // is forgeable: relocating the verdict dir does NOT help because
6
+ // understanding-gate allows all Bash once approved, so an approved agent
7
+ // could `echo '{...ready:true}' > <verdict-dir>/<id>.json` and walk the gate.
8
+ // This hook restores the invariant that the ONLY writer of the verdict dir
9
+ // is the producer (the operator-launched grounding-mcp MCP server, which
10
+ // runs real preflight and does not flow through the agent's gated tools).
11
+ //
12
+ // It denies, on the agent's tool surface:
13
+ // - Write / Edit / MultiEdit / NotebookEdit whose target file resolves
14
+ // inside the verdict dir.
15
+ // - Codex `apply_patch` whose patch body references the verdict dir.
16
+ // - Bash that is NOT provably read-only AND references the verdict dir
17
+ // (covers `echo >`, `$SOLUTION_VERDICT_DIR` spellings, `tee`, `mv`/`cp`/
18
+ // `ln`/`install`, `python3 -c '...path...'`, and `chmod`/`chattr` that
19
+ // would loosen perms) — or whose shell cwd is inside the dir.
20
+ //
21
+ // Pure reads (`cat <dir>/x.json`) are allowed so the guard is not over-broad.
22
+ //
23
+ // No manifest is consulted: the decision is a pure target-vs-dir check, so
24
+ // the guard cannot be broken by a manifest issue and never blocks a write
25
+ // that does not target the verdict dir. The hook is only wired into settings
26
+ // when the pack is enabled, so a disabled pack never invokes it. It yields to
27
+ // `harness pause` like every other gate.
28
+ //
29
+ // Honest residual (operator decision, 2026-05-30): v1 closes the ENUMERATED
30
+ // write paths above. A path constructed at runtime inside an interpreter
31
+ // with no textual reference is NOT caught; marker signing (a cross-repo
32
+ // follow-up) closes content-authenticity against an unguarded write
33
+ // primitive.
34
+ import { bashReferencesVerdictDir, isInsideDir, PACK_NAME, verdictDir as resolveVerdictDir, } from "../../policy-packs/builtin/solution-acceptance-runtime.js";
35
+ import { isReadOnlyBashCommand } from "./read-only-bash.js";
36
+ import { checkPauseFromLoader } from "../pause-check.js";
37
+ async function readStdin(stream) {
38
+ return new Promise((resolve, reject) => {
39
+ let data = "";
40
+ stream.setEncoding("utf8");
41
+ stream.on("data", (chunk) => {
42
+ data += chunk;
43
+ });
44
+ stream.on("end", () => resolve(data));
45
+ stream.on("error", (err) => reject(err));
46
+ });
47
+ }
48
+ /** Single-file target for path-mutating tools, or null when not applicable. */
49
+ function pathToolTarget(toolName, toolInput) {
50
+ if (typeof toolInput !== "object" || toolInput === null)
51
+ return null;
52
+ const input = toolInput;
53
+ switch (toolName) {
54
+ case "Write":
55
+ case "Edit":
56
+ case "MultiEdit": {
57
+ const fp = input["file_path"];
58
+ return typeof fp === "string" && fp.length > 0 ? fp : null;
59
+ }
60
+ case "NotebookEdit": {
61
+ const np = input["notebook_path"];
62
+ return typeof np === "string" && np.length > 0 ? np : null;
63
+ }
64
+ default:
65
+ return null;
66
+ }
67
+ }
68
+ function bashCommandOf(toolInput) {
69
+ if (typeof toolInput !== "object" || toolInput === null)
70
+ return "";
71
+ const cmd = toolInput["command"];
72
+ return typeof cmd === "string" ? cmd : "";
73
+ }
74
+ /**
75
+ * Pure write-guard decision for a tool event. Exported for direct unit
76
+ * testing of the full forge-attempt matrix without spinning up the CLI.
77
+ */
78
+ export function evaluateWriteGuard(toolName, toolInput, dir, cwd) {
79
+ // Path-mutating tools: block iff the target resolves inside the dir.
80
+ const target = pathToolTarget(toolName, toolInput);
81
+ if (target !== null) {
82
+ if (isInsideDir(target, dir, cwd)) {
83
+ return {
84
+ blocked: true,
85
+ reason: `${toolName} target resolves inside the harness-protected solution-verdict dir (${dir}); the verdict marker may only be written by the grounding-mcp producer`,
86
+ };
87
+ }
88
+ return { blocked: false, reason: `${toolName} target is outside the verdict dir` };
89
+ }
90
+ // Codex apply_patch: best-effort textual reference check on the patch body.
91
+ if (toolName === "apply_patch") {
92
+ const input = typeof toolInput === "object" && toolInput !== null
93
+ ? toolInput
94
+ : {};
95
+ const body = typeof input["patch"] === "string"
96
+ ? input["patch"]
97
+ : typeof input["input"] === "string"
98
+ ? input["input"]
99
+ : JSON.stringify(toolInput ?? "");
100
+ if (bashReferencesVerdictDir(body, dir)) {
101
+ return {
102
+ blocked: true,
103
+ reason: `apply_patch references the harness-protected solution-verdict dir (${dir})`,
104
+ };
105
+ }
106
+ return { blocked: false, reason: "apply_patch does not reference the verdict dir" };
107
+ }
108
+ // Bash: allow provable reads; block non-read-only commands that reference
109
+ // the dir, or whose shell cwd is inside it.
110
+ if (toolName === "Bash") {
111
+ const command = bashCommandOf(toolInput);
112
+ if (command === "")
113
+ return { blocked: false, reason: "empty Bash command" };
114
+ if (isReadOnlyBashCommand(command)) {
115
+ return { blocked: false, reason: "read-only Bash command" };
116
+ }
117
+ if (isInsideDir(".", dir, cwd)) {
118
+ return {
119
+ blocked: true,
120
+ reason: `non-read-only Bash with a shell cwd inside the harness-protected solution-verdict dir (${dir})`,
121
+ };
122
+ }
123
+ if (bashReferencesVerdictDir(command, dir)) {
124
+ return {
125
+ blocked: true,
126
+ reason: `non-read-only Bash references the harness-protected solution-verdict dir (${dir}); the verdict marker may only be written by the grounding-mcp producer`,
127
+ };
128
+ }
129
+ return { blocked: false, reason: "non-read-only Bash does not reference the verdict dir" };
130
+ }
131
+ return { blocked: false, reason: `${toolName} is not a guarded write surface` };
132
+ }
133
+ function blockJson(toolName, reason) {
134
+ const text = `solution-acceptance write-guard: refusing ${toolName}. ${reason}.\n` +
135
+ `The solution-acceptance verdict marker is derived by the producer from a ` +
136
+ `real preflight run; hand-writing it would forge a green "done". ` +
137
+ `Run \`mcp__agent-grounding__solution_evaluate({ id: "<task-id>" })\` instead, ` +
138
+ `which writes the marker for you.\n` +
139
+ `Operator override: \`harness pause\`.`;
140
+ return JSON.stringify({
141
+ decision: "block",
142
+ reason: text,
143
+ hookSpecificOutput: {
144
+ hookEventName: "PreToolUse",
145
+ permissionDecision: "deny",
146
+ permissionDecisionReason: text,
147
+ },
148
+ });
149
+ }
150
+ export async function runPackHookSolutionAcceptanceWriteguardCli(opts = {}) {
151
+ const stdin = opts.stdin ?? process.stdin;
152
+ const stdout = opts.stdout ?? process.stdout;
153
+ const stderr = opts.stderr ?? process.stderr;
154
+ const note = (msg) => {
155
+ stderr.write(`harness pack hook solution-acceptance-writeguard: ${msg}\n`);
156
+ };
157
+ const raw = await readStdin(stdin);
158
+ let event = {};
159
+ try {
160
+ event = JSON.parse(raw.trim() || "{}");
161
+ }
162
+ catch {
163
+ /* event stays {} -> not a guarded surface -> allow */
164
+ }
165
+ if (checkPauseFromLoader({ loaderOpts: opts, hookLabel: `${PACK_NAME}-writeguard`, stderr }).paused) {
166
+ const diagnostic = "harness paused; write-guard allowing without evaluating.";
167
+ return { exitCode: 0, blocked: false, diagnostic };
168
+ }
169
+ const toolName = typeof event.tool_name === "string" ? event.tool_name : "(unknown)";
170
+ const cwd = typeof opts.cwd === "string" && opts.cwd.length > 0
171
+ ? opts.cwd
172
+ : typeof event.cwd === "string" && event.cwd.length > 0
173
+ ? event.cwd
174
+ : process.cwd();
175
+ const dir = opts.verdictDir ?? resolveVerdictDir();
176
+ const decision = evaluateWriteGuard(toolName, event.tool_input, dir, cwd);
177
+ if (!decision.blocked) {
178
+ const diagnostic = `allow — ${decision.reason}`;
179
+ note(diagnostic);
180
+ return { exitCode: 0, blocked: false, diagnostic };
181
+ }
182
+ const diagnostic = `BLOCK — ${decision.reason}`;
183
+ note(diagnostic);
184
+ stdout.write(`${blockJson(toolName, decision.reason)}\n`);
185
+ return { exitCode: 0, blocked: true, diagnostic };
186
+ }
187
+ //# sourceMappingURL=hook-solution-acceptance-writeguard.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"hook-solution-acceptance-writeguard.js","sourceRoot":"","sources":["../../../src/cli/pack/hook-solution-acceptance-writeguard.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,26 @@
1
+ import { type Manifest } from "../../schema/index.js";
2
+ import { type LoaderOptions } from "../loader.js";
3
+ export interface PackHookSolutionAcceptanceOptions extends LoaderOptions {
4
+ /** Defaults to process.stdin. */
5
+ stdin?: NodeJS.ReadableStream;
6
+ /** Defaults to process.stdout. */
7
+ stdout?: NodeJS.WritableStream;
8
+ /** Defaults to process.stderr. */
9
+ stderr?: NodeJS.WritableStream;
10
+ /** Override cwd resolution (test injection). */
11
+ cwd?: string;
12
+ /** Inject a manifest (test). */
13
+ manifest?: Manifest;
14
+ /** Override the harness.generated/ directory (test injection). */
15
+ generatedDir?: string;
16
+ /** Override the verdict directory (test injection; default = producer default). */
17
+ verdictDir?: string;
18
+ /** Override the active-claim resolution (test injection). */
19
+ activeClaim?: string | null;
20
+ }
21
+ export interface PackHookSolutionAcceptanceResult {
22
+ exitCode: number;
23
+ blocked: boolean;
24
+ diagnostic: string;
25
+ }
26
+ export declare function runPackHookSolutionAcceptanceCli(opts?: PackHookSolutionAcceptanceOptions): Promise<PackHookSolutionAcceptanceResult>;
@@ -0,0 +1,237 @@
1
+ // `harness pack hook solution-acceptance` — PreToolUse completion-gate for
2
+ // the `solution-acceptance` policy pack.
3
+ //
4
+ // Receives Claude Code's PreToolUse event JSON on stdin and emits a
5
+ // `{ decision: "block" }` envelope when the agent is about to FINISH a task
6
+ // (agent-tasks completion verb, or a `git push` / `gh pr merge` bash
7
+ // command) without a READY solution-acceptance verdict at the current git
8
+ // HEAD.
9
+ //
10
+ // The verdict id is the active-claim task id (the same `active-claim` file
11
+ // `harness approve understanding` consumes). With no active claim the gate
12
+ // fails CLOSED — a sessionId fallback would reopen the wrong-scope bug class
13
+ // understanding-gate already fixed.
14
+ //
15
+ // Failure mode: any error in load / parse / HEAD-resolution / verdict-read
16
+ // resolves to BLOCK (branch-protection's fail-closed posture, not
17
+ // understanding-gate's fail-open). The gate's whole job is to prevent
18
+ // completion without earned acceptance, so a bug that silently allowed a
19
+ // finish through would defeat the purpose. The block envelope always names
20
+ // `solution_evaluate` as the recovery path so the operator is never wedged;
21
+ // `harness pause` (honored first) is the operator's hard override.
22
+ import { readActiveClaim, } from "../../policy-packs/builtin/understanding-before-execution-runtime.js";
23
+ import { DEFAULT_PUSH_BASH_RE, evaluateGate, PACK_NAME, readVerdict, resolveProtectedCompletionTools, verdictDir as resolveVerdictDir, } from "../../policy-packs/builtin/solution-acceptance-runtime.js";
24
+ import { resolveGeneratedDir } from "../../io/generated-dir.js";
25
+ import { resolveGitContext } from "../../runtime/git-context.js";
26
+ import { renderAgentFacing } from "../../runtime/agent-facing.js";
27
+ import { PolicyUxSchema } from "../../schema/index.js";
28
+ import { loadManifest } from "../loader.js";
29
+ import { checkPauseFromLoader } from "../pause-check.js";
30
+ const MCP_AGENT_TASKS_PREFIX = "mcp__agent-tasks__";
31
+ async function readStdin(stream) {
32
+ return new Promise((resolve, reject) => {
33
+ let data = "";
34
+ stream.setEncoding("utf8");
35
+ stream.on("data", (chunk) => {
36
+ data += chunk;
37
+ });
38
+ stream.on("end", () => resolve(data));
39
+ stream.on("error", (err) => reject(err));
40
+ });
41
+ }
42
+ function bashCommandOf(toolInput) {
43
+ if (typeof toolInput !== "object" || toolInput === null)
44
+ return "";
45
+ const cmd = toolInput["command"];
46
+ return typeof cmd === "string" ? cmd : "";
47
+ }
48
+ /**
49
+ * Decide whether this PreToolUse call is a gated completion action. Returns
50
+ * the human label of the action when gated, or null when this call should
51
+ * pass through (the hook matches all Bash, but only push/merge bash commands
52
+ * are completion actions).
53
+ */
54
+ function completionActionLabel(toolName, toolInput, protectedVerbs) {
55
+ if (toolName.startsWith(MCP_AGENT_TASKS_PREFIX)) {
56
+ const verb = toolName.slice(MCP_AGENT_TASKS_PREFIX.length);
57
+ if (protectedVerbs.includes(verb))
58
+ return `agent-tasks ${verb}`;
59
+ return null;
60
+ }
61
+ if (toolName === "Bash") {
62
+ const command = bashCommandOf(toolInput);
63
+ if (command && DEFAULT_PUSH_BASH_RE.test(command))
64
+ return "git push / gh pr merge";
65
+ return null;
66
+ }
67
+ return null;
68
+ }
69
+ function parseConfigUx(raw, stderr) {
70
+ if (raw === undefined)
71
+ return undefined;
72
+ const result = PolicyUxSchema.safeParse(raw);
73
+ if (!result.success) {
74
+ stderr.write(`harness pack hook solution-acceptance: config.ux ignored (${result.error.issues
75
+ .map((i) => `${i.path.join(".") || "<root>"}: ${i.message}`)
76
+ .join("; ")})\n`);
77
+ return undefined;
78
+ }
79
+ return result.data;
80
+ }
81
+ function blockJson(actionLabel, toolName, taskId, detail, ux, sessionId) {
82
+ let reasonText;
83
+ if (ux) {
84
+ reasonText = renderAgentFacing(ux, {
85
+ TOOL_NAME: toolName,
86
+ SESSION_ID: sessionId,
87
+ });
88
+ }
89
+ else {
90
+ reasonText =
91
+ `solution-acceptance: refusing ${actionLabel} (${toolName}). ${detail}\n` +
92
+ `Completion must be EARNED from a real preflight run, not claimed.\n` +
93
+ `Run the producer for this task, then retry:\n` +
94
+ ` mcp__agent-grounding__solution_evaluate({ id: "${taskId}" })\n` +
95
+ `It runs \`preflight run --json\` (lint/typecheck/test/audit/secret) and records a HEAD-pinned verdict. ` +
96
+ `A clean run at the current HEAD unblocks this tool; a failing run lists the blockers to fix.\n` +
97
+ `\n` +
98
+ `Operator override: \`harness pause\` (yields this and every other gate).`;
99
+ }
100
+ return JSON.stringify({
101
+ decision: "block",
102
+ reason: reasonText,
103
+ hookSpecificOutput: {
104
+ hookEventName: "PreToolUse",
105
+ permissionDecision: "deny",
106
+ permissionDecisionReason: reasonText,
107
+ },
108
+ });
109
+ }
110
+ export async function runPackHookSolutionAcceptanceCli(opts = {}) {
111
+ const stdin = opts.stdin ?? process.stdin;
112
+ const stdout = opts.stdout ?? process.stdout;
113
+ const stderr = opts.stderr ?? process.stderr;
114
+ const note = (msg) => {
115
+ stderr.write(`harness pack hook solution-acceptance: ${msg}\n`);
116
+ };
117
+ const raw = await readStdin(stdin);
118
+ let event = {};
119
+ try {
120
+ event = JSON.parse(raw.trim() || "{}");
121
+ }
122
+ catch {
123
+ /* event stays {} */
124
+ }
125
+ // Operator pause yields even this gate.
126
+ if (checkPauseFromLoader({ loaderOpts: opts, hookLabel: PACK_NAME, stderr }).paused) {
127
+ const diagnostic = "harness paused; solution-acceptance allowing without evaluating.";
128
+ return { exitCode: 0, blocked: false, diagnostic };
129
+ }
130
+ const sessionId = (typeof event.session_id === "string" ? event.session_id : undefined) ??
131
+ process.env["CLAUDE_CODE_SESSION_ID"] ??
132
+ process.env["CLAUDE_SESSION_ID"] ??
133
+ "";
134
+ const toolName = typeof event.tool_name === "string" ? event.tool_name : "(unknown)";
135
+ const cwd = typeof opts.cwd === "string" && opts.cwd.length > 0
136
+ ? opts.cwd
137
+ : typeof event.cwd === "string" && event.cwd.length > 0
138
+ ? event.cwd
139
+ : process.cwd();
140
+ // Load manifest to resolve the pack config. A load failure forces BLOCK
141
+ // only if this turns out to be a completion action; resolve it first.
142
+ // `manifestPath` (the resolved manifest base) feeds the harness.generated/
143
+ // lookup below — it is populated whether the operator passed --config or
144
+ // the default (~/.harness/harness.yaml) was resolved, so the bare
145
+ // production hook command still resolves the active-claim id.
146
+ let manifest = null;
147
+ let manifestPath;
148
+ if (opts.manifest) {
149
+ manifest = opts.manifest;
150
+ }
151
+ else {
152
+ try {
153
+ const loaded = loadManifest(opts);
154
+ manifest = loaded.manifest;
155
+ manifestPath = loaded.resolved.base;
156
+ }
157
+ catch (err) {
158
+ // We cannot tell if this is a gated action without the config, but a
159
+ // manifest load failure should not block unrelated tool calls. Only
160
+ // the completion verbs / push commands are ever gated, so classify
161
+ // by tool name with the DEFAULT verb set as a failsafe.
162
+ const label = completionActionLabel(toolName, event.tool_input, [
163
+ "task_finish",
164
+ "task_submit_pr",
165
+ "task_merge",
166
+ "pull_requests_merge",
167
+ ]);
168
+ if (label === null) {
169
+ const diagnostic = `manifest load failed (${err.message}) but ${toolName} is not a completion action; allowing`;
170
+ note(diagnostic);
171
+ return { exitCode: 0, blocked: false, diagnostic };
172
+ }
173
+ const reason = `manifest load failed (${err.message}); refusing ${label} on failsafe`;
174
+ const diagnostic = `BLOCK — ${reason}`;
175
+ note(diagnostic);
176
+ stdout.write(`${blockJson(label, toolName, "<unknown>", reason, undefined, sessionId)}\n`);
177
+ return { exitCode: 0, blocked: true, diagnostic };
178
+ }
179
+ }
180
+ const pack = manifest.policy_packs.find((p) => p.name === PACK_NAME);
181
+ if (!pack) {
182
+ const diagnostic = `pack "${PACK_NAME}" not declared in manifest, allowing`;
183
+ note(diagnostic);
184
+ return { exitCode: 0, blocked: false, diagnostic };
185
+ }
186
+ if (!pack.enabled) {
187
+ const diagnostic = `pack "${PACK_NAME}" is enabled:false, allowing`;
188
+ note(diagnostic);
189
+ return { exitCode: 0, blocked: false, diagnostic };
190
+ }
191
+ const protectedVerbs = resolveProtectedCompletionTools(pack);
192
+ const actionLabel = completionActionLabel(toolName, event.tool_input, protectedVerbs);
193
+ if (actionLabel === null) {
194
+ const diagnostic = `${toolName} is not a gated completion action; allowing`;
195
+ note(diagnostic);
196
+ return { exitCode: 0, blocked: false, diagnostic };
197
+ }
198
+ const configUx = parseConfigUx(pack.config["ux"], stderr);
199
+ // Resolve the verdict id from the active-claim task id. Fail CLOSED when
200
+ // there is no claim (sessionId fallback would reopen the wrong-scope bug).
201
+ const generatedDir = opts.generatedDir ??
202
+ (manifestPath !== undefined
203
+ ? resolveGeneratedDir({
204
+ ...(opts.homeDir !== undefined ? { homeDir: opts.homeDir } : {}),
205
+ manifestPath,
206
+ })
207
+ : undefined);
208
+ const taskId = opts.activeClaim !== undefined
209
+ ? opts.activeClaim
210
+ : generatedDir !== undefined
211
+ ? readActiveClaim(generatedDir)
212
+ : null;
213
+ if (!taskId) {
214
+ const detail = opts.activeClaim === undefined && generatedDir === undefined
215
+ ? " (could not resolve harness.generated/; pass --config)"
216
+ : "";
217
+ const reason = `no active-claim task id recorded${detail}; call mcp__agent-tasks__task_start first (the verdict id is the active task)`;
218
+ const diagnostic = `BLOCK — ${reason}`;
219
+ note(diagnostic);
220
+ stdout.write(`${blockJson(actionLabel, toolName, "<no-active-claim>", reason, configUx, sessionId)}\n`);
221
+ return { exitCode: 0, blocked: true, diagnostic };
222
+ }
223
+ const dir = opts.verdictDir ?? resolveVerdictDir();
224
+ const currentHead = resolveGitContext(cwd).sha || null;
225
+ const verdict = readVerdict(dir, taskId);
226
+ const gate = evaluateGate(verdict, currentHead, taskId);
227
+ if (gate.allowed) {
228
+ const diagnostic = `${gate.reason}; allowing ${actionLabel}`;
229
+ note(diagnostic);
230
+ return { exitCode: 0, blocked: false, diagnostic };
231
+ }
232
+ const diagnostic = `BLOCK — ${gate.reason}`;
233
+ note(diagnostic);
234
+ stdout.write(`${blockJson(actionLabel, toolName, taskId, gate.reason, configUx, sessionId)}\n`);
235
+ return { exitCode: 0, blocked: true, diagnostic };
236
+ }
237
+ //# sourceMappingURL=hook-solution-acceptance.js.map