@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.
- package/README.md +2 -0
- 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__/http2-interceptor.test.ts +9 -0
- 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
|
@@ -1,61 +1,181 @@
|
|
|
1
1
|
/**
|
|
2
|
-
*
|
|
3
|
-
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
16
|
-
*
|
|
17
|
-
|
|
18
|
-
|
|
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
|
|
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
|
-
*
|
|
28
|
-
*
|
|
29
|
-
*
|
|
30
|
-
*
|
|
31
|
-
*
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
const
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
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
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
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
|
|
1
|
+
{"version":3,"file":"workspace-setup.js","sourceRoot":"","sources":["../../../src/activities/execute-cursor/workspace-setup.ts"],"names":[],"mappings":"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"}
|
|
@@ -1,11 +1,15 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Session-scoped platform directory management.
|
|
3
3
|
*
|
|
4
|
-
* Each agent execution session gets a dedicated
|
|
5
|
-
* `~/.stigmer/sessions/{sessionId}
|
|
6
|
-
* attachments, and other platform-managed files
|
|
7
|
-
*
|
|
8
|
-
*
|
|
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
|
|
5
|
-
* `~/.stigmer/sessions/{sessionId}
|
|
6
|
-
* attachments, and other platform-managed files
|
|
7
|
-
*
|
|
8
|
-
*
|
|
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
|
-
|
|
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
|
|
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.
|
|
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.
|
|
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
|
-
"/
|
|
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");
|
|
@@ -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();
|