@lannguyensi/harness 0.31.0 → 0.33.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 (46) hide show
  1. package/CHANGELOG.md +33 -0
  2. package/dist/cli/approve/branch-protection.d.ts +69 -0
  3. package/dist/cli/approve/branch-protection.js +157 -0
  4. package/dist/cli/approve/branch-protection.js.map +1 -0
  5. package/dist/cli/index.js +101 -1
  6. package/dist/cli/index.js.map +1 -1
  7. package/dist/cli/init/composer.js +11 -5
  8. package/dist/cli/init/composer.js.map +1 -1
  9. package/dist/cli/init/profiles.d.ts +2 -2
  10. package/dist/cli/init/profiles.js +2 -2
  11. package/dist/cli/init/templates.d.ts +1 -1
  12. package/dist/cli/init/templates.js +23 -4
  13. package/dist/cli/init/templates.js.map +1 -1
  14. package/dist/cli/pack/hook-branch-protection.d.ts +8 -0
  15. package/dist/cli/pack/hook-branch-protection.js +59 -15
  16. package/dist/cli/pack/hook-branch-protection.js.map +1 -1
  17. package/dist/cli/pack/hook-pre-tool-use.js +31 -2
  18. package/dist/cli/pack/hook-pre-tool-use.js.map +1 -1
  19. package/dist/cli/pack/hook-solution-acceptance-writeguard.d.ts +26 -0
  20. package/dist/cli/pack/hook-solution-acceptance-writeguard.js +187 -0
  21. package/dist/cli/pack/hook-solution-acceptance-writeguard.js.map +1 -0
  22. package/dist/cli/pack/hook-solution-acceptance.d.ts +28 -0
  23. package/dist/cli/pack/hook-solution-acceptance.js +251 -0
  24. package/dist/cli/pack/hook-solution-acceptance.js.map +1 -0
  25. package/dist/cli/pack/read-only-bash.js +127 -4
  26. package/dist/cli/pack/read-only-bash.js.map +1 -1
  27. package/dist/cli/validate/checks.js +38 -0
  28. package/dist/cli/validate/checks.js.map +1 -1
  29. package/dist/policy-packs/builtin/branch-protection-runtime.d.ts +47 -6
  30. package/dist/policy-packs/builtin/branch-protection-runtime.js +53 -6
  31. package/dist/policy-packs/builtin/branch-protection-runtime.js.map +1 -1
  32. package/dist/policy-packs/builtin/branch-protection.js +21 -11
  33. package/dist/policy-packs/builtin/branch-protection.js.map +1 -1
  34. package/dist/policy-packs/builtin/solution-acceptance-runtime.d.ts +137 -0
  35. package/dist/policy-packs/builtin/solution-acceptance-runtime.js +321 -0
  36. package/dist/policy-packs/builtin/solution-acceptance-runtime.js.map +1 -0
  37. package/dist/policy-packs/builtin/solution-acceptance.d.ts +44 -0
  38. package/dist/policy-packs/builtin/solution-acceptance.js +185 -0
  39. package/dist/policy-packs/builtin/solution-acceptance.js.map +1 -0
  40. package/dist/policy-packs/builtin/understanding-before-execution.d.ts +11 -0
  41. package/dist/policy-packs/builtin/understanding-before-execution.js +15 -0
  42. package/dist/policy-packs/builtin/understanding-before-execution.js.map +1 -1
  43. package/dist/policy-packs/registry.d.ts +1 -1
  44. package/dist/policy-packs/registry.js +10 -0
  45. package/dist/policy-packs/registry.js.map +1 -1
  46. package/package.json +3 -3
@@ -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,28 @@
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
+ /** Override process.env (test injection); defaults to process.env. */
21
+ env?: NodeJS.ProcessEnv;
22
+ }
23
+ export interface PackHookSolutionAcceptanceResult {
24
+ exitCode: number;
25
+ blocked: boolean;
26
+ diagnostic: string;
27
+ }
28
+ export declare function runPackHookSolutionAcceptanceCli(opts?: PackHookSolutionAcceptanceOptions): Promise<PackHookSolutionAcceptanceResult>;
@@ -0,0 +1,251 @@
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). For solo / non-agent-tasks
12
+ // sessions that never call `task_start`, the `SOLUTION_VERDICT_ID` env knob
13
+ // supplies the id instead; it is consulted only when no active claim is
14
+ // present, so a claimed session's id stays authoritative (an env var cannot
15
+ // redirect a claimed task's verdict). With neither source the gate fails
16
+ // CLOSED — a sessionId fallback would reopen the wrong-scope bug class
17
+ // understanding-gate already fixed.
18
+ //
19
+ // Failure mode: any error in load / parse / HEAD-resolution / verdict-read
20
+ // resolves to BLOCK (branch-protection's fail-closed posture, not
21
+ // understanding-gate's fail-open). The gate's whole job is to prevent
22
+ // completion without earned acceptance, so a bug that silently allowed a
23
+ // finish through would defeat the purpose. The block envelope always names
24
+ // `solution_evaluate` as the recovery path so the operator is never wedged;
25
+ // `harness pause` (honored first) is the operator's hard override.
26
+ import { readActiveClaim, } from "../../policy-packs/builtin/understanding-before-execution-runtime.js";
27
+ import { DEFAULT_PUSH_BASH_RE, evaluateGate, PACK_NAME, readVerdict, resolveExplicitVerdictId, resolveProtectedCompletionTools, VERDICT_ID_ENV, verdictDir as resolveVerdictDir, } from "../../policy-packs/builtin/solution-acceptance-runtime.js";
28
+ import { resolveGeneratedDir } from "../../io/generated-dir.js";
29
+ import { resolveGitContext } from "../../runtime/git-context.js";
30
+ import { renderAgentFacing } from "../../runtime/agent-facing.js";
31
+ import { PolicyUxSchema } from "../../schema/index.js";
32
+ import { loadManifest } from "../loader.js";
33
+ import { checkPauseFromLoader } from "../pause-check.js";
34
+ const MCP_AGENT_TASKS_PREFIX = "mcp__agent-tasks__";
35
+ async function readStdin(stream) {
36
+ return new Promise((resolve, reject) => {
37
+ let data = "";
38
+ stream.setEncoding("utf8");
39
+ stream.on("data", (chunk) => {
40
+ data += chunk;
41
+ });
42
+ stream.on("end", () => resolve(data));
43
+ stream.on("error", (err) => reject(err));
44
+ });
45
+ }
46
+ function bashCommandOf(toolInput) {
47
+ if (typeof toolInput !== "object" || toolInput === null)
48
+ return "";
49
+ const cmd = toolInput["command"];
50
+ return typeof cmd === "string" ? cmd : "";
51
+ }
52
+ /**
53
+ * Decide whether this PreToolUse call is a gated completion action. Returns
54
+ * the human label of the action when gated, or null when this call should
55
+ * pass through (the hook matches all Bash, but only push/merge bash commands
56
+ * are completion actions).
57
+ */
58
+ function completionActionLabel(toolName, toolInput, protectedVerbs) {
59
+ if (toolName.startsWith(MCP_AGENT_TASKS_PREFIX)) {
60
+ const verb = toolName.slice(MCP_AGENT_TASKS_PREFIX.length);
61
+ if (protectedVerbs.includes(verb))
62
+ return `agent-tasks ${verb}`;
63
+ return null;
64
+ }
65
+ if (toolName === "Bash") {
66
+ const command = bashCommandOf(toolInput);
67
+ if (command && DEFAULT_PUSH_BASH_RE.test(command))
68
+ return "git push / gh pr merge";
69
+ return null;
70
+ }
71
+ return null;
72
+ }
73
+ function parseConfigUx(raw, stderr) {
74
+ if (raw === undefined)
75
+ return undefined;
76
+ const result = PolicyUxSchema.safeParse(raw);
77
+ if (!result.success) {
78
+ stderr.write(`harness pack hook solution-acceptance: config.ux ignored (${result.error.issues
79
+ .map((i) => `${i.path.join(".") || "<root>"}: ${i.message}`)
80
+ .join("; ")})\n`);
81
+ return undefined;
82
+ }
83
+ return result.data;
84
+ }
85
+ function blockJson(actionLabel, toolName, taskId, detail, ux, sessionId) {
86
+ let reasonText;
87
+ if (ux) {
88
+ reasonText = renderAgentFacing(ux, {
89
+ TOOL_NAME: toolName,
90
+ SESSION_ID: sessionId,
91
+ });
92
+ }
93
+ else {
94
+ reasonText =
95
+ `solution-acceptance: refusing ${actionLabel} (${toolName}). ${detail}\n` +
96
+ `Completion must be EARNED from a real preflight run, not claimed.\n` +
97
+ `Run the producer for this task, then retry:\n` +
98
+ ` mcp__agent-grounding__solution_evaluate({ id: "${taskId}" })\n` +
99
+ `It runs \`preflight run --json\` (lint/typecheck/test/audit/secret) and records a HEAD-pinned verdict. ` +
100
+ `A clean run at the current HEAD unblocks this tool; a failing run lists the blockers to fix.\n` +
101
+ `\n` +
102
+ `Operator override: \`harness pause\` (yields this and every other gate).`;
103
+ }
104
+ return JSON.stringify({
105
+ decision: "block",
106
+ reason: reasonText,
107
+ hookSpecificOutput: {
108
+ hookEventName: "PreToolUse",
109
+ permissionDecision: "deny",
110
+ permissionDecisionReason: reasonText,
111
+ },
112
+ });
113
+ }
114
+ export async function runPackHookSolutionAcceptanceCli(opts = {}) {
115
+ const stdin = opts.stdin ?? process.stdin;
116
+ const stdout = opts.stdout ?? process.stdout;
117
+ const stderr = opts.stderr ?? process.stderr;
118
+ const note = (msg) => {
119
+ stderr.write(`harness pack hook solution-acceptance: ${msg}\n`);
120
+ };
121
+ const env = opts.env ?? process.env;
122
+ const raw = await readStdin(stdin);
123
+ let event = {};
124
+ try {
125
+ event = JSON.parse(raw.trim() || "{}");
126
+ }
127
+ catch {
128
+ /* event stays {} */
129
+ }
130
+ // Operator pause yields even this gate.
131
+ if (checkPauseFromLoader({ loaderOpts: opts, hookLabel: PACK_NAME, stderr }).paused) {
132
+ const diagnostic = "harness paused; solution-acceptance allowing without evaluating.";
133
+ return { exitCode: 0, blocked: false, diagnostic };
134
+ }
135
+ const sessionId = (typeof event.session_id === "string" ? event.session_id : undefined) ??
136
+ env["CLAUDE_CODE_SESSION_ID"] ??
137
+ env["CLAUDE_SESSION_ID"] ??
138
+ "";
139
+ const toolName = typeof event.tool_name === "string" ? event.tool_name : "(unknown)";
140
+ const cwd = typeof opts.cwd === "string" && opts.cwd.length > 0
141
+ ? opts.cwd
142
+ : typeof event.cwd === "string" && event.cwd.length > 0
143
+ ? event.cwd
144
+ : process.cwd();
145
+ // Load manifest to resolve the pack config. A load failure forces BLOCK
146
+ // only if this turns out to be a completion action; resolve it first.
147
+ // `manifestPath` (the resolved manifest base) feeds the harness.generated/
148
+ // lookup below — it is populated whether the operator passed --config or
149
+ // the default (~/.harness/harness.yaml) was resolved, so the bare
150
+ // production hook command still resolves the active-claim id.
151
+ let manifest = null;
152
+ let manifestPath;
153
+ if (opts.manifest) {
154
+ manifest = opts.manifest;
155
+ }
156
+ else {
157
+ try {
158
+ const loaded = loadManifest(opts);
159
+ manifest = loaded.manifest;
160
+ manifestPath = loaded.resolved.base;
161
+ }
162
+ catch (err) {
163
+ // We cannot tell if this is a gated action without the config, but a
164
+ // manifest load failure should not block unrelated tool calls. Only
165
+ // the completion verbs / push commands are ever gated, so classify
166
+ // by tool name with the DEFAULT verb set as a failsafe.
167
+ const label = completionActionLabel(toolName, event.tool_input, [
168
+ "task_finish",
169
+ "task_submit_pr",
170
+ "task_merge",
171
+ "pull_requests_merge",
172
+ ]);
173
+ if (label === null) {
174
+ const diagnostic = `manifest load failed (${err.message}) but ${toolName} is not a completion action; allowing`;
175
+ note(diagnostic);
176
+ return { exitCode: 0, blocked: false, diagnostic };
177
+ }
178
+ const reason = `manifest load failed (${err.message}); refusing ${label} on failsafe`;
179
+ const diagnostic = `BLOCK — ${reason}`;
180
+ note(diagnostic);
181
+ stdout.write(`${blockJson(label, toolName, "<unknown>", reason, undefined, sessionId)}\n`);
182
+ return { exitCode: 0, blocked: true, diagnostic };
183
+ }
184
+ }
185
+ const pack = manifest.policy_packs.find((p) => p.name === PACK_NAME);
186
+ if (!pack) {
187
+ const diagnostic = `pack "${PACK_NAME}" not declared in manifest, allowing`;
188
+ note(diagnostic);
189
+ return { exitCode: 0, blocked: false, diagnostic };
190
+ }
191
+ if (!pack.enabled) {
192
+ const diagnostic = `pack "${PACK_NAME}" is enabled:false, allowing`;
193
+ note(diagnostic);
194
+ return { exitCode: 0, blocked: false, diagnostic };
195
+ }
196
+ const protectedVerbs = resolveProtectedCompletionTools(pack);
197
+ const actionLabel = completionActionLabel(toolName, event.tool_input, protectedVerbs);
198
+ if (actionLabel === null) {
199
+ const diagnostic = `${toolName} is not a gated completion action; allowing`;
200
+ note(diagnostic);
201
+ return { exitCode: 0, blocked: false, diagnostic };
202
+ }
203
+ const configUx = parseConfigUx(pack.config["ux"], stderr);
204
+ // Resolve the verdict id. Precedence: the agent-tasks active-claim task id
205
+ // first (authoritative for claimed sessions — an env var must not redirect a
206
+ // claimed task's verdict), then the SOLUTION_VERDICT_ID env knob for solo /
207
+ // non-agent-tasks sessions, then fail CLOSED. A sessionId fallback is
208
+ // intentionally NOT a source (it would reopen the wrong-scope bug class).
209
+ const generatedDir = opts.generatedDir ??
210
+ (manifestPath !== undefined
211
+ ? resolveGeneratedDir({
212
+ ...(opts.homeDir !== undefined ? { homeDir: opts.homeDir } : {}),
213
+ manifestPath,
214
+ })
215
+ : undefined);
216
+ const activeClaim = opts.activeClaim !== undefined
217
+ ? opts.activeClaim
218
+ : generatedDir !== undefined
219
+ ? readActiveClaim(generatedDir)
220
+ : null;
221
+ const taskId = activeClaim ?? resolveExplicitVerdictId(env);
222
+ if (!taskId) {
223
+ const detail = opts.activeClaim === undefined && generatedDir === undefined
224
+ ? " (could not resolve harness.generated/; pass --config)"
225
+ : "";
226
+ const reason = `no verdict id: no active-claim task id recorded${detail} and ${VERDICT_ID_ENV} is unset or invalid. ` +
227
+ `Call mcp__agent-tasks__task_start first (agent-tasks workflow; the verdict id is the active task), ` +
228
+ `or set ${VERDICT_ID_ENV} to the verdict id for a solo / non-agent-tasks session.`;
229
+ const diagnostic = `BLOCK — ${reason}`;
230
+ note(diagnostic);
231
+ stdout.write(`${blockJson(actionLabel, toolName, "<no-verdict-id>", reason, configUx, sessionId)}\n`);
232
+ return { exitCode: 0, blocked: true, diagnostic };
233
+ }
234
+ // The verdict DIR still resolves SOLUTION_VERDICT_DIR from process.env (the
235
+ // `env` seam above covers the verdict id + sessionId, not the dir); in
236
+ // production both see the same process.env, and tests inject opts.verdictDir.
237
+ const dir = opts.verdictDir ?? resolveVerdictDir();
238
+ const currentHead = resolveGitContext(cwd).sha || null;
239
+ const verdict = readVerdict(dir, taskId);
240
+ const gate = evaluateGate(verdict, currentHead, taskId);
241
+ if (gate.allowed) {
242
+ const diagnostic = `${gate.reason}; allowing ${actionLabel}`;
243
+ note(diagnostic);
244
+ return { exitCode: 0, blocked: false, diagnostic };
245
+ }
246
+ const diagnostic = `BLOCK — ${gate.reason}`;
247
+ note(diagnostic);
248
+ stdout.write(`${blockJson(actionLabel, toolName, taskId, gate.reason, configUx, sessionId)}\n`);
249
+ return { exitCode: 0, blocked: true, diagnostic };
250
+ }
251
+ //# sourceMappingURL=hook-solution-acceptance.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"hook-solution-acceptance.js","sourceRoot":"","sources":["../../../src/cli/pack/hook-solution-acceptance.ts"],"names":[],"mappings":"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"}
@@ -35,10 +35,10 @@
35
35
  * changing classification: `ls -la /tmp` is still read-only.
36
36
  */
37
37
  const SIMPLE_READ_ONLY_BINS = new Set([
38
- "ls", "cat", "pwd", "which", "type", "command",
38
+ "ls", "cat", "pwd", "which", "type",
39
39
  "grep", "rg", "wc",
40
40
  "head", "tail", "file", "stat", "tree", "du", "df",
41
- "ps", "whoami", "id", "date", "echo", "env", "printenv",
41
+ "ps", "whoami", "id", "date", "echo", "printenv",
42
42
  "true", "false", "uptime", "hostname", "uname", "tty",
43
43
  "basename", "dirname", "realpath", "readlink",
44
44
  "less", "more", "cmp", "diff", "comm",
@@ -60,6 +60,42 @@ const FIND_WRITE_FLAGS = new Set([
60
60
  "-exec", "-execdir", "-ok", "-okdir",
61
61
  "-fprint", "-fprintf", "-fprint0", "-fls",
62
62
  ]);
63
+ /**
64
+ * Command-runner binaries: their argv is itself a nested command to
65
+ * execute, so the "each accepts arguments without changing
66
+ * classification" rule does NOT hold for them. `command rm -rf /tmp/x`
67
+ * runs `rm`, and `env FOO=bar rm -rf /tmp/x` runs `rm` too. Including
68
+ * them in `SIMPLE_READ_ONLY_BINS` would classify the WRAPPER as
69
+ * read-only while the wrapped command does the write, a hard gate
70
+ * bypass. Each runner gets a `find`-style special case below that
71
+ * strips the runner's own leading flags/assignments and recurse-
72
+ * classifies the residual underlying command. Bare `env` /
73
+ * `command` (no underlying command) stay read-only: they only print
74
+ * the environment or resolve a name.
75
+ *
76
+ * `env` leading flags that take no command and do not change the fact
77
+ * that what follows is still a command to run. `-i` / `--ignore-
78
+ * environment` and `-u NAME` / `--unset=NAME` scrub the environment
79
+ * but still execute the residual command; `-` is the historical
80
+ * synonym for `-i`. `--` ends option parsing. We skip these (and any
81
+ * `NAME=VALUE` assignment tokens) to find the real underlying command.
82
+ */
83
+ const ENV_LEADING_FLAGS = new Set([
84
+ "-i", "--ignore-environment", "-", "--",
85
+ ]);
86
+ /** `env` flags that consume the following token as their value. */
87
+ const ENV_VALUE_FLAGS = new Set([
88
+ "-u", "--unset",
89
+ "-C", "--chdir",
90
+ ]);
91
+ /**
92
+ * `env -S` / `--split-string` re-parses its single string argument
93
+ * into a fresh argv, which defeats our whitespace tokenization: the
94
+ * write would live inside one quoted token. Any appearance of the
95
+ * split-string flag (bare, glued, or long-form) forfeits the
96
+ * read-only classification. Fail closed.
97
+ */
98
+ const ENV_SPLIT_STRING_FLAGS = /^(-S.*|--split-string(=.*)?)$/;
63
99
  /**
64
100
  * `less` and `more` can shell out via interactive `!cmd`. The agent
65
101
  * shell is non-interactive, so the escape is not reachable in
@@ -131,7 +167,10 @@ export function isReadOnlyBashCommand(command) {
131
167
  return false;
132
168
  // Reject any shell chaining, redirection, or command substitution.
133
169
  // These make the command unclassifiable even when every visible
134
- // piece would otherwise be read-only.
170
+ // piece would otherwise be read-only. Applied once to the whole
171
+ // string, so it also covers any residual command a runner
172
+ // (`env` / `command`) wraps: those are classified from the same
173
+ // token slice, never from a re-read of the shell.
135
174
  if (/[;&|<>]/.test(trimmed))
136
175
  return false;
137
176
  if (trimmed.includes("\n"))
@@ -140,11 +179,95 @@ export function isReadOnlyBashCommand(command) {
140
179
  return false;
141
180
  if (trimmed.includes("$("))
142
181
  return false;
143
- const tokens = trimmed.split(/\s+/);
182
+ return classifyTokens(trimmed.split(/\s+/));
183
+ }
184
+ /**
185
+ * Classify an already-tokenized, metachar-cleared argv. Factored out
186
+ * of `isReadOnlyBashCommand` so the command-runner special cases
187
+ * (`command` / `env`) can recurse on the residual underlying command
188
+ * without re-parsing a reconstructed string.
189
+ */
190
+ function classifyTokens(tokens) {
144
191
  const bin = tokens[0] ?? "";
145
192
  const sub = tokens[1] ?? "";
146
193
  if (SIMPLE_READ_ONLY_BINS.has(bin))
147
194
  return true;
195
+ // `command <cmd> ...` runs <cmd>, bypassing shell functions/aliases.
196
+ // It is read-only ONLY if the command it wraps is read-only. Strip
197
+ // `command`'s own option flags (`-p`, `-v`, `-V`, and any combined
198
+ // short flags like `-pv`), then recurse-classify the residual argv.
199
+ // Bare `command` (no residual) and the lookup-only forms `command
200
+ // -v <name>` / `command -V <name>` (which print where a name
201
+ // resolves without executing it) stay read-only.
202
+ if (bin === "command") {
203
+ let i = 1;
204
+ let lookupOnly = false;
205
+ for (; i < tokens.length; i += 1) {
206
+ const t = tokens[i];
207
+ if (t === undefined || !t.startsWith("-") || t === "--")
208
+ break;
209
+ if (/[vV]/.test(t))
210
+ lookupOnly = true;
211
+ }
212
+ if (i < tokens.length && tokens[i] === "--")
213
+ i += 1;
214
+ if (lookupOnly)
215
+ return true;
216
+ if (i >= tokens.length)
217
+ return true; // bare `command`
218
+ return classifyTokens(tokens.slice(i));
219
+ }
220
+ // `env [NAME=VALUE...] [flags] <cmd> ...` runs <cmd> in a modified
221
+ // environment. It is read-only ONLY if the command it wraps is
222
+ // read-only. Skip leading env-assignment tokens (`FOO=bar`) and
223
+ // env's own flags, then recurse-classify the residual command. Bare
224
+ // `env`, `env -u X`, `env FOO=bar` (no residual command, just prints
225
+ // the environment) stay read-only.
226
+ if (bin === "env") {
227
+ let i = 1;
228
+ while (i < tokens.length) {
229
+ const t = tokens[i];
230
+ if (t === undefined)
231
+ break;
232
+ // `env -S` / `--split-string` re-parses a string into a command:
233
+ // forfeit read-only classification (fail closed).
234
+ if (ENV_SPLIT_STRING_FLAGS.test(t))
235
+ return false;
236
+ if (t === "--") {
237
+ i += 1;
238
+ break;
239
+ }
240
+ if (ENV_VALUE_FLAGS.has(t)) {
241
+ i += 2;
242
+ continue;
243
+ }
244
+ if (ENV_LEADING_FLAGS.has(t)) {
245
+ i += 1;
246
+ continue;
247
+ }
248
+ // Long flag with a glued value (`--unset=NAME`, `--chdir=DIR`):
249
+ // single token, skip it.
250
+ if (t.startsWith("--") && t.includes("=")) {
251
+ i += 1;
252
+ continue;
253
+ }
254
+ // Short flag with a glued value (`-uNAME`, `-CDIR`): single
255
+ // token, skip it.
256
+ if (/^-[uC]./.test(t)) {
257
+ i += 1;
258
+ continue;
259
+ }
260
+ // `NAME=VALUE` environment assignment (no leading dash): skip.
261
+ if (!t.startsWith("-") && /^[A-Za-z_][A-Za-z0-9_]*=/.test(t)) {
262
+ i += 1;
263
+ continue;
264
+ }
265
+ break;
266
+ }
267
+ if (i >= tokens.length)
268
+ return true; // bare `env` / only assignments
269
+ return classifyTokens(tokens.slice(i));
270
+ }
148
271
  // `find` is read-only ONLY when none of its argv tokens are write
149
272
  // flags. Scan the whole argv: `-delete` / `-exec` / `-execdir` /
150
273
  // `-ok` / `-okdir` mutate the filesystem; `-fprint*` and `-fls`