toolroll 0.9.68 → 0.9.69

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/builder.d.ts CHANGED
@@ -1,3 +1,5 @@
1
+ import { type NetworkProfile } from "./egress.js";
2
+ import { type CheckAccess } from "./check-access.js";
1
3
  import type { WritesMode } from "./writes-mode.js";
2
4
  import type { LaunchNetwork } from "./invoke.js";
3
5
  import type { ProviderId } from "./contracts/provider.js";
@@ -187,6 +189,13 @@ export declare function redactSecretAssignments(text: string): string;
187
189
  /** A build's network (egress.ts): the one its approval sealed, or — sealed before network policies existed — the
188
190
  * narrowed legacy one (provider hosts and the repository's registries, nothing added since). */
189
191
  export declare function launchNetworkOf(store: Store, taskRef: number, profile: ExecutionProfile): LaunchNetwork;
192
+ /** A build's checks' level and network: its approval's level as this platform can give it (sign-ins sealed before
193
+ * run sandboxed while sign-ins are off, and the run is stamped with that, what actually ran), and under sign-ins the network its
194
+ * approval sealed less its agent's own provider hosts (never its checks'). */
195
+ export declare function checkLaunchOf(store: Store, runId: number, profile: ExecutionProfile, platform?: NodeJS.Platform): {
196
+ access: CheckAccess;
197
+ network: NetworkProfile | null;
198
+ };
190
199
  /** Where a build may write (writes-mode.ts): what its approval sealed, or — sealed before write allowlists — anywhere
191
200
  * its fence allows, as it was approved. */
192
201
  export declare function launchWritesOf(profile: ExecutionProfile): WritesMode;
package/dist/builder.js CHANGED
@@ -1,4 +1,5 @@
1
1
  import { legacyNetwork, repositoryEcosystems } from "./egress.js";
2
+ import { CHECK_ACCESS_DEFAULT, checkAccessHere, checkNetworkOf } from "./check-access.js";
2
3
  import { checkoutGit } from "./teardown-git.js";
3
4
  import { isAcpProvider } from "./acp-agents.js";
4
5
  import { OBSERVATION_MAILBOX, observationBrief, parseObservationCases, collectObservations } from "./observations.js";
@@ -268,7 +269,26 @@ function buildCheckoutGit(given, worktree, mainCheckout = null) {
268
269
  /** A run's setup and check commands, in its build's fence, writing where the approval this build runs under sealed
269
270
  * (never the scope as it may since have been rewritten; invoke.ts fencedCommands). */
270
271
  function commandRunner(store, runId, profile, inner) {
271
- return fencedCommands(store, runId, launchWritesOf(profile), inner);
272
+ // How far its checks reach (check-access.ts): what its approval sealed, stamped on the run (again: the dispatch stamped
273
+ // it first) so a later check of its result never runs wider; under sign-ins, behind its checks' own network.
274
+ const { access, network } = checkLaunchOf(store, runId, profile);
275
+ store.stampRunCheckAccess(runId, access, network);
276
+ return fencedCommands(store, runId, launchWritesOf(profile), inner, undefined, access, network ?? undefined);
277
+ }
278
+ /** A build's checks' level and network: its approval's level as this platform can give it (sign-ins sealed before
279
+ * run sandboxed while sign-ins are off, and the run is stamped with that, what actually ran), and under sign-ins the network its
280
+ * approval sealed less its agent's own provider hosts (never its checks'). */
281
+ export function checkLaunchOf(store, runId, profile, platform = process.platform) {
282
+ const access = checkAccessHere(profile.checkAccess ?? CHECK_ACCESS_DEFAULT, platform);
283
+ if (access !== "sign-ins")
284
+ return { access, network: null };
285
+ const taskRef = store.getRun(runId)?.taskRef;
286
+ const sealed = taskRef === undefined ? undefined : launchNetworkOf(store, taskRef, profile);
287
+ // No sealed network to narrow from: its checks run sandboxed.
288
+ if (sealed === undefined || !("policy" in sealed))
289
+ return { access: CHECK_ACCESS_DEFAULT, network: null };
290
+ const repo = store.refForId(taskRef)?.repo ?? null;
291
+ return { access, network: checkNetworkOf(sealed.policy, profile.provider, repo === null ? { mode: "allowlist", hosts: [], loopback: "proxy-only" } : store.projectNetwork(repo)) };
272
292
  }
273
293
  /** Where a build may write (writes-mode.ts): what its approval sealed, or — sealed before write allowlists — anywhere
274
294
  * its fence allows, as it was approved. */
@@ -422,6 +442,12 @@ export async function build(store, request) {
422
442
  // resume below can match on them honestly.
423
443
  const provenScopeDigest = scope?.approvedDigest ?? "";
424
444
  const provenProfileDigest = profileDigestOf(effective.profile);
445
+ // Its checks' level and network, stamped now with the proof: a batched result that runs no command here still
446
+ // carries what its approval allows its checks (check-access.ts).
447
+ {
448
+ const launch = checkLaunchOf(store, request.runId, effective.profile);
449
+ store.stampRunCheckAccess(request.runId, launch.access, launch.network);
450
+ }
425
451
  store.stampRun(request.runId, {
426
452
  scopeDigest: provenScopeDigest,
427
453
  profileDigest: provenProfileDigest,
@@ -0,0 +1,54 @@
1
+ /**
2
+ * Check access (v132, docs/plans/check-access.md): how far a project's setup and check commands reach beyond the
3
+ * plane's check fence. `sandboxed` (the default) is the fence as it is; `sign-ins` also reads the exact sign-in files
4
+ * of Claude, Codex and gh, under the project's network fence; `unsandboxed` runs them with no OS fence, trusted by the
5
+ * whole instance, for a project whose checks must create sandboxes themselves (Toolroll's own). Widening is an instance
6
+ * operator's act with a password typed now; any approver for the project may narrow it back.
7
+ */
8
+ import { type NetworkProfile, type ProjectNetwork } from "./egress.js";
9
+ export declare const CHECK_ACCESS_LEVELS: readonly ["sandboxed", "sign-ins", "unsandboxed"];
10
+ export type CheckAccess = typeof CHECK_ACCESS_LEVELS[number];
11
+ export declare const CHECK_ACCESS_DEFAULT: CheckAccess;
12
+ export declare const isCheckAccess: (value: unknown) => value is CheckAccess;
13
+ /** A stored or sealed value as a level: anything missing or unreadable is the narrowest, never a wider one. */
14
+ export declare const checkAccessOf: (value: unknown) => CheckAccess;
15
+ export declare const widerThan: (a: CheckAccess, b: CheckAccess) => boolean;
16
+ /** The narrowest of several levels (none given: the default). */
17
+ export declare const narrowestAccess: (levels: readonly CheckAccess[]) => CheckAccess;
18
+ /** The level as a person reads it, in a sentence's middle ("Checks: …"). */
19
+ export declare function checkAccessWords(level: CheckAccess): string;
20
+ /** What each wider level means, said before anyone chooses it. */
21
+ export declare const CHECK_ACCESS_WARNINGS: Readonly<Record<Exclude<CheckAccess, "sandboxed">, string>>;
22
+ /** The services whose sign-ins a `sign-ins` check reads: Claude's and Codex's own hosts (egress.ts), and GitHub's. */
23
+ export declare const SIGN_IN_HOSTS: readonly string[];
24
+ /** Which of those a project's network doesn't allow (an open network allows all). The check's network is never widened
25
+ * for them: the operator adds them, as a network change of its own. */
26
+ export declare function signInHostsMissing(network: ProjectNetwork): string[];
27
+ /** The narrower of two networks: every host both allow (a wildcard allows what it covers), local services only if both
28
+ * do, open only if both are. A check never reaches what either its sealed network or the project's current one refuses. */
29
+ export declare function narrowestNetwork(a: NetworkProfile, b: NetworkProfile): NetworkProfile;
30
+ /** A build's checks' own network: what its approval sealed, less its agent's provider hosts (those are the agent's, never
31
+ * its checks'); narrower if a project host happens to be one of them. */
32
+ export declare function checkNetworkOf(sealed: NetworkProfile, provider: string, project: ProjectNetwork): NetworkProfile;
33
+ /** Off everywhere, for now (0.9.69): no `sign-ins`. Reading a sign-in file resolves its parent folders by path (Node
34
+ * has no openat), and each review round found another way a build or a check could swap a folder on that path between
35
+ * the checks and the read (on Linux a Claude build can write ~/.claude outright). Until that read is race-free, no
36
+ * check gets sign-ins: setting the level is refused, a level saved or sealed before (or copied in from another
37
+ * database) runs sandboxed, a scope seals sandboxed, and the sign-ins launch refuses first thing. The code behind it
38
+ * (signInGrants, readGrantedFile, the copies, the network fence) stays, guarded, for when such a reader exists; flip
39
+ * SIGN_INS_READY then (Linux stays off until its copies are race-free too). */
40
+ export declare const SIGN_INS_READY = false;
41
+ export declare const signInsAvailable: (platform?: NodeJS.Platform) => boolean;
42
+ /** Why sign-ins can't be chosen here, said plainly (null: they can). */
43
+ export declare const signInsUnavailableWords: (platform?: NodeJS.Platform) => string | null;
44
+ /** The level checks actually get on this platform: `sign-ins` where it isn't available is `sandboxed` (narrower, never
45
+ * wider). Everything that seals, launches or stamps a check's level goes through this. */
46
+ export declare const checkAccessHere: (level: CheckAccess, platform?: NodeJS.Platform) => CheckAccess;
47
+ /** How a project's checks actually run here, as the settings page and `project check-access` say it: with no OS fence
48
+ * on this machine and unconfined running allowed by an operator, without a sandbox whatever is saved (never said to be
49
+ * sandboxed); otherwise the saved level as this platform can give it. */
50
+ export declare function checkAccessRunsAs(level: CheckAccess, here: {
51
+ platform: NodeJS.Platform;
52
+ fenced: boolean;
53
+ unconfinedAllowed: boolean;
54
+ }): CheckAccess;
@@ -0,0 +1,80 @@
1
+ /**
2
+ * Check access (v132, docs/plans/check-access.md): how far a project's setup and check commands reach beyond the
3
+ * plane's check fence. `sandboxed` (the default) is the fence as it is; `sign-ins` also reads the exact sign-in files
4
+ * of Claude, Codex and gh, under the project's network fence; `unsandboxed` runs them with no OS fence, trusted by the
5
+ * whole instance, for a project whose checks must create sandboxes themselves (Toolroll's own). Widening is an instance
6
+ * operator's act with a password typed now; any approver for the project may narrow it back.
7
+ */
8
+ import { PROVIDER_HOSTS } from "./egress.js";
9
+ export const CHECK_ACCESS_LEVELS = ["sandboxed", "sign-ins", "unsandboxed"];
10
+ export const CHECK_ACCESS_DEFAULT = "sandboxed";
11
+ export const isCheckAccess = (value) => typeof value === "string" && CHECK_ACCESS_LEVELS.includes(value);
12
+ /** A stored or sealed value as a level: anything missing or unreadable is the narrowest, never a wider one. */
13
+ export const checkAccessOf = (value) => isCheckAccess(value) ? value : CHECK_ACCESS_DEFAULT;
14
+ const RANK = { sandboxed: 0, "sign-ins": 1, unsandboxed: 2 };
15
+ export const widerThan = (a, b) => RANK[a] > RANK[b];
16
+ /** The narrowest of several levels (none given: the default). */
17
+ export const narrowestAccess = (levels) => levels.reduce((low, one) => RANK[one] < RANK[low] ? one : low, levels.length === 0 ? CHECK_ACCESS_DEFAULT : "unsandboxed");
18
+ /** The level as a person reads it, in a sentence's middle ("Checks: …"). */
19
+ export function checkAccessWords(level) {
20
+ return level === "sandboxed" ? "sandboxed" : level === "sign-ins" ? "sandboxed, with model and GitHub sign-ins" : "not sandboxed";
21
+ }
22
+ /** What each wider level means, said before anyone chooses it. */
23
+ export const CHECK_ACCESS_WARNINGS = {
24
+ "sign-ins": "This project's checks can read your Claude, Codex and GitHub sign-ins. Its tests could use or send those credentials anywhere its network setting allows. Only choose this for projects you trust.",
25
+ unsandboxed: "This project's checks get your full access to this machine and to Toolroll itself, including its database and every sign-in. Only for a project whose checks must create sandboxes themselves, such as Toolroll's own.",
26
+ };
27
+ /** The services whose sign-ins a `sign-ins` check reads: Claude's and Codex's own hosts (egress.ts), and GitHub's. */
28
+ export const SIGN_IN_HOSTS = [...PROVIDER_HOSTS["claude"], ...PROVIDER_HOSTS["codex"], "github.com", "api.github.com"];
29
+ /** Which of those a project's network doesn't allow (an open network allows all). The check's network is never widened
30
+ * for them: the operator adds them, as a network change of its own. */
31
+ export function signInHostsMissing(network) {
32
+ if (network.mode === "open")
33
+ return [];
34
+ const covered = (host) => network.hosts.some(one => one === host || (one.startsWith("*.") && host.endsWith(one.slice(1))));
35
+ return SIGN_IN_HOSTS.filter(host => !covered(host));
36
+ }
37
+ /** The narrower of two networks: every host both allow (a wildcard allows what it covers), local services only if both
38
+ * do, open only if both are. A check never reaches what either its sealed network or the project's current one refuses. */
39
+ export function narrowestNetwork(a, b) {
40
+ const loopback = a.loopback === "any" && b.loopback === "any" ? "any" : "proxy-only";
41
+ if (a.mode === "open" && b.mode === "open")
42
+ return { version: 1, mode: "open", hosts: [], loopback };
43
+ if (a.mode === "open")
44
+ return { ...b, loopback };
45
+ if (b.mode === "open")
46
+ return { ...a, loopback };
47
+ const covers = (list, host) => list.some(one => one === host || (one.startsWith("*.") && (host.endsWith(one.slice(1)) || host === one)));
48
+ const hosts = [...new Set([...a.hosts.filter(host => covers(b.hosts, host)), ...b.hosts.filter(host => covers(a.hosts, host))])].sort();
49
+ return { version: 1, mode: "allowlist", hosts, loopback };
50
+ }
51
+ /** A build's checks' own network: what its approval sealed, less its agent's provider hosts (those are the agent's, never
52
+ * its checks'); narrower if a project host happens to be one of them. */
53
+ export function checkNetworkOf(sealed, provider, project) {
54
+ if (sealed.mode === "open")
55
+ return sealed;
56
+ // A provider host the project itself lists (an operator added it, for its sign-ins, say) is the checks' too.
57
+ const agent = new Set((PROVIDER_HOSTS[provider] ?? []).filter(host => !project.hosts.includes(host)));
58
+ return { ...sealed, hosts: sealed.hosts.filter(host => !agent.has(host)) };
59
+ }
60
+ /** Off everywhere, for now (0.9.69): no `sign-ins`. Reading a sign-in file resolves its parent folders by path (Node
61
+ * has no openat), and each review round found another way a build or a check could swap a folder on that path between
62
+ * the checks and the read (on Linux a Claude build can write ~/.claude outright). Until that read is race-free, no
63
+ * check gets sign-ins: setting the level is refused, a level saved or sealed before (or copied in from another
64
+ * database) runs sandboxed, a scope seals sandboxed, and the sign-ins launch refuses first thing. The code behind it
65
+ * (signInGrants, readGrantedFile, the copies, the network fence) stays, guarded, for when such a reader exists; flip
66
+ * SIGN_INS_READY then (Linux stays off until its copies are race-free too). */
67
+ export const SIGN_INS_READY = false;
68
+ export const signInsAvailable = (platform = process.platform) => SIGN_INS_READY && platform !== "linux";
69
+ /** Why sign-ins can't be chosen here, said plainly (null: they can). */
70
+ export const signInsUnavailableWords = (platform = process.platform) => signInsAvailable(platform) ? null
71
+ : "Not available yet: sign-ins can't be shared with checks safely. Choose Sandboxed or Not sandboxed.";
72
+ /** The level checks actually get on this platform: `sign-ins` where it isn't available is `sandboxed` (narrower, never
73
+ * wider). Everything that seals, launches or stamps a check's level goes through this. */
74
+ export const checkAccessHere = (level, platform = process.platform) => level === "sign-ins" && !signInsAvailable(platform) ? CHECK_ACCESS_DEFAULT : level;
75
+ /** How a project's checks actually run here, as the settings page and `project check-access` say it: with no OS fence
76
+ * on this machine and unconfined running allowed by an operator, without a sandbox whatever is saved (never said to be
77
+ * sandboxed); otherwise the saved level as this platform can give it. */
78
+ export function checkAccessRunsAs(level, here) {
79
+ return !here.fenced && here.unconfinedAllowed ? "unsandboxed" : checkAccessHere(level, here.platform);
80
+ }
@@ -84,6 +84,7 @@ export declare const CONSOLE_FORMS: {
84
84
  readonly projectLiveView: FormContract<"act" | "nonce" | "password" | "repo">;
85
85
  readonly projectNetwork: FormContract<"hosts" | "local" | "mode" | "password" | "repo">;
86
86
  readonly projectWrites: FormContract<"password" | "repo" | "writes">;
87
+ readonly projectCheckAccess: FormContract<"confirm-open" | "level" | "password" | "repo">;
87
88
  readonly projectDelete: FormContract<"name" | "password" | "repo" | "step">;
88
89
  readonly policy: FormContract<"ceiling" | "models" | "password" | "provider" | "tools">;
89
90
  readonly approval: FormContract<"not_requester" | "password" | "paths" | "protect" | "repo">;
@@ -93,6 +93,7 @@ export const CONSOLE_FORMS = {
93
93
  projectLiveView: formContract("POST /settings/project/live-view", ["repo", "act", "password", "nonce"]),
94
94
  projectNetwork: formContract("POST /settings/project/network", ["repo", "mode", "local", "hosts", "password"]),
95
95
  projectWrites: formContract("POST /settings/project/writes", ["repo", "writes", "password"]),
96
+ projectCheckAccess: formContract("POST /settings/project/check-access", ["repo", "level", "confirm-open", "password"]),
96
97
  projectDelete: formContract("POST /settings/project/delete", ["repo", "name", "step", "password"]),
97
98
  policy: formContract("POST /settings/policy", ["password", "provider", "models", "tools", "ceiling"]),
98
99
  approval: formContract("POST /settings/approval", ["repo", "password", "protect", "paths", "not_requester"]),
package/dist/invoke.d.ts CHANGED
@@ -21,6 +21,8 @@ import { type VersionProbe } from "./attest.js";
21
21
  import type { Store } from "./store.js";
22
22
  import type { RunOptions } from "./exec.js";
23
23
  import type { Runner } from "./worktree.js";
24
+ import { type NetworkFence, type SignInGrants } from "./agent-fence.js";
25
+ import { type CheckAccess } from "./check-access.js";
24
26
  import { type WritesMode } from "./agent-fence.js";
25
27
  import { type NetworkProfile } from "./egress.js";
26
28
  export type { ProviderRunner } from "./provider.js";
@@ -117,11 +119,46 @@ export declare function invokeAgent(store: Store, runId: number, spec: AgentSpec
117
119
  * Their network stays as it was. A machine without an OS fence runs them as before; a fence that can't be prepared
118
120
  * (a checkout whose binding no longer proves) runs nothing.
119
121
  */
120
- export declare function fencedCommands(store: Store, runId: number, writes: WritesMode, inner: Runner, available?: () => boolean): Runner;
122
+ export declare function fencedCommands(store: Store, runId: number, writes: WritesMode, inner: Runner, available?: () => boolean, access?: CheckAccess, network?: NetworkProfile): Runner;
121
123
  /**
122
124
  * A project's checks run in a checkout of a result's commit made for them (result-follow-ups.ts, batch-checks.ts), in
123
125
  * the same fence as a build's commands: Toolroll's state, credentials, other projects and runs denied; under the
124
126
  * project's writes setting, only that checkout, its Git data, its own temp folder, the project's caches and its check
125
127
  * database writable. Where nothing can confine them, they run only when an instance operator allowed unconfined agents.
126
128
  */
127
- export declare function fencedCheckoutCommands(store: Store, repo: string, checkout: string, inner: Runner, available?: () => boolean): Runner;
129
+ export declare function fencedCheckoutCommands(store: Store, repo: string, checkout: string, inner: Runner, available?: () => boolean, access?: CheckAccess, network?: NetworkProfile): Runner;
130
+ /**
131
+ * A `sign-ins` check's launch (check-access.ts): its exact sign-in files and its own transcript folder
132
+ * (agent-fence.ts signInGrants), Codex's config and sign-in copied into a home of the launch's own (private, gone with
133
+ * the launch; ~/.codex itself stays fenced), gh pointed at its own folder, and the network the check runs under:
134
+ * open, or its allowlist through a proxy of its own (on Linux, carried in by a bridge). Nothing about it is started
135
+ * unless all of it is.
136
+ */
137
+ export declare function signInsLaunch(store: Store, own: string, temp: string, checkout: string, policy: NetworkProfile, gitPaths: readonly string[], platform?: NodeJS.Platform): Promise<{
138
+ grants: SignInGrants;
139
+ network: NetworkFence;
140
+ env: Record<string, string>;
141
+ close(): Promise<void>;
142
+ } | {
143
+ problem: string;
144
+ }>;
145
+ /** How a check at `access` actually runs here: unsandboxed where no OS fence exists (and an operator allowed that), else
146
+ * as asked. Recorded on every check's receipt, so a check that ran without a sandbox is never shown as sandboxed. */
147
+ export declare function actualCheckAccess(store: Store, access: CheckAccess): CheckAccess;
148
+ /**
149
+ * A check of saved results, in their own checkout (batch-checks.ts, result-follow-ups.ts), worked out right before each
150
+ * command: never wider than any of those results' own checks ran, nor than the project allows now; under sign-ins, the
151
+ * narrowest of their sealed check networks and the project's current one. A result with no sealed network runs it
152
+ * sandboxed.
153
+ */
154
+ export declare function checkoutLaunchOf(store: Store, repo: string, runIds: readonly number[], platform?: NodeJS.Platform): {
155
+ access: CheckAccess;
156
+ network: NetworkProfile | undefined;
157
+ };
158
+ /** The widest level a check's commands actually ran at (what its receipt says). */
159
+ export declare const widestRan: (levels: readonly CheckAccess[]) => CheckAccess;
160
+ /** A check log's line for how its commands ran, when wider than sandboxed (shown with the log). */
161
+ export declare const ranAtLine: (level: CheckAccess) => string | null;
162
+ /** A saved result's check command runner (batch-checks.ts, result-follow-ups.ts): its level and network worked out right
163
+ * now (checkoutLaunchOf), and how it actually runs told to `ran`. */
164
+ export declare function checkoutRunner(store: Store, repo: string, checkout: string, inner: Runner, runIds: readonly number[], ran: (level: CheckAccess) => void, platform?: NodeJS.Platform): Runner;
package/dist/invoke.js CHANGED
@@ -29,13 +29,14 @@ import { witnessedRunner } from "./process-custody.js";
29
29
  import { underStopWatch } from "./task-control.js";
30
30
  import { childDatabaseEnv, isolatedChildDatabase, removeChildDatabase as removeAgentDatabase } from "./child-database.js";
31
31
  import { RunSignInError, noToolsArgs, prepareRunTools } from "./project-tools.js";
32
- import { existsSync, mkdirSync, mkdtempSync, readFileSync, realpathSync, rmSync } from "node:fs";
32
+ import { existsSync, lstatSync, mkdirSync, mkdtempSync, readFileSync, realpathSync, rmSync, writeFileSync } from "node:fs";
33
33
  import { homedir, tmpdir } from "node:os";
34
34
  import { dirname, join } from "node:path";
35
35
  import { poolRootOf } from "./worktree.js";
36
- import { BRIDGE_DIR } from "./agent-fence.js";
36
+ import { BRIDGE_DIR, checkLaunchRoot, readGrantedFile, SIGN_IN_COPIES_DIR, signInCopiesRoot, signInGrants } from "./agent-fence.js";
37
+ import { CHECK_ACCESS_DEFAULT, checkAccessHere, narrowestAccess, narrowestNetwork, signInsAvailable } from "./check-access.js";
37
38
  import { agentFence, buildCacheEnv, buildCacheFolder, grantFolder, writeAllowlist, WRITES_DEFAULT, CheckoutBindingError, trustedWorktreeBinding, checkoutGitEnv, HARDENED_GIT_ARGS, codexFenceArgv, fenceGrants, fencedGitEnv, linuxFenceAvailable, linuxFenced, macosFenceAvailable, macosFenced, NO_GRANTS, runFolders, taskBoundary } from "./agent-fence.js";
38
- import { proxyEnv, providerOnlyNetwork, startEgressProxy } from "./egress.js";
39
+ import { proxyEnv, providerOnlyNetwork, repositoryEcosystems, resolveNetwork, startEgressProxy } from "./egress.js";
39
40
  import { bridgeFits, startEgressBridge } from "./egress-bridge.js";
40
41
  import { claudeConfigDir, claudeDefaults, codexDefaults, codexHomeDir, isolatedCodexHome } from "./provider-home.js";
41
42
  import { billingOf, claudeBillingFrom, seenBilling } from "./spend.js";
@@ -969,21 +970,27 @@ function runFence(store, runId, provider, keyHome, ownTools, writes = WRITES_DEF
969
970
  * Their network stays as it was. A machine without an OS fence runs them as before; a fence that can't be prepared
970
971
  * (a checkout whose binding no longer proves) runs nothing.
971
972
  */
972
- export function fencedCommands(store, runId, writes, inner, available = () => macosFenceAvailable() || linuxFenceAvailable()) {
973
+ export function fencedCommands(store, runId, writes, inner, available = () => macosFenceAvailable() || linuxFenceAvailable(), access = CHECK_ACCESS_DEFAULT, network) {
973
974
  // An injected runner (tests) starts no real command, as an injected agent starts no real agent (invokeAgent).
974
975
  if (inner !== runCommand)
975
976
  return inner;
976
977
  return async (file, args, options) => {
977
- // As a build itself: where nothing can confine them, they run only when an instance operator allowed unconfined agents.
978
+ // As a build itself: where nothing can confine them, they run only when an instance operator allowed unconfined agents
979
+ // (and the run says so: its checks ran without a sandbox, whatever was sealed).
978
980
  if (!available()) {
979
- if (store.unfencedAgentsAllowed())
981
+ if (store.unfencedAgentsAllowed()) {
982
+ store.stampRunCheckAccess(runId, "unsandboxed");
980
983
  return inner(file, args, options);
984
+ }
981
985
  return { code: 1, stdout: "", stderr: "This machine can't confine the build's commands (no macOS sandbox, no working bubblewrap), so they didn't run. Install bubblewrap, or an instance operator allows unconfined agents with `toolroll config unconfined-agents allow`.\n", timedOut: false, notFound: false };
982
986
  }
983
- const own = realpathSync(mkdtempSync(join(tmpdir(), "so-cmd-")));
987
+ const own = realpathSync(mkdtempSync(join(checkLaunchRoot(), "so-cmd-")));
984
988
  try {
985
989
  const temp = join(own, "tmp");
986
990
  mkdirSync(temp, { mode: 0o700 });
991
+ // Not sandboxed (check-access.ts): the project's checks are trusted by the whole instance, as before 0.9.66.
992
+ if (access === "unsandboxed")
993
+ return await inner(file, args, { ...options, env: { ...(options?.env ?? {}), TMPDIR: temp, TMP: temp, TEMP: temp } });
987
994
  const fenced = runFence(store, runId, "command", undefined, own, writes);
988
995
  if (fenced.problem !== undefined)
989
996
  return { code: 1, stdout: "", stderr: `${fenced.problem}\n`, timedOut: false, notFound: false };
@@ -993,12 +1000,24 @@ export function fencedCommands(store, runId, writes, inner, available = () => ma
993
1000
  const database = options?.env?.["TOOLROLL_DB"];
994
1001
  const checkData = database === undefined || fenced.grants.writes === undefined ? null : grantFolder(dirname(database), false);
995
1002
  const grants = checkData === null ? fenced.grants : { ...fenced.grants, writes: { ...fenced.grants.writes, subpaths: [...fenced.grants.writes.subpaths, checkData] } };
996
- return await inner(file, args, {
997
- ...options,
998
- fence: fenced.fence,
999
- fenceGrants: grants,
1000
- env: { ...(options?.env ?? {}), TMPDIR: temp, TMP: temp, TEMP: temp, ...(fenced.cache === undefined || fenced.grants.writes === undefined ? {} : buildCacheEnv(fenced.cache)) },
1001
- });
1003
+ const checkout = store.getRun(runId)?.worktree ?? null;
1004
+ const signIns = access !== "sign-ins" ? null : checkout === null ? { problem: "The check's checkout is unknown, so its sign-ins can't be granted." }
1005
+ : network === undefined ? { problem: "A check with sign-ins has no network sealed for it, so nothing ran." }
1006
+ : await signInsLaunch(store, own, temp, checkout, network, fenced.gitPaths ?? []);
1007
+ if (signIns !== null && "problem" in signIns)
1008
+ return { code: 1, stdout: "", stderr: `${signIns.problem}\n`, timedOut: false, notFound: false };
1009
+ try {
1010
+ return await inner(file, args, {
1011
+ ...options,
1012
+ fence: fenced.fence,
1013
+ fenceGrants: signIns === null ? grants : { ...grants, signIns: signIns.grants, network: signIns.network },
1014
+ env: { ...(options?.env ?? {}), TMPDIR: temp, TMP: temp, TEMP: temp, ...(fenced.cache === undefined || fenced.grants.writes === undefined ? {} : buildCacheEnv(fenced.cache)), ...(signIns?.env ?? {}) },
1015
+ ...(signIns === null ? {} : { omitEnv: [...(options?.omitEnv ?? []), ...SIGN_INS_OMITTED_ENV] }),
1016
+ });
1017
+ }
1018
+ finally {
1019
+ await signIns?.close();
1020
+ }
1002
1021
  }
1003
1022
  finally {
1004
1023
  rmSync(own, { recursive: true, force: true });
@@ -1011,7 +1030,7 @@ export function fencedCommands(store, runId, writes, inner, available = () => ma
1011
1030
  * project's writes setting, only that checkout, its Git data, its own temp folder, the project's caches and its check
1012
1031
  * database writable. Where nothing can confine them, they run only when an instance operator allowed unconfined agents.
1013
1032
  */
1014
- export function fencedCheckoutCommands(store, repo, checkout, inner, available = () => macosFenceAvailable() || linuxFenceAvailable()) {
1033
+ export function fencedCheckoutCommands(store, repo, checkout, inner, available = () => macosFenceAvailable() || linuxFenceAvailable(), access = CHECK_ACCESS_DEFAULT, network) {
1015
1034
  if (inner !== runCommand)
1016
1035
  return inner;
1017
1036
  return async (file, args, options) => {
@@ -1023,10 +1042,13 @@ export function fencedCheckoutCommands(store, repo, checkout, inner, available =
1023
1042
  const databaseFile = store.databaseFile();
1024
1043
  if (databaseFile === null)
1025
1044
  return inner(file, args, options);
1026
- const own = realpathSync(mkdtempSync(join(tmpdir(), "so-cmd-")));
1045
+ const own = realpathSync(mkdtempSync(join(checkLaunchRoot(), "so-cmd-")));
1027
1046
  try {
1028
1047
  const temp = join(own, "tmp");
1029
1048
  mkdirSync(temp, { mode: 0o700 });
1049
+ // Not sandboxed (check-access.ts): the project's checks are trusted by the whole instance, as before 0.9.66.
1050
+ if (access === "unsandboxed")
1051
+ return await inner(file, args, { ...options, env: { ...(options?.env ?? {}), TMPDIR: temp, TMP: temp, TEMP: temp } });
1030
1052
  let binding;
1031
1053
  try {
1032
1054
  binding = trustedWorktreeBinding(repo, checkout);
@@ -1044,12 +1066,24 @@ export function fencedCheckoutCommands(store, repo, checkout, inner, available =
1044
1066
  const base = fenceGrants({ databaseFile, worktree: checkout, boundary, runFolders: folders, linked: linkedFor(store, checkout), binding, poolRoots });
1045
1067
  const grants = allowed === null ? base
1046
1068
  : { ...base, readOnly: [...new Set([...base.readOnly, ...allowed.readOnly])].sort(), writes: { ...allowed.writes, subpaths: [...allowed.writes.subpaths, ...(checkData === null ? [] : [checkData])] } };
1047
- return await inner(file, args, {
1048
- ...options,
1049
- fence: agentFence({ databaseFile, worktree: checkout, boundary, binding, runFolders: folders, extra: allowed?.deny ?? [] }),
1050
- fenceGrants: grants,
1051
- env: { ...(options?.env ?? {}), TMPDIR: temp, TMP: temp, TEMP: temp, ...(allowed === null ? {} : buildCacheEnv(cache)) },
1052
- });
1069
+ // With sign-ins, under the network its caller proved for it (the narrowest of its results' sealed check networks and
1070
+ // the project's current one): none given, nothing runs.
1071
+ const signIns = access !== "sign-ins" ? null : network === undefined ? { problem: "A check with sign-ins has no network sealed for it, so nothing ran." }
1072
+ : await signInsLaunch(store, own, temp, checkout, network, [binding.gitdir, join(binding.main, ".git")]);
1073
+ if (signIns !== null && "problem" in signIns)
1074
+ return { code: 1, stdout: "", stderr: `${signIns.problem}\n`, timedOut: false, notFound: false };
1075
+ try {
1076
+ return await inner(file, args, {
1077
+ ...options,
1078
+ fence: agentFence({ databaseFile, worktree: checkout, boundary, binding, runFolders: folders, extra: allowed?.deny ?? [] }),
1079
+ fenceGrants: signIns === null ? grants : { ...grants, signIns: signIns.grants, network: signIns.network },
1080
+ env: { ...(options?.env ?? {}), TMPDIR: temp, TMP: temp, TEMP: temp, ...(allowed === null ? {} : buildCacheEnv(cache)), ...(signIns?.env ?? {}) },
1081
+ ...(signIns === null ? {} : { omitEnv: [...(options?.omitEnv ?? []), ...SIGN_INS_OMITTED_ENV] }),
1082
+ });
1083
+ }
1084
+ finally {
1085
+ await signIns?.close();
1086
+ }
1053
1087
  }
1054
1088
  finally {
1055
1089
  rmSync(own, { recursive: true, force: true });
@@ -1118,3 +1152,124 @@ async function codexLoginBilling(binary, omitEnv) {
1118
1152
  function plainLines(stdout) {
1119
1153
  return stdout.split("\n").filter(line => !line.trimStart().startsWith("{")).join("\n");
1120
1154
  }
1155
+ /** What a `sign-ins` check never inherits: a caller's choice of Claude's config folder (Claude's keychain sign-in is
1156
+ * keyed to its default one; check-access.ts). gh's and Codex's are set to the granted ones instead. */
1157
+ const SIGN_INS_OMITTED_ENV = ["CLAUDE_CONFIG_DIR"];
1158
+ /**
1159
+ * A `sign-ins` check's launch (check-access.ts): its exact sign-in files and its own transcript folder
1160
+ * (agent-fence.ts signInGrants), Codex's config and sign-in copied into a home of the launch's own (private, gone with
1161
+ * the launch; ~/.codex itself stays fenced), gh pointed at its own folder, and the network the check runs under:
1162
+ * open, or its allowlist through a proxy of its own (on Linux, carried in by a bridge). Nothing about it is started
1163
+ * unless all of it is.
1164
+ */
1165
+ export async function signInsLaunch(store, own, temp, checkout, policy, gitPaths, platform = process.platform) {
1166
+ // The belt (check-access.ts signInsAvailable, off for now): no sign-in file is read, copied or granted, whatever level
1167
+ // reached this far. Its callers already run such a check sandboxed; anything else that asks runs nothing.
1168
+ if (!signInsAvailable(platform))
1169
+ return { problem: "Sign-ins can't be shared with checks yet, so nothing ran." };
1170
+ const home = homedir();
1171
+ const grants = signInGrants(home, checkout);
1172
+ const cleanup = [];
1173
+ // Codex's files are copied, each read only as the real file it is (readGrantedFile: no link, here or above it).
1174
+ const codex = join(own, "codex");
1175
+ mkdirSync(codex, { mode: 0o700 });
1176
+ for (const name of ["config.toml", "auth.json"]) {
1177
+ const bytes = readGrantedFile(join(realpathSync(home), ".codex", name));
1178
+ if (bytes !== null)
1179
+ writeFileSync(join(codex, name), bytes, { mode: 0o600 });
1180
+ }
1181
+ // On Linux every granted file is bound from a copy of its own, made the same way (bubblewrap binds a source path, and
1182
+ // a link swapped in after validation must never be what it binds). A Mac's sandbox checks the real path itself.
1183
+ // Not reached for now: the belt above refuses until reading these files is race-free.
1184
+ if (process.platform === "linux") {
1185
+ const copies = {};
1186
+ // Under Toolroll's own state folder, which every fence denies whole (a launch's folder in a shared /tmp isn't masked
1187
+ // from a launch already running): bubblewrap binds from the original root, so only this check sees them.
1188
+ const database = store.databaseFile();
1189
+ if (database === null || database === "" || database === ":memory:")
1190
+ return { problem: "A check with sign-ins needs Toolroll's state folder, so nothing ran." };
1191
+ const state = dirname(realpathSync(database));
1192
+ try {
1193
+ mkdirSync(join(state, SIGN_IN_COPIES_DIR), { mode: 0o700 });
1194
+ }
1195
+ catch { /* there already: checked next */ }
1196
+ // Only the real, private folder Toolroll made (this user's, 0700, no link): never a link planted where it should be.
1197
+ const parent = signInCopiesRoot(state);
1198
+ if (parent === null)
1199
+ return { problem: "The sign-in copies folder isn't a private folder of Toolroll's, so nothing ran." };
1200
+ const folder = mkdtempSync(join(parent, `c-${process.pid}-`));
1201
+ cleanup.push(folder);
1202
+ grants.read.forEach((path, index) => {
1203
+ const bytes = readGrantedFile(path);
1204
+ if (bytes === null)
1205
+ return;
1206
+ const copy = join(folder, String(index));
1207
+ writeFileSync(copy, bytes, { mode: 0o600 });
1208
+ copies[path] = copy;
1209
+ });
1210
+ grants.read = grants.read.filter(path => copies[path] !== undefined);
1211
+ grants.copies = copies;
1212
+ }
1213
+ let egress = null;
1214
+ let bridge = null;
1215
+ const close = async () => { await egress?.close(); await bridge?.close(); for (const one of cleanup)
1216
+ rmSync(one, { recursive: true, force: true }); };
1217
+ try {
1218
+ const env = { CODEX_HOME: codex, GH_CONFIG_DIR: join(home, ".config", "gh"), XDG_CONFIG_HOME: join(home, ".config") };
1219
+ if (policy.mode === "open")
1220
+ return { grants, network: { mode: "open" }, env, close };
1221
+ // Exactly the project's network: the services the sign-ins are for are reached only if the operator allowed their
1222
+ // hosts (check-access.ts signInHostsMissing says which are missing; never added here).
1223
+ egress = await startEgressProxy({ hosts: policy.hosts });
1224
+ const network = { mode: "allowlist", proxyPort: egress.port, brokerPorts: [], loopback: policy.loopback, socketRoots: [temp, realpathSync(checkout)] };
1225
+ if (linuxFenceAvailable()) {
1226
+ const database = store.databaseFile();
1227
+ const fenced = database === null || database === "" || database === ":memory:" ? undefined : join(dirname(realpathSync(database)), BRIDGE_DIR);
1228
+ bridge = await startEgressBridge([egress.port], fenced !== undefined && bridgeFits(fenced) ? fenced : undefined);
1229
+ network.bridge = { folder: realpathSync(bridge.folder), forwarder: realpathSync(bridge.forwarder), node: process.execPath };
1230
+ // Its private /tmp gives back its checkout, its launch folder and its checkout's own Git data (as a build's does).
1231
+ network.keep = [realpathSync(checkout), own, ...gitPaths.filter(path => existsSync(path))];
1232
+ }
1233
+ return { grants, network, env: { ...env, ...proxyEnv(egress, policy.loopback) }, close };
1234
+ }
1235
+ catch (error) {
1236
+ await close();
1237
+ return { problem: `The check's network could not be set up, so nothing ran: ${error instanceof Error ? error.message : String(error)}` };
1238
+ }
1239
+ }
1240
+ /** How a check at `access` actually runs here: unsandboxed where no OS fence exists (and an operator allowed that), else
1241
+ * as asked. Recorded on every check's receipt, so a check that ran without a sandbox is never shown as sandboxed. */
1242
+ export function actualCheckAccess(store, access) {
1243
+ return macosFenceAvailable() || linuxFenceAvailable() || !store.unfencedAgentsAllowed() ? access : "unsandboxed";
1244
+ }
1245
+ /**
1246
+ * A check of saved results, in their own checkout (batch-checks.ts, result-follow-ups.ts), worked out right before each
1247
+ * command: never wider than any of those results' own checks ran, nor than the project allows now; under sign-ins, the
1248
+ * narrowest of their sealed check networks and the project's current one. A result with no sealed network runs it
1249
+ * sandboxed.
1250
+ */
1251
+ export function checkoutLaunchOf(store, repo, runIds, platform = process.platform) {
1252
+ // As checks can get it: sign-ins (off for now; a stamp or setting from before, or another database) run sandboxed.
1253
+ const access = checkAccessHere(narrowestAccess([store.projectCheckAccess(repo), ...runIds.map(id => store.runCheckAccess(id))]), platform);
1254
+ if (access !== "sign-ins")
1255
+ return { access, network: undefined };
1256
+ const sealed = runIds.map(id => store.runCheckNetwork(id));
1257
+ if (sealed.length === 0 || sealed.some(one => one === null))
1258
+ return { access: CHECK_ACCESS_DEFAULT, network: undefined };
1259
+ const live = resolveNetwork("command", store.projectNetwork(repo), repositoryEcosystems(repo));
1260
+ return { access, network: sealed.reduce((low, one) => narrowestNetwork(low, one), live) };
1261
+ }
1262
+ /** The widest level a check's commands actually ran at (what its receipt says). */
1263
+ export const widestRan = (levels) => levels.includes("unsandboxed") ? "unsandboxed" : levels.includes("sign-ins") ? "sign-ins" : CHECK_ACCESS_DEFAULT;
1264
+ /** A check log's line for how its commands ran, when wider than sandboxed (shown with the log). */
1265
+ export const ranAtLine = (level) => level === "sandboxed" ? null
1266
+ : `=== Check access ===\n${level === "unsandboxed" ? "These commands ran without a sandbox." : "These commands ran sandboxed, with model and GitHub sign-ins."}`;
1267
+ /** A saved result's check command runner (batch-checks.ts, result-follow-ups.ts): its level and network worked out right
1268
+ * now (checkoutLaunchOf), and how it actually runs told to `ran`. */
1269
+ export function checkoutRunner(store, repo, checkout, inner, runIds, ran, platform = process.platform) {
1270
+ return (file, args, options) => {
1271
+ const launch = checkoutLaunchOf(store, repo, runIds, platform);
1272
+ ran(actualCheckAccess(store, launch.access));
1273
+ return fencedCheckoutCommands(store, repo, checkout, inner, undefined, launch.access, launch.network)(file, args, options);
1274
+ };
1275
+ }
package/dist/operate.d.ts CHANGED
@@ -67,6 +67,10 @@ export type OperateOptions = {
67
67
  flowStepIo?: Partial<StepIo>;
68
68
  /** Injected by tests: how often a worker's flow passes run beside its builds (default FLOW_EVERY_MS). */
69
69
  flowEveryMs?: number;
70
+ /** Injected by tests: the operating system this runs on (check access: no sign-ins on Linux yet). */
71
+ platform?: NodeJS.Platform;
72
+ /** Injected by tests: whether this machine has an OS fence for checks (macOS sandbox or bubblewrap). */
73
+ fenceAvailable?: () => boolean;
70
74
  /** Injected by tests: how `flows import` fetches a flow file's address. */
71
75
  flowFetch?: FetchLike;
72
76
  /** Injected by tests: how `integrations` checks reach services (fetch, gh, sign-in checks, mail servers). */