@foldrun/core 1.1.0 → 1.1.2
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 +87 -24
- package/package.json +1 -1
- package/src/tool-loop.ts +88 -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,71 @@ 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
|
+
CLAUDE_CODE_MAX_OUTPUT_TOKENS: String(o.maxOutputTokens),
|
|
113
|
+
},
|
|
114
|
+
};
|
|
115
|
+
return { options, ours, scratch };
|
|
116
|
+
}
|
|
117
|
+
export async function runToolLoop(o) {
|
|
49
118
|
const abort = new AbortController();
|
|
119
|
+
const { options, ours, scratch } = loopSession(o, abort);
|
|
120
|
+
let refused = null;
|
|
50
121
|
let killed = false;
|
|
51
122
|
const watchdog = setTimeout(() => {
|
|
52
123
|
killed = true;
|
|
@@ -58,30 +129,20 @@ export async function runToolLoop(o) {
|
|
|
58
129
|
let usage = null;
|
|
59
130
|
let stopped = null;
|
|
60
131
|
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
|
-
});
|
|
132
|
+
const q = query({ prompt: o.prompt, options });
|
|
83
133
|
let turn = "";
|
|
84
134
|
for await (const message of q) {
|
|
135
|
+
// Fail closed: a session that starts with any tool or MCP server we did
|
|
136
|
+
// not give it never reaches the model.
|
|
137
|
+
if (message.type === "system" && message.subtype === "init") {
|
|
138
|
+
const extra = foreignTools(message, ours);
|
|
139
|
+
if (extra.length) {
|
|
140
|
+
refused = `the model session started with tools it must not have: ${extra.join(", ")}`;
|
|
141
|
+
abort.abort();
|
|
142
|
+
break;
|
|
143
|
+
}
|
|
144
|
+
continue;
|
|
145
|
+
}
|
|
85
146
|
if (message.type === "stream_event") {
|
|
86
147
|
const e = message.event;
|
|
87
148
|
if (e.type === "message_start")
|
|
@@ -127,6 +188,8 @@ export async function runToolLoop(o) {
|
|
|
127
188
|
clearTimeout(watchdog);
|
|
128
189
|
fs.rmSync(scratch, { recursive: true, force: true });
|
|
129
190
|
}
|
|
191
|
+
if (refused)
|
|
192
|
+
throw new Error(refused);
|
|
130
193
|
if (killed)
|
|
131
194
|
stopped = `ran past ${o.wallSeconds}s`;
|
|
132
195
|
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,71 @@ 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
|
+
CLAUDE_CODE_MAX_OUTPUT_TOKENS: String(o.maxOutputTokens),
|
|
166
|
+
},
|
|
167
|
+
};
|
|
168
|
+
return { options, ours, scratch };
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
export async function runToolLoop(o: LoopOptions): Promise<LoopResult> {
|
|
102
172
|
const abort = new AbortController();
|
|
173
|
+
const { options, ours, scratch } = loopSession(o, abort);
|
|
174
|
+
let refused: string | null = null;
|
|
103
175
|
let killed = false;
|
|
104
176
|
const watchdog = setTimeout(() => {
|
|
105
177
|
killed = true;
|
|
@@ -112,30 +184,20 @@ export async function runToolLoop(o: LoopOptions): Promise<LoopResult> {
|
|
|
112
184
|
let usage: LoopUsage | null = null;
|
|
113
185
|
let stopped: string | null = null;
|
|
114
186
|
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
|
-
});
|
|
187
|
+
const q = query({ prompt: o.prompt, options });
|
|
137
188
|
let turn = "";
|
|
138
189
|
for await (const message of q) {
|
|
190
|
+
// Fail closed: a session that starts with any tool or MCP server we did
|
|
191
|
+
// not give it never reaches the model.
|
|
192
|
+
if (message.type === "system" && message.subtype === "init") {
|
|
193
|
+
const extra = foreignTools(message, ours);
|
|
194
|
+
if (extra.length) {
|
|
195
|
+
refused = `the model session started with tools it must not have: ${extra.join(", ")}`;
|
|
196
|
+
abort.abort();
|
|
197
|
+
break;
|
|
198
|
+
}
|
|
199
|
+
continue;
|
|
200
|
+
}
|
|
139
201
|
if (message.type === "stream_event") {
|
|
140
202
|
const e = message.event;
|
|
141
203
|
if (e.type === "message_start") turn = "";
|
|
@@ -171,6 +233,7 @@ export async function runToolLoop(o: LoopOptions): Promise<LoopResult> {
|
|
|
171
233
|
clearTimeout(watchdog);
|
|
172
234
|
fs.rmSync(scratch, { recursive: true, force: true });
|
|
173
235
|
}
|
|
236
|
+
if (refused) throw new Error(refused);
|
|
174
237
|
if (killed) stopped = `ran past ${o.wallSeconds}s`;
|
|
175
238
|
return { text, costUsd, usage, stopped };
|
|
176
239
|
}
|