@stigmer/runner 3.0.8-dev.20260612100207 → 3.0.8-dev.20260612122433
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/.build-fingerprint +1 -1
- package/dist/activities/execute-cursor/approval-state.d.ts +25 -13
- package/dist/activities/execute-cursor/approval-state.js +33 -24
- package/dist/activities/execute-cursor/approval-state.js.map +1 -1
- package/dist/activities/execute-cursor/hook-script.d.ts +16 -1
- package/dist/activities/execute-cursor/hook-script.js +50 -1
- package/dist/activities/execute-cursor/hook-script.js.map +1 -1
- package/dist/activities/execute-cursor/index.js +60 -12
- package/dist/activities/execute-cursor/index.js.map +1 -1
- package/dist/activities/execute-cursor/session-lifecycle.js +6 -0
- package/dist/activities/execute-cursor/session-lifecycle.js.map +1 -1
- package/dist/activities/execute-cursor/skill-resolver.d.ts +9 -0
- package/dist/activities/execute-cursor/skill-resolver.js +24 -1
- package/dist/activities/execute-cursor/skill-resolver.js.map +1 -1
- package/dist/activities/execute-cursor/workspace-setup.d.ts +79 -21
- package/dist/activities/execute-cursor/workspace-setup.js +171 -51
- package/dist/activities/execute-cursor/workspace-setup.js.map +1 -1
- package/dist/shared/workspace/platform-dir.d.ts +26 -5
- package/dist/shared/workspace/platform-dir.js +38 -7
- package/dist/shared/workspace/platform-dir.js.map +1 -1
- package/package.json +2 -2
- package/src/activities/execute-cursor/__tests__/hitl-ledger.test.ts +4 -3
- package/src/activities/execute-cursor/__tests__/hook-script.test.ts +51 -2
- package/src/activities/execute-cursor/__tests__/workspace-setup.test.ts +204 -0
- package/src/activities/execute-cursor/approval-state.ts +33 -24
- package/src/activities/execute-cursor/hook-script.ts +54 -1
- package/src/activities/execute-cursor/index.ts +65 -16
- package/src/activities/execute-cursor/session-lifecycle.ts +6 -0
- package/src/activities/execute-cursor/skill-resolver.ts +26 -1
- package/src/activities/execute-cursor/workspace-setup.ts +228 -51
- package/src/shared/workspace/platform-dir.ts +41 -7
|
@@ -299,11 +299,12 @@ describe("reconstructAdjudicatedApprovals", () => {
|
|
|
299
299
|
describe("generateHookScript ledger wiring", () => {
|
|
300
300
|
it("wires the ledger path and records denials in both deny branches", () => {
|
|
301
301
|
const script = generateHookScript(
|
|
302
|
-
"/
|
|
303
|
-
"/
|
|
302
|
+
"/hitl/approval-state.json",
|
|
303
|
+
"/hitl/denials.jsonl",
|
|
304
|
+
process.pid,
|
|
304
305
|
);
|
|
305
306
|
|
|
306
|
-
expect(script).toContain('LEDGER_FILE="/
|
|
307
|
+
expect(script).toContain('LEDGER_FILE="/hitl/denials.jsonl"');
|
|
307
308
|
expect(script).toContain("record_denial()");
|
|
308
309
|
// One definition + a call in the gated-built-in branch + a call in the MCP
|
|
309
310
|
// branch = 3 occurrences.
|
|
@@ -47,6 +47,11 @@ function setup(opts: {
|
|
|
47
47
|
grants?: ApprovalGrant[];
|
|
48
48
|
mcpPolicies?: Record<string, McpToolPolicyEntry>;
|
|
49
49
|
noStateFile?: boolean;
|
|
50
|
+
// Process the hook treats as "the runner". Defaults to this test process,
|
|
51
|
+
// which is an ancestor of the bash child execFileSync spawns — so the scope
|
|
52
|
+
// guard sees the call as the runner's own agent and applies the gate. Pass a
|
|
53
|
+
// non-ancestor PID to exercise the foreign-client path (issue #173).
|
|
54
|
+
runnerPid?: number;
|
|
50
55
|
}): Harness {
|
|
51
56
|
const ws = mkdtempSync(join(tmpdir(), "hook-script-"));
|
|
52
57
|
tempDirs.push(ws);
|
|
@@ -55,7 +60,7 @@ function setup(opts: {
|
|
|
55
60
|
const statePath = join(dir, "state.json");
|
|
56
61
|
const ledgerPath = join(dir, "denials.jsonl");
|
|
57
62
|
const scriptPath = join(dir, "hook.sh");
|
|
58
|
-
writeFileSync(scriptPath, generateHookScript(statePath, ledgerPath), "utf-8");
|
|
63
|
+
writeFileSync(scriptPath, generateHookScript(statePath, ledgerPath, opts.runnerPid ?? process.pid), "utf-8");
|
|
59
64
|
|
|
60
65
|
if (!opts.noStateFile) {
|
|
61
66
|
const policies = new Map(
|
|
@@ -179,6 +184,50 @@ d("generated preToolUse hook", () => {
|
|
|
179
184
|
expect(h.decide(hookShell('rm -rf "/x"')).permission).toBe("deny");
|
|
180
185
|
});
|
|
181
186
|
|
|
187
|
+
// Issue #173: the hook ships on the workspace's shared .cursor/hooks.json, so
|
|
188
|
+
// the user's own Cursor IDE (a DIFFERENT process tree) would load and run it
|
|
189
|
+
// too. The scope guard must allow any invocation that does not descend from
|
|
190
|
+
// the runner process — without gating it and without writing the denial ledger
|
|
191
|
+
// (a foreign denial would surface as a phantom approval card in the session).
|
|
192
|
+
describe("scope guard (issue #173): foreign invocations are not gated", () => {
|
|
193
|
+
// A PID that cannot be an ancestor of the test's bash child. macOS pid_max is
|
|
194
|
+
// 99998; this is comfortably above it and above a freshly-booted Linux
|
|
195
|
+
// pid range, so get_ppid yields nothing and the walk reports "not own".
|
|
196
|
+
const FOREIGN_PID = 2_147_483_600;
|
|
197
|
+
|
|
198
|
+
it("allows a gated built-in when the invocation is not the runner's own agent", () => {
|
|
199
|
+
const h = setup({ runnerPid: FOREIGN_PID });
|
|
200
|
+
// The IDE would have this DENIED if the gate applied — it must be allowed.
|
|
201
|
+
expect(h.decide(hookWrite("/x/a.txt")).permission).toBe("allow");
|
|
202
|
+
expect(h.decide(hookShell("rm -rf build")).permission).toBe("allow");
|
|
203
|
+
});
|
|
204
|
+
|
|
205
|
+
it("never writes the denial ledger for a foreign invocation (no phantom approvals)", () => {
|
|
206
|
+
const h = setup({ runnerPid: FOREIGN_PID });
|
|
207
|
+
h.resetLedger();
|
|
208
|
+
h.decide(hookWrite("/x/a.txt"));
|
|
209
|
+
h.decide(hookShell("gh issue view 173"));
|
|
210
|
+
// The IDE's tool calls must NOT leak into the ledger the runner reads back.
|
|
211
|
+
expect(h.ledger()).toEqual([]);
|
|
212
|
+
});
|
|
213
|
+
|
|
214
|
+
it("allows a foreign invocation even when the state file is missing", () => {
|
|
215
|
+
// Fail-closed is for the runner's OWN agent; a foreign client must never be
|
|
216
|
+
// blocked by a missing state file (that was the multi-root exit-127 lockup).
|
|
217
|
+
const h = setup({ runnerPid: FOREIGN_PID, noStateFile: true });
|
|
218
|
+
expect(h.decide(hookWrite("/x/a.txt")).permission).toBe("allow");
|
|
219
|
+
expect(h.ledger()).toEqual([]);
|
|
220
|
+
});
|
|
221
|
+
|
|
222
|
+
it("still gates the runner's own agent (control: gate applies in-process)", () => {
|
|
223
|
+
// Same inputs, but runnerPid defaults to this process (an ancestor of the
|
|
224
|
+
// bash child) — the gate must apply, proving the guard discriminates.
|
|
225
|
+
const h = setup({});
|
|
226
|
+
expect(h.decide(hookWrite("/x/a.txt")).permission).toBe("deny");
|
|
227
|
+
expect(h.ledger()).toHaveLength(1);
|
|
228
|
+
});
|
|
229
|
+
});
|
|
230
|
+
|
|
182
231
|
it("still denies gated tools via the bash fallback when the Node binary is unavailable", () => {
|
|
183
232
|
const ws = mkdtempSync(join(tmpdir(), "hook-script-fallback-"));
|
|
184
233
|
tempDirs.push(ws);
|
|
@@ -188,7 +237,7 @@ d("generated preToolUse hook", () => {
|
|
|
188
237
|
const ledgerPath = join(dir, "denials.jsonl");
|
|
189
238
|
const scriptPath = join(dir, "hook.sh");
|
|
190
239
|
// Break the baked Node path to force the grep/cut fallback.
|
|
191
|
-
const script = generateHookScript(statePath, ledgerPath)
|
|
240
|
+
const script = generateHookScript(statePath, ledgerPath, process.pid)
|
|
192
241
|
.replace(`NODE_BIN="${process.execPath}"`, 'NODE_BIN="/nonexistent/node"');
|
|
193
242
|
writeFileSync(scriptPath, script, "utf-8");
|
|
194
243
|
writeFileSync(statePath, JSON.stringify(buildApprovalState(new Map(), false)), "utf-8");
|
|
@@ -0,0 +1,204 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tests for the HITL gate's workspace lifecycle (issue #173).
|
|
3
|
+
*
|
|
4
|
+
* The gate must leave the user's real repo untouched: its runtime artifacts live
|
|
5
|
+
* outside the workspace, the only in-repo file (`.cursor/hooks.json`) is merged
|
|
6
|
+
* with any pre-existing user config and points at the hook by absolute path, and
|
|
7
|
+
* the whole surface is restored when the turn ends. These tests pin all four of
|
|
8
|
+
* those guarantees plus the self-healing strip of a crash-leftover entry.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { describe, it, expect, afterEach } from "vitest";
|
|
12
|
+
import {
|
|
13
|
+
mkdtempSync,
|
|
14
|
+
mkdirSync,
|
|
15
|
+
writeFileSync,
|
|
16
|
+
readFileSync,
|
|
17
|
+
existsSync,
|
|
18
|
+
rmSync,
|
|
19
|
+
statSync,
|
|
20
|
+
} from "node:fs";
|
|
21
|
+
import { tmpdir } from "node:os";
|
|
22
|
+
import { join } from "node:path";
|
|
23
|
+
|
|
24
|
+
import {
|
|
25
|
+
installHitlGate,
|
|
26
|
+
removeHitlGate,
|
|
27
|
+
buildMergedConfig,
|
|
28
|
+
} from "../workspace-setup.js";
|
|
29
|
+
import { buildApprovalState } from "../approval-state.js";
|
|
30
|
+
|
|
31
|
+
const tempDirs: string[] = [];
|
|
32
|
+
afterEach(() => {
|
|
33
|
+
for (const dir of tempDirs.splice(0)) rmSync(dir, { recursive: true, force: true });
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
function freshRoot(): string {
|
|
37
|
+
const dir = mkdtempSync(join(tmpdir(), "ws-setup-"));
|
|
38
|
+
tempDirs.push(dir);
|
|
39
|
+
return dir;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// A script path that looks like the real one so isStigmerHookEntry recognizes it.
|
|
43
|
+
const stigmerScript = (root: string) =>
|
|
44
|
+
join(root, ".stigmer", "sessions", "ses-1", "hitl", "stigmer-approval.sh");
|
|
45
|
+
|
|
46
|
+
describe("buildMergedConfig", () => {
|
|
47
|
+
it("writes a standalone config and restores by delete when no hooks.json exists", () => {
|
|
48
|
+
const { merged, restoreTo } = buildMergedConfig(null, "/abs/hitl/stigmer-approval.sh");
|
|
49
|
+
const parsed = JSON.parse(merged);
|
|
50
|
+
expect(parsed.hooks.preToolUse).toHaveLength(1);
|
|
51
|
+
expect(parsed.hooks.preToolUse[0].command).toBe("/abs/hitl/stigmer-approval.sh");
|
|
52
|
+
expect(parsed.hooks.preToolUse[0].failClosed).toBe(true);
|
|
53
|
+
// null restore target → teardown deletes the file we created.
|
|
54
|
+
expect(restoreTo).toBeNull();
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
it("merges with a user's hooks.json and restores the original bytes verbatim", () => {
|
|
58
|
+
const original = JSON.stringify(
|
|
59
|
+
{
|
|
60
|
+
version: 1,
|
|
61
|
+
hooks: {
|
|
62
|
+
preToolUse: [{ command: "./user-hook.sh", timeout: 5 }],
|
|
63
|
+
postToolUse: [{ command: "./user-post.sh" }],
|
|
64
|
+
},
|
|
65
|
+
},
|
|
66
|
+
null,
|
|
67
|
+
2,
|
|
68
|
+
);
|
|
69
|
+
const script = "/abs/.stigmer/sessions/ses-1/hitl/stigmer-approval.sh";
|
|
70
|
+
const { merged, restoreTo } = buildMergedConfig(original, script);
|
|
71
|
+
const parsed = JSON.parse(merged);
|
|
72
|
+
|
|
73
|
+
// Our entry is appended; the user's preToolUse hook is preserved...
|
|
74
|
+
expect(parsed.hooks.preToolUse).toHaveLength(2);
|
|
75
|
+
expect(parsed.hooks.preToolUse[0].command).toBe("./user-hook.sh");
|
|
76
|
+
expect(parsed.hooks.preToolUse[1].command).toBe(script);
|
|
77
|
+
// ...as is every other hook type and field.
|
|
78
|
+
expect(parsed.hooks.postToolUse).toEqual([{ command: "./user-post.sh" }]);
|
|
79
|
+
// Restore is byte-identical to the user's original.
|
|
80
|
+
expect(restoreTo).toBe(original);
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
it("strips a stale Stigmer entry (crash leftover) from both merged and restore", () => {
|
|
84
|
+
const root = "/abs";
|
|
85
|
+
const stale = stigmerScript(root); // a previous turn's entry
|
|
86
|
+
const original = JSON.stringify({
|
|
87
|
+
version: 1,
|
|
88
|
+
hooks: {
|
|
89
|
+
preToolUse: [
|
|
90
|
+
{ command: "./user-hook.sh" },
|
|
91
|
+
{ command: stale, timeout: 10, failClosed: true },
|
|
92
|
+
],
|
|
93
|
+
},
|
|
94
|
+
});
|
|
95
|
+
const fresh = join(root, ".stigmer", "sessions", "ses-2", "hitl", "stigmer-approval.sh");
|
|
96
|
+
const { merged, restoreTo } = buildMergedConfig(original, fresh);
|
|
97
|
+
|
|
98
|
+
const mergedParsed = JSON.parse(merged);
|
|
99
|
+
// No duplicate: user entry + exactly one fresh Stigmer entry.
|
|
100
|
+
expect(mergedParsed.hooks.preToolUse).toHaveLength(2);
|
|
101
|
+
expect(mergedParsed.hooks.preToolUse.map((e: any) => e.command)).toEqual([
|
|
102
|
+
"./user-hook.sh",
|
|
103
|
+
fresh,
|
|
104
|
+
]);
|
|
105
|
+
// Restore is the CLEANED user config — the stale entry is gone (self-healing).
|
|
106
|
+
const restoreParsed = JSON.parse(restoreTo!);
|
|
107
|
+
expect(restoreParsed.hooks.preToolUse).toEqual([{ command: "./user-hook.sh" }]);
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
it("replaces an unparseable hooks.json for the turn but restores its exact bytes", () => {
|
|
111
|
+
const garbage = "{ this is not json ";
|
|
112
|
+
const { merged, restoreTo } = buildMergedConfig(garbage, "/abs/hitl/stigmer-approval.sh");
|
|
113
|
+
// We still install a working gate for the turn...
|
|
114
|
+
expect(JSON.parse(merged).hooks.preToolUse).toHaveLength(1);
|
|
115
|
+
// ...and never "fix" the user's file: restore their exact original bytes.
|
|
116
|
+
expect(restoreTo).toBe(garbage);
|
|
117
|
+
});
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
describe("installHitlGate / removeHitlGate", () => {
|
|
121
|
+
const approvalState = buildApprovalState(new Map(), false);
|
|
122
|
+
|
|
123
|
+
function dirs() {
|
|
124
|
+
const root = freshRoot();
|
|
125
|
+
const workspaceRoot = join(root, "repo");
|
|
126
|
+
const hitlDir = join(root, ".stigmer", "sessions", "ses-1", "hitl");
|
|
127
|
+
mkdirSync(workspaceRoot, { recursive: true });
|
|
128
|
+
return { workspaceRoot, hitlDir };
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
it("writes artifacts OUTSIDE the workspace and a hooks.json with an absolute command", async () => {
|
|
132
|
+
const { workspaceRoot, hitlDir } = dirs();
|
|
133
|
+
await installHitlGate({ workspaceRoot, hitlDir, approvalState, runnerPid: process.pid });
|
|
134
|
+
|
|
135
|
+
// Gate artifacts live in the HITL dir, never in the repo.
|
|
136
|
+
expect(existsSync(join(hitlDir, "stigmer-approval.sh"))).toBe(true);
|
|
137
|
+
expect(existsSync(join(hitlDir, "approval-state.json"))).toBe(true);
|
|
138
|
+
expect(existsSync(join(hitlDir, "denials.jsonl"))).toBe(true);
|
|
139
|
+
// The script is executable.
|
|
140
|
+
expect(statSync(join(hitlDir, "stigmer-approval.sh")).mode & 0o111).toBeTruthy();
|
|
141
|
+
|
|
142
|
+
// The only in-repo file is hooks.json, pointing at the script by ABSOLUTE
|
|
143
|
+
// path (the relative path was the multi-root exit-127 bug).
|
|
144
|
+
const hooksJson = JSON.parse(
|
|
145
|
+
readFileSync(join(workspaceRoot, ".cursor", "hooks.json"), "utf-8"),
|
|
146
|
+
);
|
|
147
|
+
const command = hooksJson.hooks.preToolUse[0].command;
|
|
148
|
+
expect(command).toBe(join(hitlDir, "stigmer-approval.sh"));
|
|
149
|
+
expect(command.startsWith("/")).toBe(true);
|
|
150
|
+
// The workspace holds no relocated artifacts.
|
|
151
|
+
expect(existsSync(join(workspaceRoot, ".cursor", "hooks"))).toBe(false);
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
it("leaves NO Stigmer files in the repo after teardown (failure mode 4)", async () => {
|
|
155
|
+
const { workspaceRoot, hitlDir } = dirs();
|
|
156
|
+
const handle = await installHitlGate({
|
|
157
|
+
workspaceRoot, hitlDir, approvalState, runnerPid: process.pid,
|
|
158
|
+
});
|
|
159
|
+
expect(existsSync(join(workspaceRoot, ".cursor", "hooks.json"))).toBe(true);
|
|
160
|
+
|
|
161
|
+
await removeHitlGate(handle);
|
|
162
|
+
|
|
163
|
+
// The repo is clean: no hooks.json, no hook scripts, no ledger.
|
|
164
|
+
expect(existsSync(join(workspaceRoot, ".cursor", "hooks.json"))).toBe(false);
|
|
165
|
+
expect(existsSync(join(workspaceRoot, ".cursor", "hooks"))).toBe(false);
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
it("restores a pre-existing user hooks.json byte-for-byte after teardown", async () => {
|
|
169
|
+
const { workspaceRoot, hitlDir } = dirs();
|
|
170
|
+
const cursorDir = join(workspaceRoot, ".cursor");
|
|
171
|
+
mkdirSync(cursorDir, { recursive: true });
|
|
172
|
+
const userConfig = JSON.stringify(
|
|
173
|
+
{ version: 1, hooks: { preToolUse: [{ command: "./mine.sh" }] } },
|
|
174
|
+
null,
|
|
175
|
+
2,
|
|
176
|
+
);
|
|
177
|
+
const hooksPath = join(cursorDir, "hooks.json");
|
|
178
|
+
writeFileSync(hooksPath, userConfig, "utf-8");
|
|
179
|
+
|
|
180
|
+
const handle = await installHitlGate({
|
|
181
|
+
workspaceRoot, hitlDir, approvalState, runnerPid: process.pid,
|
|
182
|
+
});
|
|
183
|
+
// During the turn our entry is present alongside the user's.
|
|
184
|
+
const during = JSON.parse(readFileSync(hooksPath, "utf-8"));
|
|
185
|
+
expect(during.hooks.preToolUse).toHaveLength(2);
|
|
186
|
+
|
|
187
|
+
await removeHitlGate(handle);
|
|
188
|
+
|
|
189
|
+
// After the turn the file is byte-identical to what the user had.
|
|
190
|
+
expect(readFileSync(hooksPath, "utf-8")).toBe(userConfig);
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
it("is repeatable across turns (install/remove/install/remove) and ends clean", async () => {
|
|
194
|
+
const { workspaceRoot, hitlDir } = dirs();
|
|
195
|
+
for (let turn = 0; turn < 2; turn++) {
|
|
196
|
+
const handle = await installHitlGate({
|
|
197
|
+
workspaceRoot, hitlDir, approvalState, runnerPid: process.pid,
|
|
198
|
+
});
|
|
199
|
+
expect(existsSync(join(workspaceRoot, ".cursor", "hooks.json"))).toBe(true);
|
|
200
|
+
await removeHitlGate(handle);
|
|
201
|
+
expect(existsSync(join(workspaceRoot, ".cursor", "hooks.json"))).toBe(false);
|
|
202
|
+
}
|
|
203
|
+
});
|
|
204
|
+
});
|
|
@@ -2,8 +2,9 @@
|
|
|
2
2
|
* Approval state management for the hook-deny + reinvoke HITL model.
|
|
3
3
|
*
|
|
4
4
|
* Before starting a Cursor Agent run, the cursor-runner writes a state file
|
|
5
|
-
*
|
|
6
|
-
*
|
|
5
|
+
* into the session's runner-owned HITL directory (outside the user's
|
|
6
|
+
* workspace). The preToolUse hook script reads this file to decide whether to
|
|
7
|
+
* allow or deny each tool call.
|
|
7
8
|
*
|
|
8
9
|
* State file format (JSON):
|
|
9
10
|
* {
|
|
@@ -40,7 +41,7 @@
|
|
|
40
41
|
*
|
|
41
42
|
* Denial ledger (hook → runner):
|
|
42
43
|
* The state file is the runner's INPUT to the hook. Its symmetric OUTPUT is the
|
|
43
|
-
* denial ledger (
|
|
44
|
+
* denial ledger (denials.jsonl): when the hook denies a tool, it appends
|
|
44
45
|
* the call's identity token to this file. The runner reads the ledger after the
|
|
45
46
|
* run to learn which tool calls were gated — the hook is the only component that
|
|
46
47
|
* actually makes the per-call allow/deny decision, so its ledger is the
|
|
@@ -209,11 +210,17 @@ export function buildApprovalState(
|
|
|
209
210
|
};
|
|
210
211
|
}
|
|
211
212
|
|
|
212
|
-
const
|
|
213
|
-
const STATE_FILE_NAME = "stigmer-approval-state.json";
|
|
213
|
+
const STATE_FILE_NAME = "approval-state.json";
|
|
214
214
|
|
|
215
215
|
/**
|
|
216
|
-
* Write the approval state file
|
|
216
|
+
* Write the approval state file into the session's HITL directory for the hook
|
|
217
|
+
* script to read.
|
|
218
|
+
*
|
|
219
|
+
* The HITL directory is runner-owned and lives outside the user's workspace
|
|
220
|
+
* (`~/.stigmer/sessions/{id}/hitl/`), so this file never lands in the attached
|
|
221
|
+
* repo — only a minimal `.cursor/hooks.json` pointing here by absolute path does
|
|
222
|
+
* (see issue #173). The hook reads this file fresh on every tool call, so its
|
|
223
|
+
* dynamic policy is always current even if the SDK caches `hooks.json`.
|
|
217
224
|
*
|
|
218
225
|
* Written as COMPACT JSON (no indentation): the bash hook parses it with
|
|
219
226
|
* line-oriented grep patterns that assume `"key":value` with no spaces or
|
|
@@ -221,12 +228,11 @@ const STATE_FILE_NAME = "stigmer-approval-state.json";
|
|
|
221
228
|
* would break every lookup.
|
|
222
229
|
*/
|
|
223
230
|
export async function writeApprovalStateFile(
|
|
224
|
-
|
|
231
|
+
hitlDir: string,
|
|
225
232
|
state: ApprovalStateFile,
|
|
226
233
|
): Promise<string> {
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
const filePath = join(dir, STATE_FILE_NAME);
|
|
234
|
+
await mkdir(hitlDir, { recursive: true });
|
|
235
|
+
const filePath = join(hitlDir, STATE_FILE_NAME);
|
|
230
236
|
await writeFile(filePath, JSON.stringify(state), "utf-8");
|
|
231
237
|
return filePath;
|
|
232
238
|
}
|
|
@@ -235,7 +241,7 @@ export async function writeApprovalStateFile(
|
|
|
235
241
|
// Denial ledger (hook → runner): the authoritative record of what the hook gated
|
|
236
242
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
237
243
|
|
|
238
|
-
const DENIAL_LEDGER_FILE = "
|
|
244
|
+
const DENIAL_LEDGER_FILE = "denials.jsonl";
|
|
239
245
|
|
|
240
246
|
/**
|
|
241
247
|
* One denial recorded by the preToolUse hook. `token` is the call's identity in
|
|
@@ -248,23 +254,26 @@ export interface DeniedLedgerEntry {
|
|
|
248
254
|
token: string;
|
|
249
255
|
}
|
|
250
256
|
|
|
251
|
-
/**
|
|
252
|
-
|
|
253
|
-
|
|
257
|
+
/**
|
|
258
|
+
* Absolute path of the per-turn denial ledger the hook appends to, inside the
|
|
259
|
+
* session's runner-owned HITL directory (never the user's workspace).
|
|
260
|
+
*/
|
|
261
|
+
export function denialLedgerPath(hitlDir: string): string {
|
|
262
|
+
return join(hitlDir, DENIAL_LEDGER_FILE);
|
|
254
263
|
}
|
|
255
264
|
|
|
256
265
|
/**
|
|
257
266
|
* Truncate the denial ledger to empty for a fresh turn, returning its path.
|
|
258
267
|
*
|
|
259
|
-
* Called every turn alongside writeApprovalStateFile (the
|
|
260
|
-
* and reused across HITL reinvocations), so the runner only ever reads
|
|
261
|
-
* produced by the current run. A Temporal activity retry re-runs this
|
|
262
|
-
* before re-running the agent, so the read stays deterministic under
|
|
268
|
+
* Called every turn alongside writeApprovalStateFile (the HITL directory is
|
|
269
|
+
* durable and reused across HITL reinvocations), so the runner only ever reads
|
|
270
|
+
* denials produced by the current run. A Temporal activity retry re-runs this
|
|
271
|
+
* reset before re-running the agent, so the read stays deterministic under
|
|
272
|
+
* retries.
|
|
263
273
|
*/
|
|
264
|
-
export async function resetDenialLedger(
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
const filePath = denialLedgerPath(workspaceRoot);
|
|
274
|
+
export async function resetDenialLedger(hitlDir: string): Promise<string> {
|
|
275
|
+
await mkdir(hitlDir, { recursive: true });
|
|
276
|
+
const filePath = denialLedgerPath(hitlDir);
|
|
268
277
|
await writeFile(filePath, "", "utf-8");
|
|
269
278
|
return filePath;
|
|
270
279
|
}
|
|
@@ -276,11 +285,11 @@ export async function resetDenialLedger(workspaceRoot: string): Promise<string>
|
|
|
276
285
|
* the valid denials before it.
|
|
277
286
|
*/
|
|
278
287
|
export async function readDenialLedger(
|
|
279
|
-
|
|
288
|
+
hitlDir: string,
|
|
280
289
|
): Promise<DeniedLedgerEntry[]> {
|
|
281
290
|
let raw: string;
|
|
282
291
|
try {
|
|
283
|
-
raw = await readFile(denialLedgerPath(
|
|
292
|
+
raw = await readFile(denialLedgerPath(hitlDir), "utf-8");
|
|
284
293
|
} catch {
|
|
285
294
|
return [];
|
|
286
295
|
}
|
|
@@ -131,8 +131,27 @@ function buildNodeIdentityScript(): string {
|
|
|
131
131
|
*
|
|
132
132
|
* The identity token encoding (`base64(key \n salient)`) must stay byte-identical
|
|
133
133
|
* to grantToken() in approval-state.ts.
|
|
134
|
+
*
|
|
135
|
+
* Scope guard (the crux of issue #173): the Cursor SDK loads project hooks from
|
|
136
|
+
* `<workspace>/.cursor/hooks.json`, which is the SAME per-repo surface every
|
|
137
|
+
* Cursor client reads. When a session runs against the user's real repo, the
|
|
138
|
+
* user's own interactive Cursor IDE would otherwise load and run this hook too —
|
|
139
|
+
* gating the IDE, polluting the denial ledger with the IDE's tool calls, and (in
|
|
140
|
+
* multi-root windows) failing closed. We make the gate apply ONLY to the
|
|
141
|
+
* runner's own agent by baking in the runner process PID and checking, on every
|
|
142
|
+
* invocation, whether the runner is an ancestor of the hook process. The SDK
|
|
143
|
+
* runs hooks in-process via child_process, so the runner's own agent (and its
|
|
144
|
+
* delegated sub-agents) spawn the hook as a descendant of the runner; any other
|
|
145
|
+
* Cursor client spawns it under a different process tree. A non-descendant
|
|
146
|
+
* invocation is allowed immediately and never touches the ledger. This also
|
|
147
|
+
* makes a leftover hooks.json self-neutralizing: once the runner exits, no
|
|
148
|
+
* invocation can match its (now-dead) PID, so the gate is inert.
|
|
134
149
|
*/
|
|
135
|
-
export function generateHookScript(
|
|
150
|
+
export function generateHookScript(
|
|
151
|
+
stateFilePath: string,
|
|
152
|
+
ledgerFilePath: string,
|
|
153
|
+
runnerPid: number,
|
|
154
|
+
): string {
|
|
136
155
|
const salientFields = SALIENT_ARG_FIELDS.join(" ");
|
|
137
156
|
const categoryCaseArms = buildCategoryCaseArms();
|
|
138
157
|
const nodeIdentityScript = buildNodeIdentityScript();
|
|
@@ -151,6 +170,40 @@ INPUT=$(cat)
|
|
|
151
170
|
|
|
152
171
|
STATE_FILE="${stateFilePath}"
|
|
153
172
|
LEDGER_FILE="${ledgerFilePath}"
|
|
173
|
+
RUNNER_PID="${runnerPid}"
|
|
174
|
+
|
|
175
|
+
# --- Scope guard: gate ONLY the runner's own agent (issue #173) -------------
|
|
176
|
+
# The Cursor SDK runs hooks in-process, so the runner's own agent invocations
|
|
177
|
+
# spawn this script as a DESCENDANT of the runner process (RUNNER_PID); the
|
|
178
|
+
# user's interactive IDE — sharing the same repo .cursor/hooks.json — spawns it
|
|
179
|
+
# under a different process tree. Walk the parent-PID chain: if the runner is an
|
|
180
|
+
# ancestor, apply the gate; otherwise allow immediately and DO NOT write the
|
|
181
|
+
# ledger (so foreign tool calls never appear as phantom approvals). Pure bash so
|
|
182
|
+
# it works even when the Node identity binary below is unavailable.
|
|
183
|
+
__stigmer_ppid() {
|
|
184
|
+
_p="$1"
|
|
185
|
+
if [ -r "/proc/$_p/status" ]; then
|
|
186
|
+
awk '/^PPid:/{print $2; exit}' "/proc/$_p/status" 2>/dev/null || true
|
|
187
|
+
else
|
|
188
|
+
ps -o ppid= -p "$_p" 2>/dev/null | tr -d ' ' || true
|
|
189
|
+
fi
|
|
190
|
+
}
|
|
191
|
+
__stigmer_is_own_agent() {
|
|
192
|
+
_cur="$$"
|
|
193
|
+
_i=0
|
|
194
|
+
while [ "$_i" -lt 64 ]; do
|
|
195
|
+
if [ -z "$_cur" ]; then return 1; fi
|
|
196
|
+
if [ "$_cur" = "$RUNNER_PID" ]; then return 0; fi
|
|
197
|
+
if [ "$_cur" = "1" ] || [ "$_cur" = "0" ]; then return 1; fi
|
|
198
|
+
_cur="$(__stigmer_ppid "$_cur")"
|
|
199
|
+
_i=$((_i + 1))
|
|
200
|
+
done
|
|
201
|
+
return 1
|
|
202
|
+
}
|
|
203
|
+
if ! __stigmer_is_own_agent; then
|
|
204
|
+
echo '{"permission":"allow"}'
|
|
205
|
+
exit 0
|
|
206
|
+
fi
|
|
154
207
|
|
|
155
208
|
# --- Canonical identity: tool_name / category / identity token / MCP token ---
|
|
156
209
|
# Computed by the same Node.js binary that runs the cursor-runner (absolute path
|
|
@@ -55,10 +55,11 @@ import { backfillMcpServersIfNeeded } from "./connect-backfill.js";
|
|
|
55
55
|
import { resolveExecutionEnv } from "./env-resolver.js";
|
|
56
56
|
import { resolveBlueprint } from "./blueprint-resolver.js";
|
|
57
57
|
import { buildCursorSubAgentDefinitions } from "./subagent-config.js";
|
|
58
|
-
import { resolveSkills } from "./skill-resolver.js";
|
|
58
|
+
import { resolveSkills, removeStigmerSymlink } from "./skill-resolver.js";
|
|
59
59
|
import { resolveAttachments } from "./attachment-resolver.js";
|
|
60
60
|
import { buildEnhancedPrompt, buildReinvocationPrompt } from "./prompt-builder.js";
|
|
61
|
-
import {
|
|
61
|
+
import { installHitlGate, removeHitlGate } from "./workspace-setup.js";
|
|
62
|
+
import { ensureHitlDir } from "../../shared/workspace/platform-dir.js";
|
|
62
63
|
import { buildApprovalState, buildApprovalGrants, readDenialLedger, reconstructAdjudicatedApprovals } from "./approval-state.js";
|
|
63
64
|
import { provisionCursorWorkspace } from "./workspace-provision.js";
|
|
64
65
|
import { setInterceptorExecutionId, runWithExecutionContext } from "./fetch-interceptor.js";
|
|
@@ -131,6 +132,15 @@ async function executeCursorInner(
|
|
|
131
132
|
let pauseDetected = false;
|
|
132
133
|
let workerShutdownDetected = false;
|
|
133
134
|
let periodicHeartbeat: ReturnType<typeof startHeartbeat> | undefined;
|
|
135
|
+
// Session HITL directory (runner-owned, outside the workspace) where the hook
|
|
136
|
+
// script, approval-state file, and denial ledger live. Set once the gate is
|
|
137
|
+
// installed; the WAITING_FOR_APPROVAL path reads the denial ledger from here.
|
|
138
|
+
let hitlDir: string | undefined;
|
|
139
|
+
// Teardown for the HITL gate: restores the workspace's .cursor/hooks.json and
|
|
140
|
+
// removes the .stigmer symlink so attaching a real repo leaves it untouched
|
|
141
|
+
// (issue #173). Runs in the finally, covering every success/error/approval
|
|
142
|
+
// exit path. Undefined until the gate is installed.
|
|
143
|
+
let hitlCleanup: (() => Promise<void>) | undefined;
|
|
134
144
|
// Carries model/mode/agentId out to the outer catch so a thrown CursorSdkError
|
|
135
145
|
// can be classified with the same context as the run.wait() error path.
|
|
136
146
|
let errorContext = { model: "default", mode: "local", agentId: "" };
|
|
@@ -287,7 +297,41 @@ async function executeCursorInner(
|
|
|
287
297
|
);
|
|
288
298
|
const attachmentPaths = attachmentResults.map((a) => a.relativePath);
|
|
289
299
|
|
|
290
|
-
// Phase 5c:
|
|
300
|
+
// Phase 5c: Install the HITL approval gate BEFORE resolving the agent.
|
|
301
|
+
//
|
|
302
|
+
// The gate's runtime artifacts (hook script, approval-state file, denial
|
|
303
|
+
// ledger) live in the session HITL directory OUTSIDE the workspace; only a
|
|
304
|
+
// minimal, merged, transient .cursor/hooks.json is written into the repo,
|
|
305
|
+
// pointing at the hook script by absolute path. The hook is scoped to this
|
|
306
|
+
// runner's own process so the user's interactive IDE — sharing the same repo
|
|
307
|
+
// hooks.json — is never gated (issue #173). Installing here (rather than
|
|
308
|
+
// after agent create/resume) guarantees the hook is present no matter when
|
|
309
|
+
// the SDK reads hook config, and the finally restores the repo afterward.
|
|
310
|
+
//
|
|
311
|
+
// On reinvocation, turn the user's approvals into tool-identity grants so
|
|
312
|
+
// the resumed agent's re-attempt (which carries a fresh tool-call id) is
|
|
313
|
+
// allowed through.
|
|
314
|
+
hitlDir = await ensureHitlDir(sessionId);
|
|
315
|
+
const approvalGrants = approvalDecisions
|
|
316
|
+
? buildApprovalGrants(adjudicatedApprovals, approvalDecisions)
|
|
317
|
+
: undefined;
|
|
318
|
+
const approvalState = buildApprovalState(
|
|
319
|
+
mergedPolicies,
|
|
320
|
+
effectiveAutoApproveAll,
|
|
321
|
+
approvalGrants,
|
|
322
|
+
);
|
|
323
|
+
const hitlGate = await installHitlGate({
|
|
324
|
+
workspaceRoot: primaryWorkspaceDir,
|
|
325
|
+
hitlDir,
|
|
326
|
+
approvalState,
|
|
327
|
+
runnerPid: process.pid,
|
|
328
|
+
});
|
|
329
|
+
hitlCleanup = async () => {
|
|
330
|
+
await removeHitlGate(hitlGate);
|
|
331
|
+
await removeStigmerSymlink(primaryWorkspaceDir);
|
|
332
|
+
};
|
|
333
|
+
|
|
334
|
+
// Phase 5d: Ensure model pricing registry is populated before validation
|
|
291
335
|
await ensurePricingLoaded();
|
|
292
336
|
|
|
293
337
|
// Phase 6: Validate model selection
|
|
@@ -366,18 +410,7 @@ async function executeCursorInner(
|
|
|
366
410
|
|
|
367
411
|
errorContext = { model: validatedModel, mode: agentMode, agentId: resolution.agentId };
|
|
368
412
|
|
|
369
|
-
//
|
|
370
|
-
// turn the user's approvals into tool-identity grants so the resumed agent's
|
|
371
|
-
// re-attempt (which carries a fresh tool-call id) is allowed through.
|
|
372
|
-
const approvalGrants = approvalDecisions
|
|
373
|
-
? buildApprovalGrants(adjudicatedApprovals, approvalDecisions)
|
|
374
|
-
: undefined;
|
|
375
|
-
const approvalState = buildApprovalState(
|
|
376
|
-
mergedPolicies,
|
|
377
|
-
effectiveAutoApproveAll,
|
|
378
|
-
approvalGrants,
|
|
379
|
-
);
|
|
380
|
-
await writeHooksToWorkspace(primaryWorkspaceDir, approvalState);
|
|
413
|
+
// (HITL approval gate already installed in Phase 5c, before agent resolution.)
|
|
381
414
|
|
|
382
415
|
// Phase 9: Store new agentId as harness_state_id and persist cursor_mode
|
|
383
416
|
if (resolution.isNew && resolution.agentId) {
|
|
@@ -714,7 +747,7 @@ async function executeCursorInner(
|
|
|
714
747
|
// harness — the approval surface is driven entirely by tool-call status. We
|
|
715
748
|
// deliberately do NOT set status.pendingApprovals here: any value would be
|
|
716
749
|
// discarded by the backend's recompute on the next updateStatus.
|
|
717
|
-
const deniedLedger = await readDenialLedger(
|
|
750
|
+
const deniedLedger = await readDenialLedger(hitlDir ?? "");
|
|
718
751
|
const deniedToolCalls = reconcileDeniedToolCalls(status.messages, deniedLedger, mergedPolicies);
|
|
719
752
|
if (deniedToolCalls.length > 0) {
|
|
720
753
|
status.phase = ExecutionPhase.EXECUTION_WAITING_FOR_APPROVAL;
|
|
@@ -1207,6 +1240,22 @@ async function executeCursorInner(
|
|
|
1207
1240
|
}
|
|
1208
1241
|
|
|
1209
1242
|
return slimStatus(status);
|
|
1243
|
+
} finally {
|
|
1244
|
+
// Tear down the HITL gate on EVERY exit path (success, error, approval
|
|
1245
|
+
// pause, cancellation) so attaching a real repo leaves the user's
|
|
1246
|
+
// .cursor/hooks.json and workspace untouched between turns (issue #173).
|
|
1247
|
+
// Best-effort: a leftover hooks.json is inert because the scope guard
|
|
1248
|
+
// allows all invocations once this runner PID is gone.
|
|
1249
|
+
if (hitlCleanup) {
|
|
1250
|
+
try {
|
|
1251
|
+
await hitlCleanup();
|
|
1252
|
+
} catch (cleanupErr) {
|
|
1253
|
+
console.warn(
|
|
1254
|
+
`ExecuteCursor HITL gate teardown failed (non-fatal): ` +
|
|
1255
|
+
`execution=${executionId}, error=${cleanupErr instanceof Error ? cleanupErr.message : cleanupErr}`,
|
|
1256
|
+
);
|
|
1257
|
+
}
|
|
1258
|
+
}
|
|
1210
1259
|
}
|
|
1211
1260
|
}
|
|
1212
1261
|
|
|
@@ -61,6 +61,12 @@ const CURSOR_SDK_STATE_DIR = ".stigmer/cursor-sdk-state";
|
|
|
61
61
|
* silently drops the hook and disables the entire approval gate. We must opt
|
|
62
62
|
* in to "project" so the hook loads and tool calls are actually gated.
|
|
63
63
|
*
|
|
64
|
+
* The hooks.json is the only file written into the workspace — kept minimal,
|
|
65
|
+
* merged with any user hooks.json, and restored when the turn ends; the gate's
|
|
66
|
+
* own artifacts live outside the repo and the hook is scoped to this runner's
|
|
67
|
+
* process, so the user's interactive IDE sharing the repo is never gated (see
|
|
68
|
+
* workspace-setup.ts / hook-script.ts and issue #173).
|
|
69
|
+
*
|
|
64
70
|
* Side effect: this also loads other workspace `.cursor/*` config (rules,
|
|
65
71
|
* mcp.json, commands). For runner-provisioned workspaces that is inert; for
|
|
66
72
|
* sessions running on a user's own repo their project config is now honored.
|
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
* - Returns metadata for prompt injection
|
|
10
10
|
*/
|
|
11
11
|
|
|
12
|
-
import { mkdir, writeFile, symlink, readlink, unlink, rm } from "node:fs/promises";
|
|
12
|
+
import { mkdir, writeFile, symlink, readlink, unlink, rm, lstat } from "node:fs/promises";
|
|
13
13
|
import { join, dirname } from "node:path";
|
|
14
14
|
import type { StigmerClient } from "../../client/stigmer-client.js";
|
|
15
15
|
import type { Skill } from "@stigmer/protos/ai/stigmer/agentic/skill/v1/api_pb";
|
|
@@ -163,6 +163,31 @@ async function ensureStigmerSymlink(
|
|
|
163
163
|
await symlink(platformDir, linkPath, "dir");
|
|
164
164
|
}
|
|
165
165
|
|
|
166
|
+
/**
|
|
167
|
+
* Remove the workspace `.stigmer` symlink created by {@link resolveSkills}.
|
|
168
|
+
*
|
|
169
|
+
* Called in the activity's finally so attaching a real repo leaves no Stigmer
|
|
170
|
+
* symlink behind once the turn ends (issue #173); a multi-turn session recreates
|
|
171
|
+
* it on the next turn. Only ever removes a SYMLINK — a real `.stigmer` directory
|
|
172
|
+
* (which would be the user's own, not ours) is left untouched. Best-effort.
|
|
173
|
+
*/
|
|
174
|
+
export async function removeStigmerSymlink(workspaceDir: string): Promise<void> {
|
|
175
|
+
const linkPath = join(workspaceDir, STIGMER_LOCAL_STATE_DIR);
|
|
176
|
+
try {
|
|
177
|
+
const stat = await lstat(linkPath);
|
|
178
|
+
if (stat.isSymbolicLink()) {
|
|
179
|
+
await unlink(linkPath);
|
|
180
|
+
}
|
|
181
|
+
} catch (err: any) {
|
|
182
|
+
if (err?.code !== "ENOENT") {
|
|
183
|
+
console.warn(
|
|
184
|
+
`removeStigmerSymlink: failed to remove ${linkPath} (non-fatal): ` +
|
|
185
|
+
`${err instanceof Error ? err.message : err}`,
|
|
186
|
+
);
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
|
|
166
191
|
/**
|
|
167
192
|
* Clean up platform-managed skill directory for a session.
|
|
168
193
|
*/
|