@foldrun/core 1.0.0 → 1.1.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.
@@ -98,6 +98,7 @@ const FIELDS = {
98
98
  { label: "disallowedTools", insert: "disallowedTools:\n - " },
99
99
  { label: "subagents", insert: "subagents:\n - ", hint: "colleagues it may delegate a job to — own context, own tools, no wider than its own" },
100
100
  { label: "limits", insert: "limits:\n web.search: 40\n calls: 300", hint: "per step: a call past a limit is refused, never run" },
101
+ { label: "network", insert: "network:\n - api.example.com", hint: "sites the sandbox may reach: open | trusted | none, or a list added to the trusted list" },
101
102
  {
102
103
  label: "provider",
103
104
  insert: "provider:\n name: \n token: ${PROVIDER_TOKEN}",
@@ -28,6 +28,7 @@ import { conformanceIssues } from "./okf.js";
28
28
  import { timezoneProblem } from "./clock.js";
29
29
  import { webProblems, webWarnings, runnerEngines } from "./providers.js";
30
30
  import { readLimits } from "./limits.js";
31
+ import { networkProblems } from "./network.js";
31
32
  import { actionProblems } from "./web-actions.js";
32
33
  import { languageProblem } from "./language.js";
33
34
  import { localeProblems } from "./locale.js";
@@ -306,6 +307,10 @@ export function deployIssues(files) {
306
307
  // action> — would run uncapped where the author meant a cap.
307
308
  for (const w of readLimits(front.limits).problems)
308
309
  at(f.path, w);
310
+ // `network:` that cannot be read would leave the step on the level
311
+ // above it — wider or narrower than the author wrote.
312
+ for (const w of networkProblems(front.network))
313
+ at(f.path, w);
309
314
  const lang = languageProblem(front.language);
310
315
  if (lang)
311
316
  at(f.path, lang);
@@ -1,4 +1,5 @@
1
1
  import type { TestEffect } from "./test-mode.ts";
2
+ import type { NetworkLevel } from "./network.ts";
2
3
  /** `${NAME}` — the shape a secret reference takes everywhere in foldrun. */
3
4
  export declare const PLACEHOLDER: RegExp;
4
5
  /** The env var the pod reads to know it should send through a proxy. */
@@ -41,10 +42,30 @@ export interface EgressGrant {
41
42
  * that has to put a rate-limit reset into words — and a reset time is
42
43
  * only useful in the calendar the step works to. See refusal.ts. */
43
44
  timezone?: string;
45
+ /** Which sites the step may reach through the proxy's forward door
46
+ * (network.ts). Absent on a grant from a runner that predates it: the
47
+ * forward door then allows only the hosts the secrets were granted to. */
48
+ network?: {
49
+ level: NetworkLevel;
50
+ hosts: string[];
51
+ };
52
+ /** Refuse a site off the list (true), or allow it and name it in the
53
+ * trace (false: report only). Set by the platform when it leases. */
54
+ enforce?: boolean;
44
55
  }
45
56
  export interface EgressLease {
46
57
  /** The base the pod sends through: `http://…/e/<token>`. */
47
58
  url: string;
59
+ /** The forward-proxy address for HTTP_PROXY / HTTPS_PROXY —
60
+ * `http://foldrun:<token>@host:port` — when the platform's proxy has the
61
+ * forward door. Absent on an older proxy: programs then go direct. */
62
+ proxy?: string;
63
+ /** The PEM of the CA the forward door signs with for a host a secret is
64
+ * bound to, so the sandbox can trust exactly those interceptions. */
65
+ ca?: string;
66
+ /** Whether a site off the step's list is refused (true) or only named
67
+ * in the trace (false). */
68
+ enforce?: boolean;
48
69
  /** The grant is finished being built — publish it. The runner keeps
49
70
  * adding grants after the lease exists (the model key's host needs the
50
71
  * lease URL first), so a store that is not this process's memory has
@@ -31,6 +31,14 @@
31
31
  // `FOLDRUN_EGRESS` instead, and sends through the proxy like everything
32
32
  // else.
33
33
  //
34
+ // A secret BOUND to sites in the vault (`foldrun secrets set NAME --site
35
+ // api.example.com`) is never materialised, whatever the step grants: the
36
+ // pod's programs all send through the proxy's forward door (HTTPS_PROXY),
37
+ // and for a bound site the door terminates TLS with the platform's egress CA
38
+ // — which the sandbox trusts — and fills `${NAME}` in the URL and headers on
39
+ // the way out, the way Anthropic's cloud sandboxes attach a network secret.
40
+ // Every other site is a tunnel the proxy does not read.
41
+ //
34
42
  // This file is the vocabulary both sides share. The server is the
35
43
  // platform's (it needs a long-lived process and a Service); the pod side is
36
44
  // here in core, because it is what the runner image runs.
@@ -0,0 +1,120 @@
1
+ /** How wide a step's network is. */
2
+ export type NetworkLevel = "open" | "trusted" | "none";
3
+ export declare const NETWORK_LEVELS: readonly NetworkLevel[];
4
+ /** One level's `network:` line, read. Absent fields inherit. */
5
+ export interface NetworkSetting {
6
+ level?: NetworkLevel;
7
+ /** Sites added at this level. */
8
+ allow: string[];
9
+ /** false drops the trusted list, leaving only `allow` and what the step declares. */
10
+ defaults?: boolean;
11
+ }
12
+ /** What one step may reach, resolved. Plain JSON: it crosses into the egress
13
+ * grant and the run record. */
14
+ export interface NetworkPolicy {
15
+ level: NetworkLevel;
16
+ /** Every site allowed beyond what `open` would allow — the trusted list
17
+ * (unless dropped), the `allow` lists, and the hosts the step declares.
18
+ * Empty for `open`, which needs no list. */
19
+ hosts: string[];
20
+ /** Where the level came from, for the trace: "agent", "workspace",
21
+ * "account", or "default" with the reason. */
22
+ from: string;
23
+ /** Whether the trusted list is part of `hosts`. */
24
+ defaults: boolean;
25
+ /** The sites the files added (`allow`, every level), apart from the
26
+ * trusted list — what the trace and the dashboard show as "added". */
27
+ added: string[];
28
+ /** Hosts the step declares (apis, MCP servers, providers, bound secrets). */
29
+ implied: string[];
30
+ }
31
+ /**
32
+ * The trusted list: the domains Anthropic's Claude Code cloud environments
33
+ * allow at their default "Trusted" level, as published on
34
+ * code.claude.com/docs/en/cloud-environments#default-allowed-domains
35
+ * (read 14 Oct 2026), plus the Debian mirrors the runner image installs
36
+ * `runtime: system:` packages from. `*.` matches the subdomains and the
37
+ * bare domain.
38
+ */
39
+ export declare const TRUSTED_SITES: readonly string[];
40
+ /** A site as `network:` takes it: a domain or `*.domain` — no scheme, no
41
+ * path, no port. The same shape `web.browse.allowed_domains` takes. */
42
+ export declare const SITE: RegExp;
43
+ /** A host as it arrives — `API.Example.com:443.` — as the lists hold it. */
44
+ export declare function normalHost(host: string): string;
45
+ /** Does `site` (a domain or `*.domain`) cover `host`? `*.example.com`
46
+ * covers `a.example.com`, `a.b.example.com` and `example.com` itself. */
47
+ export declare function siteCovers(site: string, host: string): boolean;
48
+ /** May a step under `policy` reach `host`? `open` reaches any public host —
49
+ * the caller still refuses private addresses, which no level opens. */
50
+ export declare function networkAllows(policy: Pick<NetworkPolicy, "level" | "hosts">, host: string): boolean;
51
+ /**
52
+ * One `network:` value, read. Never throws: what cannot be read is a
53
+ * problem `check` and deploy report, and the run treats that level as
54
+ * saying nothing — the next level up, or the default, decides.
55
+ */
56
+ export declare function readNetwork(raw: unknown, where?: string): {
57
+ setting: NetworkSetting | null;
58
+ problems: string[];
59
+ };
60
+ /** `check`'s and deploy's view: the problems only. */
61
+ export declare function networkProblems(raw: unknown): string[];
62
+ /**
63
+ * The step's network, from its three levels and what it declares.
64
+ *
65
+ * `readsWeb` — the step may fetch or browse the open web (the `web` tool
66
+ * with fetch or browse). It decides the default level only; a `network:`
67
+ * written anywhere beats it.
68
+ */
69
+ export declare function resolveNetwork(args: {
70
+ account?: unknown;
71
+ workspace?: unknown;
72
+ agent?: unknown;
73
+ readsWeb: boolean;
74
+ implied?: Iterable<string>;
75
+ }): NetworkPolicy;
76
+ /** One line for the run's trace. */
77
+ export declare function networkLine(policy: NetworkPolicy, enforced: boolean | null): string;
78
+ /** What the proxy says when it refuses a site. */
79
+ export declare function refusalText(policy: Pick<NetworkPolicy, "level">, host: string): string;
80
+ /** The proxy variables a sandbox's programs read, for a forward-proxy URL
81
+ * (`http://foldrun:<token>@host:port`). Both spellings: curl reads only the
82
+ * lower-case http_proxy, much else only the upper-case. NO_PROXY keeps the
83
+ * cluster's own names — the egress address itself, the account's pods —
84
+ * off the proxy. NODE_USE_ENV_PROXY turns the variables on for Node's own
85
+ * fetch and http (Node 22.21 and later). */
86
+ export declare function proxyEnv(proxyUrl: string, extraNoProxy?: string[]): Record<string, string>;
87
+ /** The env var the egress CA travels in — its PEM, base64'd so it survives
88
+ * every env channel (docker's env file has no multi-line values). The
89
+ * pod's start writes it to a file and points every TLS client at a bundle
90
+ * that includes it. */
91
+ export declare const EGRESS_CA_ENV = "FOLDRUN_EGRESS_CA";
92
+ /**
93
+ * The shell the sandbox's start runs before anything else: when the step
94
+ * carries the egress CA, write it, build a bundle of the system's CAs plus
95
+ * it, and point every TLS client the image has at that bundle. The CA only
96
+ * ever signs for hosts a secret is bound to — every other connection is a
97
+ * tunnel the proxy cannot read — but those clients must trust it there.
98
+ * Sourced after the env file, before system packages install (apt goes
99
+ * through the proxy too). `dir` is a directory the start may write.
100
+ */
101
+ export declare function egressCaSh(dir: string, systemBundle?: string): string;
102
+ /** `network:` as one line of YAML, the shortest of the three shapes that
103
+ * says it; null when there is nothing to say. */
104
+ export declare function networkYaml(setting: {
105
+ level?: NetworkLevel;
106
+ allow?: string[];
107
+ defaults?: boolean;
108
+ } | null): string | null;
109
+ /**
110
+ * The text of an agent.md (or AGENTS.md) with its `network:` replaced by
111
+ * `setting` — or removed, for null — and every other byte as it was. The
112
+ * old value's indented lines go with it, whatever shape it was written in.
113
+ * Pure string work, so the dashboard runs it in the browser and the CLI on
114
+ * a file, and both write exactly the same line.
115
+ */
116
+ export declare function setNetworkSetting(raw: string, setting: {
117
+ level?: NetworkLevel;
118
+ allow?: string[];
119
+ defaults?: boolean;
120
+ } | null): string;
@@ -0,0 +1,360 @@
1
+ // Network: which sites a step's sandbox may reach.
2
+ //
3
+ // network: trusted # the trusted list, plus what the step declares
4
+ // network: open # any public site
5
+ // network: none # only what the step declares
6
+ // network: [api.example.com, "*.example.org"] # add sites; the level stays
7
+ // network: { level: trusted, allow: [api.example.com], defaults: false }
8
+ //
9
+ // Written on an agent, a workspace's AGENTS.md or the account's. The level
10
+ // and `defaults` are nearest-wins (agent, then workspace, then account);
11
+ // `allow` adds up across all three, so an account can name a site every
12
+ // agent may reach without each agent repeating it.
13
+ //
14
+ // Unset, an agent that reads the open web — `web` with fetch or browse — is
15
+ // `open`, because a search result can name any site and an allowlist would
16
+ // break it. Every other agent is `trusted`: package registries, source
17
+ // hosts and cloud SDKs, the list Anthropic's own cloud sandboxes start from
18
+ // (TRUSTED_SITES below), plus the hosts the step itself declares — its
19
+ // `apis:`, its remote MCP servers, its web providers, the sites its secrets
20
+ // are bound to. That is the default the cloud sandboxes this platform is
21
+ // measured against use: least access, widened on purpose.
22
+ //
23
+ // The rule is enforced at the egress proxy, which every request from a
24
+ // sandbox goes through (HTTPS_PROXY in the pod), and, on the Kubernetes
25
+ // tier, by the pod's network policy: a `trusted` or `none` pod reaches the
26
+ // proxy and DNS and nothing else, so a program that ignores the proxy
27
+ // variables fails instead of going round. This file is the vocabulary; the
28
+ // proxy (foldrun-platform egress-proxy.ts) holds the rule per request.
29
+ export const NETWORK_LEVELS = ["open", "trusted", "none"];
30
+ /**
31
+ * The trusted list: the domains Anthropic's Claude Code cloud environments
32
+ * allow at their default "Trusted" level, as published on
33
+ * code.claude.com/docs/en/cloud-environments#default-allowed-domains
34
+ * (read 14 Oct 2026), plus the Debian mirrors the runner image installs
35
+ * `runtime: system:` packages from. `*.` matches the subdomains and the
36
+ * bare domain.
37
+ */
38
+ export const TRUSTED_SITES = [
39
+ // Anthropic services
40
+ "api.anthropic.com", "docs.claude.com", "platform.claude.com", "code.claude.com", "claude.ai", "claude.com",
41
+ "support.claude.com", "anthropic.com", "www.anthropic.com",
42
+ // Version control
43
+ "github.com", "www.github.com", "api.github.com", "npm.pkg.github.com", "raw.githubusercontent.com",
44
+ "pkg-npm.githubusercontent.com", "objects.githubusercontent.com", "release-assets.githubusercontent.com",
45
+ "codeload.github.com", "avatars.githubusercontent.com", "camo.githubusercontent.com", "gist.github.com",
46
+ "gitlab.com", "www.gitlab.com", "registry.gitlab.com", "bitbucket.org", "www.bitbucket.org", "api.bitbucket.org",
47
+ // Container registries
48
+ "registry-1.docker.io", "auth.docker.io", "index.docker.io", "hub.docker.com", "www.docker.com",
49
+ "production.cloudflare.docker.com", "production.cloudfront.docker.com", "download.docker.com", "gcr.io",
50
+ "*.gcr.io", "ghcr.io", "mcr.microsoft.com", "*.data.mcr.microsoft.com", "public.ecr.aws",
51
+ // Cloud platforms
52
+ "cloud.google.com", "accounts.google.com", "gcloud.google.com", "*.googleapis.com", "storage.googleapis.com",
53
+ "compute.googleapis.com", "container.googleapis.com", "azure.com", "portal.azure.com", "microsoft.com",
54
+ "www.microsoft.com", "*.microsoftonline.com", "packages.microsoft.com", "dotnet.microsoft.com", "dot.net",
55
+ "visualstudio.com", "dev.azure.com", "*.amazonaws.com", "*.api.aws", "oracle.com", "www.oracle.com", "java.com",
56
+ "www.java.com", "java.net", "www.java.net", "download.oracle.com", "yum.oracle.com", "*.r2.cloudflarestorage.com",
57
+ // JavaScript and Node
58
+ "registry.npmjs.org", "www.npmjs.com", "www.npmjs.org", "npmjs.com", "npmjs.org", "yarnpkg.com",
59
+ "registry.yarnpkg.com", "jsr.io", "npm.jsr.io",
60
+ // Python
61
+ "pypi.org", "www.pypi.org", "files.pythonhosted.org", "pythonhosted.org", "test.pypi.org", "pypi.python.org",
62
+ "pypa.io", "www.pypa.io",
63
+ // Ruby
64
+ "rubygems.org", "www.rubygems.org", "api.rubygems.org", "index.rubygems.org", "ruby-lang.org",
65
+ "www.ruby-lang.org", "rubyforge.org", "www.rubyforge.org", "rubyonrails.org", "www.rubyonrails.org", "rvm.io",
66
+ "get.rvm.io",
67
+ // Rust
68
+ "crates.io", "www.crates.io", "index.crates.io", "static.crates.io", "rustup.rs", "static.rust-lang.org",
69
+ "www.rust-lang.org",
70
+ // Go
71
+ "proxy.golang.org", "sum.golang.org", "index.golang.org", "golang.org", "www.golang.org", "goproxy.io", "pkg.go.dev",
72
+ // JVM
73
+ "maven.org", "repo.maven.org", "central.maven.org", "repo1.maven.org", "repo.maven.apache.org",
74
+ "maven.google.com", "jcenter.bintray.com", "gradle.org", "www.gradle.org", "services.gradle.org",
75
+ "plugins.gradle.org", "plugins-artifacts.gradle.org", "kotlinlang.org", "www.kotlinlang.org", "spring.io",
76
+ "repo.spring.io",
77
+ // Other package managers
78
+ "packagist.org", "www.packagist.org", "repo.packagist.org", "nuget.org", "www.nuget.org", "api.nuget.org",
79
+ "pub.dev", "api.pub.dev", "hex.pm", "www.hex.pm", "cpan.org", "www.cpan.org", "metacpan.org", "www.metacpan.org",
80
+ "api.metacpan.org", "cocoapods.org", "www.cocoapods.org", "cdn.cocoapods.org", "haskell.org", "www.haskell.org",
81
+ "hackage.haskell.org", "swift.org", "www.swift.org",
82
+ // Linux distributions — Anthropic's list, then Debian's (the runner image is Debian)
83
+ "archive.ubuntu.com", "security.ubuntu.com", "ubuntu.com", "www.ubuntu.com", "*.ubuntu.com",
84
+ "ppa.launchpad.net", "launchpad.net", "www.launchpad.net", "*.nixos.org",
85
+ "deb.debian.org", "security.debian.org", "snapshot.debian.org",
86
+ // Development tools and platforms
87
+ "dl.k8s.io", "pkgs.k8s.io", "k8s.io", "www.k8s.io", "releases.hashicorp.com", "apt.releases.hashicorp.com",
88
+ "rpm.releases.hashicorp.com", "archive.releases.hashicorp.com", "hashicorp.com", "www.hashicorp.com",
89
+ "repo.anaconda.com", "conda.anaconda.org", "anaconda.org", "www.anaconda.com", "anaconda.com", "continuum.io",
90
+ "apache.org", "www.apache.org", "archive.apache.org", "downloads.apache.org", "eclipse.org", "www.eclipse.org",
91
+ "download.eclipse.org", "nodejs.org", "www.nodejs.org", "developer.apple.com", "developer.android.com",
92
+ "pkg.stainless.com", "binaries.prisma.sh",
93
+ // Cloud services and monitoring
94
+ "http-intake.logs.datadoghq.com", "*.datadoghq.com", "*.datadoghq.eu", "api.honeycomb.io",
95
+ // Content delivery and mirrors
96
+ "sourceforge.net", "*.sourceforge.net", "packagecloud.io", "*.packagecloud.io", "fonts.googleapis.com",
97
+ "fonts.gstatic.com",
98
+ // Schema and configuration
99
+ "json-schema.org", "www.json-schema.org", "json.schemastore.org", "www.schemastore.org",
100
+ // Model Context Protocol
101
+ "*.modelcontextprotocol.io",
102
+ ];
103
+ /** A site as `network:` takes it: a domain or `*.domain` — no scheme, no
104
+ * path, no port. The same shape `web.browse.allowed_domains` takes. */
105
+ export const SITE = /^(\*\.)?[a-z0-9]([a-z0-9-]*[a-z0-9])?(\.[a-z0-9]([a-z0-9-]*[a-z0-9])?)*$/;
106
+ /** A host as it arrives — `API.Example.com:443.` — as the lists hold it. */
107
+ export function normalHost(host) {
108
+ let h = host.trim().toLowerCase();
109
+ if (h.startsWith("["))
110
+ return h.replace(/^\[|\](:\d+)?$/g, ""); // an IPv6 literal
111
+ h = h.replace(/:\d+$/, "");
112
+ return h.replace(/\.$/, "");
113
+ }
114
+ /** Does `site` (a domain or `*.domain`) cover `host`? `*.example.com`
115
+ * covers `a.example.com`, `a.b.example.com` and `example.com` itself. */
116
+ export function siteCovers(site, host) {
117
+ const h = normalHost(host);
118
+ const s = site.toLowerCase();
119
+ if (s.startsWith("*.")) {
120
+ const base = s.slice(2);
121
+ return h === base || h.endsWith(`.${base}`);
122
+ }
123
+ return h === s;
124
+ }
125
+ /** May a step under `policy` reach `host`? `open` reaches any public host —
126
+ * the caller still refuses private addresses, which no level opens. */
127
+ export function networkAllows(policy, host) {
128
+ if (policy.level === "open")
129
+ return true;
130
+ return policy.hosts.some((s) => siteCovers(s, host));
131
+ }
132
+ /** A list of sites from YAML's list or one comma-separated line. */
133
+ function siteList(v) {
134
+ if (typeof v === "string")
135
+ return v.split(",").map((x) => x.trim()).filter(Boolean);
136
+ if (Array.isArray(v) && v.every((x) => typeof x === "string"))
137
+ return v.map((x) => x.trim()).filter(Boolean);
138
+ return null;
139
+ }
140
+ /**
141
+ * One `network:` value, read. Never throws: what cannot be read is a
142
+ * problem `check` and deploy report, and the run treats that level as
143
+ * saying nothing — the next level up, or the default, decides.
144
+ */
145
+ export function readNetwork(raw, where = "network") {
146
+ if (raw === undefined || raw === null)
147
+ return { setting: null, problems: [] };
148
+ const problems = [];
149
+ const sites = (v, key) => {
150
+ const list = siteList(v);
151
+ if (!list) {
152
+ problems.push(`${key} is a list of sites, like [api.example.com, "*.example.org"].`);
153
+ return [];
154
+ }
155
+ const out = [];
156
+ for (const s of list.map((x) => x.toLowerCase())) {
157
+ if (/^https?:\/\//.test(s) || s.includes("/"))
158
+ problems.push(`${key}: ${s} — a site is a domain, no scheme or path: ${s.replace(/^https?:\/\//, "").split("/")[0]}`);
159
+ else if (/:\d+$/.test(s))
160
+ problems.push(`${key}: ${s} — a site has no port; every port of the host is covered`);
161
+ else if (!SITE.test(s))
162
+ problems.push(`${key}: ${s} is not a domain or *.domain`);
163
+ else
164
+ out.push(s);
165
+ }
166
+ return out;
167
+ };
168
+ const level = (v, key) => {
169
+ if (typeof v === "string" && NETWORK_LEVELS.includes(v.trim().toLowerCase())) {
170
+ return v.trim().toLowerCase();
171
+ }
172
+ problems.push(`${key} is ${NETWORK_LEVELS.join(", ")} — not ${JSON.stringify(v)}.`);
173
+ return undefined;
174
+ };
175
+ if (typeof raw === "string" && NETWORK_LEVELS.includes(raw.trim().toLowerCase())) {
176
+ return { setting: { level: raw.trim().toLowerCase(), allow: [] }, problems };
177
+ }
178
+ if (typeof raw === "string" || Array.isArray(raw)) {
179
+ // A bare word that is not a level reads as a site only when it looks
180
+ // like one — `network: closed` is a typo, not a domain called "closed".
181
+ if (typeof raw === "string" && !raw.includes(".") && !raw.includes(",")) {
182
+ return { setting: null, problems: [`${where} is ${NETWORK_LEVELS.join(", ")}, a list of sites, or {level, allow, defaults} — not "${raw}".`] };
183
+ }
184
+ const allow = sites(raw, where);
185
+ return { setting: problems.length && !allow.length ? null : { allow }, problems };
186
+ }
187
+ if (typeof raw === "object") {
188
+ const o = raw;
189
+ for (const k of Object.keys(o)) {
190
+ if (!["level", "allow", "defaults"].includes(k))
191
+ problems.push(`${where}.${k} is not a key — level, allow, defaults.`);
192
+ }
193
+ const setting = { allow: [] };
194
+ if (o.level !== undefined)
195
+ setting.level = level(o.level, `${where}.level`);
196
+ if (o.allow !== undefined)
197
+ setting.allow = sites(o.allow, `${where}.allow`);
198
+ if (o.defaults !== undefined) {
199
+ if (o.defaults === true || o.defaults === false)
200
+ setting.defaults = o.defaults;
201
+ else
202
+ problems.push(`${where}.defaults is true or false — false drops the trusted list.`);
203
+ }
204
+ return { setting, problems };
205
+ }
206
+ return { setting: null, problems: [`${where} is ${NETWORK_LEVELS.join(", ")}, a list of sites, or {level, allow, defaults}.`] };
207
+ }
208
+ /** `check`'s and deploy's view: the problems only. */
209
+ export function networkProblems(raw) {
210
+ return readNetwork(raw).problems;
211
+ }
212
+ /**
213
+ * The step's network, from its three levels and what it declares.
214
+ *
215
+ * `readsWeb` — the step may fetch or browse the open web (the `web` tool
216
+ * with fetch or browse). It decides the default level only; a `network:`
217
+ * written anywhere beats it.
218
+ */
219
+ export function resolveNetwork(args) {
220
+ const levels = [
221
+ ["agent", readNetwork(args.agent).setting],
222
+ ["workspace", readNetwork(args.workspace).setting],
223
+ ["account", readNetwork(args.account).setting],
224
+ ];
225
+ const levelHit = levels.find(([, s]) => s?.level);
226
+ const level = levelHit ? levelHit[1].level : args.readsWeb ? "open" : "trusted";
227
+ const from = levelHit ? levelHit[0] : args.readsWeb ? "default — the agent reads the web" : "default";
228
+ const defaultsHit = levels.find(([, s]) => s?.defaults !== undefined);
229
+ const defaults = level === "trusted" && (defaultsHit ? defaultsHit[1].defaults : true);
230
+ const added = [...new Set(levels.flatMap(([, s]) => s?.allow ?? []))];
231
+ const implied = [...new Set([...(args.implied ?? [])].map(normalHost).filter((h) => h && SITE.test(h)))];
232
+ const hosts = level === "open" ? [] : [...new Set([...(defaults ? TRUSTED_SITES : []), ...added, ...implied])];
233
+ return { level, hosts, from, defaults, added, implied };
234
+ }
235
+ /** One line for the run's trace. */
236
+ export function networkLine(policy, enforced) {
237
+ const how = enforced === null
238
+ ? " — not enforced here (no sandbox network)"
239
+ : enforced
240
+ ? ""
241
+ : " — report only on this install: a site off the list is allowed and named in the trace";
242
+ if (policy.level === "open")
243
+ return `network: open (${policy.from}) — any public site${how}`;
244
+ const parts = [];
245
+ if (policy.defaults)
246
+ parts.push(`the trusted list (${TRUSTED_SITES.length} sites)`);
247
+ if (policy.added.length)
248
+ parts.push(`added: ${policy.added.join(", ")}`);
249
+ if (policy.implied.length)
250
+ parts.push(`declared by the step: ${policy.implied.join(", ")}`);
251
+ const what = parts.length ? parts.join("; ") : "nothing but the platform's own services";
252
+ return `network: ${policy.level} (${policy.from}) — ${what}${how}`;
253
+ }
254
+ /** What the proxy says when it refuses a site. */
255
+ export function refusalText(policy, host) {
256
+ return `network: ${host} is not a site this step may reach (network: ${policy.level}). Add it to the agent's network: list — network: [${normalHost(host)}] — or set network: open.`;
257
+ }
258
+ /** The proxy variables a sandbox's programs read, for a forward-proxy URL
259
+ * (`http://foldrun:<token>@host:port`). Both spellings: curl reads only the
260
+ * lower-case http_proxy, much else only the upper-case. NO_PROXY keeps the
261
+ * cluster's own names — the egress address itself, the account's pods —
262
+ * off the proxy. NODE_USE_ENV_PROXY turns the variables on for Node's own
263
+ * fetch and http (Node 22.21 and later). */
264
+ export function proxyEnv(proxyUrl, extraNoProxy = []) {
265
+ let egressHost = "";
266
+ try {
267
+ egressHost = new URL(proxyUrl).hostname;
268
+ }
269
+ catch {
270
+ // a malformed URL is still exported as given; the client says why it fails
271
+ }
272
+ const noProxy = [...new Set(["localhost", "127.0.0.1", "::1", ".svc", ".cluster.local", egressHost, ...extraNoProxy].filter(Boolean))].join(",");
273
+ return {
274
+ HTTP_PROXY: proxyUrl,
275
+ HTTPS_PROXY: proxyUrl,
276
+ http_proxy: proxyUrl,
277
+ https_proxy: proxyUrl,
278
+ NO_PROXY: noProxy,
279
+ no_proxy: noProxy,
280
+ NODE_USE_ENV_PROXY: "1",
281
+ // Node 22 warns "EnvHttpProxyAgent is experimental" on the first request
282
+ // of every process; a script tool's stderr then reads as an error to the
283
+ // model. That one warning, and no other.
284
+ NODE_OPTIONS: "--disable-warning=UNDICI-EHPA",
285
+ };
286
+ }
287
+ /** The env var the egress CA travels in — its PEM, base64'd so it survives
288
+ * every env channel (docker's env file has no multi-line values). The
289
+ * pod's start writes it to a file and points every TLS client at a bundle
290
+ * that includes it. */
291
+ export const EGRESS_CA_ENV = "FOLDRUN_EGRESS_CA";
292
+ /**
293
+ * The shell the sandbox's start runs before anything else: when the step
294
+ * carries the egress CA, write it, build a bundle of the system's CAs plus
295
+ * it, and point every TLS client the image has at that bundle. The CA only
296
+ * ever signs for hosts a secret is bound to — every other connection is a
297
+ * tunnel the proxy cannot read — but those clients must trust it there.
298
+ * Sourced after the env file, before system packages install (apt goes
299
+ * through the proxy too). `dir` is a directory the start may write.
300
+ */
301
+ export function egressCaSh(dir, systemBundle = "/etc/ssl/certs/ca-certificates.crt") {
302
+ // Without the system's bundle to add to, a bundle of the egress CA alone
303
+ // would make every other site untrusted — so only Node, which ADDS the
304
+ // file to its own roots, is told; nothing else changes.
305
+ return `if [ -n "\${${EGRESS_CA_ENV}:-}" ] && printf '%s' "\$${EGRESS_CA_ENV}" | base64 -d > ${dir}/egress-ca.pem 2>/dev/null; then
306
+ if [ -s ${systemBundle} ] && cat ${systemBundle} ${dir}/egress-ca.pem > ${dir}/ca-bundle.pem; then
307
+ export SSL_CERT_FILE=${dir}/ca-bundle.pem REQUESTS_CA_BUNDLE=${dir}/ca-bundle.pem CURL_CA_BUNDLE=${dir}/ca-bundle.pem PIP_CERT=${dir}/ca-bundle.pem GIT_SSL_CAINFO=${dir}/ca-bundle.pem
308
+ fi
309
+ export NODE_EXTRA_CA_CERTS=${dir}/egress-ca.pem
310
+ fi`;
311
+ }
312
+ /** A site as a YAML flow item: `*.x` would read as an alias, so it is quoted. */
313
+ const siteItem = (site) => (/^[a-z0-9]/.test(site) ? site : JSON.stringify(site));
314
+ /** `network:` as one line of YAML, the shortest of the three shapes that
315
+ * says it; null when there is nothing to say. */
316
+ export function networkYaml(setting) {
317
+ if (!setting)
318
+ return null;
319
+ const allow = [...new Set((setting.allow ?? []).map((s) => s.trim().toLowerCase()).filter(Boolean))];
320
+ const list = `[${allow.map(siteItem).join(", ")}]`;
321
+ if (setting.defaults === false) {
322
+ return `{ ${[setting.level ? `level: ${setting.level}` : "", allow.length ? `allow: ${list}` : "", "defaults: false"].filter(Boolean).join(", ")} }`;
323
+ }
324
+ if (setting.level && allow.length)
325
+ return `{ level: ${setting.level}, allow: ${list} }`;
326
+ if (setting.level)
327
+ return setting.level;
328
+ if (allow.length)
329
+ return list;
330
+ return null;
331
+ }
332
+ /**
333
+ * The text of an agent.md (or AGENTS.md) with its `network:` replaced by
334
+ * `setting` — or removed, for null — and every other byte as it was. The
335
+ * old value's indented lines go with it, whatever shape it was written in.
336
+ * Pure string work, so the dashboard runs it in the browser and the CLI on
337
+ * a file, and both write exactly the same line.
338
+ */
339
+ export function setNetworkSetting(raw, setting) {
340
+ const eol = raw.includes("\r\n") ? "\r\n" : "\n";
341
+ const lines = raw.split(eol);
342
+ const value = networkYaml(setting);
343
+ const line = value ? `network: ${value}` : null;
344
+ if (lines[0]?.trim() !== "---")
345
+ return line ? ["---", line, "---", ...lines].join(eol) : raw;
346
+ const close = lines.findIndex((l, i) => i > 0 && l.trim() === "---");
347
+ if (close < 0)
348
+ return raw;
349
+ const at = lines.findIndex((l, i) => i > 0 && i < close && /^network\s*:/.test(l));
350
+ if (at < 0) {
351
+ if (line)
352
+ lines.splice(close, 0, line);
353
+ return lines.join(eol);
354
+ }
355
+ let end = at + 1;
356
+ while (end < close && (/^\s+\S/.test(lines[end]) || /^\s*-\s/.test(lines[end])))
357
+ end++;
358
+ lines.splice(at, end - at, ...(line ? [line] : []));
359
+ return lines.join(eol);
360
+ }
@@ -3,6 +3,7 @@ import type { McpServerConfig } from "@anthropic-ai/claude-agent-sdk";
3
3
  import { type ApiSpec, type Effort } from "./store.ts";
4
4
  import type { EventExtra } from "./step-exec.ts";
5
5
  import { type RuntimeSpec } from "./runtime.ts";
6
+ import { type NetworkLevel } from "./network.ts";
6
7
  import type { ScriptSpec } from "./script-tools.ts";
7
8
  import type { ConsultSpec } from "./agent-tools.ts";
8
9
  import type { SubagentSpec } from "./subagents.ts";
@@ -359,6 +360,13 @@ export interface RunInContainerArgs {
359
360
  input: ContainerStepInput;
360
361
  /** Secret + provider env — crosses as an env file, never argv. */
361
362
  env: Record<string, string>;
363
+ /** The step's network (network.ts), for an executor that can wall the
364
+ * sandbox in itself: the k8s tier labels a filtered pod so its policy
365
+ * admits the proxy and DNS alone. `enforced` false is report-only. */
366
+ network?: {
367
+ level: NetworkLevel;
368
+ enforced: boolean;
369
+ };
362
370
  emit: (type: "text" | "tool" | "info" | "error", text: string, extra?: EventExtra) => void;
363
371
  /** The run this step belongs to. Stamped on the sandbox as a label so a
364
372
  * person stopping the run can reach the thing actually burning money —
@@ -27,6 +27,7 @@ import { isPlatformPath } from "./store.js";
27
27
  import { divertedSummary, isDivertedPath, overlayTestWrites } from "./test-mode.js";
28
28
  import { isFileValue, fileContent } from "./secrets.js";
29
29
  import { safeTenantSegment, systemInstallSh, SYSTEM_FROM_INPUT_SH, INSTALL_SOCKET } from "./runtime.js";
30
+ import { egressCaSh } from "./network.js";
30
31
  import { isOperatorEvent } from "./operator.js";
31
32
  import { parseBrowserPod } from "./browser-pod.js";
32
33
  /**
@@ -812,6 +813,7 @@ ${CORE_INSTALL}`;
812
813
  // reads the status file and fails the step with apt's own words.
813
814
  const ENTRY = `#!/bin/sh
814
815
  set +e
816
+ ${egressCaSh("/opt/runner/job")}
815
817
  ${SYSTEM_FROM_INPUT_SH}
816
818
  ${systemInstallSh(RUNTIME_CACHE)}
817
819
  ${INSTALL_HELPER_SH}