@stigmer/runner 3.0.8-dev.20260612082024 → 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 (33) hide show
  1. package/README.md +2 -0
  2. package/dist/.build-fingerprint +1 -1
  3. package/dist/activities/execute-cursor/approval-state.d.ts +25 -13
  4. package/dist/activities/execute-cursor/approval-state.js +33 -24
  5. package/dist/activities/execute-cursor/approval-state.js.map +1 -1
  6. package/dist/activities/execute-cursor/hook-script.d.ts +16 -1
  7. package/dist/activities/execute-cursor/hook-script.js +50 -1
  8. package/dist/activities/execute-cursor/hook-script.js.map +1 -1
  9. package/dist/activities/execute-cursor/index.js +60 -12
  10. package/dist/activities/execute-cursor/index.js.map +1 -1
  11. package/dist/activities/execute-cursor/session-lifecycle.js +6 -0
  12. package/dist/activities/execute-cursor/session-lifecycle.js.map +1 -1
  13. package/dist/activities/execute-cursor/skill-resolver.d.ts +9 -0
  14. package/dist/activities/execute-cursor/skill-resolver.js +24 -1
  15. package/dist/activities/execute-cursor/skill-resolver.js.map +1 -1
  16. package/dist/activities/execute-cursor/workspace-setup.d.ts +79 -21
  17. package/dist/activities/execute-cursor/workspace-setup.js +171 -51
  18. package/dist/activities/execute-cursor/workspace-setup.js.map +1 -1
  19. package/dist/shared/workspace/platform-dir.d.ts +26 -5
  20. package/dist/shared/workspace/platform-dir.js +38 -7
  21. package/dist/shared/workspace/platform-dir.js.map +1 -1
  22. package/package.json +2 -2
  23. package/src/activities/execute-cursor/__tests__/hitl-ledger.test.ts +4 -3
  24. package/src/activities/execute-cursor/__tests__/hook-script.test.ts +51 -2
  25. package/src/activities/execute-cursor/__tests__/http2-interceptor.test.ts +9 -0
  26. package/src/activities/execute-cursor/__tests__/workspace-setup.test.ts +204 -0
  27. package/src/activities/execute-cursor/approval-state.ts +33 -24
  28. package/src/activities/execute-cursor/hook-script.ts +54 -1
  29. package/src/activities/execute-cursor/index.ts +65 -16
  30. package/src/activities/execute-cursor/session-lifecycle.ts +6 -0
  31. package/src/activities/execute-cursor/skill-resolver.ts +26 -1
  32. package/src/activities/execute-cursor/workspace-setup.ts +228 -51
  33. package/src/shared/workspace/platform-dir.ts +41 -7
@@ -1,61 +1,181 @@
1
1
  /**
2
- * Writes Cursor hooks configuration and scripts to the workspace.
3
- *
4
- * Before creating or resuming a Cursor Agent, the cursor-runner writes:
5
- * 1. .cursor/hooks.json — declares the preToolUse hook
6
- * 2. .cursor/hooks/stigmer-approval.sh — the hook script
7
- * 3. .cursor/hooks/stigmer-approval-state.json — approval state for the hook
8
- * 4. .cursor/hooks/stigmer-denials.jsonl — per-turn denial ledger (reset here,
9
- * appended by the hook on each deny, read back by the activity)
10
- *
11
- * This setup enables the durable HITL model: the hook denies tools that need
12
- * approval and records each denial to the ledger; the activity reads the ledger,
13
- * marks the gated tool calls WAITING_APPROVAL (the backend then projects
14
- * pending_approvals from that status), and returns to the workflow. On
15
- * reinvocation the state file is updated with the approved tools so the hook
16
- * allows them.
17
- */
18
- import { writeFile, mkdir, chmod } from "node:fs/promises";
2
+ * Installs and tears down the Cursor HITL approval gate around an agent turn.
3
+ *
4
+ * The gate has two surfaces, kept deliberately separate (issue #173):
5
+ *
6
+ * 1. Runner-owned artifacts — the hook script, approval-state file, and denial
7
+ * ledger — live in the session's HITL directory OUTSIDE the user's workspace
8
+ * (`~/.stigmer/sessions/{id}/hitl/`). They never touch the attached repo.
9
+ *
10
+ * 2. Workspace surface — a single `.cursor/hooks.json` written into the
11
+ * workspace, because the Cursor SDK only loads project hooks from that
12
+ * hard-coded path. It is kept minimal, MERGED with any pre-existing user
13
+ * hooks.json, points at the hook script by ABSOLUTE path (so multi-root IDE
14
+ * windows can always find it instead of failing closed), and is RESTORED to
15
+ * its original content when the turn ends.
16
+ *
17
+ * Why this shape. The previous design wrote all four files into the workspace
18
+ * with a repo-relative hook command and never cleaned up. For a local-folder
19
+ * workspace (the user's real repo, often open in their Cursor IDE) that gated
20
+ * the user's own IDE, ingested the IDE's tool calls into the denial ledger,
21
+ * failed closed in multi-root windows (relative path → exit 127), and left the
22
+ * gate behind after the session. Relocating the artifacts, scoping the hook to
23
+ * the runner's own process (see hook-script.ts), and restoring hooks.json after
24
+ * every turn together leave the user's repo and tooling untouched.
25
+ *
26
+ * Durability model: the install runs before every agent create/resume (and on
27
+ * every HITL reinvocation / Temporal activity retry); the teardown runs in the
28
+ * activity's finally. Each turn snapshots and restores independently, so the
29
+ * repo is byte-identical between turns. If a crash skips teardown, the leftover
30
+ * hooks.json is inert: the scope guard allows every invocation once the runner
31
+ * PID is gone, and the relocated artifacts are not in the repo.
32
+ */
33
+ import { writeFile, readFile, mkdir, chmod, rm } from "node:fs/promises";
19
34
  import { join } from "node:path";
20
35
  import { generateHookScript } from "./hook-script.js";
21
36
  import { writeApprovalStateFile, resetDenialLedger } from "./approval-state.js";
22
- const HOOKS_DIR = ".cursor";
23
- const HOOKS_SCRIPTS_DIR = ".cursor/hooks";
37
+ const CURSOR_DIR = ".cursor";
24
38
  const HOOKS_CONFIG_FILE = "hooks.json";
25
39
  const HOOK_SCRIPT_FILE = "stigmer-approval.sh";
40
+ /** preToolUse hook timeout (seconds) — the script is a quick local decision. */
41
+ const HOOK_TIMEOUT_SECONDS = 10;
26
42
  /**
27
- * Write the complete hooks setup to the workspace directory.
28
- *
29
- * Creates or overwrites:
30
- * - .cursor/hooks.json (hooks configuration)
31
- * - .cursor/hooks/stigmer-approval.sh (hook script, executable)
32
- * - .cursor/hooks/stigmer-approval-state.json (approval state)
33
- */
34
- export async function writeHooksToWorkspace(workspaceRoot, approvalState) {
35
- const hooksDir = join(workspaceRoot, HOOKS_DIR);
36
- const scriptsDir = join(workspaceRoot, HOOKS_SCRIPTS_DIR);
37
- await mkdir(hooksDir, { recursive: true });
38
- await mkdir(scriptsDir, { recursive: true });
39
- const stateFilePath = await writeApprovalStateFile(workspaceRoot, approvalState);
40
- // Reset the denial ledger for this turn (co-located with the state-file write
41
- // so the runner only reads denials produced by the current run, even across
42
- // HITL reinvocations on the durable workspace and Temporal activity retries).
43
- const ledgerFilePath = await resetDenialLedger(workspaceRoot);
44
- const hookScriptPath = join(scriptsDir, HOOK_SCRIPT_FILE);
45
- await writeFile(hookScriptPath, generateHookScript(stateFilePath, ledgerFilePath), "utf-8");
43
+ * Install the HITL approval gate for one agent turn.
44
+ *
45
+ * Writes the runner-owned artifacts into {@link hitlDir} and installs the merged
46
+ * `.cursor/hooks.json` into {@link workspaceRoot}, returning a handle that
47
+ * {@link removeHitlGate} uses to restore the workspace afterward.
48
+ */
49
+ export async function installHitlGate(params) {
50
+ const { workspaceRoot, hitlDir, approvalState, runnerPid } = params;
51
+ const scriptPath = await writeHitlArtifacts(hitlDir, approvalState, runnerPid);
52
+ return installWorkspaceHook(workspaceRoot, scriptPath);
53
+ }
54
+ /**
55
+ * Restore the workspace to its pre-turn state.
56
+ *
57
+ * Best-effort and never throws: a teardown failure must not fail the execution,
58
+ * and a leftover hooks.json is inert anyway (see the module doc).
59
+ */
60
+ export async function removeHitlGate(handle) {
61
+ try {
62
+ if (handle.restoreTo === null) {
63
+ await rm(handle.hooksJsonPath, { force: true });
64
+ }
65
+ else {
66
+ await writeFile(handle.hooksJsonPath, handle.restoreTo, "utf-8");
67
+ }
68
+ }
69
+ catch (err) {
70
+ console.warn(`removeHitlGate: failed to restore ${handle.hooksJsonPath} (non-fatal): ` +
71
+ `${err instanceof Error ? err.message : err}`);
72
+ }
73
+ }
74
+ /**
75
+ * Write the runner-owned gate artifacts (approval-state file, fresh denial
76
+ * ledger, hook script) into the session HITL directory and return the absolute
77
+ * hook-script path. The script is regenerated every turn so the current runner
78
+ * PID and the current state-file/ledger paths are always baked in.
79
+ */
80
+ async function writeHitlArtifacts(hitlDir, approvalState, runnerPid) {
81
+ await mkdir(hitlDir, { recursive: true });
82
+ const stateFilePath = await writeApprovalStateFile(hitlDir, approvalState);
83
+ // Reset the denial ledger for this turn so the runner only reads denials
84
+ // produced by the current run, even across HITL reinvocations on the durable
85
+ // HITL directory and Temporal activity retries.
86
+ const ledgerFilePath = await resetDenialLedger(hitlDir);
87
+ const hookScriptPath = join(hitlDir, HOOK_SCRIPT_FILE);
88
+ await writeFile(hookScriptPath, generateHookScript(stateFilePath, ledgerFilePath, runnerPid), "utf-8");
46
89
  await chmod(hookScriptPath, 0o755);
47
- const hooksConfig = {
48
- version: 1,
49
- hooks: {
50
- preToolUse: [
51
- {
52
- command: `.cursor/hooks/${HOOK_SCRIPT_FILE}`,
53
- timeout: 10,
54
- failClosed: true,
55
- },
56
- ],
57
- },
58
- };
59
- await writeFile(join(hooksDir, HOOKS_CONFIG_FILE), JSON.stringify(hooksConfig, null, 2), "utf-8");
90
+ return hookScriptPath;
91
+ }
92
+ /**
93
+ * Snapshot the workspace's existing `.cursor/hooks.json`, write a merged config
94
+ * that adds our preToolUse entry (absolute script path) while preserving the
95
+ * user's own hooks, and return the handle for restoration.
96
+ */
97
+ async function installWorkspaceHook(workspaceRoot, scriptPath) {
98
+ const cursorDir = join(workspaceRoot, CURSOR_DIR);
99
+ const hooksJsonPath = join(cursorDir, HOOKS_CONFIG_FILE);
100
+ let originalRaw = null;
101
+ try {
102
+ originalRaw = await readFile(hooksJsonPath, "utf-8");
103
+ }
104
+ catch {
105
+ originalRaw = null;
106
+ }
107
+ const { merged, restoreTo } = buildMergedConfig(originalRaw, scriptPath);
108
+ await mkdir(cursorDir, { recursive: true });
109
+ await writeFile(hooksJsonPath, merged, "utf-8");
110
+ return { hooksJsonPath, restoreTo };
111
+ }
112
+ /**
113
+ * The preToolUse entry the gate installs. Absolute `command` so the hook is
114
+ * found regardless of which workspace root a multi-root IDE resolves against.
115
+ */
116
+ function buildHookEntry(scriptPath) {
117
+ return { command: scriptPath, timeout: HOOK_TIMEOUT_SECONDS, failClosed: true };
118
+ }
119
+ /**
120
+ * Identify a preToolUse entry the gate itself wrote (in this or a prior,
121
+ * crash-leftover turn) so a re-install never duplicates it and a restore strips
122
+ * it. Matched by the unmistakable HITL script path, never by a user's own hook.
123
+ */
124
+ function isStigmerHookEntry(entry) {
125
+ if (!entry || typeof entry !== "object")
126
+ return false;
127
+ const command = entry.command;
128
+ return (typeof command === "string" &&
129
+ command.includes("/.stigmer/sessions/") &&
130
+ command.endsWith(`/${HOOK_SCRIPT_FILE}`));
131
+ }
132
+ const STANDALONE_CONFIG = (scriptPath) => JSON.stringify({ version: 1, hooks: { preToolUse: [buildHookEntry(scriptPath)] } }, null, 2);
133
+ /**
134
+ * Compute the merged hooks.json to write for this turn and the content to
135
+ * restore afterward.
136
+ *
137
+ * - No existing file → write our standalone config; restore by deleting (null).
138
+ * - Existing, parseable file → append our entry to `hooks.preToolUse`,
139
+ * preserving every other hook type and field; restore the user's original
140
+ * bytes. Any stale Stigmer entry from a prior crashed turn is stripped from
141
+ * BOTH the merged config (no duplicate) and the restore target (self-healing).
142
+ * - Existing, unparseable file → replace for the turn with our standalone
143
+ * config; restore the user's exact original bytes (we never "fix" their file).
144
+ *
145
+ * Exported for unit testing — this is the load-bearing data transformation.
146
+ */
147
+ export function buildMergedConfig(originalRaw, scriptPath) {
148
+ if (originalRaw === null) {
149
+ return { merged: STANDALONE_CONFIG(scriptPath), restoreTo: null };
150
+ }
151
+ let parsed;
152
+ try {
153
+ parsed = JSON.parse(originalRaw);
154
+ }
155
+ catch {
156
+ return { merged: STANDALONE_CONFIG(scriptPath), restoreTo: originalRaw };
157
+ }
158
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
159
+ return { merged: STANDALONE_CONFIG(scriptPath), restoreTo: originalRaw };
160
+ }
161
+ const root = parsed;
162
+ const hooks = root.hooks && typeof root.hooks === "object" && !Array.isArray(root.hooks)
163
+ ? root.hooks
164
+ : {};
165
+ const existingPreToolUse = Array.isArray(hooks.preToolUse) ? hooks.preToolUse : [];
166
+ const userEntries = existingPreToolUse.filter((e) => !isStigmerHookEntry(e));
167
+ const strippedStale = userEntries.length !== existingPreToolUse.length;
168
+ const version = typeof root.version === "number" ? root.version : 1;
169
+ const merged = JSON.stringify({
170
+ ...root,
171
+ version,
172
+ hooks: { ...hooks, preToolUse: [...userEntries, buildHookEntry(scriptPath)] },
173
+ }, null, 2);
174
+ // Restore the user's exact original bytes — unless we stripped a stale Stigmer
175
+ // entry, in which case restore the cleaned form so our leftover never lingers.
176
+ const restoreTo = strippedStale
177
+ ? JSON.stringify({ ...root, version, hooks: { ...hooks, preToolUse: userEntries } }, null, 2)
178
+ : originalRaw;
179
+ return { merged, restoreTo };
60
180
  }
61
181
  //# sourceMappingURL=workspace-setup.js.map
@@ -1 +1 @@
1
- {"version":3,"file":"workspace-setup.js","sourceRoot":"","sources":["../../../src/activities/execute-cursor/workspace-setup.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;GAgBG;AAEH,OAAO,EAAE,SAAS,EAAE,KAAK,EAAE,KAAK,EAAE,MAAM,kBAAkB,CAAC;AAC3D,OAAO,EAAE,IAAI,EAAE,MAAM,WAAW,CAAC;AACjC,OAAO,EAAE,kBAAkB,EAAE,MAAM,kBAAkB,CAAC;AACtD,OAAO,EAAE,sBAAsB,EAAE,iBAAiB,EAA0B,MAAM,qBAAqB,CAAC;AAExG,MAAM,SAAS,GAAG,SAAS,CAAC;AAC5B,MAAM,iBAAiB,GAAG,eAAe,CAAC;AAC1C,MAAM,iBAAiB,GAAG,YAAY,CAAC;AACvC,MAAM,gBAAgB,GAAG,qBAAqB,CAAC;AAE/C;;;;;;;GAOG;AACH,MAAM,CAAC,KAAK,UAAU,qBAAqB,CACzC,aAAqB,EACrB,aAAgC;IAEhC,MAAM,QAAQ,GAAG,IAAI,CAAC,aAAa,EAAE,SAAS,CAAC,CAAC;IAChD,MAAM,UAAU,GAAG,IAAI,CAAC,aAAa,EAAE,iBAAiB,CAAC,CAAC;IAC1D,MAAM,KAAK,CAAC,QAAQ,EAAE,EAAE,SAAS,EAAE,IAAI,EAAE,CAAC,CAAC;IAC3C,MAAM,KAAK,CAAC,UAAU,EAAE,EAAE,SAAS,EAAE,IAAI,EAAE,CAAC,CAAC;IAE7C,MAAM,aAAa,GAAG,MAAM,sBAAsB,CAAC,aAAa,EAAE,aAAa,CAAC,CAAC;IAEjF,8EAA8E;IAC9E,4EAA4E;IAC5E,8EAA8E;IAC9E,MAAM,cAAc,GAAG,MAAM,iBAAiB,CAAC,aAAa,CAAC,CAAC;IAE9D,MAAM,cAAc,GAAG,IAAI,CAAC,UAAU,EAAE,gBAAgB,CAAC,CAAC;IAC1D,MAAM,SAAS,CAAC,cAAc,EAAE,kBAAkB,CAAC,aAAa,EAAE,cAAc,CAAC,EAAE,OAAO,CAAC,CAAC;IAC5F,MAAM,KAAK,CAAC,cAAc,EAAE,KAAK,CAAC,CAAC;IAEnC,MAAM,WAAW,GAAG;QAClB,OAAO,EAAE,CAAC;QACV,KAAK,EAAE;YACL,UAAU,EAAE;gBACV;oBACE,OAAO,EAAE,iBAAiB,gBAAgB,EAAE;oBAC5C,OAAO,EAAE,EAAE;oBACX,UAAU,EAAE,IAAI;iBACjB;aACF;SACF;KACF,CAAC;IAEF,MAAM,SAAS,CACb,IAAI,CAAC,QAAQ,EAAE,iBAAiB,CAAC,EACjC,IAAI,CAAC,SAAS,CAAC,WAAW,EAAE,IAAI,EAAE,CAAC,CAAC,EACpC,OAAO,CACR,CAAC;AACJ,CAAC"}
1
+ {"version":3,"file":"workspace-setup.js","sourceRoot":"","sources":["../../../src/activities/execute-cursor/workspace-setup.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA+BG;AAEH,OAAO,EAAE,SAAS,EAAE,QAAQ,EAAE,KAAK,EAAE,KAAK,EAAE,EAAE,EAAE,MAAM,kBAAkB,CAAC;AACzE,OAAO,EAAE,IAAI,EAAE,MAAM,WAAW,CAAC;AACjC,OAAO,EAAE,kBAAkB,EAAE,MAAM,kBAAkB,CAAC;AACtD,OAAO,EAAE,sBAAsB,EAAE,iBAAiB,EAA0B,MAAM,qBAAqB,CAAC;AAExG,MAAM,UAAU,GAAG,SAAS,CAAC;AAC7B,MAAM,iBAAiB,GAAG,YAAY,CAAC;AACvC,MAAM,gBAAgB,GAAG,qBAAqB,CAAC;AAE/C,gFAAgF;AAChF,MAAM,oBAAoB,GAAG,EAAE,CAAC;AAiBhC;;;;;;GAMG;AACH,MAAM,CAAC,KAAK,UAAU,eAAe,CAAC,MAKrC;IACC,MAAM,EAAE,aAAa,EAAE,OAAO,EAAE,aAAa,EAAE,SAAS,EAAE,GAAG,MAAM,CAAC;IAEpE,MAAM,UAAU,GAAG,MAAM,kBAAkB,CAAC,OAAO,EAAE,aAAa,EAAE,SAAS,CAAC,CAAC;IAC/E,OAAO,oBAAoB,CAAC,aAAa,EAAE,UAAU,CAAC,CAAC;AACzD,CAAC;AAED;;;;;GAKG;AACH,MAAM,CAAC,KAAK,UAAU,cAAc,CAAC,MAAsB;IACzD,IAAI,CAAC;QACH,IAAI,MAAM,CAAC,SAAS,KAAK,IAAI,EAAE,CAAC;YAC9B,MAAM,EAAE,CAAC,MAAM,CAAC,aAAa,EAAE,EAAE,KAAK,EAAE,IAAI,EAAE,CAAC,CAAC;QAClD,CAAC;aAAM,CAAC;YACN,MAAM,SAAS,CAAC,MAAM,CAAC,aAAa,EAAE,MAAM,CAAC,SAAS,EAAE,OAAO,CAAC,CAAC;QACnE,CAAC;IACH,CAAC;IAAC,OAAO,GAAG,EAAE,CAAC;QACb,OAAO,CAAC,IAAI,CACV,qCAAqC,MAAM,CAAC,aAAa,gBAAgB;YACzE,GAAG,GAAG,YAAY,KAAK,CAAC,CAAC,CAAC,GAAG,CAAC,OAAO,CAAC,CAAC,CAAC,GAAG,EAAE,CAC9C,CAAC;IACJ,CAAC;AACH,CAAC;AAED;;;;;GAKG;AACH,KAAK,UAAU,kBAAkB,CAC/B,OAAe,EACf,aAAgC,EAChC,SAAiB;IAEjB,MAAM,KAAK,CAAC,OAAO,EAAE,EAAE,SAAS,EAAE,IAAI,EAAE,CAAC,CAAC;IAE1C,MAAM,aAAa,GAAG,MAAM,sBAAsB,CAAC,OAAO,EAAE,aAAa,CAAC,CAAC;IAC3E,yEAAyE;IACzE,6EAA6E;IAC7E,gDAAgD;IAChD,MAAM,cAAc,GAAG,MAAM,iBAAiB,CAAC,OAAO,CAAC,CAAC;IAExD,MAAM,cAAc,GAAG,IAAI,CAAC,OAAO,EAAE,gBAAgB,CAAC,CAAC;IACvD,MAAM,SAAS,CACb,cAAc,EACd,kBAAkB,CAAC,aAAa,EAAE,cAAc,EAAE,SAAS,CAAC,EAC5D,OAAO,CACR,CAAC;IACF,MAAM,KAAK,CAAC,cAAc,EAAE,KAAK,CAAC,CAAC;IAEnC,OAAO,cAAc,CAAC;AACxB,CAAC;AAED;;;;GAIG;AACH,KAAK,UAAU,oBAAoB,CACjC,aAAqB,EACrB,UAAkB;IAElB,MAAM,SAAS,GAAG,IAAI,CAAC,aAAa,EAAE,UAAU,CAAC,CAAC;IAClD,MAAM,aAAa,GAAG,IAAI,CAAC,SAAS,EAAE,iBAAiB,CAAC,CAAC;IAEzD,IAAI,WAAW,GAAkB,IAAI,CAAC;IACtC,IAAI,CAAC;QACH,WAAW,GAAG,MAAM,QAAQ,CAAC,aAAa,EAAE,OAAO,CAAC,CAAC;IACvD,CAAC;IAAC,MAAM,CAAC;QACP,WAAW,GAAG,IAAI,CAAC;IACrB,CAAC;IAED,MAAM,EAAE,MAAM,EAAE,SAAS,EAAE,GAAG,iBAAiB,CAAC,WAAW,EAAE,UAAU,CAAC,CAAC;IAEzE,MAAM,KAAK,CAAC,SAAS,EAAE,EAAE,SAAS,EAAE,IAAI,EAAE,CAAC,CAAC;IAC5C,MAAM,SAAS,CAAC,aAAa,EAAE,MAAM,EAAE,OAAO,CAAC,CAAC;IAEhD,OAAO,EAAE,aAAa,EAAE,SAAS,EAAE,CAAC;AACtC,CAAC;AAED;;;GAGG;AACH,SAAS,cAAc,CAAC,UAAkB;IACxC,OAAO,EAAE,OAAO,EAAE,UAAU,EAAE,OAAO,EAAE,oBAAoB,EAAE,UAAU,EAAE,IAAI,EAAE,CAAC;AAClF,CAAC;AAED;;;;GAIG;AACH,SAAS,kBAAkB,CAAC,KAAc;IACxC,IAAI,CAAC,KAAK,IAAI,OAAO,KAAK,KAAK,QAAQ;QAAE,OAAO,KAAK,CAAC;IACtD,MAAM,OAAO,GAAI,KAA+B,CAAC,OAAO,CAAC;IACzD,OAAO,CACL,OAAO,OAAO,KAAK,QAAQ;QAC3B,OAAO,CAAC,QAAQ,CAAC,qBAAqB,CAAC;QACvC,OAAO,CAAC,QAAQ,CAAC,IAAI,gBAAgB,EAAE,CAAC,CACzC,CAAC;AACJ,CAAC;AAED,MAAM,iBAAiB,GAAG,CAAC,UAAkB,EAAU,EAAE,CACvD,IAAI,CAAC,SAAS,CACZ,EAAE,OAAO,EAAE,CAAC,EAAE,KAAK,EAAE,EAAE,UAAU,EAAE,CAAC,cAAc,CAAC,UAAU,CAAC,CAAC,EAAE,EAAE,EACnE,IAAI,EACJ,CAAC,CACF,CAAC;AAEJ;;;;;;;;;;;;;GAaG;AACH,MAAM,UAAU,iBAAiB,CAC/B,WAA0B,EAC1B,UAAkB;IAElB,IAAI,WAAW,KAAK,IAAI,EAAE,CAAC;QACzB,OAAO,EAAE,MAAM,EAAE,iBAAiB,CAAC,UAAU,CAAC,EAAE,SAAS,EAAE,IAAI,EAAE,CAAC;IACpE,CAAC;IAED,IAAI,MAAe,CAAC;IACpB,IAAI,CAAC;QACH,MAAM,GAAG,IAAI,CAAC,KAAK,CAAC,WAAW,CAAC,CAAC;IACnC,CAAC;IAAC,MAAM,CAAC;QACP,OAAO,EAAE,MAAM,EAAE,iBAAiB,CAAC,UAAU,CAAC,EAAE,SAAS,EAAE,WAAW,EAAE,CAAC;IAC3E,CAAC;IACD,IAAI,CAAC,MAAM,IAAI,OAAO,MAAM,KAAK,QAAQ,IAAI,KAAK,CAAC,OAAO,CAAC,MAAM,CAAC,EAAE,CAAC;QACnE,OAAO,EAAE,MAAM,EAAE,iBAAiB,CAAC,UAAU,CAAC,EAAE,SAAS,EAAE,WAAW,EAAE,CAAC;IAC3E,CAAC;IAED,MAAM,IAAI,GAAG,MAAiC,CAAC;IAC/C,MAAM,KAAK,GACT,IAAI,CAAC,KAAK,IAAI,OAAO,IAAI,CAAC,KAAK,KAAK,QAAQ,IAAI,CAAC,KAAK,CAAC,OAAO,CAAC,IAAI,CAAC,KAAK,CAAC;QACxE,CAAC,CAAE,IAAI,CAAC,KAAiC;QACzC,CAAC,CAAC,EAAE,CAAC;IACT,MAAM,kBAAkB,GAAG,KAAK,CAAC,OAAO,CAAC,KAAK,CAAC,UAAU,CAAC,CAAC,CAAC,CAAC,KAAK,CAAC,UAAU,CAAC,CAAC,CAAC,EAAE,CAAC;IACnF,MAAM,WAAW,GAAG,kBAAkB,CAAC,MAAM,CAAC,CAAC,CAAC,EAAE,EAAE,CAAC,CAAC,kBAAkB,CAAC,CAAC,CAAC,CAAC,CAAC;IAC7E,MAAM,aAAa,GAAG,WAAW,CAAC,MAAM,KAAK,kBAAkB,CAAC,MAAM,CAAC;IAEvE,MAAM,OAAO,GAAG,OAAO,IAAI,CAAC,OAAO,KAAK,QAAQ,CAAC,CAAC,CAAC,IAAI,CAAC,OAAO,CAAC,CAAC,CAAC,CAAC,CAAC;IAEpE,MAAM,MAAM,GAAG,IAAI,CAAC,SAAS,CAC3B;QACE,GAAG,IAAI;QACP,OAAO;QACP,KAAK,EAAE,EAAE,GAAG,KAAK,EAAE,UAAU,EAAE,CAAC,GAAG,WAAW,EAAE,cAAc,CAAC,UAAU,CAAC,CAAC,EAAE;KAC9E,EACD,IAAI,EACJ,CAAC,CACF,CAAC;IAEF,+EAA+E;IAC/E,+EAA+E;IAC/E,MAAM,SAAS,GAAG,aAAa;QAC7B,CAAC,CAAC,IAAI,CAAC,SAAS,CACZ,EAAE,GAAG,IAAI,EAAE,OAAO,EAAE,KAAK,EAAE,EAAE,GAAG,KAAK,EAAE,UAAU,EAAE,WAAW,EAAE,EAAE,EAClE,IAAI,EACJ,CAAC,CACF;QACH,CAAC,CAAC,WAAW,CAAC;IAEhB,OAAO,EAAE,MAAM,EAAE,SAAS,EAAE,CAAC;AAC/B,CAAC"}
@@ -1,11 +1,15 @@
1
1
  /**
2
2
  * Session-scoped platform directory management.
3
3
  *
4
- * Each agent execution session gets a dedicated platform directory at
5
- * `~/.stigmer/sessions/{sessionId}/platform/` for storing skills,
6
- * attachments, and other platform-managed files. This directory is
7
- * separate from the user's workspace — the agent sees it via the
8
- * `.stigmer/` virtual namespace through WorkspaceBackend routing.
4
+ * Each agent execution session gets a dedicated directory tree under
5
+ * `~/.stigmer/sessions/{sessionId}/`:
6
+ * - `platform/` — skills, attachments, and other platform-managed files the
7
+ * agent sees via the `.stigmer/` virtual namespace (WorkspaceBackend routing).
8
+ * - `hitl/` — the HITL approval gate's runtime artifacts (hook script, approval
9
+ * state, denial ledger). These live OUTSIDE the user's workspace so attaching
10
+ * a real repo never leaves Stigmer files behind in it (see issue #173); only
11
+ * a minimal, transient `.cursor/hooks.json` referencing the absolute script
12
+ * path is written into the workspace itself.
9
13
  *
10
14
  * This module replaces the duplicated `getPlatformDir` helpers in
11
15
  * execute-cursor/skill-resolver.ts and execute-cursor/attachment-resolver.ts.
@@ -23,3 +27,20 @@ export declare function getPlatformDir(sessionId: string): string;
23
27
  * safe to call multiple times for the same session.
24
28
  */
25
29
  export declare function ensurePlatformDir(sessionId: string): Promise<string>;
30
+ /**
31
+ * Compute the HITL directory path for a session — the runner-owned home for the
32
+ * approval gate's hook script, approval-state file, and denial ledger.
33
+ *
34
+ * Kept separate from the workspace so the gate's machinery never pollutes the
35
+ * user's repo: the workspace only ever holds a transient `.cursor/hooks.json`
36
+ * that points here by absolute path. Pure function — performs no I/O.
37
+ */
38
+ export declare function getHitlDir(sessionId: string): string;
39
+ /**
40
+ * Ensure the HITL directory exists and return its path.
41
+ *
42
+ * Creates the full directory tree if it does not exist. Idempotent —
43
+ * safe to call multiple times for the same session (including across HITL
44
+ * reinvocations and Temporal activity retries).
45
+ */
46
+ export declare function ensureHitlDir(sessionId: string): Promise<string>;
@@ -1,11 +1,15 @@
1
1
  /**
2
2
  * Session-scoped platform directory management.
3
3
  *
4
- * Each agent execution session gets a dedicated platform directory at
5
- * `~/.stigmer/sessions/{sessionId}/platform/` for storing skills,
6
- * attachments, and other platform-managed files. This directory is
7
- * separate from the user's workspace — the agent sees it via the
8
- * `.stigmer/` virtual namespace through WorkspaceBackend routing.
4
+ * Each agent execution session gets a dedicated directory tree under
5
+ * `~/.stigmer/sessions/{sessionId}/`:
6
+ * - `platform/` — skills, attachments, and other platform-managed files the
7
+ * agent sees via the `.stigmer/` virtual namespace (WorkspaceBackend routing).
8
+ * - `hitl/` — the HITL approval gate's runtime artifacts (hook script, approval
9
+ * state, denial ledger). These live OUTSIDE the user's workspace so attaching
10
+ * a real repo never leaves Stigmer files behind in it (see issue #173); only
11
+ * a minimal, transient `.cursor/hooks.json` referencing the absolute script
12
+ * path is written into the workspace itself.
9
13
  *
10
14
  * This module replaces the duplicated `getPlatformDir` helpers in
11
15
  * execute-cursor/skill-resolver.ts and execute-cursor/attachment-resolver.ts.
@@ -13,14 +17,18 @@
13
17
  import { join } from "node:path";
14
18
  import { mkdir } from "node:fs/promises";
15
19
  import { homedir } from "node:os";
20
+ /** Root of a session's runner-owned directory tree (outside the workspace). */
21
+ function getSessionDir(sessionId) {
22
+ const home = process.env.HOME || process.env.USERPROFILE || homedir();
23
+ return join(home, ".stigmer", "sessions", sessionId);
24
+ }
16
25
  /**
17
26
  * Compute the platform directory path for a session.
18
27
  *
19
28
  * Pure function — does not create the directory or perform I/O.
20
29
  */
21
30
  export function getPlatformDir(sessionId) {
22
- const home = process.env.HOME || process.env.USERPROFILE || homedir();
23
- return join(home, ".stigmer", "sessions", sessionId, "platform");
31
+ return join(getSessionDir(sessionId), "platform");
24
32
  }
25
33
  /**
26
34
  * Ensure the platform directory exists and return its path.
@@ -33,4 +41,27 @@ export async function ensurePlatformDir(sessionId) {
33
41
  await mkdir(dir, { recursive: true });
34
42
  return dir;
35
43
  }
44
+ /**
45
+ * Compute the HITL directory path for a session — the runner-owned home for the
46
+ * approval gate's hook script, approval-state file, and denial ledger.
47
+ *
48
+ * Kept separate from the workspace so the gate's machinery never pollutes the
49
+ * user's repo: the workspace only ever holds a transient `.cursor/hooks.json`
50
+ * that points here by absolute path. Pure function — performs no I/O.
51
+ */
52
+ export function getHitlDir(sessionId) {
53
+ return join(getSessionDir(sessionId), "hitl");
54
+ }
55
+ /**
56
+ * Ensure the HITL directory exists and return its path.
57
+ *
58
+ * Creates the full directory tree if it does not exist. Idempotent —
59
+ * safe to call multiple times for the same session (including across HITL
60
+ * reinvocations and Temporal activity retries).
61
+ */
62
+ export async function ensureHitlDir(sessionId) {
63
+ const dir = getHitlDir(sessionId);
64
+ await mkdir(dir, { recursive: true });
65
+ return dir;
66
+ }
36
67
  //# sourceMappingURL=platform-dir.js.map
@@ -1 +1 @@
1
- {"version":3,"file":"platform-dir.js","sourceRoot":"","sources":["../../../src/shared/workspace/platform-dir.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;GAWG;AAEH,OAAO,EAAE,IAAI,EAAE,MAAM,WAAW,CAAC;AACjC,OAAO,EAAE,KAAK,EAAE,MAAM,kBAAkB,CAAC;AACzC,OAAO,EAAE,OAAO,EAAE,MAAM,SAAS,CAAC;AAElC;;;;GAIG;AACH,MAAM,UAAU,cAAc,CAAC,SAAiB;IAC9C,MAAM,IAAI,GAAG,OAAO,CAAC,GAAG,CAAC,IAAI,IAAI,OAAO,CAAC,GAAG,CAAC,WAAW,IAAI,OAAO,EAAE,CAAC;IACtE,OAAO,IAAI,CAAC,IAAI,EAAE,UAAU,EAAE,UAAU,EAAE,SAAS,EAAE,UAAU,CAAC,CAAC;AACnE,CAAC;AAED;;;;;GAKG;AACH,MAAM,CAAC,KAAK,UAAU,iBAAiB,CAAC,SAAiB;IACvD,MAAM,GAAG,GAAG,cAAc,CAAC,SAAS,CAAC,CAAC;IACtC,MAAM,KAAK,CAAC,GAAG,EAAE,EAAE,SAAS,EAAE,IAAI,EAAE,CAAC,CAAC;IACtC,OAAO,GAAG,CAAC;AACb,CAAC"}
1
+ {"version":3,"file":"platform-dir.js","sourceRoot":"","sources":["../../../src/shared/workspace/platform-dir.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;GAeG;AAEH,OAAO,EAAE,IAAI,EAAE,MAAM,WAAW,CAAC;AACjC,OAAO,EAAE,KAAK,EAAE,MAAM,kBAAkB,CAAC;AACzC,OAAO,EAAE,OAAO,EAAE,MAAM,SAAS,CAAC;AAElC,+EAA+E;AAC/E,SAAS,aAAa,CAAC,SAAiB;IACtC,MAAM,IAAI,GAAG,OAAO,CAAC,GAAG,CAAC,IAAI,IAAI,OAAO,CAAC,GAAG,CAAC,WAAW,IAAI,OAAO,EAAE,CAAC;IACtE,OAAO,IAAI,CAAC,IAAI,EAAE,UAAU,EAAE,UAAU,EAAE,SAAS,CAAC,CAAC;AACvD,CAAC;AAED;;;;GAIG;AACH,MAAM,UAAU,cAAc,CAAC,SAAiB;IAC9C,OAAO,IAAI,CAAC,aAAa,CAAC,SAAS,CAAC,EAAE,UAAU,CAAC,CAAC;AACpD,CAAC;AAED;;;;;GAKG;AACH,MAAM,CAAC,KAAK,UAAU,iBAAiB,CAAC,SAAiB;IACvD,MAAM,GAAG,GAAG,cAAc,CAAC,SAAS,CAAC,CAAC;IACtC,MAAM,KAAK,CAAC,GAAG,EAAE,EAAE,SAAS,EAAE,IAAI,EAAE,CAAC,CAAC;IACtC,OAAO,GAAG,CAAC;AACb,CAAC;AAED;;;;;;;GAOG;AACH,MAAM,UAAU,UAAU,CAAC,SAAiB;IAC1C,OAAO,IAAI,CAAC,aAAa,CAAC,SAAS,CAAC,EAAE,MAAM,CAAC,CAAC;AAChD,CAAC;AAED;;;;;;GAMG;AACH,MAAM,CAAC,KAAK,UAAU,aAAa,CAAC,SAAiB;IACnD,MAAM,GAAG,GAAG,UAAU,CAAC,SAAS,CAAC,CAAC;IAClC,MAAM,KAAK,CAAC,GAAG,EAAE,EAAE,SAAS,EAAE,IAAI,EAAE,CAAC,CAAC;IACtC,OAAO,GAAG,CAAC;AACb,CAAC"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@stigmer/runner",
3
- "version": "3.0.8-dev.20260612082024",
3
+ "version": "3.0.8-dev.20260612122433",
4
4
  "description": "Embeddable Temporal worker for the Stigmer AI agent platform — handles agent execution, workflow orchestration, and MCP server management",
5
5
  "license": "Apache-2.0",
6
6
  "type": "module",
@@ -86,7 +86,7 @@
86
86
  "@opentelemetry/resources": "^2.0.0",
87
87
  "@opentelemetry/sdk-trace-base": "^2.0.0",
88
88
  "@opentelemetry/sdk-trace-node": "^2.0.0",
89
- "@stigmer/protos": "3.0.8-dev.20260612082024",
89
+ "@stigmer/protos": "3.0.8-dev.20260612122433",
90
90
  "@temporalio/activity": "^1.11.0",
91
91
  "@temporalio/client": "^1.11.0",
92
92
  "@temporalio/common": "^1.11.0",
@@ -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");
@@ -337,6 +337,15 @@ describe("http2-interceptor", () => {
337
337
  // factories. Here we cover the two deterministic contracts: it is a no-op
338
338
  // when unconfigured (no import, so it never freezes the facade), and it
339
339
  // throws when the frozen facade is out of sync with the patched connect.
340
+ //
341
+ // This guard does double duty as a BUNDLER regression detector. The
342
+ // load-order contract is also defeatable at build time: an ESM esbuild
343
+ // bundle hoists every external `import` (including the node:http2 pulled in
344
+ // by connect-node) to the top of the output, freezing the facade before any
345
+ // install() runs. That is exactly stigmer/stigmer#170's second failure — the
346
+ // slim bundle is therefore emitted as CJS (scripts/bundle-slim.mjs) and
347
+ // verified on the authenticated boot path (scripts/verify-slim-artifact.mjs).
348
+ // The "out of sync" case below is the unit-level analog of that bundle bug.
340
349
 
341
350
  it("resolves without throwing (and without importing node:http2) when unconfigured", async () => {
342
351
  uninstallHttp2Interceptor();