@foldrun/core 1.1.1 → 1.1.3
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/src/tool-loop.d.ts +18 -0
- package/dist/src/tool-loop.js +91 -24
- package/package.json +1 -1
- package/src/tool-loop.ts +92 -25
package/dist/src/tool-loop.d.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { type Options } from "@anthropic-ai/claude-agent-sdk";
|
|
1
2
|
/** One string argument: what it means, whether it may be left out, how long it may be. */
|
|
2
3
|
export interface LoopArg {
|
|
3
4
|
description?: string;
|
|
@@ -42,4 +43,21 @@ export interface LoopOptions {
|
|
|
42
43
|
}
|
|
43
44
|
/** The Agent SDK's name for one of our tools. */
|
|
44
45
|
export declare const loopToolName: (name: string) => string;
|
|
46
|
+
/** What a session was started with that we did not give it: any tool or MCP server not ours. */
|
|
47
|
+
export declare function foreignTools(init: {
|
|
48
|
+
tools: string[];
|
|
49
|
+
mcp_servers: {
|
|
50
|
+
name: string;
|
|
51
|
+
}[];
|
|
52
|
+
}, ours: Set<string>): string[];
|
|
53
|
+
/**
|
|
54
|
+
* The query's options, and the throwaway directory they run in (the caller
|
|
55
|
+
* removes it). Apart from runToolLoop so a test can start a session with
|
|
56
|
+
* exactly these and read what it was given.
|
|
57
|
+
*/
|
|
58
|
+
export declare function loopSession(o: LoopOptions, abort: AbortController): {
|
|
59
|
+
options: Options;
|
|
60
|
+
ours: Set<string>;
|
|
61
|
+
scratch: string;
|
|
62
|
+
};
|
|
45
63
|
export declare function runToolLoop(o: LoopOptions): Promise<LoopResult>;
|
package/dist/src/tool-loop.js
CHANGED
|
@@ -29,7 +29,16 @@ function schemaOf(args) {
|
|
|
29
29
|
return [name, a.optional ? s.optional() : s];
|
|
30
30
|
}));
|
|
31
31
|
}
|
|
32
|
-
|
|
32
|
+
/** What a session was started with that we did not give it: any tool or MCP server not ours. */
|
|
33
|
+
export function foreignTools(init, ours) {
|
|
34
|
+
return [...init.tools.filter((t) => !ours.has(t)), ...init.mcp_servers.map((m) => m.name).filter((n) => n !== SERVER)];
|
|
35
|
+
}
|
|
36
|
+
/**
|
|
37
|
+
* The query's options, and the throwaway directory they run in (the caller
|
|
38
|
+
* removes it). Apart from runToolLoop so a test can start a session with
|
|
39
|
+
* exactly these and read what it was given.
|
|
40
|
+
*/
|
|
41
|
+
export function loopSession(o, abort) {
|
|
33
42
|
const server = createSdkMcpServer({
|
|
34
43
|
name: SERVER,
|
|
35
44
|
version: "1.0.0",
|
|
@@ -44,9 +53,75 @@ export async function runToolLoop(o) {
|
|
|
44
53
|
})),
|
|
45
54
|
});
|
|
46
55
|
// Homeless on purpose: there are no file tools, and a throwaway cwd means
|
|
47
|
-
// even a confused call has no tree to touch.
|
|
56
|
+
// even a confused call has no tree to touch. Its own home too: the CLI
|
|
57
|
+
// reads ~/.claude.json and auto memory whatever settingSources says, so
|
|
58
|
+
// one reply must never find another's.
|
|
48
59
|
const scratch = fs.mkdtempSync(path.join(os.tmpdir(), "foldrun-loop-"));
|
|
60
|
+
const home = path.join(scratch, "home");
|
|
61
|
+
const work = path.join(scratch, "work");
|
|
62
|
+
fs.mkdirSync(home);
|
|
63
|
+
fs.mkdirSync(work);
|
|
64
|
+
const ours = new Set(o.tools.map((t) => loopToolName(t.name)));
|
|
65
|
+
const options = {
|
|
66
|
+
model: o.model,
|
|
67
|
+
systemPrompt: o.system,
|
|
68
|
+
// No built-in tool at all (no Task, so no sub-agents either) — only
|
|
69
|
+
// ours, each pre-approved; anything else is refused unasked.
|
|
70
|
+
tools: [],
|
|
71
|
+
mcpServers: { [SERVER]: server },
|
|
72
|
+
strictMcpConfig: true,
|
|
73
|
+
allowedTools: [...ours],
|
|
74
|
+
permissionMode: "dontAsk",
|
|
75
|
+
settingSources: [],
|
|
76
|
+
persistSession: false,
|
|
77
|
+
maxTurns: o.maxTurns,
|
|
78
|
+
maxBudgetUsd: o.maxBudgetUsd,
|
|
79
|
+
includePartialMessages: true,
|
|
80
|
+
cwd: work,
|
|
81
|
+
abortController: abort,
|
|
82
|
+
// tools: [] is what keeps the built-ins out, and the SDK does not
|
|
83
|
+
// document it; a hook runs before every other permission step, so
|
|
84
|
+
// anything not ours is refused even if a later SDK lets it back in.
|
|
85
|
+
hooks: {
|
|
86
|
+
PreToolUse: [
|
|
87
|
+
{
|
|
88
|
+
hooks: [
|
|
89
|
+
async (input) => {
|
|
90
|
+
const name = "tool_name" in input ? String(input.tool_name) : "";
|
|
91
|
+
if (ours.has(name))
|
|
92
|
+
return {};
|
|
93
|
+
return {
|
|
94
|
+
hookSpecificOutput: {
|
|
95
|
+
hookEventName: "PreToolUse",
|
|
96
|
+
permissionDecision: "deny",
|
|
97
|
+
permissionDecisionReason: `${name} is not one of this assistant's tools`,
|
|
98
|
+
},
|
|
99
|
+
};
|
|
100
|
+
},
|
|
101
|
+
],
|
|
102
|
+
},
|
|
103
|
+
],
|
|
104
|
+
},
|
|
105
|
+
env: {
|
|
106
|
+
...hostSafeEnv(),
|
|
107
|
+
...o.env,
|
|
108
|
+
HOME: home,
|
|
109
|
+
CLAUDE_CONFIG_DIR: path.join(home, ".claude"),
|
|
110
|
+
CLAUDE_CODE_DISABLE_AUTO_MEMORY: "1",
|
|
111
|
+
ENABLE_CLAUDEAI_MCP_SERVERS: "false",
|
|
112
|
+
// Telemetry, update checks, feature flags: none of it is the reply, and
|
|
113
|
+
// where the network allows only the model API it would hang on a
|
|
114
|
+
// blocked host.
|
|
115
|
+
CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC: "1",
|
|
116
|
+
CLAUDE_CODE_MAX_OUTPUT_TOKENS: String(o.maxOutputTokens),
|
|
117
|
+
},
|
|
118
|
+
};
|
|
119
|
+
return { options, ours, scratch };
|
|
120
|
+
}
|
|
121
|
+
export async function runToolLoop(o) {
|
|
49
122
|
const abort = new AbortController();
|
|
123
|
+
const { options, ours, scratch } = loopSession(o, abort);
|
|
124
|
+
let refused = null;
|
|
50
125
|
let killed = false;
|
|
51
126
|
const watchdog = setTimeout(() => {
|
|
52
127
|
killed = true;
|
|
@@ -58,30 +133,20 @@ export async function runToolLoop(o) {
|
|
|
58
133
|
let usage = null;
|
|
59
134
|
let stopped = null;
|
|
60
135
|
try {
|
|
61
|
-
const q = query({
|
|
62
|
-
prompt: o.prompt,
|
|
63
|
-
options: {
|
|
64
|
-
model: o.model,
|
|
65
|
-
systemPrompt: o.system,
|
|
66
|
-
// No built-in tool at all (no Task, so no sub-agents either) — only
|
|
67
|
-
// ours, each pre-approved; anything else is refused unasked.
|
|
68
|
-
tools: [],
|
|
69
|
-
mcpServers: { [SERVER]: server },
|
|
70
|
-
strictMcpConfig: true,
|
|
71
|
-
allowedTools: o.tools.map((t) => loopToolName(t.name)),
|
|
72
|
-
permissionMode: "dontAsk",
|
|
73
|
-
settingSources: [],
|
|
74
|
-
persistSession: false,
|
|
75
|
-
maxTurns: o.maxTurns,
|
|
76
|
-
maxBudgetUsd: o.maxBudgetUsd,
|
|
77
|
-
includePartialMessages: true,
|
|
78
|
-
cwd: scratch,
|
|
79
|
-
abortController: abort,
|
|
80
|
-
env: { ...hostSafeEnv(), ...o.env, CLAUDE_CODE_MAX_OUTPUT_TOKENS: String(o.maxOutputTokens) },
|
|
81
|
-
},
|
|
82
|
-
});
|
|
136
|
+
const q = query({ prompt: o.prompt, options });
|
|
83
137
|
let turn = "";
|
|
84
138
|
for await (const message of q) {
|
|
139
|
+
// Fail closed: a session that starts with any tool or MCP server we did
|
|
140
|
+
// not give it never reaches the model.
|
|
141
|
+
if (message.type === "system" && message.subtype === "init") {
|
|
142
|
+
const extra = foreignTools(message, ours);
|
|
143
|
+
if (extra.length) {
|
|
144
|
+
refused = `the model session started with tools it must not have: ${extra.join(", ")}`;
|
|
145
|
+
abort.abort();
|
|
146
|
+
break;
|
|
147
|
+
}
|
|
148
|
+
continue;
|
|
149
|
+
}
|
|
85
150
|
if (message.type === "stream_event") {
|
|
86
151
|
const e = message.event;
|
|
87
152
|
if (e.type === "message_start")
|
|
@@ -127,6 +192,8 @@ export async function runToolLoop(o) {
|
|
|
127
192
|
clearTimeout(watchdog);
|
|
128
193
|
fs.rmSync(scratch, { recursive: true, force: true });
|
|
129
194
|
}
|
|
195
|
+
if (refused)
|
|
196
|
+
throw new Error(refused);
|
|
130
197
|
if (killed)
|
|
131
198
|
stopped = `ran past ${o.wallSeconds}s`;
|
|
132
199
|
return { text, costUsd, usage, stopped };
|
package/package.json
CHANGED
package/src/tool-loop.ts
CHANGED
|
@@ -15,7 +15,7 @@ import fs from "node:fs";
|
|
|
15
15
|
import os from "node:os";
|
|
16
16
|
import path from "node:path";
|
|
17
17
|
import { z } from "zod";
|
|
18
|
-
import { query, createSdkMcpServer, tool } from "@anthropic-ai/claude-agent-sdk";
|
|
18
|
+
import { query, createSdkMcpServer, tool, type Options } from "@anthropic-ai/claude-agent-sdk";
|
|
19
19
|
import { hostSafeEnv } from "./host-env.ts";
|
|
20
20
|
|
|
21
21
|
/** One string argument: what it means, whether it may be left out, how long it may be. */
|
|
@@ -81,7 +81,17 @@ function schemaOf(args: Record<string, LoopArg>): z.ZodRawShape {
|
|
|
81
81
|
);
|
|
82
82
|
}
|
|
83
83
|
|
|
84
|
-
|
|
84
|
+
/** What a session was started with that we did not give it: any tool or MCP server not ours. */
|
|
85
|
+
export function foreignTools(init: { tools: string[]; mcp_servers: { name: string }[] }, ours: Set<string>): string[] {
|
|
86
|
+
return [...init.tools.filter((t) => !ours.has(t)), ...init.mcp_servers.map((m) => m.name).filter((n) => n !== SERVER)];
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* The query's options, and the throwaway directory they run in (the caller
|
|
91
|
+
* removes it). Apart from runToolLoop so a test can start a session with
|
|
92
|
+
* exactly these and read what it was given.
|
|
93
|
+
*/
|
|
94
|
+
export function loopSession(o: LoopOptions, abort: AbortController): { options: Options; ours: Set<string>; scratch: string } {
|
|
85
95
|
const server = createSdkMcpServer({
|
|
86
96
|
name: SERVER,
|
|
87
97
|
version: "1.0.0",
|
|
@@ -97,9 +107,75 @@ export async function runToolLoop(o: LoopOptions): Promise<LoopResult> {
|
|
|
97
107
|
),
|
|
98
108
|
});
|
|
99
109
|
// Homeless on purpose: there are no file tools, and a throwaway cwd means
|
|
100
|
-
// even a confused call has no tree to touch.
|
|
110
|
+
// even a confused call has no tree to touch. Its own home too: the CLI
|
|
111
|
+
// reads ~/.claude.json and auto memory whatever settingSources says, so
|
|
112
|
+
// one reply must never find another's.
|
|
101
113
|
const scratch = fs.mkdtempSync(path.join(os.tmpdir(), "foldrun-loop-"));
|
|
114
|
+
const home = path.join(scratch, "home");
|
|
115
|
+
const work = path.join(scratch, "work");
|
|
116
|
+
fs.mkdirSync(home);
|
|
117
|
+
fs.mkdirSync(work);
|
|
118
|
+
const ours = new Set(o.tools.map((t) => loopToolName(t.name)));
|
|
119
|
+
const options: Options = {
|
|
120
|
+
model: o.model,
|
|
121
|
+
systemPrompt: o.system,
|
|
122
|
+
// No built-in tool at all (no Task, so no sub-agents either) — only
|
|
123
|
+
// ours, each pre-approved; anything else is refused unasked.
|
|
124
|
+
tools: [],
|
|
125
|
+
mcpServers: { [SERVER]: server },
|
|
126
|
+
strictMcpConfig: true,
|
|
127
|
+
allowedTools: [...ours],
|
|
128
|
+
permissionMode: "dontAsk",
|
|
129
|
+
settingSources: [],
|
|
130
|
+
persistSession: false,
|
|
131
|
+
maxTurns: o.maxTurns,
|
|
132
|
+
maxBudgetUsd: o.maxBudgetUsd,
|
|
133
|
+
includePartialMessages: true,
|
|
134
|
+
cwd: work,
|
|
135
|
+
abortController: abort,
|
|
136
|
+
// tools: [] is what keeps the built-ins out, and the SDK does not
|
|
137
|
+
// document it; a hook runs before every other permission step, so
|
|
138
|
+
// anything not ours is refused even if a later SDK lets it back in.
|
|
139
|
+
hooks: {
|
|
140
|
+
PreToolUse: [
|
|
141
|
+
{
|
|
142
|
+
hooks: [
|
|
143
|
+
async (input) => {
|
|
144
|
+
const name = "tool_name" in input ? String(input.tool_name) : "";
|
|
145
|
+
if (ours.has(name)) return {};
|
|
146
|
+
return {
|
|
147
|
+
hookSpecificOutput: {
|
|
148
|
+
hookEventName: "PreToolUse" as const,
|
|
149
|
+
permissionDecision: "deny" as const,
|
|
150
|
+
permissionDecisionReason: `${name} is not one of this assistant's tools`,
|
|
151
|
+
},
|
|
152
|
+
};
|
|
153
|
+
},
|
|
154
|
+
],
|
|
155
|
+
},
|
|
156
|
+
],
|
|
157
|
+
},
|
|
158
|
+
env: {
|
|
159
|
+
...hostSafeEnv(),
|
|
160
|
+
...o.env,
|
|
161
|
+
HOME: home,
|
|
162
|
+
CLAUDE_CONFIG_DIR: path.join(home, ".claude"),
|
|
163
|
+
CLAUDE_CODE_DISABLE_AUTO_MEMORY: "1",
|
|
164
|
+
ENABLE_CLAUDEAI_MCP_SERVERS: "false",
|
|
165
|
+
// Telemetry, update checks, feature flags: none of it is the reply, and
|
|
166
|
+
// where the network allows only the model API it would hang on a
|
|
167
|
+
// blocked host.
|
|
168
|
+
CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC: "1",
|
|
169
|
+
CLAUDE_CODE_MAX_OUTPUT_TOKENS: String(o.maxOutputTokens),
|
|
170
|
+
},
|
|
171
|
+
};
|
|
172
|
+
return { options, ours, scratch };
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
export async function runToolLoop(o: LoopOptions): Promise<LoopResult> {
|
|
102
176
|
const abort = new AbortController();
|
|
177
|
+
const { options, ours, scratch } = loopSession(o, abort);
|
|
178
|
+
let refused: string | null = null;
|
|
103
179
|
let killed = false;
|
|
104
180
|
const watchdog = setTimeout(() => {
|
|
105
181
|
killed = true;
|
|
@@ -112,30 +188,20 @@ export async function runToolLoop(o: LoopOptions): Promise<LoopResult> {
|
|
|
112
188
|
let usage: LoopUsage | null = null;
|
|
113
189
|
let stopped: string | null = null;
|
|
114
190
|
try {
|
|
115
|
-
const q = query({
|
|
116
|
-
prompt: o.prompt,
|
|
117
|
-
options: {
|
|
118
|
-
model: o.model,
|
|
119
|
-
systemPrompt: o.system,
|
|
120
|
-
// No built-in tool at all (no Task, so no sub-agents either) — only
|
|
121
|
-
// ours, each pre-approved; anything else is refused unasked.
|
|
122
|
-
tools: [],
|
|
123
|
-
mcpServers: { [SERVER]: server },
|
|
124
|
-
strictMcpConfig: true,
|
|
125
|
-
allowedTools: o.tools.map((t) => loopToolName(t.name)),
|
|
126
|
-
permissionMode: "dontAsk",
|
|
127
|
-
settingSources: [],
|
|
128
|
-
persistSession: false,
|
|
129
|
-
maxTurns: o.maxTurns,
|
|
130
|
-
maxBudgetUsd: o.maxBudgetUsd,
|
|
131
|
-
includePartialMessages: true,
|
|
132
|
-
cwd: scratch,
|
|
133
|
-
abortController: abort,
|
|
134
|
-
env: { ...hostSafeEnv(), ...o.env, CLAUDE_CODE_MAX_OUTPUT_TOKENS: String(o.maxOutputTokens) },
|
|
135
|
-
},
|
|
136
|
-
});
|
|
191
|
+
const q = query({ prompt: o.prompt, options });
|
|
137
192
|
let turn = "";
|
|
138
193
|
for await (const message of q) {
|
|
194
|
+
// Fail closed: a session that starts with any tool or MCP server we did
|
|
195
|
+
// not give it never reaches the model.
|
|
196
|
+
if (message.type === "system" && message.subtype === "init") {
|
|
197
|
+
const extra = foreignTools(message, ours);
|
|
198
|
+
if (extra.length) {
|
|
199
|
+
refused = `the model session started with tools it must not have: ${extra.join(", ")}`;
|
|
200
|
+
abort.abort();
|
|
201
|
+
break;
|
|
202
|
+
}
|
|
203
|
+
continue;
|
|
204
|
+
}
|
|
139
205
|
if (message.type === "stream_event") {
|
|
140
206
|
const e = message.event;
|
|
141
207
|
if (e.type === "message_start") turn = "";
|
|
@@ -171,6 +237,7 @@ export async function runToolLoop(o: LoopOptions): Promise<LoopResult> {
|
|
|
171
237
|
clearTimeout(watchdog);
|
|
172
238
|
fs.rmSync(scratch, { recursive: true, force: true });
|
|
173
239
|
}
|
|
240
|
+
if (refused) throw new Error(refused);
|
|
174
241
|
if (killed) stopped = `ran past ${o.wallSeconds}s`;
|
|
175
242
|
return { text, costUsd, usage, stopped };
|
|
176
243
|
}
|