@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.
- package/CHANGELOG.md +31 -0
- package/README.md +1 -0
- package/dist/cli/index.js +59 -0
- package/dist/cli/index.js.map +1 -1
- package/dist/cli/init/templates.d.ts +1 -1
- package/dist/cli/init/templates.js +15 -0
- package/dist/cli/init/templates.js.map +1 -1
- package/dist/cli/pack/hook-branch-protection.js +2 -1
- package/dist/cli/pack/hook-branch-protection.js.map +1 -1
- package/dist/cli/pack/hook-codex-pre-tool-use.js +4 -3
- package/dist/cli/pack/hook-codex-pre-tool-use.js.map +1 -1
- package/dist/cli/pack/hook-codex-stop.js +1 -0
- package/dist/cli/pack/hook-codex-stop.js.map +1 -1
- package/dist/cli/pack/hook-post-tool-use.js +1 -0
- package/dist/cli/pack/hook-post-tool-use.js.map +1 -1
- package/dist/cli/pack/hook-pre-tool-use.js +3 -2
- package/dist/cli/pack/hook-pre-tool-use.js.map +1 -1
- package/dist/cli/pack/hook-runtime-reality.d.ts +50 -0
- package/dist/cli/pack/hook-runtime-reality.js +160 -0
- package/dist/cli/pack/hook-runtime-reality.js.map +1 -0
- 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 +26 -0
- package/dist/cli/pack/hook-solution-acceptance.js +237 -0
- package/dist/cli/pack/hook-solution-acceptance.js.map +1 -0
- package/dist/cli/pause/index.d.ts +17 -3
- package/dist/cli/pause/index.js +30 -6
- package/dist/cli/pause/index.js.map +1 -1
- package/dist/cli/policy/intercept.js +7 -1
- package/dist/cli/policy/intercept.js.map +1 -1
- package/dist/cli/session-start/branch-check.js +4 -2
- package/dist/cli/session-start/branch-check.js.map +1 -1
- package/dist/cli/session-start/index.js +4 -2
- package/dist/cli/session-start/index.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/solution-acceptance-runtime.d.ts +119 -0
- package/dist/policy-packs/builtin/solution-acceptance-runtime.js +289 -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/registry.d.ts +1 -1
- package/dist/policy-packs/registry.js +10 -0
- package/dist/policy-packs/registry.js.map +1 -1
- package/dist/runtime/risk-classifier.js +64 -0
- package/dist/runtime/risk-classifier.js.map +1 -1
- package/dist/runtime/session-id.d.ts +4 -3
- package/dist/runtime/session-id.js +17 -5
- package/dist/runtime/session-id.js.map +1 -1
- package/package.json +3 -1
- 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
|