@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.
- package/CHANGELOG.md +33 -0
- package/dist/cli/approve/branch-protection.d.ts +69 -0
- package/dist/cli/approve/branch-protection.js +157 -0
- package/dist/cli/approve/branch-protection.js.map +1 -0
- package/dist/cli/index.js +101 -1
- package/dist/cli/index.js.map +1 -1
- package/dist/cli/init/composer.js +11 -5
- package/dist/cli/init/composer.js.map +1 -1
- package/dist/cli/init/profiles.d.ts +2 -2
- package/dist/cli/init/profiles.js +2 -2
- package/dist/cli/init/templates.d.ts +1 -1
- package/dist/cli/init/templates.js +23 -4
- package/dist/cli/init/templates.js.map +1 -1
- package/dist/cli/pack/hook-branch-protection.d.ts +8 -0
- package/dist/cli/pack/hook-branch-protection.js +59 -15
- package/dist/cli/pack/hook-branch-protection.js.map +1 -1
- package/dist/cli/pack/hook-pre-tool-use.js +31 -2
- package/dist/cli/pack/hook-pre-tool-use.js.map +1 -1
- package/dist/cli/pack/hook-solution-acceptance-writeguard.d.ts +26 -0
- package/dist/cli/pack/hook-solution-acceptance-writeguard.js +187 -0
- package/dist/cli/pack/hook-solution-acceptance-writeguard.js.map +1 -0
- package/dist/cli/pack/hook-solution-acceptance.d.ts +28 -0
- package/dist/cli/pack/hook-solution-acceptance.js +251 -0
- package/dist/cli/pack/hook-solution-acceptance.js.map +1 -0
- package/dist/cli/pack/read-only-bash.js +127 -4
- package/dist/cli/pack/read-only-bash.js.map +1 -1
- package/dist/cli/validate/checks.js +38 -0
- package/dist/cli/validate/checks.js.map +1 -1
- package/dist/policy-packs/builtin/branch-protection-runtime.d.ts +47 -6
- package/dist/policy-packs/builtin/branch-protection-runtime.js +53 -6
- package/dist/policy-packs/builtin/branch-protection-runtime.js.map +1 -1
- package/dist/policy-packs/builtin/branch-protection.js +21 -11
- package/dist/policy-packs/builtin/branch-protection.js.map +1 -1
- package/dist/policy-packs/builtin/solution-acceptance-runtime.d.ts +137 -0
- package/dist/policy-packs/builtin/solution-acceptance-runtime.js +321 -0
- package/dist/policy-packs/builtin/solution-acceptance-runtime.js.map +1 -0
- package/dist/policy-packs/builtin/solution-acceptance.d.ts +44 -0
- package/dist/policy-packs/builtin/solution-acceptance.js +185 -0
- package/dist/policy-packs/builtin/solution-acceptance.js.map +1 -0
- package/dist/policy-packs/builtin/understanding-before-execution.d.ts +11 -0
- package/dist/policy-packs/builtin/understanding-before-execution.js +15 -0
- package/dist/policy-packs/builtin/understanding-before-execution.js.map +1 -1
- package/dist/policy-packs/registry.d.ts +1 -1
- package/dist/policy-packs/registry.js +10 -0
- package/dist/policy-packs/registry.js.map +1 -1
- 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",
|
|
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", "
|
|
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
|
-
|
|
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`
|