@gethmy/harness 1.1.1 → 1.2.1
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/dist/cli.js +219 -51
- package/dist/index.js +393 -50
- package/package.json +2 -2
- package/src/cli.ts +171 -26
- package/src/confine-to-repo.test.ts +144 -0
- package/src/confine-to-repo.ts +113 -0
- package/src/gate-config-error.ts +19 -69
- package/src/harmony-client.ts +3 -0
- package/src/index.ts +3 -0
- package/src/model-tier.test.ts +88 -24
- package/src/model-tier.ts +45 -15
- package/src/motor-stream.ts +120 -0
- package/src/oracle-collector.ts +15 -1
- package/src/oracle.ts +7 -0
- package/src/run-sizing.test.ts +321 -0
- package/src/run-sizing.ts +393 -0
- package/src/runner.ts +16 -0
- package/src/sdk-agent-runner.ts +44 -3
- package/src/stage-cli.ts +94 -2
- package/src/stage-run.ts +32 -4
- package/src/worktree.ts +147 -21
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@gethmy/harness",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.2.1",
|
|
4
4
|
"description": "Execution motor for Harmony playbook stages. Runs exactly one stage per invocation: worktree, role-separated subagents, held oracle, gate evidence. It never routes, never judges, never pushes.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "dist/index.js",
|
|
@@ -55,7 +55,7 @@
|
|
|
55
55
|
},
|
|
56
56
|
"dependencies": {
|
|
57
57
|
"@anthropic-ai/claude-agent-sdk": "^0.3.178",
|
|
58
|
-
"@gethmy/mcp": "2.
|
|
58
|
+
"@gethmy/mcp": "2.25.0",
|
|
59
59
|
"@supabase/supabase-js": "2.95.3"
|
|
60
60
|
},
|
|
61
61
|
"devDependencies": {
|
package/src/cli.ts
CHANGED
|
@@ -31,6 +31,7 @@ import {
|
|
|
31
31
|
} from "./gate-collectors.js";
|
|
32
32
|
import { HarmonyClient, readClientConfig } from "./harmony-client.js";
|
|
33
33
|
import { log } from "./log.js";
|
|
34
|
+
import { relayAgentEvent } from "./motor-stream.js";
|
|
34
35
|
import { place, remove, runHeldOracle } from "./oracle.js";
|
|
35
36
|
import { SdkAgentRunner } from "./sdk-agent-runner.js";
|
|
36
37
|
import {
|
|
@@ -38,11 +39,13 @@ import {
|
|
|
38
39
|
assertStageIsAgentRunnable,
|
|
39
40
|
buildStagePrompt,
|
|
40
41
|
buildStageRunnerConfig,
|
|
42
|
+
describeShutdown,
|
|
41
43
|
findOracleTargetStage,
|
|
42
44
|
parseMetricsAllowlist,
|
|
43
45
|
parseStageRunArgs,
|
|
44
46
|
resolvePinnedStage,
|
|
45
47
|
STAGE_RUN_USAGE,
|
|
48
|
+
stageTimeoutMs,
|
|
46
49
|
} from "./stage-cli.js";
|
|
47
50
|
import { runStage, type StageRunRequest } from "./stage-run.js";
|
|
48
51
|
|
|
@@ -56,6 +59,65 @@ const TAG = "cli";
|
|
|
56
59
|
*/
|
|
57
60
|
const GATE_VERIFICATION_TIMEOUT_MS = 600_000;
|
|
58
61
|
|
|
62
|
+
/** Hard exit if the motor's own teardown does not finish after a driver signal. */
|
|
63
|
+
const SHUTDOWN_HARD_EXIT_MS = 15_000;
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* The subagent runner for the stage currently in flight, published so the
|
|
67
|
+
* signal handlers can tear it down (card #885).
|
|
68
|
+
*
|
|
69
|
+
* A driver signals THIS process. The `claude` subagent is a grandchild in its
|
|
70
|
+
* OWN process group (`spawnInGroup` detaches it), so a group kill aimed at the
|
|
71
|
+
* motor never reaches it — only the motor can, through `SdkAgentRunner.stop`,
|
|
72
|
+
* which aborts the query and then escalates on the subagent's own pgid. Without
|
|
73
|
+
* a handler here, Node's default SIGTERM disposition killed the motor outright
|
|
74
|
+
* and left a token-spending `claude` writing into a worktree the driver was
|
|
75
|
+
* already tearing down.
|
|
76
|
+
*/
|
|
77
|
+
let activeRunner: SdkAgentRunner | null = null;
|
|
78
|
+
let shuttingDown = false;
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Stop the stage subagent, then exit non-zero with the motor's error protocol
|
|
82
|
+
* so the driver reads a cause rather than a bare signal death. Re-entrant: a
|
|
83
|
+
* second signal while the first teardown runs is ignored, and the unref'd hard
|
|
84
|
+
* exit is the floor under a `stop()` that never returns.
|
|
85
|
+
*/
|
|
86
|
+
async function shutdown(signal: NodeJS.Signals): Promise<void> {
|
|
87
|
+
if (shuttingDown) return;
|
|
88
|
+
shuttingDown = true;
|
|
89
|
+
const runner = activeRunner;
|
|
90
|
+
// `describeShutdown` refuses to claim a teardown that did not happen: no
|
|
91
|
+
// subagent is in flight during argument parsing, the card and playbook
|
|
92
|
+
// fetches, or the whole gate-collection window, and a driver renders this
|
|
93
|
+
// message verbatim.
|
|
94
|
+
const plan = describeShutdown(signal, runner !== null);
|
|
95
|
+
// Written BEFORE the teardown. Node writes stdout/stderr to a pipe
|
|
96
|
+
// synchronously on Windows and Linux but ASYNCHRONOUSLY on macOS, and
|
|
97
|
+
// `process.exit` does not flush a pending async write — so on the platform
|
|
98
|
+
// this daemon usually runs on, a line written immediately before exiting can
|
|
99
|
+
// be lost outright. Emitting it here gives the teardown's own duration as the
|
|
100
|
+
// flush margin.
|
|
101
|
+
//
|
|
102
|
+
// Note this line reaches a HUMAN or the interactive `hmy` driver, not the
|
|
103
|
+
// daemon: when the daemon signals, it does so through its own abort, which
|
|
104
|
+
// settles `runMotorStage` synchronously with its own `errorMessage` and never
|
|
105
|
+
// reads this one.
|
|
106
|
+
process.stderr.write(
|
|
107
|
+
`${JSON.stringify({ type: "error", message: plan.message })}\n`,
|
|
108
|
+
);
|
|
109
|
+
const hardExit = setTimeout(() => process.exit(1), SHUTDOWN_HARD_EXIT_MS);
|
|
110
|
+
hardExit.unref?.();
|
|
111
|
+
if (plan.stopSubagent) {
|
|
112
|
+
try {
|
|
113
|
+
await runner?.stop("shutdown");
|
|
114
|
+
} catch {
|
|
115
|
+
// Already gone — the exit below is the point.
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
process.exit(1);
|
|
119
|
+
}
|
|
120
|
+
|
|
59
121
|
/**
|
|
60
122
|
* Run the stage's subagent to completion. The launch decides the role, the
|
|
61
123
|
* environment and the tool denies (runner.ts); `envKeysDroppedByLaunch` turns
|
|
@@ -63,13 +125,18 @@ const GATE_VERIFICATION_TIMEOUT_MS = 600_000;
|
|
|
63
125
|
* key would not remove it, because the child's environment is rebuilt from the
|
|
64
126
|
* motor's own `process.env`.
|
|
65
127
|
*
|
|
66
|
-
* The subagent's
|
|
67
|
-
*
|
|
68
|
-
* the
|
|
128
|
+
* The subagent's events are RELAYED to the driver on stdout (`agent_event`,
|
|
129
|
+
* card #885) and logged on stderr. They are diagnostics to a human reader and
|
|
130
|
+
* live telemetry to a driver: the relayed draft is the very shape the daemon's
|
|
131
|
+
* `ProgressTracker` ingests, which is what puts the run's turns, tokens, cost
|
|
132
|
+
* and tool actions on the board. The stderr line is `log.event`, not
|
|
133
|
+
* `log.debug` — a default-level run used to print exactly one line for a
|
|
134
|
+
* nine-minute stage, because `debug` is dropped unless `DEBUG` is set.
|
|
69
135
|
*/
|
|
70
136
|
async function runRole(
|
|
71
137
|
request: StageRunRequest,
|
|
72
138
|
prompt: string,
|
|
139
|
+
emit: (line: unknown) => void,
|
|
73
140
|
): Promise<void> {
|
|
74
141
|
// `buildStageRunnerConfig` owns the role's env strip and tool deny — see
|
|
75
142
|
// stage-cli.ts for why they live behind a tested seam rather than inline here.
|
|
@@ -80,29 +147,94 @@ async function runRole(
|
|
|
80
147
|
parentEnv: process.env,
|
|
81
148
|
});
|
|
82
149
|
const runner = new SdkAgentRunner(launch.config);
|
|
150
|
+
activeRunner = runner;
|
|
83
151
|
|
|
84
152
|
log.info(
|
|
85
153
|
TAG,
|
|
86
154
|
`Running stage ${request.stageId} as role ${launch.role ?? "(none — fail-closed)"}`,
|
|
87
155
|
);
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
156
|
+
|
|
157
|
+
// The motor's own clock. `stop("timeout")` runs the same cooperative
|
|
158
|
+
// abort + pgid escalation a driver's signal would, so the subagent dies with
|
|
159
|
+
// the motor instead of outliving it — and the `for await` below ends, which
|
|
160
|
+
// is what lets the run report the timeout instead of hanging on it.
|
|
161
|
+
const timeoutMs = stageTimeoutMs(process.env);
|
|
162
|
+
let timedOut = false;
|
|
163
|
+
const clock =
|
|
164
|
+
timeoutMs > 0
|
|
165
|
+
? setTimeout(() => {
|
|
166
|
+
timedOut = true;
|
|
167
|
+
log.warn(
|
|
168
|
+
TAG,
|
|
169
|
+
`stage ${request.stageId} exceeded ${timeoutMs}ms — stopping the subagent`,
|
|
170
|
+
);
|
|
171
|
+
void runner.stop("timeout");
|
|
172
|
+
}, timeoutMs)
|
|
173
|
+
: null;
|
|
174
|
+
clock?.unref?.();
|
|
175
|
+
|
|
176
|
+
try {
|
|
177
|
+
for await (const event of runner.start({
|
|
178
|
+
// The Harmony agent-session id the runner LABELS its events with. It never
|
|
179
|
+
// enters the subagent's context: the prompt is built without it, and
|
|
180
|
+
// `RoleLaunch` has no field for it.
|
|
181
|
+
sessionId: request.sessionId,
|
|
182
|
+
cardId: request.cardId,
|
|
183
|
+
workspaceId: request.workspaceId,
|
|
184
|
+
prompt: launch.prompt,
|
|
185
|
+
cwd: launch.cwd,
|
|
186
|
+
})) {
|
|
187
|
+
const relayed = relayAgentEvent(event);
|
|
188
|
+
if (relayed) emit(relayed);
|
|
189
|
+
if (event.kind === "error") {
|
|
190
|
+
log.warn(TAG, `subagent error: ${event.payload.message}`);
|
|
191
|
+
} else {
|
|
192
|
+
log.event(TAG, `subagent ${event.kind}`);
|
|
193
|
+
}
|
|
102
194
|
}
|
|
195
|
+
} finally {
|
|
196
|
+
if (clock) clearTimeout(clock);
|
|
197
|
+
activeRunner = null;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
if (timedOut) {
|
|
201
|
+
throw new Error(
|
|
202
|
+
`stage ${request.stageId} exceeded its ${timeoutMs}ms wall-clock bound and its subagent was stopped`,
|
|
203
|
+
);
|
|
103
204
|
}
|
|
104
205
|
}
|
|
105
206
|
|
|
207
|
+
/**
|
|
208
|
+
* Write one protocol line to stdout, now. Every line the motor prints goes
|
|
209
|
+
* through here so "when a driver sees it" has exactly one answer: at the moment
|
|
210
|
+
* it happened (card #885).
|
|
211
|
+
*
|
|
212
|
+
* Total by design. A driver that went away leaves a closed pipe, and the write
|
|
213
|
+
* then fails EPIPE — which must not abort a stage run that is otherwise fine
|
|
214
|
+
* and whose evidence row is already persisted. Nobody is left to read the line,
|
|
215
|
+
* so there is nothing to salvage by failing here.
|
|
216
|
+
*
|
|
217
|
+
* The `try` alone does not deliver that. A pipe write is synchronous on Windows
|
|
218
|
+
* and Linux, where EPIPE arrives as a throw and is caught here — but it is
|
|
219
|
+
* ASYNCHRONOUS on macOS, where it arrives as an `'error'` event instead and
|
|
220
|
+
* would go unhandled. Hence the stream handlers installed below. That path
|
|
221
|
+
* matters far more since #885: stdout now carries thousands of lines during a
|
|
222
|
+
* run rather than four at the end of it.
|
|
223
|
+
*/
|
|
224
|
+
function emitLine(line: unknown): void {
|
|
225
|
+
try {
|
|
226
|
+
process.stdout.write(`${JSON.stringify(line)}\n`);
|
|
227
|
+
} catch {
|
|
228
|
+
// Deliberately swallowed — see above.
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
// The async half of the same contract: an EPIPE on a macOS pipe surfaces here,
|
|
233
|
+
// not as a throw, and an unhandled stream 'error' would take the motor down
|
|
234
|
+
// mid-stage over an audience that already left.
|
|
235
|
+
process.stdout.on("error", () => {});
|
|
236
|
+
process.stderr.on("error", () => {});
|
|
237
|
+
|
|
106
238
|
async function main(): Promise<void> {
|
|
107
239
|
const parsed = parseStageRunArgs(process.argv.slice(2));
|
|
108
240
|
if (!parsed.ok) {
|
|
@@ -192,10 +324,13 @@ async function main(): Promise<void> {
|
|
|
192
324
|
};
|
|
193
325
|
|
|
194
326
|
const result = await runStage(request, {
|
|
327
|
+
// Flush every protocol event when it happens (card #885). `runStage` still
|
|
328
|
+
// accumulates them for the final `result` line — this is the live copy.
|
|
329
|
+
emit: emitLine,
|
|
195
330
|
// Already resolved above, because the stage's ROLE and prompt come off the
|
|
196
331
|
// same pinned def and have to be known before the subagent launches.
|
|
197
332
|
resolveGate: async () => pinned.gate,
|
|
198
|
-
runRole: (req) => runRole(req, prompt),
|
|
333
|
+
runRole: (req) => runRole(req, prompt, emitLine),
|
|
199
334
|
collect: async (req, gate) => {
|
|
200
335
|
const registry = buildGateCollectorRegistry({
|
|
201
336
|
build: {
|
|
@@ -235,10 +370,9 @@ async function main(): Promise<void> {
|
|
|
235
370
|
},
|
|
236
371
|
});
|
|
237
372
|
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
373
|
+
// `result.events` is NOT reprinted here: `emit` above already wrote each one
|
|
374
|
+
// at the moment it happened, and a second pass would duplicate every line for
|
|
375
|
+
// a driver that parses the stream.
|
|
242
376
|
if (result.evidence && pinned.gate) {
|
|
243
377
|
const context: GateEvidenceContext = {
|
|
244
378
|
cardId,
|
|
@@ -255,12 +389,23 @@ async function main(): Promise<void> {
|
|
|
255
389
|
await client.recordStageGateEvidence(
|
|
256
390
|
toStageGateEvidenceInsert(context, result.evidence),
|
|
257
391
|
);
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
392
|
+
emitLine({
|
|
393
|
+
type: "gate_verdict",
|
|
394
|
+
passed: evaluation.passed,
|
|
395
|
+
findings: evaluation.findings,
|
|
396
|
+
});
|
|
261
397
|
}
|
|
262
398
|
|
|
263
|
-
|
|
399
|
+
emitLine({ type: "result", ...result });
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
// Installed BEFORE main runs: a driver that signals during argument parsing or
|
|
403
|
+
// the first API call must still get the motor's error protocol rather than a
|
|
404
|
+
// silent signal death (card #885).
|
|
405
|
+
for (const signal of ["SIGINT", "SIGTERM"] as const) {
|
|
406
|
+
process.on(signal, () => {
|
|
407
|
+
void shutdown(signal);
|
|
408
|
+
});
|
|
264
409
|
}
|
|
265
410
|
|
|
266
411
|
main().catch((err: unknown) => {
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import {
|
|
3
|
+
confineToRepo,
|
|
4
|
+
decideConfinedTool,
|
|
5
|
+
isInsideTree,
|
|
6
|
+
} from "./confine-to-repo.js";
|
|
7
|
+
|
|
8
|
+
const ROOT = "/Users/dev/repo";
|
|
9
|
+
|
|
10
|
+
function decide(tool: string, input: Record<string, unknown>) {
|
|
11
|
+
return decideConfinedTool(ROOT, tool, input);
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
describe("isInsideTree", () => {
|
|
15
|
+
it("accepts the root itself and anything under it", () => {
|
|
16
|
+
expect(isInsideTree(ROOT, ROOT)).toBe(true);
|
|
17
|
+
expect(isInsideTree(ROOT, `${ROOT}/src/index.ts`)).toBe(true);
|
|
18
|
+
expect(isInsideTree(ROOT, `${ROOT}/a/b/c/d.ts`)).toBe(true);
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
it("rejects a sibling whose name merely starts with the root", () => {
|
|
22
|
+
// The separator check is the whole point: without it `/Users/dev/repo-secrets`
|
|
23
|
+
// reads as inside `/Users/dev/repo`.
|
|
24
|
+
expect(isInsideTree(ROOT, "/Users/dev/repo-secrets/.env")).toBe(false);
|
|
25
|
+
expect(isInsideTree(ROOT, "/Users/dev/repository/x")).toBe(false);
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
it("rejects a parent and an unrelated tree", () => {
|
|
29
|
+
expect(isInsideTree(ROOT, "/Users/dev")).toBe(false);
|
|
30
|
+
expect(isInsideTree(ROOT, "/etc/passwd")).toBe(false);
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
it("normalises traversal before comparing", () => {
|
|
34
|
+
expect(isInsideTree(ROOT, `${ROOT}/../repo/src/a.ts`)).toBe(true);
|
|
35
|
+
expect(isInsideTree(ROOT, `${ROOT}/../../etc/passwd`)).toBe(false);
|
|
36
|
+
});
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
describe("decideConfinedTool", () => {
|
|
40
|
+
it("allows a read inside the tree", () => {
|
|
41
|
+
expect(decide("Read", { file_path: `${ROOT}/src/index.ts` })).toEqual({
|
|
42
|
+
behavior: "allow",
|
|
43
|
+
});
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
it("resolves a relative path against the repo, not process.cwd()", () => {
|
|
47
|
+
// The daemon's own cwd may be somewhere else entirely.
|
|
48
|
+
expect(decide("Read", { file_path: "src/index.ts" })).toEqual({
|
|
49
|
+
behavior: "allow",
|
|
50
|
+
});
|
|
51
|
+
expect(decide("Read", { file_path: "../../.ssh/id_rsa" }).behavior).toBe(
|
|
52
|
+
"deny",
|
|
53
|
+
);
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
// --- The paths this exists to close. ---
|
|
57
|
+
|
|
58
|
+
it("denies the operator's credential directory", () => {
|
|
59
|
+
const r = decide("Read", {
|
|
60
|
+
file_path: "/Users/dev/.harmony-mcp/config.json",
|
|
61
|
+
});
|
|
62
|
+
expect(r.behavior).toBe("deny");
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
it("denies an ssh key", () => {
|
|
66
|
+
expect(
|
|
67
|
+
decide("Read", { file_path: "/Users/dev/.ssh/id_rsa" }).behavior,
|
|
68
|
+
).toBe("deny");
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
it("denies traversal out of the tree", () => {
|
|
72
|
+
expect(
|
|
73
|
+
decide("Read", { file_path: `${ROOT}/../../.aws/credentials` }).behavior,
|
|
74
|
+
).toBe("deny");
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
it("denies a sibling directory that shares the root's prefix", () => {
|
|
78
|
+
expect(
|
|
79
|
+
decide("Read", { file_path: "/Users/dev/repo-secrets/.env" }).behavior,
|
|
80
|
+
).toBe("deny");
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
it("names the refused path so the denial is legible", () => {
|
|
84
|
+
const r = decide("Read", { file_path: "/etc/passwd" });
|
|
85
|
+
expect(r.behavior).toBe("deny");
|
|
86
|
+
if (r.behavior === "deny") expect(r.message).toContain("/etc/passwd");
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
// --- Every read-only tool, and every argument each can carry. ---
|
|
90
|
+
|
|
91
|
+
it("checks Grep and Glob paths too", () => {
|
|
92
|
+
expect(decide("Grep", { path: `${ROOT}/src` })).toEqual({
|
|
93
|
+
behavior: "allow",
|
|
94
|
+
});
|
|
95
|
+
expect(decide("Grep", { path: "/etc" }).behavior).toBe("deny");
|
|
96
|
+
expect(decide("Glob", { path: "/Users/dev" }).behavior).toBe("deny");
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
it("checks every path argument a Read can carry", () => {
|
|
100
|
+
expect(decide("Read", { path: "/etc/hosts" }).behavior).toBe("deny");
|
|
101
|
+
expect(decide("Read", { notebook_path: "/etc/x.ipynb" }).behavior).toBe(
|
|
102
|
+
"deny",
|
|
103
|
+
);
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
it("allows a pathless call, which defaults to the session cwd", () => {
|
|
107
|
+
// Grep with no `path` searches cwd — which IS the confined tree.
|
|
108
|
+
expect(decide("Grep", { pattern: "foo" })).toEqual({ behavior: "allow" });
|
|
109
|
+
expect(decide("Glob", { pattern: "**/*.ts" })).toEqual({
|
|
110
|
+
behavior: "allow",
|
|
111
|
+
});
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
it("ignores a non-string or empty path rather than crashing", () => {
|
|
115
|
+
expect(decide("Read", { file_path: 42 })).toEqual({ behavior: "allow" });
|
|
116
|
+
expect(decide("Read", { file_path: "" })).toEqual({ behavior: "allow" });
|
|
117
|
+
expect(decide("Read", { file_path: null })).toEqual({ behavior: "allow" });
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
it("denies any tool it does not recognise", () => {
|
|
121
|
+
// Fails closed: an unrecognised tool means the allow-list drifted or the
|
|
122
|
+
// model reached for something this spawn should not have.
|
|
123
|
+
expect(decide("Bash", { command: "cat /etc/passwd" }).behavior).toBe(
|
|
124
|
+
"deny",
|
|
125
|
+
);
|
|
126
|
+
expect(decide("Write", { file_path: `${ROOT}/x.ts` }).behavior).toBe(
|
|
127
|
+
"deny",
|
|
128
|
+
);
|
|
129
|
+
expect(decide("Edit", { file_path: `${ROOT}/x.ts` }).behavior).toBe("deny");
|
|
130
|
+
expect(decide("WebFetch", { url: "https://x" }).behavior).toBe("deny");
|
|
131
|
+
});
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
describe("confineToRepo", () => {
|
|
135
|
+
it("returns the async shape the SDK expects", async () => {
|
|
136
|
+
const handler = confineToRepo(ROOT);
|
|
137
|
+
await expect(
|
|
138
|
+
handler("Read", { file_path: `${ROOT}/a.ts` }),
|
|
139
|
+
).resolves.toEqual({ behavior: "allow" });
|
|
140
|
+
await expect(
|
|
141
|
+
handler("Read", { file_path: "/etc/passwd" }),
|
|
142
|
+
).resolves.toMatchObject({ behavior: "deny" });
|
|
143
|
+
});
|
|
144
|
+
});
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Confine a read-only spawn to one directory tree.
|
|
3
|
+
*
|
|
4
|
+
* ## Why a handler and not a permission string
|
|
5
|
+
*
|
|
6
|
+
* The obvious move is a scoped rule in `allowedTools` — `Read(/**)` and friends.
|
|
7
|
+
* The Agent SDK documents `allowedTools` as a list of tool NAMES ("To restrict
|
|
8
|
+
* which tools are available, use the `tools` option instead"), so a rule-shaped
|
|
9
|
+
* entry there most likely matches no tool at all. That failure is silent and
|
|
10
|
+
* expensive: the tool is denied, the preflight returns no verdict, `sizeRun`
|
|
11
|
+
* answers `null`, and the daemon degrades to the policy fallback forever with
|
|
12
|
+
* nothing in the logs to say the sizing step stopped working.
|
|
13
|
+
*
|
|
14
|
+
* `canUseTool` is documented to run before each tool execution and receives the
|
|
15
|
+
* tool's INPUT, so the path can be checked directly. It is an ordinary function,
|
|
16
|
+
* which means the rule is unit-testable rather than a string whose semantics we
|
|
17
|
+
* would be guessing at.
|
|
18
|
+
*
|
|
19
|
+
* ## What it is for
|
|
20
|
+
*
|
|
21
|
+
* The pickup sizing preflight reads the operator's PRIMARY checkout, not a
|
|
22
|
+
* disposable worktree — the card's worktree does not exist when the model has to
|
|
23
|
+
* be chosen. Its prompt is built from card text anyone in the workspace can
|
|
24
|
+
* write. Denying the credential directory by name closes the worst path, but it
|
|
25
|
+
* is a blocklist: a repo-root `.env`, an `~/.ssh` key, or anything else the
|
|
26
|
+
* daemon's user can read stays reachable. This inverts that into an allowlist —
|
|
27
|
+
* inside the tree, or denied.
|
|
28
|
+
*
|
|
29
|
+
* Prompt-level containment (JSON-encoded card data) makes injection harder; this
|
|
30
|
+
* is what bounds it when that fails.
|
|
31
|
+
*/
|
|
32
|
+
import { isAbsolute, resolve, sep } from "node:path";
|
|
33
|
+
|
|
34
|
+
/** The result shape the Agent SDK's `canUseTool` must return. */
|
|
35
|
+
export type ConfineDecision =
|
|
36
|
+
| { behavior: "allow" }
|
|
37
|
+
| { behavior: "deny"; message: string };
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Where each read-only tool carries the path it wants to touch.
|
|
41
|
+
*
|
|
42
|
+
* A tool absent from this map is denied outright rather than allowed: this
|
|
43
|
+
* guards a spawn whose allow-list is meant to be exactly these three, so an
|
|
44
|
+
* unrecognised tool means either the allow-list drifted or the model reached
|
|
45
|
+
* for something it should not have. Failing closed is the honest answer to
|
|
46
|
+
* "I do not know what this tool would do".
|
|
47
|
+
*/
|
|
48
|
+
const PATH_ARG_BY_TOOL: Record<string, readonly string[]> = {
|
|
49
|
+
Read: ["file_path", "path", "notebook_path"],
|
|
50
|
+
Grep: ["path"],
|
|
51
|
+
Glob: ["path"],
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
/** Is `target` inside `root` (or root itself)? */
|
|
55
|
+
export function isInsideTree(root: string, target: string): boolean {
|
|
56
|
+
const normalizedRoot = resolve(root);
|
|
57
|
+
const normalizedTarget = resolve(target);
|
|
58
|
+
if (normalizedTarget === normalizedRoot) return true;
|
|
59
|
+
// The separator matters: without it `/repo-secrets` reads as inside `/repo`.
|
|
60
|
+
return normalizedTarget.startsWith(normalizedRoot + sep);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Decide one tool call. Pure, so the policy is testable without a spawn.
|
|
65
|
+
*
|
|
66
|
+
* A tool with no path argument is allowed: `Grep` and `Glob` default to the
|
|
67
|
+
* session's `cwd`, which IS the tree being confined to. A relative path is
|
|
68
|
+
* resolved against that same root rather than `process.cwd()`, which may be
|
|
69
|
+
* somewhere else entirely in the daemon.
|
|
70
|
+
*/
|
|
71
|
+
export function decideConfinedTool(
|
|
72
|
+
repoRoot: string,
|
|
73
|
+
toolName: string,
|
|
74
|
+
input: Record<string, unknown>,
|
|
75
|
+
): ConfineDecision {
|
|
76
|
+
const pathArgs = PATH_ARG_BY_TOOL[toolName];
|
|
77
|
+
if (!pathArgs) {
|
|
78
|
+
return {
|
|
79
|
+
behavior: "deny",
|
|
80
|
+
message: `${toolName} is not available to this run.`,
|
|
81
|
+
};
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
for (const key of pathArgs) {
|
|
85
|
+
const value = input[key];
|
|
86
|
+
if (typeof value !== "string" || value.length === 0) continue;
|
|
87
|
+
const candidate = isAbsolute(value) ? value : resolve(repoRoot, value);
|
|
88
|
+
if (!isInsideTree(repoRoot, candidate)) {
|
|
89
|
+
return {
|
|
90
|
+
behavior: "deny",
|
|
91
|
+
message: `${toolName} may only read inside the repository. Refused: ${value}`,
|
|
92
|
+
};
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
return { behavior: "allow" };
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* Build the `canUseTool` handler for a spawn confined to `repoRoot`.
|
|
101
|
+
*
|
|
102
|
+
* Kept separate from {@link decideConfinedTool} so the policy stays synchronous
|
|
103
|
+
* and testable while the SDK gets the async shape it expects.
|
|
104
|
+
*/
|
|
105
|
+
export function confineToRepo(
|
|
106
|
+
repoRoot: string,
|
|
107
|
+
): (
|
|
108
|
+
toolName: string,
|
|
109
|
+
input: Record<string, unknown>,
|
|
110
|
+
) => Promise<ConfineDecision> {
|
|
111
|
+
return async (toolName, input) =>
|
|
112
|
+
decideConfinedTool(repoRoot, toolName, input);
|
|
113
|
+
}
|
package/src/gate-config-error.ts
CHANGED
|
@@ -1,73 +1,23 @@
|
|
|
1
1
|
/**
|
|
2
|
-
*
|
|
2
|
+
* Re-export of the "gate cannot be measured as configured" marker (#823), which
|
|
3
|
+
* now lives in `@harmony/shared` (`gateConfigError.ts` — read its module doc for
|
|
4
|
+
* the whole rule; card #914 moved it).
|
|
3
5
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
6
|
+
* It moved because the marker is a persistence contract on
|
|
7
|
+
* `stage_gate_evidence.structured` with readers on three runtimes: the harness
|
|
8
|
+
* collector writes it, the daemon's `stage-advance.ts` holds on it, and the Deno
|
|
9
|
+
* edge's `decideAdvance` now reads it so the board can tell a misconfigured gate
|
|
10
|
+
* apart from an unmet one. The edge cannot import the harness (dependency
|
|
11
|
+
* direction is agent → harness → shared), and a second spelling of the key in the
|
|
12
|
+
* edge is the per-path drift #495 forbids.
|
|
7
13
|
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
* - **The gate is misconfigured.** The metric name is undeclared, the
|
|
13
|
-
* declaration carries no `command`, or its `parse` mode is unknown. Nothing
|
|
14
|
-
* ran and nothing ever will: the inputs are static config, so attempt N+1
|
|
15
|
-
* computes exactly the same answer as attempt 1. Re-running costs a full
|
|
16
|
-
* Claude implementation run each time and cannot help.
|
|
17
|
-
*
|
|
18
|
-
* Before this marker existed, both routed into the generic "gate unmet → re-run
|
|
19
|
-
* this stage" path, so a typo'd metric name burned the card's whole `maxAttempts`
|
|
20
|
-
* budget (and a converge loop's whole iteration budget) before holding. The
|
|
21
|
-
* collector now tags the config-defect blocks, and `stage-advance.ts` holds on
|
|
22
|
-
* them immediately with the attempt rolled back.
|
|
23
|
-
*
|
|
24
|
-
* The marker is a plain own key on the evidence's `structured` doc, so it survives
|
|
25
|
-
* the round-trip every other field takes: `gateEvaluate` echoes `structured` into
|
|
26
|
-
* its `GateEvaluation` verbatim, and `stage_gate_evidence.structured` persists it
|
|
27
|
-
* for the board/edge to read later. It is deliberately NOT part of the gate
|
|
28
|
-
* predicate surface — `blockedDetail` composes the human-facing finding from
|
|
29
|
-
* `structured.reason` alone, so adding this key changes no verdict text.
|
|
30
|
-
*
|
|
31
|
-
* Fail-safe by omission: evidence without the key reads as retryable, which is the
|
|
32
|
-
* pre-#823 behavior. A collector opts a block IN to the hold-immediately path; it
|
|
33
|
-
* can never opt one out by accident.
|
|
34
|
-
*/
|
|
35
|
-
|
|
36
|
-
/** Own key on `GateEvidence.structured` marking a block as a configuration defect. */
|
|
37
|
-
export const GATE_CONFIG_ERROR_KEY = "configError";
|
|
38
|
-
|
|
39
|
-
/** The structured fragment a collector spreads into config-defect `blocked` evidence. */
|
|
40
|
-
export const GATE_CONFIG_ERROR_MARK: Readonly<Record<string, unknown>> =
|
|
41
|
-
Object.freeze({ [GATE_CONFIG_ERROR_KEY]: true });
|
|
42
|
-
|
|
43
|
-
/** The minimal evaluation shape this predicate reads — structurally a `GateEvaluation`. */
|
|
44
|
-
interface EvaluationLike {
|
|
45
|
-
passed: boolean;
|
|
46
|
-
structured: Record<string, unknown>;
|
|
47
|
-
}
|
|
48
|
-
|
|
49
|
-
/**
|
|
50
|
-
* The reason a gate cannot be measured as configured, or `null` when this is a
|
|
51
|
-
* normal (retryable) verdict. PURE + TOTAL — never throws, so a malformed
|
|
52
|
-
* structured doc degrades to "retryable" rather than crashing the engine.
|
|
53
|
-
*
|
|
54
|
-
* A *passing* evaluation is never a config error: `gateEvaluate` cannot pass
|
|
55
|
-
* blocked evidence, so a marked-and-passed evaluation would mean the marker was
|
|
56
|
-
* forged into a passing doc. Reading `passed` first makes that unreachable instead
|
|
57
|
-
* of merely unlikely.
|
|
14
|
+
* This file stays as the harness's own surface: `@gethmy/harness` exports these
|
|
15
|
+
* three names (`packages/harmony-agent/src/stage-advance.ts` and the motor-driver
|
|
16
|
+
* tests import them from the published package), so re-exporting keeps every
|
|
17
|
+
* existing import path working with no consumer change.
|
|
58
18
|
*/
|
|
59
|
-
export
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
if (!structured || typeof structured !== "object") return null;
|
|
65
|
-
if (!Object.hasOwn(structured, GATE_CONFIG_ERROR_KEY)) return null;
|
|
66
|
-
if ((structured as Record<string, unknown>)[GATE_CONFIG_ERROR_KEY] !== true) {
|
|
67
|
-
return null;
|
|
68
|
-
}
|
|
69
|
-
const reason = (structured as Record<string, unknown>).reason;
|
|
70
|
-
return typeof reason === "string" && reason.trim().length > 0
|
|
71
|
-
? reason.trim()
|
|
72
|
-
: "the gate cannot be measured as configured";
|
|
73
|
-
}
|
|
19
|
+
export {
|
|
20
|
+
GATE_CONFIG_ERROR_KEY,
|
|
21
|
+
GATE_CONFIG_ERROR_MARK,
|
|
22
|
+
gateConfigErrorReason,
|
|
23
|
+
} from "@harmony/shared";
|
package/src/harmony-client.ts
CHANGED
|
@@ -52,6 +52,8 @@ export type StageCardPin = Pick<
|
|
|
52
52
|
|
|
53
53
|
/** The oracle payload `POST /stage-oracle/fetch` returns (camelCase, edge-side). */
|
|
54
54
|
interface OracleResponse {
|
|
55
|
+
/** The row id — absent from pre-#927 deployments, so optional here. */
|
|
56
|
+
id?: string | null;
|
|
55
57
|
path: string;
|
|
56
58
|
content: string;
|
|
57
59
|
runnerHint?: string | null;
|
|
@@ -162,6 +164,7 @@ export class HarmonyClient {
|
|
|
162
164
|
}
|
|
163
165
|
const body = (await response.json()) as OracleResponse;
|
|
164
166
|
return {
|
|
167
|
+
id: body.id ?? null,
|
|
165
168
|
path: body.path,
|
|
166
169
|
content: body.content,
|
|
167
170
|
runnerHint: body.runnerHint ?? null,
|