@foldrun/core 0.2.0

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.
Files changed (166) hide show
  1. package/LICENSE +202 -0
  2. package/README.md +239 -0
  3. package/dist/index.d.ts +32 -0
  4. package/dist/index.js +43 -0
  5. package/dist/src/agent-tools.d.ts +31 -0
  6. package/dist/src/agent-tools.js +99 -0
  7. package/dist/src/agents-md.d.ts +9 -0
  8. package/dist/src/agents-md.js +83 -0
  9. package/dist/src/api-tools.d.ts +38 -0
  10. package/dist/src/api-tools.js +313 -0
  11. package/dist/src/approvals.d.ts +39 -0
  12. package/dist/src/approvals.js +103 -0
  13. package/dist/src/arrange.d.ts +8 -0
  14. package/dist/src/arrange.js +40 -0
  15. package/dist/src/catalog.d.ts +108 -0
  16. package/dist/src/catalog.js +255 -0
  17. package/dist/src/completions.d.ts +61 -0
  18. package/dist/src/completions.js +578 -0
  19. package/dist/src/confine.d.ts +31 -0
  20. package/dist/src/confine.js +298 -0
  21. package/dist/src/container.d.ts +30 -0
  22. package/dist/src/container.js +172 -0
  23. package/dist/src/context-tools.d.ts +81 -0
  24. package/dist/src/context-tools.js +347 -0
  25. package/dist/src/deploy.d.ts +100 -0
  26. package/dist/src/deploy.js +377 -0
  27. package/dist/src/egress.d.ts +75 -0
  28. package/dist/src/egress.js +133 -0
  29. package/dist/src/evals.d.ts +150 -0
  30. package/dist/src/evals.js +651 -0
  31. package/dist/src/flow-lint.d.ts +16 -0
  32. package/dist/src/flow-lint.js +177 -0
  33. package/dist/src/git-hook.d.ts +1 -0
  34. package/dist/src/git-hook.js +70 -0
  35. package/dist/src/git.d.ts +37 -0
  36. package/dist/src/git.js +101 -0
  37. package/dist/src/gitrepo.d.ts +95 -0
  38. package/dist/src/gitrepo.js +355 -0
  39. package/dist/src/history.d.ts +82 -0
  40. package/dist/src/history.js +259 -0
  41. package/dist/src/kinds.d.ts +95 -0
  42. package/dist/src/kinds.js +421 -0
  43. package/dist/src/library.d.ts +94 -0
  44. package/dist/src/library.js +341 -0
  45. package/dist/src/linkable.d.ts +20 -0
  46. package/dist/src/linkable.js +113 -0
  47. package/dist/src/model-probe.d.ts +21 -0
  48. package/dist/src/model-probe.js +81 -0
  49. package/dist/src/notify.d.ts +61 -0
  50. package/dist/src/notify.js +252 -0
  51. package/dist/src/oauth-presets.d.ts +13 -0
  52. package/dist/src/oauth-presets.js +39 -0
  53. package/dist/src/okf.d.ts +220 -0
  54. package/dist/src/okf.js +656 -0
  55. package/dist/src/openapi.d.ts +66 -0
  56. package/dist/src/openapi.js +342 -0
  57. package/dist/src/paths.d.ts +33 -0
  58. package/dist/src/paths.js +78 -0
  59. package/dist/src/platform.d.ts +32 -0
  60. package/dist/src/platform.js +48 -0
  61. package/dist/src/providers.d.ts +47 -0
  62. package/dist/src/providers.js +114 -0
  63. package/dist/src/refs.d.ts +20 -0
  64. package/dist/src/refs.js +78 -0
  65. package/dist/src/run-container.d.ts +227 -0
  66. package/dist/src/run-container.js +760 -0
  67. package/dist/src/runner.d.ts +241 -0
  68. package/dist/src/runner.js +3215 -0
  69. package/dist/src/runtime.d.ts +41 -0
  70. package/dist/src/runtime.js +357 -0
  71. package/dist/src/script-tools.d.ts +62 -0
  72. package/dist/src/script-tools.js +296 -0
  73. package/dist/src/secret-files.d.ts +12 -0
  74. package/dist/src/secret-files.js +92 -0
  75. package/dist/src/secrets.d.ts +147 -0
  76. package/dist/src/secrets.js +626 -0
  77. package/dist/src/starter.d.ts +21 -0
  78. package/dist/src/starter.js +399 -0
  79. package/dist/src/step-exec.d.ts +136 -0
  80. package/dist/src/step-exec.js +391 -0
  81. package/dist/src/storage.d.ts +123 -0
  82. package/dist/src/storage.js +640 -0
  83. package/dist/src/store.d.ts +1052 -0
  84. package/dist/src/store.js +2378 -0
  85. package/dist/src/tar.d.ts +30 -0
  86. package/dist/src/tar.js +111 -0
  87. package/dist/src/tool-names.d.ts +39 -0
  88. package/dist/src/tool-names.js +79 -0
  89. package/dist/src/tool-programs.d.ts +18 -0
  90. package/dist/src/tool-programs.js +52 -0
  91. package/dist/src/tool-test.d.ts +19 -0
  92. package/dist/src/tool-test.js +335 -0
  93. package/dist/src/translator.d.ts +159 -0
  94. package/dist/src/translator.js +986 -0
  95. package/dist/src/triggers.d.ts +60 -0
  96. package/dist/src/triggers.js +270 -0
  97. package/dist/src/webhook.d.ts +47 -0
  98. package/dist/src/webhook.js +148 -0
  99. package/index.ts +44 -0
  100. package/package.json +64 -0
  101. package/src/agent-tools.ts +138 -0
  102. package/src/agents-md.ts +81 -0
  103. package/src/api-tools.ts +389 -0
  104. package/src/approvals.ts +129 -0
  105. package/src/arrange.ts +42 -0
  106. package/src/catalog.ts +321 -0
  107. package/src/completions.ts +652 -0
  108. package/src/confine.ts +350 -0
  109. package/src/container.ts +220 -0
  110. package/src/context-tools.ts +435 -0
  111. package/src/deploy.ts +458 -0
  112. package/src/egress.ts +176 -0
  113. package/src/evals.ts +812 -0
  114. package/src/flow-lint.ts +215 -0
  115. package/src/git-hook.ts +73 -0
  116. package/src/git.ts +117 -0
  117. package/src/gitrepo.ts +391 -0
  118. package/src/history.ts +329 -0
  119. package/src/kinds.ts +507 -0
  120. package/src/library.ts +419 -0
  121. package/src/linkable.ts +123 -0
  122. package/src/model-probe.ts +112 -0
  123. package/src/notify.ts +281 -0
  124. package/src/oauth-presets.ts +54 -0
  125. package/src/okf.ts +768 -0
  126. package/src/openapi.ts +383 -0
  127. package/src/paths.ts +79 -0
  128. package/src/platform.ts +87 -0
  129. package/src/providers.ts +147 -0
  130. package/src/refs.ts +83 -0
  131. package/src/run-container.ts +883 -0
  132. package/src/runner.ts +3446 -0
  133. package/src/runtime.ts +404 -0
  134. package/src/script-tools.ts +383 -0
  135. package/src/secret-files.ts +101 -0
  136. package/src/secrets.ts +799 -0
  137. package/src/starter.ts +415 -0
  138. package/src/step-exec.ts +498 -0
  139. package/src/storage.ts +771 -0
  140. package/src/store.ts +2985 -0
  141. package/src/tar.ts +124 -0
  142. package/src/tool-names.ts +91 -0
  143. package/src/tool-programs.ts +65 -0
  144. package/src/tool-test.ts +401 -0
  145. package/src/translator.ts +1029 -0
  146. package/src/triggers.ts +287 -0
  147. package/src/webhook.ts +183 -0
  148. package/templates/blog-desk/AGENTS.md +13 -0
  149. package/templates/blog-desk/agents/researcher/agent.md +11 -0
  150. package/templates/blog-desk/agents/writer/agent.md +8 -0
  151. package/templates/blog-desk/evals/writer-quality.md +10 -0
  152. package/templates/blog-desk/flows/publish.md +10 -0
  153. package/templates/blog-desk/knowledge/house-style.md +9 -0
  154. package/templates/blog-desk/knowledge/index.md +9 -0
  155. package/templates/hello/AGENTS.md +18 -0
  156. package/templates/hello/agents/curator/agent.md +32 -0
  157. package/templates/hello/agents/notetaker/agent.md +24 -0
  158. package/templates/hello/evals/house-style.md +16 -0
  159. package/templates/hello/flows/memory-review.md +7 -0
  160. package/templates/hello/flows/note.md +7 -0
  161. package/templates/hello/knowledge/house-style.md +15 -0
  162. package/templates/hello/knowledge/index.md +9 -0
  163. package/templates/hello/skills/plain-english/SKILL.md +11 -0
  164. package/templates/hello/state/publishing.md +7 -0
  165. package/templates/hello/tools/wordcount/run.py +13 -0
  166. package/templates/hello/tools/wordcount/tool.md +22 -0
package/src/confine.ts ADDED
@@ -0,0 +1,350 @@
1
+ // Filesystem confinement for an agent's own tools.
2
+ //
3
+ // Scripts already run in containers. The agent's *built-in* tools —
4
+ // Read/Write/Edit/Glob/Grep and friends — do not: they execute in the server
5
+ // process with its full permissions. A run proved this is not theoretical: an
6
+ // agent told to write `outputs/SHIPPED.txt` created it two directories up and
7
+ // invented a whole phantom workspace on the way.
8
+ //
9
+ // The boundary is the WORKSPACE, not the agent. Agents in one workspace are a
10
+ // team — a reviewer legitimately reads what the writer produced — while the
11
+ // workspace is the isolation unit for everything else in the platform, so it
12
+ // is the honest place to draw the line.
13
+ //
14
+ // Inside that boundary, three things are still off limits:
15
+ // · secrets.json — the encrypted store; an agent gets secrets as env vars,
16
+ // never as a file it can exfiltrate wholesale
17
+ // · runs/ — the run journal is the audit trail, and a step that can rewrite
18
+ // its own history makes every other guarantee unverifiable
19
+ // · index.md and log.md inside a knowledge/ or memory/ bundle — generated by
20
+ // the platform on every write. An agent scribbling on one would be undone
21
+ // by the next syncBundleFor, but until then the index says whatever it was
22
+ // told to say, and log.md is provenance for the same reason runs/ is.
23
+ //
24
+ // There is no curated index beside the generated one. A bundle has exactly the
25
+ // two reserved files OKF defines, and both are generated.
26
+ //
27
+ // The bundle scoping matters. An earlier draft denied any index.md anywhere,
28
+ // which would have blocked an agent writing outputs/index.md — a perfectly
29
+ // ordinary deliverable — so the rule names the two directories it applies to.
30
+
31
+ import fs from "node:fs";
32
+ import path from "node:path";
33
+
34
+ /**
35
+ * A path with its symlinks resolved, as far as it exists.
36
+ *
37
+ * Both sides of the containment check have to be resolved the same way or the
38
+ * comparison is between two spellings of one place. On macOS `/var` is a
39
+ * symlink to `/private/var`, so a workspace under a system temp directory was
40
+ * handed to the runtime as `/var/folders/…` while every path the agent
41
+ * produced came back as `/private/var/folders/…`. `path.relative` then said
42
+ * `..`, and the agent was told its own workspace was outside the workspace —
43
+ * on every write, for the whole run, while the run still reported success
44
+ * because the agent gave up politely.
45
+ *
46
+ * Falls back a level at a time because the path may not exist yet: a Write
47
+ * creating a file needs the directory resolved, not the file.
48
+ */
49
+ function real(p: string): string {
50
+ try {
51
+ return fs.realpathSync.native(p);
52
+ } catch {
53
+ const parent = path.dirname(p);
54
+ return parent === p ? p : path.join(real(parent), path.basename(p));
55
+ }
56
+ }
57
+
58
+ /** Tool argument names that carry a filesystem path. */
59
+ const PATH_KEYS = [
60
+ "file_path",
61
+ "path",
62
+ "notebook_path",
63
+ "filePath",
64
+ "edits", // NotebookEdit-style payloads are checked via file_path too
65
+ ];
66
+
67
+ const DENIED_WITHIN = [
68
+ /(^|\/)secrets\.json$/,
69
+ /(^|\/)runs(\/|$)/,
70
+ ];
71
+
72
+ /**
73
+ * Generated, and therefore not an agent's to edit — but very much an agent's
74
+ * to read.
75
+ *
76
+ * These were denied outright, and a run proved what that costs: an agent told
77
+ * to start from the index asked for `knowledge/index.md`, was refused, and
78
+ * spent a turn recovering. The index is the navigational artifact the whole
79
+ * progressive-disclosure design rests on; refusing to let a reader open it is
80
+ * the opposite of the intent. The reason they are protected is that a
81
+ * generated file an agent can edit is one that can lie — which is an argument
82
+ * about writing, and only about writing.
83
+ */
84
+ const READ_ONLY_WITHIN = [/(^|\/)(knowledge|memory)\/(.*\/)?(index|log)\.md$/];
85
+
86
+ export interface ConfineVerdict {
87
+ ok: boolean;
88
+ reason?: string;
89
+ /** Rewritten tool input, when a virtual prefix was expanded. */
90
+ updatedInput?: Record<string, unknown>;
91
+ }
92
+
93
+ export interface Roots {
94
+ /** The agent's own directory — relative paths resolve from here. */
95
+ agentDir: string;
96
+ /** The workspace: the read/write boundary. */
97
+ workspaceRoot: string;
98
+ /** The account library: readable, never writable. */
99
+ libraryRoot: string;
100
+ }
101
+
102
+ // Skills are announced to the agent with virtual prefixes — `workspace/skills/…`
103
+ // for the workspace's, `library/skills/…` for the account's — because a skill
104
+ // at either scope lives outside the agent's own folder. Symlinking them in
105
+ // was tried and reverted: it recursed into the workspace's own file listing.
106
+ // Expanding the prefix here instead keeps one honest path per skill, with no
107
+ // duplication on disk.
108
+ // Two prefixes, named after the two scopes above an agent. There used to be
109
+ // three — `workspace/` meant the workspace root while `shared/` meant the
110
+ // workspace's *scripts*, which is exactly the kind of near-synonym that gets
111
+ // mis-typed. The old spellings still resolve so nothing existing breaks.
112
+ const VIRTUAL = [
113
+ { prefix: "account/", root: (r: Roots) => r.libraryRoot, readOnly: true },
114
+ { prefix: "workspace/", root: (r: Roots) => r.workspaceRoot, readOnly: false },
115
+ // legacy, still accepted
116
+ { prefix: "library/", root: (r: Roots) => r.libraryRoot, readOnly: true },
117
+ { prefix: "workspace/", root: (r: Roots) => r.workspaceRoot, readOnly: false },
118
+ ];
119
+
120
+ const WRITE_TOOLS = new Set(["Write", "Edit", "MultiEdit", "NotebookEdit"]);
121
+
122
+ // Only the built-in filesystem tools get path checks. foldrun's own MCP tools
123
+ // are already confined at their own layer — script paths by resolveRunPath,
124
+ // APIs by their declared base URL — and their arguments are not filesystem
125
+ // paths at all: `call_<api>` takes a *URL* path like "/uuid", which this
126
+ // check would otherwise reject as an escape.
127
+ const FS_TOOLS = new Set([
128
+ "Read",
129
+ "Write",
130
+ "Edit",
131
+ "MultiEdit",
132
+ "NotebookEdit",
133
+ "NotebookRead",
134
+ "Glob",
135
+ "Grep",
136
+ "LS",
137
+ ]);
138
+
139
+ export const isFilesystemTool = (toolName: string) => FS_TOOLS.has(toolName);
140
+
141
+ /**
142
+ * The path the agent probably meant. Most escapes are one of three guesses —
143
+ * an invented `/tmp/outputs/…`, an absolute spelling of a workspace
144
+ * directory, or a directory a tool made outside the workspace — and a
145
+ * refusal that names the correction is taken on the next turn, where a
146
+ * generic one is followed by two more wrong guesses. Nothing here is
147
+ * enforced; it is advice in the error text.
148
+ */
149
+ export function suggestPath(raw: string): string {
150
+ const posix = raw.split(path.sep).join("/");
151
+ const m = posix.match(/(?:^|\/)(outputs|storage|state|memory|knowledge)\/(.+)$/);
152
+ if (m) {
153
+ const [, dir, rest] = m;
154
+ return dir === "outputs"
155
+ ? `Did you mean outputs/${rest} (relative to your agent directory)?`
156
+ : `Did you mean workspace/${dir}/${rest}?`;
157
+ }
158
+ if (/^\/tmp\//.test(posix)) {
159
+ return (
160
+ `There is no /tmp for you. If a tool cloned or built something there, reach it through that tool ` +
161
+ `(for example its read or find action); scratch files go to outputs/.`
162
+ );
163
+ }
164
+ return `Use a path relative to your agent directory, or workspace/<dir>/… for the workspace root (workspace/storage/, workspace/state/).`;
165
+ }
166
+
167
+ /** Expand a virtual prefix to a real absolute path, or null if it has none. */
168
+ export function expandVirtual(raw: string, roots: Roots): { abs: string; readOnly: boolean } | null {
169
+ for (const v of VIRTUAL) {
170
+ if (raw.startsWith(v.prefix)) {
171
+ return { abs: path.resolve(v.root(roots), raw.slice(v.prefix.length)), readOnly: v.readOnly };
172
+ }
173
+ }
174
+ return null;
175
+ }
176
+
177
+ export function checkPaths(
178
+ toolName: string,
179
+ input: Record<string, unknown>,
180
+ roots: Roots,
181
+ ): ConfineVerdict {
182
+ const workspace = real(path.resolve(roots.workspaceRoot));
183
+ const library = real(path.resolve(roots.libraryRoot));
184
+ const isWrite = WRITE_TOOLS.has(toolName);
185
+ let rewritten: Record<string, unknown> | undefined;
186
+
187
+ // Grep's `pattern` is a REGEX, not a location — Grep says where to look
188
+ // with `path`. Checking it as a path denied an agent searching for the
189
+ // literal text "/dev/null", reporting a workspace escape for a string it
190
+ // wanted to find inside the workspace. Glob's `pattern` really is a path
191
+ // shape, so it stays checked.
192
+ const keys = [...PATH_KEYS, "glob", ...(toolName === "Grep" ? [] : ["pattern"])];
193
+ for (const key of keys) {
194
+ const raw = input[key];
195
+ if (typeof raw !== "string" || !raw.trim()) continue;
196
+ // Glob patterns and grep's file filter are only paths when anchored.
197
+ if ((key === "glob" || key === "pattern") && !raw.startsWith("/") && !expandVirtual(raw, roots)) {
198
+ continue;
199
+ }
200
+
201
+ const virtual = expandVirtual(raw, roots);
202
+ const abs = real(virtual ? virtual.abs : path.resolve(roots.agentDir, raw));
203
+
204
+ // Knowledge is given, not learned: an agent that rewrites the price list
205
+ // it was handed has corrupted its own source of truth. What it discovers
206
+ // goes to memory/, which it owns.
207
+ if (isWrite && /(^|\/)knowledge\//.test(abs.split(path.sep).join("/"))) {
208
+ return {
209
+ ok: false,
210
+ reason:
211
+ `${toolName} was denied: knowledge/ is reference material maintained by people, not by ` +
212
+ `you. If you learned something new, write it to memory/ instead.`,
213
+ };
214
+ }
215
+
216
+ if (virtual?.readOnly && isWrite) {
217
+ return {
218
+ ok: false,
219
+ reason:
220
+ `${toolName} was denied: "${raw}" is in the shared library, which is read-only from a ` +
221
+ `run. Change it in the dashboard under Shared, or write your own copy in this workspace.`,
222
+ };
223
+ }
224
+
225
+ const inWorkspace = !path.relative(workspace, abs).startsWith("..");
226
+ const inLibrary = !path.relative(library, abs).startsWith("..");
227
+ if (!inWorkspace && !inLibrary) {
228
+ return {
229
+ ok: false,
230
+ reason:
231
+ `${toolName} was denied: "${raw}" is outside this workspace. Agents may only read and ` +
232
+ `write inside their own workspace. ${suggestPath(raw)}`,
233
+ };
234
+ }
235
+
236
+ const within = path.relative(inLibrary ? library : workspace, abs).split(path.sep).join("/");
237
+ if (DENIED_WITHIN.some((re) => re.test(within))) {
238
+ return {
239
+ ok: false,
240
+ reason:
241
+ `${toolName} was denied: "${raw}" is a protected platform file. Secrets reach you as ` +
242
+ `environment variables, and the run journal is not yours to read or rewrite.`,
243
+ };
244
+ }
245
+
246
+ if (isWrite && READ_ONLY_WITHIN.some((re) => re.test(within))) {
247
+ return {
248
+ ok: false,
249
+ reason:
250
+ `${toolName} was denied: "${raw}" is generated from the files around it and will be ` +
251
+ `rewritten on the next write. Read it freely — to change what it says, change a ` +
252
+ `concept file instead.`,
253
+ };
254
+ }
255
+
256
+ // Hand the real path back to the tool, so the virtual prefix the prompt
257
+ // advertises is one the agent can actually open.
258
+ if (virtual) rewritten = { ...(rewritten ?? input), [key]: abs };
259
+ }
260
+
261
+ return { ok: true, updatedInput: rewritten };
262
+ }
263
+
264
+ // A shell can defeat any argument-level path check, so Bash is handled
265
+ // separately: it runs under the SDK sandbox, and these patterns are a second
266
+ // layer that catches the obvious escapes with a clear message rather than a
267
+ // confusing sandbox error.
268
+ const BASH_ESCAPES = [
269
+ /\.\.\/\.\.\/\.\.\/\.\.\/\.\.\//, // five levels up leaves even the account
270
+ /(^|[\s;|&(<>])cd\s+(\/|~)/, // cd to an absolute path or home
271
+ // `~` was guarded only for `cd`, so `cat ~/.ssh/id_rsa` and
272
+ // `cp ~/.aws/credentials .` walked straight past — the exact "obvious
273
+ // escape" this layer exists to name before the sandbox does.
274
+ /(^|[\s;|&(<>=:"'])~\//, // a home-relative path, whatever the command
275
+ /(^|[\s;|&(<>])[a-z_]+\s+\/(etc|root|home|Users|var|proc|dev)\b/, // absolute system paths
276
+ /secrets\.json/,
277
+ /\.secret-key/,
278
+ // Don't let a command *print* a credential out — but only that. This used
279
+ // to match any reference to a secret env var, which denied the entire
280
+ // point of secrets: `ssh -i "$SSH_KEY"`, `curl -H "Authorization: $TOKEN"`
281
+ // and `sshpass -e ssh …` all use a credential without emitting it, and all
282
+ // were being refused. Now only an output builtin fronting the variable is
283
+ // blocked; the journal redaction layer is the backstop for the rest.
284
+ /\b(echo|printf|print|cat|head|tail|env|printenv)\b[^\n|;&]*\$\{?[A-Z_]*(TOKEN|KEY|SECRET|PASSWORD)/,
285
+ ];
286
+
287
+ /**
288
+ * Shapes that modify a file, as opposed to reading one.
289
+ *
290
+ * Bash cannot be checked the way a tool call can — there is no path argument,
291
+ * only a string — so the protections checkPaths applies to Write and Edit have
292
+ * to be recognised here from the command's shape. That is why `knowledge/` is
293
+ * matched only alongside one of these: reading the price list is the entire
294
+ * reason it exists, and a blanket rule would have banned `cat`.
295
+ */
296
+ const WRITES_TO_A_FILE =
297
+ /(>>?|\btee\b|\bsed\b[^]*?\s-i\b|\b(rm|mv|cp|truncate|dd|install|ln|chmod|chown|mkdir|rmdir|touch)\b)/;
298
+
299
+ /** Protections that mirror checkPaths, per command segment. */
300
+ const PROTECTED_BASH: { re: RegExp; writeOnly: boolean; reason: string }[] = [
301
+ {
302
+ re: /(^|[\s'"(/])knowledge\//,
303
+ writeOnly: true,
304
+ reason:
305
+ "knowledge/ is reference material maintained by people, not by you. If you learned " +
306
+ "something new, write it to memory/ instead.",
307
+ },
308
+ {
309
+ // Not write-only: checkPaths denies reading the journal too, and a rule
310
+ // that differs by tool is a rule an agent can shop around for.
311
+ re: /(^|[\s'"(/])runs(\/|$)/,
312
+ writeOnly: false,
313
+ reason:
314
+ "the run journal is the audit trail for this run, and is not yours to read or rewrite.",
315
+ },
316
+ {
317
+ re: /(^|[\s'"(/])(knowledge|memory)\/(\S*\/)?(index|log)\.md/,
318
+ // Write-only, matching checkPaths. A rule that differs by tool is a rule
319
+ // an agent can shop around for — and reading the index is the point of it.
320
+ writeOnly: true,
321
+ reason:
322
+ "index.md and log.md are generated from the files around them — write the concept file " +
323
+ "instead.",
324
+ },
325
+ ];
326
+
327
+ export function checkBash(command: string): ConfineVerdict {
328
+ for (const re of BASH_ESCAPES) {
329
+ if (re.test(command)) {
330
+ return {
331
+ ok: false,
332
+ reason:
333
+ `Bash was denied: this command reaches outside the workspace. Work with paths relative ` +
334
+ `to your agent directory.`,
335
+ };
336
+ }
337
+ }
338
+
339
+ // Per segment, so `cat knowledge/prices.md && echo done` is judged on the
340
+ // part that touches the path rather than on the whole line.
341
+ for (const segment of command.split(/[;|&]+|\n/)) {
342
+ if (!segment.trim()) continue;
343
+ const writes = WRITES_TO_A_FILE.test(segment);
344
+ for (const rule of PROTECTED_BASH) {
345
+ if (rule.writeOnly && !writes) continue;
346
+ if (rule.re.test(segment)) return { ok: false, reason: `Bash was denied: ${rule.reason}` };
347
+ }
348
+ }
349
+ return { ok: true };
350
+ }
@@ -0,0 +1,220 @@
1
+ // Container execution for scripts.
2
+ //
3
+ // The isolation boundary is the run: one container image per runtime
4
+ // declaration (keyed by the same fingerprint the host path uses), and every
5
+ // script call in a run executes inside a container from that image with the
6
+ // agent's directory mounted. Scripts therefore cannot read other tenants'
7
+ // data, the key file, or anything else on the host.
8
+ //
9
+ // Falls back to host execution when Docker isn't available, so local
10
+ // development keeps working with zero configuration.
11
+
12
+ import fs from "node:fs";
13
+ import path from "node:path";
14
+ import { dataRoot } from "./paths.ts";
15
+ import { spawnSync, spawn } from "node:child_process";
16
+ import type { RuntimeSpec } from "./runtime.ts";
17
+ import { fingerprint } from "./runtime.ts";
18
+
19
+ const BUILD_TIMEOUT_MS = 600_000;
20
+ const IMAGE_PREFIX = "foldrun-runtime";
21
+
22
+ // Any Docker-compatible CLI works — Docker Engine, colima, Podman, nerdctl,
23
+ // OrbStack. Copying files in and out (rather than bind-mounting) is what
24
+ // makes that portability real: no host path has to be shared with a VM.
25
+ const CLI = process.env.FOLDRUN_CONTAINER_CLI ?? "docker";
26
+
27
+ // "sandbox" means "run the script directly in whatever environment this step
28
+ // is already isolated by" — the run pod under FOLDRUN_RUN_ISOLATION=k8s, or
29
+ // the local process on a laptop. It was called "host", which read as "on the
30
+ // platform's own machine" and made a correct, isolated setup look like a
31
+ // tenant escape to anyone reading a run trace.
32
+ export type Executor = "docker" | "sandbox";
33
+
34
+ let cachedAvailability: boolean | null = null;
35
+
36
+ export function dockerAvailable(): boolean {
37
+ if (process.env.FOLDRUN_EXECUTOR === "host" || process.env.FOLDRUN_EXECUTOR === "sandbox") return false;
38
+ if (cachedAvailability !== null) return cachedAvailability;
39
+ const res = spawnSync(CLI, ["info", "--format", "{{.ServerVersion}}"], {
40
+ encoding: "utf8",
41
+ timeout: 15_000,
42
+ });
43
+ cachedAvailability = res.status === 0;
44
+ return cachedAvailability;
45
+ }
46
+
47
+ export function chooseExecutor(): Executor {
48
+ return dockerAvailable() ? "docker" : "sandbox";
49
+ }
50
+
51
+ export function imageTag(spec: RuntimeSpec | null): string {
52
+ return `${IMAGE_PREFIX}:${spec ? fingerprint(spec) : "base"}`;
53
+ }
54
+
55
+ function imageExists(tag: string): boolean {
56
+ return spawnSync(CLI, ["image", "inspect", tag], { timeout: 20_000 }).status === 0;
57
+ }
58
+
59
+ // A minimal image carrying just the declared runtimes and packages.
60
+ function dockerfileFor(spec: RuntimeSpec | null): string {
61
+ const wantsNode = spec ? spec.node !== undefined || spec.npm.length > 0 : false;
62
+ const pyVersion = typeof spec?.python === "string" ? spec.python : "3.12";
63
+
64
+ const lines: string[] = [];
65
+ if (wantsNode && !spec?.packages.length && spec?.python === undefined) {
66
+ lines.push("FROM node:22-slim");
67
+ } else {
68
+ lines.push(`FROM python:${pyVersion}-slim`);
69
+ if (wantsNode) {
70
+ lines.push(
71
+ "RUN apt-get update && apt-get install -y --no-install-recommends nodejs npm && rm -rf /var/lib/apt/lists/*",
72
+ );
73
+ }
74
+ }
75
+
76
+ if (spec?.packages.length) {
77
+ lines.push(`RUN pip install --no-cache-dir ${spec.packages.map((p) => `'${p}'`).join(" ")}`);
78
+ }
79
+ if (spec?.npm.length) {
80
+ lines.push("WORKDIR /opt/npm");
81
+ lines.push(`RUN npm install --no-fund --no-audit ${spec.npm.map((p) => `'${p}'`).join(" ")}`);
82
+ lines.push("ENV NODE_PATH=/opt/npm/node_modules");
83
+ }
84
+
85
+ // Scripts run as a non-root user with no write access outside the mount.
86
+ lines.push("RUN useradd -m -u 10001 agent");
87
+ lines.push("USER agent");
88
+ lines.push("WORKDIR /workspace");
89
+ return lines.join("\n") + "\n";
90
+ }
91
+
92
+ export interface ImageResult {
93
+ tag: string;
94
+ built: boolean;
95
+ error: string | null;
96
+ log: string[];
97
+ }
98
+
99
+ export function ensureImage(spec: RuntimeSpec | null): ImageResult {
100
+ const tag = imageTag(spec);
101
+ if (imageExists(tag)) return { tag, built: false, error: null, log: [`image ${tag}: cached`] };
102
+
103
+ // dataRoot() may not exist yet on a brand-new install or a fresh workspace
104
+ // whose first-ever step is a script tool — mkdtemp does not create parents,
105
+ // so without this the first script build dies with ENOENT and the whole run
106
+ // fails on nothing the author did wrong.
107
+ fs.mkdirSync(dataRoot(), { recursive: true });
108
+ const dir = fs.mkdtempSync(path.join(dataRoot(), ".build-"));
109
+ try {
110
+ fs.writeFileSync(path.join(dir, "Dockerfile"), dockerfileFor(spec));
111
+ const res = spawnSync(CLI, ["build", "-q", "-t", tag, dir], {
112
+ encoding: "utf8",
113
+ timeout: BUILD_TIMEOUT_MS,
114
+ });
115
+ if (res.status !== 0) {
116
+ return {
117
+ tag,
118
+ built: false,
119
+ error: `image build failed: ${`${res.stderr ?? ""}`.slice(-400)}`,
120
+ log: [],
121
+ };
122
+ }
123
+ return { tag, built: true, error: null, log: [`image ${tag}: built`] };
124
+ } finally {
125
+ fs.rmSync(dir, { recursive: true, force: true });
126
+ }
127
+ }
128
+
129
+ export interface ContainerRunOptions {
130
+ /** Host directory mounted at /workspace — the agent's own folder. */
131
+ agentDir: string;
132
+ /** Extra read-only mounts: host path → container path. */
133
+ readOnly?: Record<string, string>;
134
+ image: string;
135
+ argv: string[]; // command inside the container
136
+ env: Record<string, string>;
137
+ /** Only when the tool.md set one — the platform has no clock of its own. */
138
+ timeoutMs?: number;
139
+ /** Allow outbound network (agents that declared APIs need it). */
140
+ network: boolean;
141
+ maxOutput: number;
142
+ }
143
+
144
+ // Files are copied in and out rather than bind-mounted. Bind mounts need the
145
+ // host path to be inside Docker's shared-paths configuration, which fails
146
+ // silently (the mount appears empty) on Docker Desktop for directories it
147
+ // isn't allowed to share. Copying is what CI systems do, needs no host
148
+ // configuration, and has the side benefit that a script cannot corrupt the
149
+ // source tree — only the copy it was given.
150
+ export async function runInContainer(
151
+ opts: ContainerRunOptions,
152
+ ): Promise<{ code: number | null; out: string }> {
153
+ const docker = (args: string[], timeout = 60_000) =>
154
+ spawnSync(CLI, args, { encoding: "utf8", timeout });
155
+
156
+ const createArgs = [
157
+ "create",
158
+ "-i",
159
+ "--workdir",
160
+ "/workspace",
161
+ // Guard rails: no privilege escalation, bounded CPU/memory/processes.
162
+ "--security-opt",
163
+ "no-new-privileges",
164
+ "--cap-drop",
165
+ "ALL",
166
+ "--pids-limit",
167
+ "256",
168
+ "--memory",
169
+ "1g",
170
+ "--cpus",
171
+ "1",
172
+ ];
173
+ if (!opts.network) createArgs.push("--network", "none");
174
+ for (const [k, v] of Object.entries(opts.env)) createArgs.push("-e", `${k}=${v}`);
175
+ createArgs.push(opts.image, ...opts.argv);
176
+
177
+ const created = docker(createArgs);
178
+ if (created.status !== 0) {
179
+ return { code: null, out: `container create failed: ${(created.stderr ?? "").slice(-300)}` };
180
+ }
181
+ const cid = (created.stdout ?? "").trim();
182
+
183
+ try {
184
+ // The agent's own directory becomes /workspace…
185
+ const copied = docker(["cp", `${opts.agentDir}/.`, `${cid}:/workspace`], 120_000);
186
+ if (copied.status !== 0) {
187
+ return { code: null, out: `copy in failed: ${(copied.stderr ?? "").slice(-300)}` };
188
+ }
189
+ // …and any shared directories land at their declared paths.
190
+ for (const [host, mount] of Object.entries(opts.readOnly ?? {})) {
191
+ if (fs.existsSync(host)) docker(["cp", `${host}/.`, `${cid}:${mount}`], 120_000);
192
+ }
193
+
194
+ const result = await new Promise<{ code: number | null; out: string }>((resolve) => {
195
+ const child = spawn(CLI, ["start", "-a", cid], { timeout: opts.timeoutMs });
196
+ let out = "";
197
+ const append = (chunk: Buffer) => {
198
+ if (out.length < opts.maxOutput) out += chunk.toString();
199
+ };
200
+ child.stdout.on("data", append);
201
+ child.stderr.on("data", append);
202
+ child.on("error", (err) => resolve({ code: null, out: `container failed: ${err.message}` }));
203
+ child.on("close", (code) =>
204
+ resolve({
205
+ code,
206
+ out:
207
+ out.length > opts.maxOutput
208
+ ? `${out.slice(0, opts.maxOutput)}\n…[truncated]`
209
+ : out || "(no output)",
210
+ }),
211
+ );
212
+ });
213
+
214
+ // Bring deliverables back so outputs/ behaves the same either way.
215
+ docker(["cp", `${cid}:/workspace/outputs/.`, path.join(opts.agentDir, "outputs")], 120_000);
216
+ return result;
217
+ } finally {
218
+ docker(["rm", "-f", cid], 30_000);
219
+ }
220
+ }