@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.
Files changed (31) hide show
  1. package/dist/.build-fingerprint +1 -1
  2. package/dist/activities/execute-cursor/approval-state.d.ts +25 -13
  3. package/dist/activities/execute-cursor/approval-state.js +33 -24
  4. package/dist/activities/execute-cursor/approval-state.js.map +1 -1
  5. package/dist/activities/execute-cursor/hook-script.d.ts +16 -1
  6. package/dist/activities/execute-cursor/hook-script.js +50 -1
  7. package/dist/activities/execute-cursor/hook-script.js.map +1 -1
  8. package/dist/activities/execute-cursor/index.js +60 -12
  9. package/dist/activities/execute-cursor/index.js.map +1 -1
  10. package/dist/activities/execute-cursor/session-lifecycle.js +6 -0
  11. package/dist/activities/execute-cursor/session-lifecycle.js.map +1 -1
  12. package/dist/activities/execute-cursor/skill-resolver.d.ts +9 -0
  13. package/dist/activities/execute-cursor/skill-resolver.js +24 -1
  14. package/dist/activities/execute-cursor/skill-resolver.js.map +1 -1
  15. package/dist/activities/execute-cursor/workspace-setup.d.ts +79 -21
  16. package/dist/activities/execute-cursor/workspace-setup.js +171 -51
  17. package/dist/activities/execute-cursor/workspace-setup.js.map +1 -1
  18. package/dist/shared/workspace/platform-dir.d.ts +26 -5
  19. package/dist/shared/workspace/platform-dir.js +38 -7
  20. package/dist/shared/workspace/platform-dir.js.map +1 -1
  21. package/package.json +2 -2
  22. package/src/activities/execute-cursor/__tests__/hitl-ledger.test.ts +4 -3
  23. package/src/activities/execute-cursor/__tests__/hook-script.test.ts +51 -2
  24. package/src/activities/execute-cursor/__tests__/workspace-setup.test.ts +204 -0
  25. package/src/activities/execute-cursor/approval-state.ts +33 -24
  26. package/src/activities/execute-cursor/hook-script.ts +54 -1
  27. package/src/activities/execute-cursor/index.ts +65 -16
  28. package/src/activities/execute-cursor/session-lifecycle.ts +6 -0
  29. package/src/activities/execute-cursor/skill-resolver.ts +26 -1
  30. package/src/activities/execute-cursor/workspace-setup.ts +228 -51
  31. 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
- "/ws/.cursor/hooks/stigmer-approval-state.json",
303
- "/ws/.cursor/hooks/stigmer-denials.jsonl",
302
+ "/hitl/approval-state.json",
303
+ "/hitl/denials.jsonl",
304
+ process.pid,
304
305
  );
305
306
 
306
- expect(script).toContain('LEDGER_FILE="/ws/.cursor/hooks/stigmer-denials.jsonl"');
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
- * to the workspace. The preToolUse hook script reads this file to decide
6
- * whether to allow or deny each tool call.
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 (stigmer-denials.jsonl): when the hook denies a tool, it appends
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 STATE_FILE_DIR = ".cursor/hooks";
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 to the workspace for the hook script to read.
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
- workspaceRoot: string,
231
+ hitlDir: string,
225
232
  state: ApprovalStateFile,
226
233
  ): Promise<string> {
227
- const dir = join(workspaceRoot, STATE_FILE_DIR);
228
- await mkdir(dir, { recursive: true });
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 = "stigmer-denials.jsonl";
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
- /** Absolute path of the per-turn denial ledger the hook appends to. */
252
- export function denialLedgerPath(workspaceRoot: string): string {
253
- return join(workspaceRoot, STATE_FILE_DIR, DENIAL_LEDGER_FILE);
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 workspace is durable
260
- * and reused across HITL reinvocations), so the runner only ever reads denials
261
- * produced by the current run. A Temporal activity retry re-runs this reset
262
- * before re-running the agent, so the read stays deterministic under retries.
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(workspaceRoot: string): Promise<string> {
265
- const dir = join(workspaceRoot, STATE_FILE_DIR);
266
- await mkdir(dir, { recursive: true });
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
- workspaceRoot: string,
288
+ hitlDir: string,
280
289
  ): Promise<DeniedLedgerEntry[]> {
281
290
  let raw: string;
282
291
  try {
283
- raw = await readFile(denialLedgerPath(workspaceRoot), "utf-8");
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(stateFilePath: string, ledgerFilePath: string): string {
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 { writeHooksToWorkspace } from "./workspace-setup.js";
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: Ensure model pricing registry is populated before validation
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
- // Phase 8: Write hooks for HITL with policy-aware state. On reinvocation,
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(primaryWorkspaceDir);
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
  */