@stigmer/runner 3.1.12 → 3.1.14

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-deep-agent/cas-capture-backend.d.ts +35 -15
  3. package/dist/activities/execute-deep-agent/cas-capture-backend.js +40 -12
  4. package/dist/activities/execute-deep-agent/cas-capture-backend.js.map +1 -1
  5. package/dist/activities/execute-deep-agent/deepagents-profiles.d.ts +16 -0
  6. package/dist/activities/execute-deep-agent/deepagents-profiles.js +31 -0
  7. package/dist/activities/execute-deep-agent/deepagents-profiles.js.map +1 -0
  8. package/dist/activities/execute-deep-agent/setup.js +12 -3
  9. package/dist/activities/execute-deep-agent/setup.js.map +1 -1
  10. package/dist/activities/execute-deep-agent/shell-env.d.ts +16 -0
  11. package/dist/activities/execute-deep-agent/shell-env.js +36 -0
  12. package/dist/activities/execute-deep-agent/shell-env.js.map +1 -0
  13. package/dist/activities/execute-deep-agent/subagent-transformer.d.ts +29 -6
  14. package/dist/activities/execute-deep-agent/subagent-transformer.js +73 -19
  15. package/dist/activities/execute-deep-agent/subagent-transformer.js.map +1 -1
  16. package/dist/runner-manager.js +2 -0
  17. package/dist/runner-manager.js.map +1 -1
  18. package/dist/runner.js +2 -0
  19. package/dist/runner.js.map +1 -1
  20. package/package.json +3 -3
  21. package/src/activities/execute-deep-agent/__tests__/cas-capture-backend.test.ts +134 -1
  22. package/src/activities/execute-deep-agent/__tests__/shell-env.test.ts +46 -0
  23. package/src/activities/execute-deep-agent/__tests__/shell-execute-gate.test.ts +291 -0
  24. package/src/activities/execute-deep-agent/__tests__/subagent-transformer.test.ts +7 -4
  25. package/src/activities/execute-deep-agent/cas-capture-backend.ts +69 -15
  26. package/src/activities/execute-deep-agent/deepagents-profiles.ts +35 -0
  27. package/src/activities/execute-deep-agent/setup.ts +12 -6
  28. package/src/activities/execute-deep-agent/shell-env.ts +42 -0
  29. package/src/activities/execute-deep-agent/subagent-transformer.ts +93 -20
  30. package/src/runner-manager.ts +5 -0
  31. package/src/runner.ts +5 -0
@@ -0,0 +1,291 @@
1
+ /**
2
+ * Shell `execute` tool approval gating and general-purpose sub-agent gating.
3
+ */
4
+
5
+ import { describe, it, expect, beforeEach, afterEach } from "vitest";
6
+ import { mkdtemp, rm, readFile } from "node:fs/promises";
7
+ import { join } from "node:path";
8
+ import { tmpdir } from "node:os";
9
+ import { HumanMessage } from "@langchain/core/messages";
10
+ import { Command, MemorySaver } from "@langchain/langgraph";
11
+ import { createDeepAgent } from "deepagents";
12
+ import { createApprovalGateMiddleware } from "../../../middleware/approval-gate.js";
13
+ import { createCasCaptureBackend } from "../cas-capture-backend.js";
14
+ import { CasCaptureObserver } from "../cas-capture-observer.js";
15
+ import {
16
+ registerStigmerDeepagentsProfiles,
17
+ resetStigmerDeepagentsProfilesForTests,
18
+ } from "../deepagents-profiles.js";
19
+ import {
20
+ ScriptedModel,
21
+ readPendingInterrupts,
22
+ type ScriptSelector,
23
+ } from "../__test-utils__/scripted-model.js";
24
+ import { compileSubagents, type TransformedSubagent } from "../subagent-transformer.js";
25
+
26
+ describe("native harness shell execute approval", () => {
27
+ let root: string;
28
+
29
+ beforeEach(async () => {
30
+ root = await mkdtemp(join(tmpdir(), "shell-gate-"));
31
+ });
32
+
33
+ afterEach(async () => {
34
+ await rm(root, { recursive: true, force: true });
35
+ });
36
+
37
+ it("interrupts before execute runs and resumes after approval", async () => {
38
+ const marker = join(root, "shell-marker.txt");
39
+ const script: ScriptSelector = () => ({
40
+ toolCalls: [{
41
+ name: "execute",
42
+ args: { command: `echo ran > shell-marker.txt` },
43
+ id: "exec_1",
44
+ }],
45
+ done: "shell done",
46
+ });
47
+
48
+ const observer = new CasCaptureObserver({
49
+ rootDir: root,
50
+ isIgnored: async () => false,
51
+ });
52
+ const backend = await createCasCaptureBackend({
53
+ rootDir: root,
54
+ observer,
55
+ shellEnv: {},
56
+ });
57
+
58
+ const checkpointer = new MemorySaver();
59
+ const agent = await createDeepAgent({
60
+ model: new ScriptedModel(script),
61
+ checkpointer: checkpointer as never,
62
+ backend,
63
+ middleware: [
64
+ createApprovalGateMiddleware({ policies: new Map(), toolServerMap: new Map() }),
65
+ ],
66
+ } as Parameters<typeof createDeepAgent>[0]);
67
+
68
+ const config = { configurable: { thread_id: "shell-thread" }, recursionLimit: 50 };
69
+
70
+ await agent.invoke({ messages: [new HumanMessage({ content: "go" })] }, config);
71
+
72
+ const pending = readPendingInterrupts(await agent.getState(config) as never);
73
+ expect(pending.length).toBeGreaterThanOrEqual(1);
74
+ expect(pending[0].toolName).toBe("execute");
75
+ await expect(readFile(marker, "utf8")).rejects.toThrow();
76
+
77
+ const resumeDict: Record<string, { action: string }> = {};
78
+ for (const p of pending) resumeDict[p.interruptId] = { action: "approve" };
79
+
80
+ await agent.invoke(new Command({ resume: resumeDict }), config);
81
+
82
+ expect(await readFile(marker, "utf8")).toBe("ran\n");
83
+ });
84
+ });
85
+
86
+ describe("sub-agent shell capability", () => {
87
+ let root: string;
88
+
89
+ beforeEach(async () => {
90
+ root = await mkdtemp(join(tmpdir(), "sa-shell-cap-"));
91
+ });
92
+
93
+ afterEach(async () => {
94
+ await rm(root, { recursive: true, force: true });
95
+ });
96
+
97
+ /**
98
+ * Compile a sub-agent and capture the tool names deepagents actually binds to
99
+ * its model — the filesystem middleware filters `execute` out of the model's
100
+ * tool list when the backend is not sandbox-capable, so the bound set is the
101
+ * authoritative observable for shell capability.
102
+ */
103
+ async function boundToolNamesFor(shellEnv: Record<string, string> | undefined) {
104
+ let bound: string[] = [];
105
+ const script: ScriptSelector = (toolNames) => {
106
+ if (toolNames.length > 0) bound = toolNames;
107
+ return { toolCalls: [], done: "ok" };
108
+ };
109
+
110
+ const compiled = await compileSubagents(
111
+ [{ name: "worker", description: "test worker", systemPrompt: "work", tools: [] }],
112
+ {
113
+ parentModelName: "claude-sonnet-4-6",
114
+ workspaceRootDir: root,
115
+ shellEnv,
116
+ modelFactory: async () => new ScriptedModel(script),
117
+ },
118
+ );
119
+ expect(compiled).toHaveLength(1);
120
+
121
+ await compiled[0].runnable.invoke(
122
+ { messages: [new HumanMessage({ content: "go" })] },
123
+ { configurable: { thread_id: "t1" }, recursionLimit: 50 },
124
+ );
125
+ return bound;
126
+ }
127
+
128
+ it("binds execute when shellEnv is present (non-plan mode)", async () => {
129
+ expect(await boundToolNamesFor({})).toContain("execute");
130
+ });
131
+
132
+ it("omits execute when shellEnv is absent (plan mode)", async () => {
133
+ expect(await boundToolNamesFor(undefined)).not.toContain("execute");
134
+ });
135
+ });
136
+
137
+ describe("general-purpose sub-agent gating", () => {
138
+ let root: string;
139
+
140
+ beforeEach(async () => {
141
+ root = await mkdtemp(join(tmpdir(), "gp-gate-"));
142
+ registerStigmerDeepagentsProfiles();
143
+ });
144
+
145
+ afterEach(async () => {
146
+ await rm(root, { recursive: true, force: true });
147
+ resetStigmerDeepagentsProfilesForTests();
148
+ });
149
+
150
+ it("gates execute inside a compiled general-purpose sub-agent", async () => {
151
+ const marker = join(root, "gp-shell-marker.txt");
152
+ // Role selection: only the SUB-AGENT is shell-capable in this test (the
153
+ // parent gets a non-shell backend below), so `execute` in the bound tool
154
+ // set uniquely identifies the sub-agent's turn.
155
+ const roleScript: ScriptSelector = (toolNames) =>
156
+ toolNames.includes("execute")
157
+ ? {
158
+ toolCalls: [{
159
+ name: "execute",
160
+ args: { command: "echo gp > gp-shell-marker.txt" },
161
+ id: "gp_exec_1",
162
+ }],
163
+ done: "gp done",
164
+ }
165
+ : {
166
+ toolCalls: [{
167
+ name: "task",
168
+ args: { description: "run shell", subagent_type: "general-purpose" },
169
+ id: "task_1",
170
+ }],
171
+ done: "parent done",
172
+ };
173
+
174
+ const gpSpec: TransformedSubagent = {
175
+ name: "general-purpose",
176
+ description: "Gated general-purpose test double",
177
+ systemPrompt: "Run the delegated task.",
178
+ tools: [],
179
+ };
180
+
181
+ const compiled = await compileSubagents([gpSpec], {
182
+ parentModelName: "claude-sonnet-4-6",
183
+ workspaceRootDir: root,
184
+ approvalGate: { policies: new Map(), toolServerMap: new Map() },
185
+ shellEnv: {},
186
+ modelFactory: async () => new ScriptedModel(roleScript),
187
+ });
188
+ expect(compiled).toHaveLength(1);
189
+
190
+ const observer = new CasCaptureObserver({
191
+ rootDir: root,
192
+ isIgnored: async () => false,
193
+ });
194
+ // Non-shell parent backend: keeps `execute` out of the parent's tool set so
195
+ // the role script above delegates instead of running the command itself.
196
+ const parentBackend = await createCasCaptureBackend({
197
+ rootDir: root,
198
+ observer,
199
+ });
200
+
201
+ const checkpointer = new MemorySaver();
202
+ const parent = await createDeepAgent({
203
+ model: new ScriptedModel(roleScript),
204
+ checkpointer: checkpointer as never,
205
+ backend: parentBackend,
206
+ subagents: compiled,
207
+ } as Parameters<typeof createDeepAgent>[0]);
208
+
209
+ const config = { configurable: { thread_id: "gp-thread" }, recursionLimit: 50 };
210
+ await parent.invoke({ messages: [new HumanMessage({ content: "go" })] }, config);
211
+
212
+ const pending = readPendingInterrupts(await parent.getState(config) as never);
213
+ expect(pending.some((p) => p.toolName === "execute")).toBe(true);
214
+ await expect(readFile(marker, "utf8")).rejects.toThrow();
215
+ });
216
+ });
217
+
218
+ describe("registerStigmerDeepagentsProfiles", () => {
219
+ beforeEach(() => {
220
+ resetStigmerDeepagentsProfilesForTests();
221
+ registerStigmerDeepagentsProfiles();
222
+ });
223
+
224
+ afterEach(() => {
225
+ resetStigmerDeepagentsProfilesForTests();
226
+ });
227
+
228
+ /**
229
+ * deepagents resolves harness profiles from the model's provider, and for a
230
+ * model INSTANCE the provider comes from the class name (ChatAnthropic /
231
+ * ChatOpenAI — see getModelProvider in deepagents). Production models always
232
+ * come from buildChatModel and are one of those two classes; a bare
233
+ * ScriptedModel resolves NO provider, so the suppression profile would never
234
+ * match it. Masquerading as ChatAnthropic exercises the exact production
235
+ * resolution path.
236
+ */
237
+ class ScriptedAnthropicModel extends ScriptedModel {
238
+ static override lc_name(): string {
239
+ return "ChatAnthropic";
240
+ }
241
+ }
242
+
243
+ it("prevents deepagents from auto-injecting an ungated general-purpose sub-agent", async () => {
244
+ const root = await mkdtemp(join(tmpdir(), "gp-profile-"));
245
+ try {
246
+ const observer = new CasCaptureObserver({
247
+ rootDir: root,
248
+ isIgnored: async () => false,
249
+ });
250
+ const backend = await createCasCaptureBackend({
251
+ rootDir: root,
252
+ observer,
253
+ shellEnv: {},
254
+ });
255
+
256
+ const script: ScriptSelector = () => ({
257
+ toolCalls: [{
258
+ name: "task",
259
+ args: { description: "delegate", subagent_type: "general-purpose" },
260
+ id: "task_1",
261
+ }],
262
+ done: "done",
263
+ });
264
+
265
+ // No `subagents` supplied: without the profile suppression deepagents
266
+ // would inject its own ungated general-purpose sub-agent here.
267
+ const agent = await createDeepAgent({
268
+ model: new ScriptedAnthropicModel(script),
269
+ backend,
270
+ } as Parameters<typeof createDeepAgent>[0]);
271
+
272
+ // With the injection suppressed, the scripted `task` delegation has no
273
+ // general-purpose target. Depending on the runtime's tool-error handling
274
+ // that surfaces either as a rejected invoke or as an error ToolMessage —
275
+ // both prove the delegation target does not exist.
276
+ let outcome: string;
277
+ try {
278
+ const result = await agent.invoke(
279
+ { messages: [new HumanMessage({ content: "go" })] },
280
+ { recursionLimit: 20 },
281
+ ) as { messages: unknown[] };
282
+ outcome = JSON.stringify(result.messages);
283
+ } catch (err) {
284
+ outcome = String(err);
285
+ }
286
+ expect(outcome).toMatch(/allowed types/i);
287
+ } finally {
288
+ await rm(root, { recursive: true, force: true });
289
+ }
290
+ });
291
+ });
@@ -53,10 +53,11 @@ function mockMcpUsage(slug: string): McpServerUsage {
53
53
  // =========================================================================
54
54
 
55
55
  describe("BUILTIN_SUBAGENT_TYPES", () => {
56
- it("contains explore and shell", () => {
56
+ it("contains explore, shell, and general-purpose", () => {
57
57
  expect(BUILTIN_SUBAGENT_TYPES.has("explore")).toBe(true);
58
58
  expect(BUILTIN_SUBAGENT_TYPES.has("shell")).toBe(true);
59
- expect(BUILTIN_SUBAGENT_TYPES.size).toBe(2);
59
+ expect(BUILTIN_SUBAGENT_TYPES.has("general-purpose")).toBe(true);
60
+ expect(BUILTIN_SUBAGENT_TYPES.size).toBe(3);
60
61
  });
61
62
  });
62
63
 
@@ -70,13 +71,14 @@ describe("createBuiltinSubagents", () => {
70
71
  expect(result).toEqual([]);
71
72
  });
72
73
 
73
- it("creates explore and shell subagents when workspace exists", () => {
74
+ it("creates explore, shell, and general-purpose subagents when workspace exists", () => {
74
75
  const result = createBuiltinSubagents(true);
75
- expect(result).toHaveLength(2);
76
+ expect(result).toHaveLength(3);
76
77
 
77
78
  const names = result.map((r) => r.name);
78
79
  expect(names).toContain("explore");
79
80
  expect(names).toContain("shell");
81
+ expect(names).toContain("general-purpose");
80
82
  });
81
83
 
82
84
  it("explore has read-only prompt with strict boundaries", () => {
@@ -470,6 +472,7 @@ describe("transformAndCompileSubagents", () => {
470
472
  const names = result!.map((r) => r.name);
471
473
  expect(names).toContain("explore");
472
474
  expect(names).toContain("shell");
475
+ expect(names).toContain("general-purpose");
473
476
 
474
477
  logSpy.mockRestore();
475
478
  });
@@ -1,6 +1,5 @@
1
1
  /**
2
- * A capture-mode `FilesystemBackend` that observes gitignored file mutations for
3
- * CAS review (design docs 08/11/12; the `.gitignored` half of apply-then-review).
2
+ * CAS-capturing deepagents backends for the native harness.
4
3
  *
5
4
  * WHY A BACKEND WRAPPER, NOT THE APPROVAL GATE
6
5
  * --------------------------------------------
@@ -12,24 +11,24 @@
12
11
  * escape review under auto-approve-all. Observing at the backend keeps CAS
13
12
  * capture gate-independent, so it is symmetric with the git-tracked path.
14
13
  *
15
- * THIS CLASS IS A THIN ADAPTER
16
- * ----------------------------
17
- * All capture state and logic live in the shared {@link CasCaptureObserver} (one
18
- * per turn). The parent graph and every sub-agent graph are each handed a backend
19
- * pointing at the SAME observer, so their gitignored writes compose into one CAS
20
- * change set with race-free first-touch-wins (see the observer's docs). This
21
- * backend only forwards the mutation point to the observer before delegating to
22
- * the base `FilesystemBackend`.
14
+ * SHELL VS PLAN MODE
15
+ * ------------------
16
+ * Outside plan mode the harness uses {@link CasCaptureShellBackend} (deepagents
17
+ * LocalShellBackend + CAS observation) so the `execute` tool is available and
18
+ * approval-gated. Plan mode keeps {@link CasCaptureFilesystemBackend} because
19
+ * deepagents rejects filesystem `permissions` combined with an execution-capable
20
+ * backend plan mode is read-only by construction, so no shell tool there.
23
21
  *
24
22
  * deepagents mutates only via `write` (create/overwrite) and `edit` (modify);
25
23
  * there is no backend delete/rename, so this observes CREATE and MODIFY. A
26
24
  * gitignored delete can only arrive via shell, which stays on the approval gate.
27
25
  *
28
26
  * @since File-Change HITL Redesign (Phase 3 — CAS deep-agent wiring); sub-agent
29
- * gitignored capture parity (Session 26, DD-19)
27
+ * gitignored capture parity (Session 26, DD-19); shell restore (issue #248)
30
28
  */
31
29
 
32
- import { FilesystemBackend } from "deepagents";
30
+ import { FilesystemBackend, LocalShellBackend } from "deepagents";
31
+ import type { LocalShellBackendOptions } from "deepagents";
33
32
  import type { CasCaptureObserver } from "./cas-capture-observer.js";
34
33
 
35
34
  export type { CasBeforeMap } from "./cas-capture-observer.js";
@@ -38,13 +37,36 @@ type FilesystemBackendOptions = NonNullable<
38
37
  ConstructorParameters<typeof FilesystemBackend>[0]
39
38
  >;
40
39
 
40
+ type CasCaptureDeps = { readonly observer: CasCaptureObserver };
41
+
41
42
  export class CasCaptureFilesystemBackend extends FilesystemBackend {
42
43
  private readonly observer: CasCaptureObserver;
43
44
 
44
- constructor(
45
- options: FilesystemBackendOptions,
46
- deps: { readonly observer: CasCaptureObserver },
45
+ constructor(options: FilesystemBackendOptions, deps: CasCaptureDeps) {
46
+ super(options);
47
+ this.observer = deps.observer;
48
+ }
49
+
50
+ override async write(filePath: string, content: string) {
51
+ await this.observer.recordBefore(filePath);
52
+ return super.write(filePath, content);
53
+ }
54
+
55
+ override async edit(
56
+ filePath: string,
57
+ oldString: string,
58
+ newString: string,
59
+ replaceAll?: boolean,
47
60
  ) {
61
+ await this.observer.recordBefore(filePath);
62
+ return super.edit(filePath, oldString, newString, replaceAll);
63
+ }
64
+ }
65
+
66
+ export class CasCaptureShellBackend extends LocalShellBackend {
67
+ private readonly observer: CasCaptureObserver;
68
+
69
+ constructor(options: LocalShellBackendOptions, deps: CasCaptureDeps) {
48
70
  super(options);
49
71
  this.observer = deps.observer;
50
72
  }
@@ -64,3 +86,35 @@ export class CasCaptureFilesystemBackend extends FilesystemBackend {
64
86
  return super.edit(filePath, oldString, newString, replaceAll);
65
87
  }
66
88
  }
89
+
90
+ export type CasCaptureBackend = CasCaptureFilesystemBackend | CasCaptureShellBackend;
91
+
92
+ export interface CreateCasCaptureBackendOptions {
93
+ readonly rootDir: string;
94
+ readonly observer: CasCaptureObserver;
95
+ /** When set, builds a shell-capable backend with this env for `execute`. */
96
+ readonly shellEnv?: Record<string, string>;
97
+ }
98
+
99
+ /**
100
+ * Construct the CAS-observing backend for a deepagents agent graph.
101
+ *
102
+ * Shell-capable when `shellEnv` is provided; otherwise filesystem-only (plan mode).
103
+ * Shell backends honor LocalShellBackend's documented initialize contract.
104
+ */
105
+ export async function createCasCaptureBackend(
106
+ options: CreateCasCaptureBackendOptions,
107
+ ): Promise<CasCaptureBackend> {
108
+ const { rootDir, observer, shellEnv } = options;
109
+
110
+ if (shellEnv !== undefined) {
111
+ const backend = new CasCaptureShellBackend(
112
+ { rootDir, env: shellEnv },
113
+ { observer },
114
+ );
115
+ await backend.initialize();
116
+ return backend;
117
+ }
118
+
119
+ return new CasCaptureFilesystemBackend({ rootDir }, { observer });
120
+ }
@@ -0,0 +1,35 @@
1
+ /**
2
+ * Stigmer-specific deepagents harness profile registrations.
3
+ *
4
+ * deepagents 1.10.x auto-injects a built-in `general-purpose` sub-agent into
5
+ * every `createDeepAgent()` call unless the caller supplies one with that name.
6
+ * The injected sub-agent carries only deepagents' built-in middleware — not our
7
+ * approval gate — so it is an ungated write/edit path today and would be an
8
+ * ungated `execute` path with a shell backend.
9
+ *
10
+ * Registrations merge additively with deepagents' built-in prompt profiles, so
11
+ * disabling auto-injection here does not alter model prompt overlays.
12
+ */
13
+
14
+ import { registerHarnessProfile } from "deepagents";
15
+
16
+ const PROVIDERS_WITH_GP_SUPPRESSION = ["anthropic", "openai"] as const;
17
+
18
+ let registered = false;
19
+
20
+ /** Idempotent: safe to call from runner boot and from tests. */
21
+ export function registerStigmerDeepagentsProfiles(): void {
22
+ if (registered) return;
23
+ registered = true;
24
+
25
+ for (const provider of PROVIDERS_WITH_GP_SUPPRESSION) {
26
+ registerHarnessProfile(provider, {
27
+ generalPurposeSubagent: { enabled: false },
28
+ });
29
+ }
30
+ }
31
+
32
+ /** Test-only: reset the once guard so profile registration can be re-exercised. */
33
+ export function resetStigmerDeepagentsProfilesForTests(): void {
34
+ registered = false;
35
+ }
@@ -29,7 +29,8 @@ import { backfillMcpServersIfNeeded } from "../../shared/connect-backfill.js";
29
29
  import { WorkspaceProvisioner } from "../../shared/workspace/provisioner.js";
30
30
  import { LocalWorkspaceBackend } from "../../shared/workspace/local-backend.js";
31
31
  import type { WorkspaceBackend, ProvisionResult } from "../../shared/workspace/types.js";
32
- import { CasCaptureFilesystemBackend } from "./cas-capture-backend.js";
32
+ import { createCasCaptureBackend } from "./cas-capture-backend.js";
33
+ import { buildShellEnv } from "./shell-env.js";
33
34
  import { CasCaptureObserver } from "./cas-capture-observer.js";
34
35
  import { isGitWorkTree, isPathCapturable } from "../../shared/filereview/git-substrate.js";
35
36
  import { deriveCaptureMode } from "../../shared/filereview/capture.js";
@@ -400,6 +401,8 @@ export async function performSetup(deps: SetupDependencies): Promise<SetupResult
400
401
  await ensurePricingLoaded();
401
402
  const pricing = getModelPricing(modelName);
402
403
  const execConfig = execution.spec!.executionConfig;
404
+ const isPlanMode = execConfig?.interactionMode === InteractionMode.PLAN;
405
+ const shellEnv = isPlanMode ? undefined : buildShellEnv(envResult.mergedEnvVars);
403
406
 
404
407
  const toolServerMap = new Map<string, string>();
405
408
  if (mcpConnection) {
@@ -538,6 +541,9 @@ export async function performSetup(deps: SetupDependencies): Promise<SetupResult
538
541
  stigmerToken: config.stigmerToken ?? undefined,
539
542
  headerScope: { executionId },
540
543
  })).model,
544
+ // Presence of shellEnv is the shell-capability switch for sub-agent
545
+ // backends too (undefined in plan mode; see buildShellEnv above).
546
+ shellEnv,
541
547
  });
542
548
  }
543
549
 
@@ -556,7 +562,6 @@ export async function performSetup(deps: SetupDependencies): Promise<SetupResult
556
562
  // first-match-wins with a permissive default, so a single deny-all-writes
557
563
  // rule is sufficient. (The Cursor harness enforces plan mode via its prompt
558
564
  // prefix; the native harness enforces it here.)
559
- const isPlanMode = execConfig?.interactionMode === InteractionMode.PLAN;
560
565
  const planModePermissions: FilesystemPermission[] = [
561
566
  { operations: ["write"], paths: ["/**"], mode: "deny" },
562
567
  ];
@@ -567,10 +572,11 @@ export async function performSetup(deps: SetupDependencies): Promise<SetupResult
567
572
  // bytes of every CAS-owned path — .gitignored paths in a git work tree, all
568
573
  // touched paths in a non-git one — so the boundary can capture them into CAS.
569
574
  // It is gate-independent, so it holds even under the global bypass.
570
- const fileBackend = new CasCaptureFilesystemBackend(
571
- { rootDir: workspaceBackend.rootDir },
572
- { observer: casObserver },
573
- );
575
+ const fileBackend = await createCasCaptureBackend({
576
+ rootDir: workspaceBackend.rootDir,
577
+ observer: casObserver,
578
+ shellEnv,
579
+ });
574
580
  const agentGraph = await createDeepAgent({
575
581
  model,
576
582
  checkpointer: checkpointer as any,
@@ -0,0 +1,42 @@
1
+ /**
2
+ * Shell environment for the native harness `execute` tool.
3
+ *
4
+ * Per-execution snapshot: runner-manager rotates `STIGMER_TOKEN` in
5
+ * `process.env` at runtime, so this must run inside setup for each execution,
6
+ * never once at process start.
7
+ */
8
+
9
+ /** Runner-internal keys that must never reach agent shell commands. */
10
+ export const SHELL_ENV_DENYLIST: readonly string[] = [
11
+ // Derives HITL approval fingerprints — an agent that reads this could forge receipts.
12
+ "STIGMER_RUNNER_HITL_SECRET",
13
+ // Cursor harness credential; shell commands do not need direct Cursor API access.
14
+ "CURSOR_API_KEY",
15
+ // Stigmer control-plane auth; shell commands use ExecutionContext overlay instead.
16
+ "STIGMER_TOKEN",
17
+ ];
18
+
19
+ /**
20
+ * Build the environment map passed to deepagents' LocalShellBackend.
21
+ *
22
+ * Base: runner process env minus {@link SHELL_ENV_DENYLIST}, then overlay
23
+ * {@link mergedEnvVars} (ExecutionContext wins on conflict).
24
+ */
25
+ export function buildShellEnv(
26
+ mergedEnvVars: Readonly<Record<string, string>>,
27
+ baseEnv: NodeJS.ProcessEnv = process.env,
28
+ ): Record<string, string> {
29
+ const deny = new Set(SHELL_ENV_DENYLIST);
30
+ const env: Record<string, string> = {};
31
+
32
+ for (const [key, value] of Object.entries(baseEnv)) {
33
+ if (deny.has(key) || value === undefined) continue;
34
+ env[key] = value;
35
+ }
36
+
37
+ for (const [key, value] of Object.entries(mergedEnvVars)) {
38
+ env[key] = value;
39
+ }
40
+
41
+ return env;
42
+ }