@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.
@@ -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,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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@foldrun/core",
3
- "version": "1.1.0",
3
+ "version": "1.1.2",
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,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
  }