@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.
@@ -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>;
@@ -29,7 +29,16 @@ function schemaOf(args) {
29
29
  return [name, a.optional ? s.optional() : s];
30
30
  }));
31
31
  }
32
- export async function runToolLoop(o) {
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@foldrun/core",
3
- "version": "1.1.1",
3
+ "version": "1.1.3",
4
4
  "description": "Agents are just folders. The format, the runner, the checks.",
5
5
  "license": "Apache-2.0",
6
6
  "type": "module",
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
- export async function runToolLoop(o: LoopOptions): Promise<LoopResult> {
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
  }