@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.
- package/dist/.build-fingerprint +1 -1
- package/dist/activities/execute-deep-agent/cas-capture-backend.d.ts +35 -15
- package/dist/activities/execute-deep-agent/cas-capture-backend.js +40 -12
- package/dist/activities/execute-deep-agent/cas-capture-backend.js.map +1 -1
- package/dist/activities/execute-deep-agent/deepagents-profiles.d.ts +16 -0
- package/dist/activities/execute-deep-agent/deepagents-profiles.js +31 -0
- package/dist/activities/execute-deep-agent/deepagents-profiles.js.map +1 -0
- package/dist/activities/execute-deep-agent/setup.js +12 -3
- package/dist/activities/execute-deep-agent/setup.js.map +1 -1
- package/dist/activities/execute-deep-agent/shell-env.d.ts +16 -0
- package/dist/activities/execute-deep-agent/shell-env.js +36 -0
- package/dist/activities/execute-deep-agent/shell-env.js.map +1 -0
- package/dist/activities/execute-deep-agent/subagent-transformer.d.ts +29 -6
- package/dist/activities/execute-deep-agent/subagent-transformer.js +73 -19
- package/dist/activities/execute-deep-agent/subagent-transformer.js.map +1 -1
- package/dist/runner-manager.js +2 -0
- package/dist/runner-manager.js.map +1 -1
- package/dist/runner.js +2 -0
- package/dist/runner.js.map +1 -1
- package/package.json +3 -3
- package/src/activities/execute-deep-agent/__tests__/cas-capture-backend.test.ts +134 -1
- package/src/activities/execute-deep-agent/__tests__/shell-env.test.ts +46 -0
- package/src/activities/execute-deep-agent/__tests__/shell-execute-gate.test.ts +291 -0
- package/src/activities/execute-deep-agent/__tests__/subagent-transformer.test.ts +7 -4
- package/src/activities/execute-deep-agent/cas-capture-backend.ts +69 -15
- package/src/activities/execute-deep-agent/deepagents-profiles.ts +35 -0
- package/src/activities/execute-deep-agent/setup.ts +12 -6
- package/src/activities/execute-deep-agent/shell-env.ts +42 -0
- package/src/activities/execute-deep-agent/subagent-transformer.ts +93 -20
- package/src/runner-manager.ts +5 -0
- 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
|
|
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.
|
|
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
|
|
74
|
+
it("creates explore, shell, and general-purpose subagents when workspace exists", () => {
|
|
74
75
|
const result = createBuiltinSubagents(true);
|
|
75
|
-
expect(result).toHaveLength(
|
|
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
|
-
*
|
|
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
|
-
*
|
|
16
|
-
*
|
|
17
|
-
*
|
|
18
|
-
*
|
|
19
|
-
*
|
|
20
|
-
*
|
|
21
|
-
* backend
|
|
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
|
|
46
|
-
|
|
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 {
|
|
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 =
|
|
571
|
-
|
|
572
|
-
|
|
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
|
+
}
|