toolroll 0.9.68 → 0.9.70
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/CHANGELOG.md +81 -0
- package/README.md +43 -17
- package/THIRD_PARTY_NOTICES.md +21 -1
- package/dist/agent-fence.d.ts +204 -81
- package/dist/agent-fence.js +467 -397
- package/dist/agentconfig.d.ts +1 -1
- package/dist/agentconfig.js +11 -2
- package/dist/batch-checks.js +14 -26
- package/dist/browser/THIRD_PARTY_NOTICES.txt +21 -0
- package/dist/builder.d.ts +9 -0
- package/dist/builder.js +50 -4
- package/dist/check-access.d.ts +51 -0
- package/dist/check-access.js +77 -0
- package/dist/coding-provider.d.ts +14 -0
- package/dist/coding-provider.js +33 -1
- package/dist/coding-workspace.d.ts +15 -0
- package/dist/coding-workspace.js +9 -2
- package/dist/contracts/console-api.d.ts +2 -0
- package/dist/contracts/console-api.js +2 -0
- package/dist/doctor.d.ts +7 -0
- package/dist/doctor.js +4 -0
- package/dist/egress.d.ts +2 -0
- package/dist/egress.js +13 -4
- package/dist/exec.d.ts +17 -3
- package/dist/exec.js +54 -17
- package/dist/flow-actions.d.ts +1 -0
- package/dist/flow-actions.js +3 -2
- package/dist/flow-code.d.ts +31 -2
- package/dist/flow-code.js +57 -5
- package/dist/flow-steps.d.ts +4 -0
- package/dist/flow-steps.js +9 -5
- package/dist/flow-triggers.d.ts +4 -0
- package/dist/flow-triggers.js +8 -3
- package/dist/invoke.d.ts +50 -3
- package/dist/invoke.js +204 -66
- package/dist/operate.d.ts +4 -0
- package/dist/operate.js +119 -0
- package/dist/project-delete-ui.d.ts +23 -6
- package/dist/project-delete-ui.js +94 -1
- package/dist/project-sandbox.d.ts +59 -0
- package/dist/project-sandbox.js +83 -0
- package/dist/project-tools.d.ts +29 -5
- package/dist/project-tools.js +43 -7
- package/dist/release.d.ts +1 -1
- package/dist/release.js +1 -0
- package/dist/result-follow-ups.js +10 -15
- package/dist/sandbox-launch.d.ts +1 -0
- package/dist/sandbox-launch.js +148 -0
- package/dist/sandbox-runtime.d.ts +62 -0
- package/dist/sandbox-runtime.js +140 -0
- package/dist/scope.d.ts +37 -0
- package/dist/scope.js +32 -5
- package/dist/scout-browser-lock.d.ts +69 -0
- package/dist/scout-browser-lock.js +69 -0
- package/dist/scout-browser.d.ts +60 -1
- package/dist/scout-browser.js +200 -4
- package/dist/scout.d.ts +9 -4
- package/dist/scout.js +14 -4
- package/dist/serve.js +12 -1
- package/dist/server/http.d.ts +2 -0
- package/dist/server/render-results.d.ts +3 -0
- package/dist/server/render-results.js +1 -0
- package/dist/server/render-tasks.js +5 -1
- package/dist/server/route-table.js +2 -0
- package/dist/server/settings.js +64 -0
- package/dist/server/tasks.js +2 -0
- package/dist/store.d.ts +59 -2
- package/dist/store.js +166 -4
- package/dist/subagent-tools.d.ts +1 -0
- package/dist/subagent-tools.js +7 -5
- package/dist/surface.js +2 -1
- package/dist/tool-broker.d.ts +2 -1
- package/dist/tool-broker.js +2 -2
- package/dist/tool-launcher.js +10 -16
- package/package.json +2 -1
- package/scripts/provider-canary.mjs +5 -6
- package/dist/egress-bridge.d.ts +0 -17
- package/dist/egress-bridge.js +0 -111
package/dist/invoke.js
CHANGED
|
@@ -29,14 +29,18 @@ 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 {
|
|
32
|
+
import { createHash } from "node:crypto";
|
|
33
|
+
import { existsSync, lstatSync, mkdirSync, mkdtempSync, readFileSync, realpathSync, rmSync, writeFileSync } from "node:fs";
|
|
33
34
|
import { homedir, tmpdir } from "node:os";
|
|
34
35
|
import { dirname, join } from "node:path";
|
|
35
36
|
import { poolRootOf } from "./worktree.js";
|
|
36
|
-
import {
|
|
37
|
-
import {
|
|
38
|
-
import {
|
|
39
|
-
import {
|
|
37
|
+
import { checkLaunchRoot, signInGrants, signInIsItself } from "./agent-fence.js";
|
|
38
|
+
import { CHECK_ACCESS_DEFAULT, checkAccessHere, narrowestAccess, narrowestNetwork, signInsAvailable } from "./check-access.js";
|
|
39
|
+
import { readDenyPath } from "./project-sandbox.js";
|
|
40
|
+
import { sandboxRuntimeWords } from "./sandbox-runtime.js";
|
|
41
|
+
import { canonicalJson } from "./canonical-json.js";
|
|
42
|
+
import { buildCacheEnv, buildCacheFolder, grantFolder, writeAllowlist, WRITES_DEFAULT, CheckoutBindingError, trustedWorktreeBinding, checkoutGitEnv, HARDENED_GIT_ARGS, codexFenceArgv, fenceFor, fencedGitEnv, LEGACY_SANDBOX, launchSandboxOf, linuxFenceAvailable, macosFenceAvailable, NO_GRANTS, sandboxedSpawn, serverGrantsOf, runFolders, taskBoundary } from "./agent-fence.js";
|
|
43
|
+
import { proxyEnv, providerOnlyNetwork, repositoryEcosystems, resolveNetwork, startEgressProxy } from "./egress.js";
|
|
40
44
|
import { claudeConfigDir, claudeDefaults, codexDefaults, codexHomeDir, isolatedCodexHome } from "./provider-home.js";
|
|
41
45
|
import { billingOf, claudeBillingFrom, seenBilling } from "./spend.js";
|
|
42
46
|
/** "Within seconds": how soon a silent failed run must end for its stderr to
|
|
@@ -241,7 +245,7 @@ export async function invokeAgent(store, runId, spec, invocation, options) {
|
|
|
241
245
|
// re-supply survives resolveChildEnv's chat-key strip); in
|
|
242
246
|
// "subscription" mode the key is STRIPPED from the child so the CLI's
|
|
243
247
|
// own login is used — its key is retained, just not handed over.
|
|
244
|
-
const { runner, clock: _clock, versionProbe: _probe, keyHome, timeoutMs: _hardTimeout, idleTimeoutMs: _idleTimeout, stdin: _callerStdin, accumulateUsage: _accumulateUsage, liveInput: _liveInput, acpCheck: _acpCheck, network: launchNetwork, writes: launchWrites, ...runOptions } = options;
|
|
248
|
+
const { runner, clock: _clock, versionProbe: _probe, keyHome, timeoutMs: _hardTimeout, idleTimeoutMs: _idleTimeout, stdin: _callerStdin, accumulateUsage: _accumulateUsage, liveInput: _liveInput, acpCheck: _acpCheck, network: launchNetwork, writes: launchWrites, sandbox: launchSandbox, ...runOptions } = options;
|
|
245
249
|
// The live setting is read STRICTLY (atomic authority closure): a
|
|
246
250
|
// present mode file that says neither word is a value-shaped refusal
|
|
247
251
|
// before any stamp or process — never the subscription default a
|
|
@@ -304,6 +308,9 @@ export async function invokeAgent(store, runId, spec, invocation, options) {
|
|
|
304
308
|
// (tool-broker.ts), which alone holds their secrets. A review keeps its isolation.
|
|
305
309
|
// Set once the fence is known: a fenced build's servers never start outside a fence.
|
|
306
310
|
let serverMustBeFenced = false;
|
|
311
|
+
// Pinned for the attempt (sandbox parity C11): whether an instance operator allowed unconfined agents, read once now.
|
|
312
|
+
// A tool server the agent starts late gets the decision this attempt launched with, never one changed meanwhile.
|
|
313
|
+
const unconfinedAtLaunch = store.unfencedAgentsAllowed();
|
|
307
314
|
// What the build writes (its checkout, its project's caches): a tool server never runs from there, nor reads it at all.
|
|
308
315
|
const serverUntrusted = workspace === null ? [] : toolUntrusted(store, workspace, keyHome);
|
|
309
316
|
const launchTools = invocation.phase === "review" ? null : await runTools(store, runId, spec, options.keyHome, clock, invocation.readOnlyTools, invocation.researchWithheld, containment, {
|
|
@@ -315,7 +322,7 @@ export async function invokeAgent(store, runId, spec, invocation, options) {
|
|
|
315
322
|
untrusted: serverUntrusted,
|
|
316
323
|
wrap: () => serverFence,
|
|
317
324
|
// A fenced build's tool servers start only in a fence too, unless an instance operator allowed unconfined agents.
|
|
318
|
-
unwrapped: () => serverMustBeFenced && !
|
|
325
|
+
unwrapped: () => serverMustBeFenced && !unconfinedAtLaunch
|
|
319
326
|
? "This machine can't confine the tool's server (no macOS sandbox, no working bubblewrap), so it was not started." : null,
|
|
320
327
|
});
|
|
321
328
|
if (launchTools !== null && "refused" in launchTools) {
|
|
@@ -344,7 +351,7 @@ export async function invokeAgent(store, runId, spec, invocation, options) {
|
|
|
344
351
|
// tool servers outside its command sandbox, so its own launch folder is
|
|
345
352
|
// fenced too; Claude reads its config from there, inside the sandbox.
|
|
346
353
|
const ownTools = tools?.privateDir === undefined ? null : realpathSync(tools.privateDir);
|
|
347
|
-
const fenceOf = invocation.phase === "review" ? { fence: [], fileTools: [], grants: NO_GRANTS, gitPaths: [], cache: undefined, problem: undefined } : runFence(store, runId, spec.provider, options.keyHome, codexHarness ? null : ownTools, launchWrites ?? WRITES_DEFAULT);
|
|
354
|
+
const fenceOf = invocation.phase === "review" ? { fence: [], fileTools: [], grants: NO_GRANTS, gitPaths: [], cache: undefined, problem: undefined } : runFence(store, runId, spec.provider, options.keyHome, codexHarness ? null : ownTools, launchWrites ?? WRITES_DEFAULT, launchSandbox ?? LEGACY_SANDBOX);
|
|
348
355
|
if (fenceOf.problem !== undefined) {
|
|
349
356
|
tools?.cleanup();
|
|
350
357
|
codexHome?.cleanup();
|
|
@@ -385,13 +392,13 @@ export async function invokeAgent(store, runId, spec, invocation, options) {
|
|
|
385
392
|
if (fence.length > 0) {
|
|
386
393
|
// A tool server keeps its own reach (it holds its tool's secrets, not the agent's code): never the agent's network,
|
|
387
394
|
// nor its write allowlist (a server keeps its own state where it always has).
|
|
388
|
-
const serverGrants = () =>
|
|
395
|
+
const serverGrants = () => serverGrantsOf(grants);
|
|
389
396
|
// Nor read what the build wrote: however its arguments are spelled, no byte of the checkout or caches can load.
|
|
390
397
|
const serverDenies = [...fence, ...serverUntrusted.filter(path => existsSync(path)).map(path => realpathSync(path))];
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
serverFence = (file, args) =>
|
|
398
|
+
// Pinned: the server fence this attempt launched with, a copy, whatever changes later (C11).
|
|
399
|
+
const pinned = structuredClone(serverGrants());
|
|
400
|
+
if (macosFenceAvailable() || linuxFenceAvailable())
|
|
401
|
+
serverFence = (file, args, cwd) => sandboxedSpawn(file, args, serverDenies, pinned, cwd);
|
|
395
402
|
serverMustBeFenced = true;
|
|
396
403
|
}
|
|
397
404
|
// No silent fallback (arc 1): a build this machine can't confine at the OS — Claude reduced to its own file-tool
|
|
@@ -403,10 +410,9 @@ export async function invokeAgent(store, runId, spec, invocation, options) {
|
|
|
403
410
|
codexHome?.cleanup();
|
|
404
411
|
removeAgentDatabase(isolatedDb.dir);
|
|
405
412
|
return { kind: "refused", reason: "containment", providerVersion: attested?.version ?? null,
|
|
406
|
-
diagnostic: "This machine can't confine an agent (no macOS sandbox, no working bubblewrap), so nothing was started. Install bubblewrap, or an instance operator allows unconfined agents with `toolroll config unconfined-agents allow`." };
|
|
413
|
+
diagnostic: "This machine can't confine an agent (no macOS sandbox, no working bubblewrap), so nothing was started. Install bubblewrap, socat and ripgrep (Linux), or an instance operator allows unconfined agents with `toolroll config unconfined-agents allow`." };
|
|
407
414
|
}
|
|
408
415
|
let egress = null;
|
|
409
|
-
let bridge = null;
|
|
410
416
|
let runTemp = null;
|
|
411
417
|
const confinedOs = fenced.method === "macos-sandbox" || fenced.method === "linux-bubblewrap";
|
|
412
418
|
if (fence.length > 0 && !codexHarness && confinedOs) {
|
|
@@ -428,22 +434,19 @@ export async function invokeAgent(store, runId, spec, invocation, options) {
|
|
|
428
434
|
}
|
|
429
435
|
else {
|
|
430
436
|
egress = await startEgressProxy({ hosts: network.policy.hosts });
|
|
431
|
-
grants.network = { mode: "allowlist", proxyPort: egress.port, brokerPorts, loopback: network.policy.loopback, socketRoots };
|
|
437
|
+
grants.network = { mode: "allowlist", proxyPort: egress.port, brokerPorts, loopback: network.policy.loopback, socketRoots, token: egress.secret };
|
|
432
438
|
}
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
const
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
// Its private /tmp gives back only its own folders: its checkout and its launch folder (its temp is inside).
|
|
441
|
-
grants.network.keep = [...(workspace === null ? [] : [realpathSync(workspace)]), ...(ownTools === null ? [] : [ownTools]), ...(fenceOf.gitPaths ?? [])];
|
|
439
|
+
// On Linux the build has a network of its own, and the sandbox runtime relays only two ports into it: the
|
|
440
|
+
// proxy's (3128) and a second (1080), which carries this run's tool broker. Claude's tool list names it there.
|
|
441
|
+
// (An injected runner, in tests, runs outside every sandbox and keeps the broker's own address.)
|
|
442
|
+
if (fenced.method === "linux-bubblewrap" && grants.network.mode === "allowlist" && runner === undefined && tools?.brokerPort !== undefined && tools.privateDir !== undefined) {
|
|
443
|
+
const list = join(tools.privateDir, "mcp.json");
|
|
444
|
+
if (existsSync(list))
|
|
445
|
+
writeFileSync(list, readFileSync(list, "utf8").replaceAll(`http://127.0.0.1:${tools.brokerPort}/`, "http://127.0.0.1:1080/"), { mode: 0o600 });
|
|
442
446
|
}
|
|
443
447
|
}
|
|
444
448
|
catch (error) {
|
|
445
449
|
await egress?.close();
|
|
446
|
-
await bridge?.close();
|
|
447
450
|
tools?.cleanup();
|
|
448
451
|
codexHome?.cleanup();
|
|
449
452
|
removeAgentDatabase(isolatedDb.dir);
|
|
@@ -451,8 +454,11 @@ export async function invokeAgent(store, runId, spec, invocation, options) {
|
|
|
451
454
|
diagnostic: `The build's network could not be set up, so nothing was started: ${error instanceof Error ? error.message : String(error)}` };
|
|
452
455
|
}
|
|
453
456
|
}
|
|
457
|
+
// The attempt's pinned fence, stamped on the run (C11): its method, policy version, the digest of exactly what it was
|
|
458
|
+
// given, and the sandbox engine and version it runs in.
|
|
454
459
|
if (invocation.phase !== "review")
|
|
455
|
-
store.recordRunFence(runId, { method: fenced.method, paths: fence.length
|
|
460
|
+
store.recordRunFence(runId, { method: fenced.method, paths: fence.length, policy: (launchSandbox ?? LEGACY_SANDBOX).policy,
|
|
461
|
+
digest: fenceDigest(fence, grants), engine: sandboxRuntimeWords() }, clock());
|
|
456
462
|
const billingSeen = { keySource: null, model: null, planWindows: false, answered: false };
|
|
457
463
|
// v105: a Codex run on its sign-in bills as Codex says it's signed in (a ChatGPT plan, or a saved key, or Bedrock),
|
|
458
464
|
// read right before it runs. Never with an injected runner: tests don't start a real Codex.
|
|
@@ -495,7 +501,7 @@ export async function invokeAgent(store, runId, spec, invocation, options) {
|
|
|
495
501
|
: ownKeyEnv.filter(name => name !== keyEnvName)),
|
|
496
502
|
];
|
|
497
503
|
const launchOptions = {
|
|
498
|
-
...(fenced.wrap.length === 0 ? {} : { fence: fenced.wrap, fenceGrants: grants }),
|
|
504
|
+
...(fenced.wrap.length === 0 ? {} : { fence: fenced.wrap, fenceGrants: grants, confinement: "required" }),
|
|
499
505
|
...runOptions,
|
|
500
506
|
...(workspace === null ? {} : { cwd: workspace }),
|
|
501
507
|
// v105: how the CLI really billed this turn (its key source, its plan's windows), read off its own stream.
|
|
@@ -565,7 +571,7 @@ export async function invokeAgent(store, runId, spec, invocation, options) {
|
|
|
565
571
|
const insideFence = (file, args) => codexHarness
|
|
566
572
|
? runCommand(executable, ["sandbox", ...codexFenceArgv(fence, invocation.skipPermissions, grants),
|
|
567
573
|
"-P", "standing-orders", "--", file, ...args], probeOptions)
|
|
568
|
-
: runCommand(file, args, { ...probeOptions, fence: launchOptions.fence,
|
|
574
|
+
: runCommand(file, args, { ...probeOptions, fence: launchOptions.fence, confinement: "required",
|
|
569
575
|
...(launchOptions.fenceGrants === undefined ? {} : { fenceGrants: launchOptions.fenceGrants }) });
|
|
570
576
|
const check = await insideFence("git", gitArgs);
|
|
571
577
|
if (check.code !== 0) {
|
|
@@ -581,7 +587,7 @@ export async function invokeAgent(store, runId, spec, invocation, options) {
|
|
|
581
587
|
// key in the launching environment must be absent. No agent takes part, so no agent can fake it.
|
|
582
588
|
if (acpCheckAsk?.isolation !== undefined && launchOptions.fence !== undefined && workspace !== null) {
|
|
583
589
|
// processGroup: run() applies the fence only on the process-group road (a plain execFile carries none).
|
|
584
|
-
const probe = { cwd: workspace, env: childEnv, omitEnv: childOmitEnv, fence: launchOptions.fence, ...(launchOptions.fenceGrants === undefined ? {} : { fenceGrants: launchOptions.fenceGrants }), timeoutMs: 15_000, processGroup: true };
|
|
590
|
+
const probe = { cwd: workspace, env: childEnv, omitEnv: childOmitEnv, fence: launchOptions.fence, confinement: "required", ...(launchOptions.fenceGrants === undefined ? {} : { fenceGrants: launchOptions.fenceGrants }), timeoutMs: 15_000, processGroup: true };
|
|
585
591
|
const read = await runCommand("/bin/cat", [acpCheckAsk.isolation.file], probe);
|
|
586
592
|
const env = await runCommand("/usr/bin/env", [], probe);
|
|
587
593
|
acpIsolation = {
|
|
@@ -704,7 +710,6 @@ export async function invokeAgent(store, runId, spec, invocation, options) {
|
|
|
704
710
|
}, clock());
|
|
705
711
|
}
|
|
706
712
|
await egress?.close();
|
|
707
|
-
await bridge?.close();
|
|
708
713
|
}
|
|
709
714
|
// v105: the run's billing, fixed from evidence before its usage is priced: a key it was given is a key; otherwise
|
|
710
715
|
// what the CLI said (Claude's stream) or was last seen doing (Codex's account), and the setting only when neither.
|
|
@@ -925,7 +930,7 @@ function linkedFor(store, worktree) {
|
|
|
925
930
|
/** The paths this run's agent may not reach: the state folder around the live database (but not its own worktree),
|
|
926
931
|
* ~/.toolroll (or its older name), its task boundary and other launches' private folders; what the fence gives back
|
|
927
932
|
* (FenceGrants); and what only Claude's file tools refuse. */
|
|
928
|
-
function runFence(store, runId, provider, keyHome, ownTools, writes = WRITES_DEFAULT) {
|
|
933
|
+
function runFence(store, runId, provider, keyHome, ownTools, writes = WRITES_DEFAULT, sandbox = LEGACY_SANDBOX) {
|
|
929
934
|
try {
|
|
930
935
|
// A plane always has a database file; an in-memory store (a test) has nothing to fence.
|
|
931
936
|
const databaseFile = store.databaseFile();
|
|
@@ -938,22 +943,24 @@ function runFence(store, runId, provider, keyHome, ownTools, writes = WRITES_DEF
|
|
|
938
943
|
throw new CheckoutBindingError("The checkout's Git pointer was changed or its trusted binding could not be verified.");
|
|
939
944
|
const poolRoots = [join(dirname(databaseFile), "worktrees"), ...store.listWorktrees().map(record => poolRootOf(record.path, record.repo))];
|
|
940
945
|
const binding = worktree === null || row === null ? null : trustedWorktreeBinding(row.repo, worktree, poolRoots);
|
|
941
|
-
const boundary = worktree === null ? null : taskBoundary({ worktree, provider, ...home, binding });
|
|
946
|
+
const boundary = worktree === null ? null : taskBoundary({ worktree, provider, ...home, binding, policy: sandbox.policy });
|
|
942
947
|
const folders = runFolders(ownTools);
|
|
943
948
|
// A build with a checkout writes only its workspace unless its approval said anywhere (arc 2): its checkout and
|
|
944
949
|
// Git data, its launch folder, its project's caches and the provider state its CLI must write.
|
|
945
950
|
const allowed = worktree === null || row === null || writes !== "workspace" ? null
|
|
946
951
|
: writeAllowlist({ provider, home: keyHome ?? homedir(), worktree, binding, launchFolder: ownTools, cache: buildCacheFolder(row.repo, keyHome ?? homedir()) });
|
|
947
|
-
|
|
948
|
-
|
|
952
|
+
// The shared install is granted only as the plane linked it: this checkout's project and stamped setup.
|
|
953
|
+
// Every plane checkout pool is denied around this one: the plane's own beside the database, and the one this
|
|
954
|
+
// checkout was recorded in (a --pool elsewhere), so another project's worktree, present at launch, is out of reach. Native wrappers also cover later creations.
|
|
955
|
+
const launch = fenceFor(provider === "command" ? "command" : "build", { databaseFile, worktree, ...home, boundary, binding, runFolders: folders, poolRoots,
|
|
956
|
+
extra: allowed?.deny ?? [], linked: linkedFor(store, worktree), writes: allowed === null ? null : { writes: allowed.writes, readOnly: allowed.readOnly }, policy: sandbox.policy, provider,
|
|
957
|
+
// The sealed read denies, and whatever the project lists now too (adding a deny only narrows).
|
|
958
|
+
readDeny: [...new Set([...sandbox.readDeny, ...(row === null ? [] : store.projectSandbox(row.repo).readDeny)])].map(one => readDenyPath(one, keyHome ?? homedir())), machLookup: sandbox.machLookup });
|
|
949
959
|
return {
|
|
950
|
-
fence,
|
|
960
|
+
fence: launch.fence,
|
|
951
961
|
fileTools: boundary?.fileTools ?? [],
|
|
952
|
-
|
|
953
|
-
//
|
|
954
|
-
// checkout was recorded in (a --pool elsewhere), so another project's worktree, present at launch, is out of reach. Native wrappers also cover later creations.
|
|
955
|
-
grants: allowed === null ? grants : { ...grants, writes: allowed.writes, readOnly: [...new Set([...grants.readOnly, ...allowed.readOnly])].sort() },
|
|
956
|
-
// The checkout's own Git data, which a private /tmp gives back beside the checkout (agent-fence.ts linuxFenced).
|
|
962
|
+
grants: launch.fenceGrants,
|
|
963
|
+
// The checkout's own Git data (a sign-ins check's network keeps it reachable).
|
|
957
964
|
gitPaths: binding === null ? [] : [binding.gitdir, join(binding.main, ".git")],
|
|
958
965
|
...(allowed === null || row === null ? {} : { cache: buildCacheFolder(row.repo, keyHome ?? homedir()) }),
|
|
959
966
|
};
|
|
@@ -969,22 +976,28 @@ function runFence(store, runId, provider, keyHome, ownTools, writes = WRITES_DEF
|
|
|
969
976
|
* Their network stays as it was. A machine without an OS fence runs them as before; a fence that can't be prepared
|
|
970
977
|
* (a checkout whose binding no longer proves) runs nothing.
|
|
971
978
|
*/
|
|
972
|
-
export function fencedCommands(store, runId, writes, inner, available = () => macosFenceAvailable() || linuxFenceAvailable()) {
|
|
979
|
+
export function fencedCommands(store, runId, writes, inner, available = () => macosFenceAvailable() || linuxFenceAvailable(), access = CHECK_ACCESS_DEFAULT, network, sandbox = LEGACY_SANDBOX) {
|
|
973
980
|
// An injected runner (tests) starts no real command, as an injected agent starts no real agent (invokeAgent).
|
|
974
981
|
if (inner !== runCommand)
|
|
975
982
|
return inner;
|
|
976
983
|
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
|
|
984
|
+
// As a build itself: where nothing can confine them, they run only when an instance operator allowed unconfined agents
|
|
985
|
+
// (and the run says so: its checks ran without a sandbox, whatever was sealed).
|
|
978
986
|
if (!available()) {
|
|
979
|
-
if (store.unfencedAgentsAllowed())
|
|
987
|
+
if (store.unfencedAgentsAllowed()) {
|
|
988
|
+
store.stampRunCheckAccess(runId, "unsandboxed");
|
|
980
989
|
return inner(file, args, options);
|
|
981
|
-
|
|
990
|
+
}
|
|
991
|
+
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, socat and ripgrep (Linux), or an instance operator allows unconfined agents with `toolroll config unconfined-agents allow`.\n", timedOut: false, notFound: false };
|
|
982
992
|
}
|
|
983
|
-
const own = realpathSync(mkdtempSync(join(
|
|
993
|
+
const own = realpathSync(mkdtempSync(join(checkLaunchRoot(), "so-cmd-")));
|
|
984
994
|
try {
|
|
985
995
|
const temp = join(own, "tmp");
|
|
986
996
|
mkdirSync(temp, { mode: 0o700 });
|
|
987
|
-
|
|
997
|
+
// Not sandboxed (check-access.ts): the project's checks are trusted by the whole instance, as before 0.9.66.
|
|
998
|
+
if (access === "unsandboxed")
|
|
999
|
+
return await inner(file, args, { ...options, env: { ...(options?.env ?? {}), TMPDIR: temp, TMP: temp, TEMP: temp } });
|
|
1000
|
+
const fenced = runFence(store, runId, "command", undefined, own, writes, sandbox);
|
|
988
1001
|
if (fenced.problem !== undefined)
|
|
989
1002
|
return { code: 1, stdout: "", stderr: `${fenced.problem}\n`, timedOut: false, notFound: false };
|
|
990
1003
|
if (fenced.fence.length === 0)
|
|
@@ -993,12 +1006,26 @@ export function fencedCommands(store, runId, writes, inner, available = () => ma
|
|
|
993
1006
|
const database = options?.env?.["TOOLROLL_DB"];
|
|
994
1007
|
const checkData = database === undefined || fenced.grants.writes === undefined ? null : grantFolder(dirname(database), false);
|
|
995
1008
|
const grants = checkData === null ? fenced.grants : { ...fenced.grants, writes: { ...fenced.grants.writes, subpaths: [...fenced.grants.writes.subpaths, checkData] } };
|
|
996
|
-
|
|
997
|
-
|
|
998
|
-
|
|
999
|
-
|
|
1000
|
-
|
|
1001
|
-
|
|
1009
|
+
const checkout = store.getRun(runId)?.worktree ?? null;
|
|
1010
|
+
const signIns = access !== "sign-ins" ? null : checkout === null ? { problem: "The check's checkout is unknown, so its sign-ins can't be granted." }
|
|
1011
|
+
: network === undefined ? { problem: "A check with sign-ins has no network sealed for it, so nothing ran." }
|
|
1012
|
+
: await signInsLaunch(store, own, temp, checkout, network, fenced.gitPaths ?? []);
|
|
1013
|
+
if (signIns !== null && "problem" in signIns)
|
|
1014
|
+
return { code: 1, stdout: "", stderr: `${signIns.problem}\n`, timedOut: false, notFound: false };
|
|
1015
|
+
try {
|
|
1016
|
+
return await inner(file, args, {
|
|
1017
|
+
...options,
|
|
1018
|
+
fence: fenced.fence,
|
|
1019
|
+
// Each sign-in file looked at again right before the launch: one no longer itself isn't granted.
|
|
1020
|
+
fenceGrants: signIns === null ? grants : { ...grants, signIns: { ...signIns.grants, read: signIns.grants.read.filter(path => signInIsItself(realpathSync(homedir()), path)) }, network: signIns.network },
|
|
1021
|
+
confinement: "required",
|
|
1022
|
+
env: { ...(options?.env ?? {}), TMPDIR: temp, TMP: temp, TEMP: temp, ...(fenced.cache === undefined || fenced.grants.writes === undefined ? {} : buildCacheEnv(fenced.cache)), ...(signIns?.env ?? {}) },
|
|
1023
|
+
...(signIns === null ? {} : { omitEnv: [...(options?.omitEnv ?? []), ...SIGN_INS_OMITTED_ENV] }),
|
|
1024
|
+
});
|
|
1025
|
+
}
|
|
1026
|
+
finally {
|
|
1027
|
+
await signIns?.close();
|
|
1028
|
+
}
|
|
1002
1029
|
}
|
|
1003
1030
|
finally {
|
|
1004
1031
|
rmSync(own, { recursive: true, force: true });
|
|
@@ -1011,22 +1038,25 @@ export function fencedCommands(store, runId, writes, inner, available = () => ma
|
|
|
1011
1038
|
* project's writes setting, only that checkout, its Git data, its own temp folder, the project's caches and its check
|
|
1012
1039
|
* database writable. Where nothing can confine them, they run only when an instance operator allowed unconfined agents.
|
|
1013
1040
|
*/
|
|
1014
|
-
export function fencedCheckoutCommands(store, repo, checkout, inner, available = () => macosFenceAvailable() || linuxFenceAvailable()) {
|
|
1041
|
+
export function fencedCheckoutCommands(store, repo, checkout, inner, available = () => macosFenceAvailable() || linuxFenceAvailable(), access = CHECK_ACCESS_DEFAULT, network, sandbox = LEGACY_SANDBOX) {
|
|
1015
1042
|
if (inner !== runCommand)
|
|
1016
1043
|
return inner;
|
|
1017
1044
|
return async (file, args, options) => {
|
|
1018
1045
|
if (!available()) {
|
|
1019
1046
|
if (store.unfencedAgentsAllowed())
|
|
1020
1047
|
return inner(file, args, options);
|
|
1021
|
-
return { code: 1, stdout: "", stderr: "This machine can't confine the project's checks (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 };
|
|
1048
|
+
return { code: 1, stdout: "", stderr: "This machine can't confine the project's checks (no macOS sandbox, no working bubblewrap), so they didn't run. Install bubblewrap, socat and ripgrep (Linux), or an instance operator allows unconfined agents with `toolroll config unconfined-agents allow`.\n", timedOut: false, notFound: false };
|
|
1022
1049
|
}
|
|
1023
1050
|
const databaseFile = store.databaseFile();
|
|
1024
1051
|
if (databaseFile === null)
|
|
1025
1052
|
return inner(file, args, options);
|
|
1026
|
-
const own = realpathSync(mkdtempSync(join(
|
|
1053
|
+
const own = realpathSync(mkdtempSync(join(checkLaunchRoot(), "so-cmd-")));
|
|
1027
1054
|
try {
|
|
1028
1055
|
const temp = join(own, "tmp");
|
|
1029
1056
|
mkdirSync(temp, { mode: 0o700 });
|
|
1057
|
+
// Not sandboxed (check-access.ts): the project's checks are trusted by the whole instance, as before 0.9.66.
|
|
1058
|
+
if (access === "unsandboxed")
|
|
1059
|
+
return await inner(file, args, { ...options, env: { ...(options?.env ?? {}), TMPDIR: temp, TMP: temp, TEMP: temp } });
|
|
1030
1060
|
let binding;
|
|
1031
1061
|
try {
|
|
1032
1062
|
binding = trustedWorktreeBinding(repo, checkout);
|
|
@@ -1035,21 +1065,36 @@ export function fencedCheckoutCommands(store, repo, checkout, inner, available =
|
|
|
1035
1065
|
return { code: 1, stdout: "", stderr: "The check's checkout couldn't be bound to its project, so nothing ran.\n", timedOut: false, notFound: false };
|
|
1036
1066
|
}
|
|
1037
1067
|
const poolRoots = [join(dirname(databaseFile), "worktrees"), ...store.listWorktrees().map(record => poolRootOf(record.path, record.repo))];
|
|
1038
|
-
const boundary = taskBoundary({ worktree: checkout, provider: "command", binding });
|
|
1068
|
+
const boundary = taskBoundary({ worktree: checkout, provider: "command", binding, policy: sandbox.policy });
|
|
1039
1069
|
const folders = runFolders(own);
|
|
1040
1070
|
const cache = buildCacheFolder(repo);
|
|
1041
1071
|
const allowed = store.projectWrites(repo) !== "workspace" ? null : writeAllowlist({ provider: "command", home: homedir(), worktree: checkout, binding, launchFolder: own, cache });
|
|
1042
1072
|
const database = options?.env?.["TOOLROLL_DB"];
|
|
1043
1073
|
const checkData = allowed === null || database === undefined ? null : grantFolder(dirname(database), false);
|
|
1044
|
-
const
|
|
1045
|
-
|
|
1046
|
-
:
|
|
1047
|
-
|
|
1048
|
-
|
|
1049
|
-
|
|
1050
|
-
|
|
1051
|
-
|
|
1052
|
-
|
|
1074
|
+
const launch = fenceFor("check", { databaseFile, worktree: checkout, boundary, binding, runFolders: folders, poolRoots, extra: allowed?.deny ?? [], linked: linkedFor(store, checkout),
|
|
1075
|
+
readDeny: sandbox.readDeny.map(one => readDenyPath(one, homedir())), machLookup: sandbox.machLookup, policy: sandbox.policy,
|
|
1076
|
+
writes: allowed === null ? null : { writes: { ...allowed.writes, subpaths: [...allowed.writes.subpaths, ...(checkData === null ? [] : [checkData])] }, readOnly: allowed.readOnly } });
|
|
1077
|
+
const grants = launch.fenceGrants;
|
|
1078
|
+
// With sign-ins, under the network its caller proved for it (the narrowest of its results' sealed check networks and
|
|
1079
|
+
// the project's current one): none given, nothing runs.
|
|
1080
|
+
const signIns = access !== "sign-ins" ? null : network === undefined ? { problem: "A check with sign-ins has no network sealed for it, so nothing ran." }
|
|
1081
|
+
: await signInsLaunch(store, own, temp, checkout, network, [binding.gitdir, join(binding.main, ".git")]);
|
|
1082
|
+
if (signIns !== null && "problem" in signIns)
|
|
1083
|
+
return { code: 1, stdout: "", stderr: `${signIns.problem}\n`, timedOut: false, notFound: false };
|
|
1084
|
+
try {
|
|
1085
|
+
return await inner(file, args, {
|
|
1086
|
+
...options,
|
|
1087
|
+
fence: launch.fence,
|
|
1088
|
+
// Each sign-in file looked at again right before the launch: one no longer itself isn't granted.
|
|
1089
|
+
fenceGrants: signIns === null ? grants : { ...grants, signIns: { ...signIns.grants, read: signIns.grants.read.filter(path => signInIsItself(realpathSync(homedir()), path)) }, network: signIns.network },
|
|
1090
|
+
confinement: launch.confinement,
|
|
1091
|
+
env: { ...(options?.env ?? {}), TMPDIR: temp, TMP: temp, TEMP: temp, ...(allowed === null ? {} : buildCacheEnv(cache)), ...(signIns?.env ?? {}) },
|
|
1092
|
+
...(signIns === null ? {} : { omitEnv: [...(options?.omitEnv ?? []), ...SIGN_INS_OMITTED_ENV] }),
|
|
1093
|
+
});
|
|
1094
|
+
}
|
|
1095
|
+
finally {
|
|
1096
|
+
await signIns?.close();
|
|
1097
|
+
}
|
|
1053
1098
|
}
|
|
1054
1099
|
finally {
|
|
1055
1100
|
rmSync(own, { recursive: true, force: true });
|
|
@@ -1118,3 +1163,96 @@ async function codexLoginBilling(binary, omitEnv) {
|
|
|
1118
1163
|
function plainLines(stdout) {
|
|
1119
1164
|
return stdout.split("\n").filter(line => !line.trimStart().startsWith("{")).join("\n");
|
|
1120
1165
|
}
|
|
1166
|
+
/** What a `sign-ins` check never inherits: a caller's choice of Claude's config folder (Claude's keychain sign-in is
|
|
1167
|
+
* keyed to its default one; check-access.ts). gh's and Codex's are set to the granted ones instead. */
|
|
1168
|
+
const SIGN_INS_OMITTED_ENV = ["CLAUDE_CONFIG_DIR"];
|
|
1169
|
+
/**
|
|
1170
|
+
* A `sign-ins` check's launch (check-access.ts, sandbox parity C10): its exact sign-in files, read by the check itself
|
|
1171
|
+
* through its sandbox (agent-fence.ts signInGrants: nothing privileged reads or copies one), its own transcript folder
|
|
1172
|
+
* and Codex's session and log folders; Codex, gh and XDG pointed at the real folders; and the network the check runs
|
|
1173
|
+
* under: open, or its allowlist through a proxy of its own (on Linux, relayed in by the sandbox runtime). Nothing about it is
|
|
1174
|
+
* started unless all of it is.
|
|
1175
|
+
*/
|
|
1176
|
+
export async function signInsLaunch(store, own, temp, checkout, policy, gitPaths, platform = process.platform) {
|
|
1177
|
+
if (!signInsAvailable(platform))
|
|
1178
|
+
return { problem: "Sign-ins can't be shared with checks here, so nothing ran." };
|
|
1179
|
+
const home = homedir();
|
|
1180
|
+
const grants = signInGrants(home, checkout, platform);
|
|
1181
|
+
let egress = null;
|
|
1182
|
+
const close = async () => { await egress?.close(); };
|
|
1183
|
+
try {
|
|
1184
|
+
const env = { CODEX_HOME: join(home, ".codex"), GH_CONFIG_DIR: join(home, ".config", "gh"), XDG_CONFIG_HOME: join(home, ".config") };
|
|
1185
|
+
if (policy.mode === "open")
|
|
1186
|
+
return { grants, network: { mode: "open" }, env, close };
|
|
1187
|
+
// Exactly the project's network: the services the sign-ins are for are reached only if the operator allowed their
|
|
1188
|
+
// hosts (check-access.ts signInHostsMissing says which are missing; never added here).
|
|
1189
|
+
egress = await startEgressProxy({ hosts: policy.hosts });
|
|
1190
|
+
const network = { mode: "allowlist", proxyPort: egress.port, brokerPorts: [], loopback: policy.loopback, socketRoots: [temp, realpathSync(checkout)], token: egress.secret };
|
|
1191
|
+
return { grants, network, env: { ...env, ...proxyEnv(egress, policy.loopback) }, close };
|
|
1192
|
+
}
|
|
1193
|
+
catch (error) {
|
|
1194
|
+
await close();
|
|
1195
|
+
return { problem: `The check's network could not be set up, so nothing ran: ${error instanceof Error ? error.message : String(error)}` };
|
|
1196
|
+
}
|
|
1197
|
+
}
|
|
1198
|
+
/** How a check at `access` actually runs here: unsandboxed where no OS fence exists (and an operator allowed that), else
|
|
1199
|
+
* as asked. Recorded on every check's receipt, so a check that ran without a sandbox is never shown as sandboxed. */
|
|
1200
|
+
export function actualCheckAccess(store, access) {
|
|
1201
|
+
return macosFenceAvailable() || linuxFenceAvailable() || !store.unfencedAgentsAllowed() ? access : "unsandboxed";
|
|
1202
|
+
}
|
|
1203
|
+
/**
|
|
1204
|
+
* A check of saved results, in their own checkout (batch-checks.ts, result-follow-ups.ts), worked out right before each
|
|
1205
|
+
* command: never wider than any of those results' own checks ran, nor than the project allows now; under sign-ins, the
|
|
1206
|
+
* narrowest of their sealed check networks and the project's current one. A result with no sealed network runs it
|
|
1207
|
+
* sandboxed.
|
|
1208
|
+
*/
|
|
1209
|
+
export function checkoutLaunchOf(store, repo, runIds, platform = process.platform) {
|
|
1210
|
+
// As checks can get it: sign-ins (off for now; a stamp or setting from before, or another database) run sandboxed.
|
|
1211
|
+
const access = checkAccessHere(narrowestAccess([store.projectCheckAccess(repo), ...runIds.map(id => store.runCheckAccess(id))]), platform);
|
|
1212
|
+
if (access !== "sign-ins")
|
|
1213
|
+
return { access, network: undefined };
|
|
1214
|
+
const sealed = runIds.map(id => store.runCheckNetwork(id));
|
|
1215
|
+
if (sealed.length === 0 || sealed.some(one => one === null))
|
|
1216
|
+
return { access: CHECK_ACCESS_DEFAULT, network: undefined };
|
|
1217
|
+
const live = resolveNetwork("command", store.projectNetwork(repo), repositoryEcosystems(repo));
|
|
1218
|
+
return { access, network: sealed.reduce((low, one) => narrowestNetwork(low, one), live) };
|
|
1219
|
+
}
|
|
1220
|
+
/** The widest level a check's commands actually ran at (what its receipt says). */
|
|
1221
|
+
export const widestRan = (levels) => levels.includes("unsandboxed") ? "unsandboxed" : levels.includes("sign-ins") ? "sign-ins" : CHECK_ACCESS_DEFAULT;
|
|
1222
|
+
/** A check log's line for how its commands ran, when wider than sandboxed (shown with the log). */
|
|
1223
|
+
export const ranAtLine = (level) => level === "sandboxed" ? null
|
|
1224
|
+
: `=== Check access ===\n${level === "unsandboxed" ? "These commands ran without a sandbox." : "These commands ran sandboxed, with model and GitHub sign-ins."}`;
|
|
1225
|
+
/** A saved result's check command runner (batch-checks.ts, result-follow-ups.ts): its level and network worked out right
|
|
1226
|
+
* now (checkoutLaunchOf), and how it actually runs told to `ran`. */
|
|
1227
|
+
export function checkoutRunner(store, repo, checkout, inner, runIds, ran, platform = process.platform) {
|
|
1228
|
+
return (file, args, options) => {
|
|
1229
|
+
const launch = checkoutLaunchOf(store, repo, runIds, platform);
|
|
1230
|
+
ran(actualCheckAccess(store, launch.access));
|
|
1231
|
+
return fencedCheckoutCommands(store, repo, checkout, inner, undefined, launch.access, launch.network, checkoutSandboxOf(store, repo, runIds))(file, args, options);
|
|
1232
|
+
};
|
|
1233
|
+
}
|
|
1234
|
+
/**
|
|
1235
|
+
* A saved result's checks' sandbox terms (sandbox parity C9), never wider than any of those results' own approvals nor
|
|
1236
|
+
* the project now: v2 only when every result's approval sealed v2; every read deny any of them sealed or the project
|
|
1237
|
+
* lists now; mach services only those all of them sealed and the project still lists. A result with
|
|
1238
|
+
* no readable approval counts as v1 with nothing listed.
|
|
1239
|
+
*/
|
|
1240
|
+
export function checkoutSandboxOf(store, repo, runIds) {
|
|
1241
|
+
const sealed = runIds.map(id => {
|
|
1242
|
+
const taskRef = store.getRun(id)?.taskRef;
|
|
1243
|
+
const ref = taskRef === undefined ? null : store.refForId(taskRef);
|
|
1244
|
+
const profile = ref === null ? null : store.getScope(ref.externalId)?.approvedProfile ?? null;
|
|
1245
|
+
return profile === null ? LEGACY_SANDBOX : launchSandboxOf(profile);
|
|
1246
|
+
});
|
|
1247
|
+
const live = store.projectSandbox(repo);
|
|
1248
|
+
const common = (pick, now) => now.filter(one => sealed.length > 0 && sealed.every(terms => pick(terms).includes(one)));
|
|
1249
|
+
return {
|
|
1250
|
+
policy: sealed.length > 0 && sealed.every(one => one.policy === 2) ? 2 : 1,
|
|
1251
|
+
readDeny: [...new Set([...live.readDeny, ...sealed.flatMap(one => one.readDeny)])].sort(),
|
|
1252
|
+
machLookup: common(one => one.machLookup, live.machLookup),
|
|
1253
|
+
};
|
|
1254
|
+
}
|
|
1255
|
+
/** The digest of a launch's whole fence (its denied paths and grants, canonical), as stamped on its run (C11). */
|
|
1256
|
+
export function fenceDigest(fence, grants) {
|
|
1257
|
+
return createHash("sha256").update(`toolroll:fence:${canonicalJson({ fence: [...fence], grants: grants })}`).digest("hex").slice(0, 32);
|
|
1258
|
+
}
|
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). */
|