toolroll 0.9.69 → 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 +47 -0
- package/README.md +43 -17
- package/THIRD_PARTY_NOTICES.md +21 -1
- package/dist/agent-fence.d.ts +180 -115
- package/dist/agent-fence.js +413 -535
- package/dist/agentconfig.js +6 -1
- package/dist/browser/THIRD_PARTY_NOTICES.txt +21 -0
- package/dist/builder.js +24 -4
- package/dist/check-access.d.ts +6 -9
- package/dist/check-access.js +7 -10
- 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 +1 -0
- package/dist/contracts/console-api.js +1 -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 +18 -8
- package/dist/invoke.js +98 -115
- package/dist/operate.js +46 -0
- package/dist/project-delete-ui.d.ts +7 -6
- package/dist/project-delete-ui.js +36 -2
- 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/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 +28 -0
- package/dist/scope.js +25 -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 +3 -1
- package/dist/server/render-tasks.js +4 -1
- package/dist/server/route-table.js +1 -0
- package/dist/server/settings.js +27 -0
- package/dist/store.d.ts +31 -1
- package/dist/store.js +83 -4
- package/dist/subagent-tools.d.ts +1 -0
- package/dist/subagent-tools.js +7 -5
- 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,15 +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 { createHash } from "node:crypto";
|
|
32
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 { checkLaunchRoot, signInGrants, signInIsItself } from "./agent-fence.js";
|
|
37
38
|
import { CHECK_ACCESS_DEFAULT, checkAccessHere, narrowestAccess, narrowestNetwork, signInsAvailable } from "./check-access.js";
|
|
38
|
-
import {
|
|
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";
|
|
39
43
|
import { proxyEnv, providerOnlyNetwork, repositoryEcosystems, resolveNetwork, startEgressProxy } from "./egress.js";
|
|
40
|
-
import { bridgeFits, startEgressBridge } from "./egress-bridge.js";
|
|
41
44
|
import { claudeConfigDir, claudeDefaults, codexDefaults, codexHomeDir, isolatedCodexHome } from "./provider-home.js";
|
|
42
45
|
import { billingOf, claudeBillingFrom, seenBilling } from "./spend.js";
|
|
43
46
|
/** "Within seconds": how soon a silent failed run must end for its stderr to
|
|
@@ -242,7 +245,7 @@ export async function invokeAgent(store, runId, spec, invocation, options) {
|
|
|
242
245
|
// re-supply survives resolveChildEnv's chat-key strip); in
|
|
243
246
|
// "subscription" mode the key is STRIPPED from the child so the CLI's
|
|
244
247
|
// own login is used — its key is retained, just not handed over.
|
|
245
|
-
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;
|
|
246
249
|
// The live setting is read STRICTLY (atomic authority closure): a
|
|
247
250
|
// present mode file that says neither word is a value-shaped refusal
|
|
248
251
|
// before any stamp or process — never the subscription default a
|
|
@@ -305,6 +308,9 @@ export async function invokeAgent(store, runId, spec, invocation, options) {
|
|
|
305
308
|
// (tool-broker.ts), which alone holds their secrets. A review keeps its isolation.
|
|
306
309
|
// Set once the fence is known: a fenced build's servers never start outside a fence.
|
|
307
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();
|
|
308
314
|
// What the build writes (its checkout, its project's caches): a tool server never runs from there, nor reads it at all.
|
|
309
315
|
const serverUntrusted = workspace === null ? [] : toolUntrusted(store, workspace, keyHome);
|
|
310
316
|
const launchTools = invocation.phase === "review" ? null : await runTools(store, runId, spec, options.keyHome, clock, invocation.readOnlyTools, invocation.researchWithheld, containment, {
|
|
@@ -316,7 +322,7 @@ export async function invokeAgent(store, runId, spec, invocation, options) {
|
|
|
316
322
|
untrusted: serverUntrusted,
|
|
317
323
|
wrap: () => serverFence,
|
|
318
324
|
// A fenced build's tool servers start only in a fence too, unless an instance operator allowed unconfined agents.
|
|
319
|
-
unwrapped: () => serverMustBeFenced && !
|
|
325
|
+
unwrapped: () => serverMustBeFenced && !unconfinedAtLaunch
|
|
320
326
|
? "This machine can't confine the tool's server (no macOS sandbox, no working bubblewrap), so it was not started." : null,
|
|
321
327
|
});
|
|
322
328
|
if (launchTools !== null && "refused" in launchTools) {
|
|
@@ -345,7 +351,7 @@ export async function invokeAgent(store, runId, spec, invocation, options) {
|
|
|
345
351
|
// tool servers outside its command sandbox, so its own launch folder is
|
|
346
352
|
// fenced too; Claude reads its config from there, inside the sandbox.
|
|
347
353
|
const ownTools = tools?.privateDir === undefined ? null : realpathSync(tools.privateDir);
|
|
348
|
-
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);
|
|
349
355
|
if (fenceOf.problem !== undefined) {
|
|
350
356
|
tools?.cleanup();
|
|
351
357
|
codexHome?.cleanup();
|
|
@@ -386,13 +392,13 @@ export async function invokeAgent(store, runId, spec, invocation, options) {
|
|
|
386
392
|
if (fence.length > 0) {
|
|
387
393
|
// A tool server keeps its own reach (it holds its tool's secrets, not the agent's code): never the agent's network,
|
|
388
394
|
// nor its write allowlist (a server keeps its own state where it always has).
|
|
389
|
-
const serverGrants = () =>
|
|
395
|
+
const serverGrants = () => serverGrantsOf(grants);
|
|
390
396
|
// Nor read what the build wrote: however its arguments are spelled, no byte of the checkout or caches can load.
|
|
391
397
|
const serverDenies = [...fence, ...serverUntrusted.filter(path => existsSync(path)).map(path => realpathSync(path))];
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
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);
|
|
396
402
|
serverMustBeFenced = true;
|
|
397
403
|
}
|
|
398
404
|
// No silent fallback (arc 1): a build this machine can't confine at the OS — Claude reduced to its own file-tool
|
|
@@ -404,10 +410,9 @@ export async function invokeAgent(store, runId, spec, invocation, options) {
|
|
|
404
410
|
codexHome?.cleanup();
|
|
405
411
|
removeAgentDatabase(isolatedDb.dir);
|
|
406
412
|
return { kind: "refused", reason: "containment", providerVersion: attested?.version ?? null,
|
|
407
|
-
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`." };
|
|
408
414
|
}
|
|
409
415
|
let egress = null;
|
|
410
|
-
let bridge = null;
|
|
411
416
|
let runTemp = null;
|
|
412
417
|
const confinedOs = fenced.method === "macos-sandbox" || fenced.method === "linux-bubblewrap";
|
|
413
418
|
if (fence.length > 0 && !codexHarness && confinedOs) {
|
|
@@ -429,22 +434,19 @@ export async function invokeAgent(store, runId, spec, invocation, options) {
|
|
|
429
434
|
}
|
|
430
435
|
else {
|
|
431
436
|
egress = await startEgressProxy({ hosts: network.policy.hosts });
|
|
432
|
-
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 };
|
|
433
438
|
}
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
const
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
// Its private /tmp gives back only its own folders: its checkout and its launch folder (its temp is inside).
|
|
442
|
-
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 });
|
|
443
446
|
}
|
|
444
447
|
}
|
|
445
448
|
catch (error) {
|
|
446
449
|
await egress?.close();
|
|
447
|
-
await bridge?.close();
|
|
448
450
|
tools?.cleanup();
|
|
449
451
|
codexHome?.cleanup();
|
|
450
452
|
removeAgentDatabase(isolatedDb.dir);
|
|
@@ -452,8 +454,11 @@ export async function invokeAgent(store, runId, spec, invocation, options) {
|
|
|
452
454
|
diagnostic: `The build's network could not be set up, so nothing was started: ${error instanceof Error ? error.message : String(error)}` };
|
|
453
455
|
}
|
|
454
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.
|
|
455
459
|
if (invocation.phase !== "review")
|
|
456
|
-
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());
|
|
457
462
|
const billingSeen = { keySource: null, model: null, planWindows: false, answered: false };
|
|
458
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),
|
|
459
464
|
// read right before it runs. Never with an injected runner: tests don't start a real Codex.
|
|
@@ -496,7 +501,7 @@ export async function invokeAgent(store, runId, spec, invocation, options) {
|
|
|
496
501
|
: ownKeyEnv.filter(name => name !== keyEnvName)),
|
|
497
502
|
];
|
|
498
503
|
const launchOptions = {
|
|
499
|
-
...(fenced.wrap.length === 0 ? {} : { fence: fenced.wrap, fenceGrants: grants }),
|
|
504
|
+
...(fenced.wrap.length === 0 ? {} : { fence: fenced.wrap, fenceGrants: grants, confinement: "required" }),
|
|
500
505
|
...runOptions,
|
|
501
506
|
...(workspace === null ? {} : { cwd: workspace }),
|
|
502
507
|
// v105: how the CLI really billed this turn (its key source, its plan's windows), read off its own stream.
|
|
@@ -566,7 +571,7 @@ export async function invokeAgent(store, runId, spec, invocation, options) {
|
|
|
566
571
|
const insideFence = (file, args) => codexHarness
|
|
567
572
|
? runCommand(executable, ["sandbox", ...codexFenceArgv(fence, invocation.skipPermissions, grants),
|
|
568
573
|
"-P", "standing-orders", "--", file, ...args], probeOptions)
|
|
569
|
-
: runCommand(file, args, { ...probeOptions, fence: launchOptions.fence,
|
|
574
|
+
: runCommand(file, args, { ...probeOptions, fence: launchOptions.fence, confinement: "required",
|
|
570
575
|
...(launchOptions.fenceGrants === undefined ? {} : { fenceGrants: launchOptions.fenceGrants }) });
|
|
571
576
|
const check = await insideFence("git", gitArgs);
|
|
572
577
|
if (check.code !== 0) {
|
|
@@ -582,7 +587,7 @@ export async function invokeAgent(store, runId, spec, invocation, options) {
|
|
|
582
587
|
// key in the launching environment must be absent. No agent takes part, so no agent can fake it.
|
|
583
588
|
if (acpCheckAsk?.isolation !== undefined && launchOptions.fence !== undefined && workspace !== null) {
|
|
584
589
|
// processGroup: run() applies the fence only on the process-group road (a plain execFile carries none).
|
|
585
|
-
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 };
|
|
586
591
|
const read = await runCommand("/bin/cat", [acpCheckAsk.isolation.file], probe);
|
|
587
592
|
const env = await runCommand("/usr/bin/env", [], probe);
|
|
588
593
|
acpIsolation = {
|
|
@@ -705,7 +710,6 @@ export async function invokeAgent(store, runId, spec, invocation, options) {
|
|
|
705
710
|
}, clock());
|
|
706
711
|
}
|
|
707
712
|
await egress?.close();
|
|
708
|
-
await bridge?.close();
|
|
709
713
|
}
|
|
710
714
|
// v105: the run's billing, fixed from evidence before its usage is priced: a key it was given is a key; otherwise
|
|
711
715
|
// what the CLI said (Claude's stream) or was last seen doing (Codex's account), and the setting only when neither.
|
|
@@ -926,7 +930,7 @@ function linkedFor(store, worktree) {
|
|
|
926
930
|
/** The paths this run's agent may not reach: the state folder around the live database (but not its own worktree),
|
|
927
931
|
* ~/.toolroll (or its older name), its task boundary and other launches' private folders; what the fence gives back
|
|
928
932
|
* (FenceGrants); and what only Claude's file tools refuse. */
|
|
929
|
-
function runFence(store, runId, provider, keyHome, ownTools, writes = WRITES_DEFAULT) {
|
|
933
|
+
function runFence(store, runId, provider, keyHome, ownTools, writes = WRITES_DEFAULT, sandbox = LEGACY_SANDBOX) {
|
|
930
934
|
try {
|
|
931
935
|
// A plane always has a database file; an in-memory store (a test) has nothing to fence.
|
|
932
936
|
const databaseFile = store.databaseFile();
|
|
@@ -939,22 +943,24 @@ function runFence(store, runId, provider, keyHome, ownTools, writes = WRITES_DEF
|
|
|
939
943
|
throw new CheckoutBindingError("The checkout's Git pointer was changed or its trusted binding could not be verified.");
|
|
940
944
|
const poolRoots = [join(dirname(databaseFile), "worktrees"), ...store.listWorktrees().map(record => poolRootOf(record.path, record.repo))];
|
|
941
945
|
const binding = worktree === null || row === null ? null : trustedWorktreeBinding(row.repo, worktree, poolRoots);
|
|
942
|
-
const boundary = worktree === null ? null : taskBoundary({ worktree, provider, ...home, binding });
|
|
946
|
+
const boundary = worktree === null ? null : taskBoundary({ worktree, provider, ...home, binding, policy: sandbox.policy });
|
|
943
947
|
const folders = runFolders(ownTools);
|
|
944
948
|
// A build with a checkout writes only its workspace unless its approval said anywhere (arc 2): its checkout and
|
|
945
949
|
// Git data, its launch folder, its project's caches and the provider state its CLI must write.
|
|
946
950
|
const allowed = worktree === null || row === null || writes !== "workspace" ? null
|
|
947
951
|
: writeAllowlist({ provider, home: keyHome ?? homedir(), worktree, binding, launchFolder: ownTools, cache: buildCacheFolder(row.repo, keyHome ?? homedir()) });
|
|
948
|
-
|
|
949
|
-
|
|
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 });
|
|
950
959
|
return {
|
|
951
|
-
fence,
|
|
960
|
+
fence: launch.fence,
|
|
952
961
|
fileTools: boundary?.fileTools ?? [],
|
|
953
|
-
|
|
954
|
-
//
|
|
955
|
-
// 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.
|
|
956
|
-
grants: allowed === null ? grants : { ...grants, writes: allowed.writes, readOnly: [...new Set([...grants.readOnly, ...allowed.readOnly])].sort() },
|
|
957
|
-
// 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).
|
|
958
964
|
gitPaths: binding === null ? [] : [binding.gitdir, join(binding.main, ".git")],
|
|
959
965
|
...(allowed === null || row === null ? {} : { cache: buildCacheFolder(row.repo, keyHome ?? homedir()) }),
|
|
960
966
|
};
|
|
@@ -970,7 +976,7 @@ function runFence(store, runId, provider, keyHome, ownTools, writes = WRITES_DEF
|
|
|
970
976
|
* Their network stays as it was. A machine without an OS fence runs them as before; a fence that can't be prepared
|
|
971
977
|
* (a checkout whose binding no longer proves) runs nothing.
|
|
972
978
|
*/
|
|
973
|
-
export function fencedCommands(store, runId, writes, inner, available = () => macosFenceAvailable() || linuxFenceAvailable(), access = CHECK_ACCESS_DEFAULT, network) {
|
|
979
|
+
export function fencedCommands(store, runId, writes, inner, available = () => macosFenceAvailable() || linuxFenceAvailable(), access = CHECK_ACCESS_DEFAULT, network, sandbox = LEGACY_SANDBOX) {
|
|
974
980
|
// An injected runner (tests) starts no real command, as an injected agent starts no real agent (invokeAgent).
|
|
975
981
|
if (inner !== runCommand)
|
|
976
982
|
return inner;
|
|
@@ -982,7 +988,7 @@ export function fencedCommands(store, runId, writes, inner, available = () => ma
|
|
|
982
988
|
store.stampRunCheckAccess(runId, "unsandboxed");
|
|
983
989
|
return inner(file, args, options);
|
|
984
990
|
}
|
|
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 };
|
|
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 };
|
|
986
992
|
}
|
|
987
993
|
const own = realpathSync(mkdtempSync(join(checkLaunchRoot(), "so-cmd-")));
|
|
988
994
|
try {
|
|
@@ -991,7 +997,7 @@ export function fencedCommands(store, runId, writes, inner, available = () => ma
|
|
|
991
997
|
// Not sandboxed (check-access.ts): the project's checks are trusted by the whole instance, as before 0.9.66.
|
|
992
998
|
if (access === "unsandboxed")
|
|
993
999
|
return await inner(file, args, { ...options, env: { ...(options?.env ?? {}), TMPDIR: temp, TMP: temp, TEMP: temp } });
|
|
994
|
-
const fenced = runFence(store, runId, "command", undefined, own, writes);
|
|
1000
|
+
const fenced = runFence(store, runId, "command", undefined, own, writes, sandbox);
|
|
995
1001
|
if (fenced.problem !== undefined)
|
|
996
1002
|
return { code: 1, stdout: "", stderr: `${fenced.problem}\n`, timedOut: false, notFound: false };
|
|
997
1003
|
if (fenced.fence.length === 0)
|
|
@@ -1010,7 +1016,9 @@ export function fencedCommands(store, runId, writes, inner, available = () => ma
|
|
|
1010
1016
|
return await inner(file, args, {
|
|
1011
1017
|
...options,
|
|
1012
1018
|
fence: fenced.fence,
|
|
1013
|
-
|
|
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",
|
|
1014
1022
|
env: { ...(options?.env ?? {}), TMPDIR: temp, TMP: temp, TEMP: temp, ...(fenced.cache === undefined || fenced.grants.writes === undefined ? {} : buildCacheEnv(fenced.cache)), ...(signIns?.env ?? {}) },
|
|
1015
1023
|
...(signIns === null ? {} : { omitEnv: [...(options?.omitEnv ?? []), ...SIGN_INS_OMITTED_ENV] }),
|
|
1016
1024
|
});
|
|
@@ -1030,14 +1038,14 @@ export function fencedCommands(store, runId, writes, inner, available = () => ma
|
|
|
1030
1038
|
* project's writes setting, only that checkout, its Git data, its own temp folder, the project's caches and its check
|
|
1031
1039
|
* database writable. Where nothing can confine them, they run only when an instance operator allowed unconfined agents.
|
|
1032
1040
|
*/
|
|
1033
|
-
export function fencedCheckoutCommands(store, repo, checkout, inner, available = () => macosFenceAvailable() || linuxFenceAvailable(), access = CHECK_ACCESS_DEFAULT, network) {
|
|
1041
|
+
export function fencedCheckoutCommands(store, repo, checkout, inner, available = () => macosFenceAvailable() || linuxFenceAvailable(), access = CHECK_ACCESS_DEFAULT, network, sandbox = LEGACY_SANDBOX) {
|
|
1034
1042
|
if (inner !== runCommand)
|
|
1035
1043
|
return inner;
|
|
1036
1044
|
return async (file, args, options) => {
|
|
1037
1045
|
if (!available()) {
|
|
1038
1046
|
if (store.unfencedAgentsAllowed())
|
|
1039
1047
|
return inner(file, args, options);
|
|
1040
|
-
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 };
|
|
1041
1049
|
}
|
|
1042
1050
|
const databaseFile = store.databaseFile();
|
|
1043
1051
|
if (databaseFile === null)
|
|
@@ -1057,15 +1065,16 @@ export function fencedCheckoutCommands(store, repo, checkout, inner, available =
|
|
|
1057
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 };
|
|
1058
1066
|
}
|
|
1059
1067
|
const poolRoots = [join(dirname(databaseFile), "worktrees"), ...store.listWorktrees().map(record => poolRootOf(record.path, record.repo))];
|
|
1060
|
-
const boundary = taskBoundary({ worktree: checkout, provider: "command", binding });
|
|
1068
|
+
const boundary = taskBoundary({ worktree: checkout, provider: "command", binding, policy: sandbox.policy });
|
|
1061
1069
|
const folders = runFolders(own);
|
|
1062
1070
|
const cache = buildCacheFolder(repo);
|
|
1063
1071
|
const allowed = store.projectWrites(repo) !== "workspace" ? null : writeAllowlist({ provider: "command", home: homedir(), worktree: checkout, binding, launchFolder: own, cache });
|
|
1064
1072
|
const database = options?.env?.["TOOLROLL_DB"];
|
|
1065
1073
|
const checkData = allowed === null || database === undefined ? null : grantFolder(dirname(database), false);
|
|
1066
|
-
const
|
|
1067
|
-
|
|
1068
|
-
:
|
|
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;
|
|
1069
1078
|
// With sign-ins, under the network its caller proved for it (the narrowest of its results' sealed check networks and
|
|
1070
1079
|
// the project's current one): none given, nothing runs.
|
|
1071
1080
|
const signIns = access !== "sign-ins" ? null : network === undefined ? { problem: "A check with sign-ins has no network sealed for it, so nothing ran." }
|
|
@@ -1075,8 +1084,10 @@ export function fencedCheckoutCommands(store, repo, checkout, inner, available =
|
|
|
1075
1084
|
try {
|
|
1076
1085
|
return await inner(file, args, {
|
|
1077
1086
|
...options,
|
|
1078
|
-
fence:
|
|
1079
|
-
|
|
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,
|
|
1080
1091
|
env: { ...(options?.env ?? {}), TMPDIR: temp, TMP: temp, TEMP: temp, ...(allowed === null ? {} : buildCacheEnv(cache)), ...(signIns?.env ?? {}) },
|
|
1081
1092
|
...(signIns === null ? {} : { omitEnv: [...(options?.omitEnv ?? []), ...SIGN_INS_OMITTED_ENV] }),
|
|
1082
1093
|
});
|
|
@@ -1156,80 +1167,27 @@ function plainLines(stdout) {
|
|
|
1156
1167
|
* keyed to its default one; check-access.ts). gh's and Codex's are set to the granted ones instead. */
|
|
1157
1168
|
const SIGN_INS_OMITTED_ENV = ["CLAUDE_CONFIG_DIR"];
|
|
1158
1169
|
/**
|
|
1159
|
-
* A `sign-ins` check's launch (check-access.ts): its exact sign-in files
|
|
1160
|
-
* (agent-fence.ts signInGrants
|
|
1161
|
-
*
|
|
1162
|
-
* open, or its allowlist through a proxy of its own (on Linux,
|
|
1163
|
-
* unless all of it is.
|
|
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.
|
|
1164
1175
|
*/
|
|
1165
1176
|
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
1177
|
if (!signInsAvailable(platform))
|
|
1169
|
-
return { problem: "Sign-ins can't be shared with checks
|
|
1178
|
+
return { problem: "Sign-ins can't be shared with checks here, so nothing ran." };
|
|
1170
1179
|
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
|
-
}
|
|
1180
|
+
const grants = signInGrants(home, checkout, platform);
|
|
1213
1181
|
let egress = null;
|
|
1214
|
-
|
|
1215
|
-
const close = async () => { await egress?.close(); await bridge?.close(); for (const one of cleanup)
|
|
1216
|
-
rmSync(one, { recursive: true, force: true }); };
|
|
1182
|
+
const close = async () => { await egress?.close(); };
|
|
1217
1183
|
try {
|
|
1218
|
-
const env = { CODEX_HOME: codex, GH_CONFIG_DIR: join(home, ".config", "gh"), XDG_CONFIG_HOME: join(home, ".config") };
|
|
1184
|
+
const env = { CODEX_HOME: join(home, ".codex"), GH_CONFIG_DIR: join(home, ".config", "gh"), XDG_CONFIG_HOME: join(home, ".config") };
|
|
1219
1185
|
if (policy.mode === "open")
|
|
1220
1186
|
return { grants, network: { mode: "open" }, env, close };
|
|
1221
1187
|
// Exactly the project's network: the services the sign-ins are for are reached only if the operator allowed their
|
|
1222
1188
|
// hosts (check-access.ts signInHostsMissing says which are missing; never added here).
|
|
1223
1189
|
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
|
-
}
|
|
1190
|
+
const network = { mode: "allowlist", proxyPort: egress.port, brokerPorts: [], loopback: policy.loopback, socketRoots: [temp, realpathSync(checkout)], token: egress.secret };
|
|
1233
1191
|
return { grants, network, env: { ...env, ...proxyEnv(egress, policy.loopback) }, close };
|
|
1234
1192
|
}
|
|
1235
1193
|
catch (error) {
|
|
@@ -1270,6 +1228,31 @@ export function checkoutRunner(store, repo, checkout, inner, runIds, ran, platfo
|
|
|
1270
1228
|
return (file, args, options) => {
|
|
1271
1229
|
const launch = checkoutLaunchOf(store, repo, runIds, platform);
|
|
1272
1230
|
ran(actualCheckAccess(store, launch.access));
|
|
1273
|
-
return fencedCheckoutCommands(store, repo, checkout, inner, undefined, launch.access, launch.network)(file, args, options);
|
|
1231
|
+
return fencedCheckoutCommands(store, repo, checkout, inner, undefined, launch.access, launch.network, checkoutSandboxOf(store, repo, runIds))(file, args, options);
|
|
1274
1232
|
};
|
|
1275
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.js
CHANGED
|
@@ -113,6 +113,7 @@ import { parseLeadToken } from "./api-tokens.js";
|
|
|
113
113
|
import { MAX_NETWORK_HOSTS, normalizeHost } from "./egress.js";
|
|
114
114
|
import { isWritesMode, writesWords } from "./writes-mode.js";
|
|
115
115
|
import { linuxFenceAvailable, macosFenceAvailable } from "./agent-fence.js";
|
|
116
|
+
import { parseMachLookup, parseReadDeny, readDenyWords } from "./project-sandbox.js";
|
|
116
117
|
import { CHECK_ACCESS_WARNINGS, checkAccessRunsAs, checkAccessWords, isCheckAccess, signInHostsMissing, signInsUnavailableWords, widerThan } from "./check-access.js";
|
|
117
118
|
import { CLI_PASSWORD_SOURCE, withPasswordSource } from "./sign-in-guard.js";
|
|
118
119
|
import { admissionRecorded, admitProject, commandLineActor } from "./project-admission.js";
|
|
@@ -691,6 +692,8 @@ export const OPERATE_VALUE_FLAGS = new Set([
|
|
|
691
692
|
"token-name",
|
|
692
693
|
// task pause / task steer --now: the asker's own request id, so a repeat is the same request.
|
|
693
694
|
"request",
|
|
695
|
+
// project sandbox (sandbox parity C9): read denies and macOS services.
|
|
696
|
+
"read-deny", "mach",
|
|
694
697
|
]);
|
|
695
698
|
export const OPERATE_BOOLEAN_FLAGS = new Set([
|
|
696
699
|
"json", "yes", "all", "brief", "local", "history", "latest-watch", "dry-run", "file",
|
|
@@ -1180,6 +1183,8 @@ async function dispatch(command, positional, flags, context) {
|
|
|
1180
1183
|
return projectWritesCommand(positional, flags, context);
|
|
1181
1184
|
if (positional[0] === "check-access")
|
|
1182
1185
|
return projectCheckAccessCommand(positional, flags, context);
|
|
1186
|
+
if (positional[0] === "sandbox")
|
|
1187
|
+
return projectSandboxCommand(positional, flags, context);
|
|
1183
1188
|
if (positional[0] === "checks")
|
|
1184
1189
|
return projectChecksCommand(positional, flags, context);
|
|
1185
1190
|
if (positional[0] === "demo")
|
|
@@ -11640,3 +11645,44 @@ async function projectCheckAccessCommand(positional, flags, context) {
|
|
|
11640
11645
|
"Scopes written from now on use this; scopes already approved keep what they were approved with.",
|
|
11641
11646
|
]);
|
|
11642
11647
|
}
|
|
11648
|
+
/**
|
|
11649
|
+
* `project sandbox --repo <p> [--read-deny <a,b>] [--mach <a,b>]` (project-sandbox.ts, sandbox parity C9): what the
|
|
11650
|
+
* project's builds and checks may not read (paths, `~/…` or absolute) and the macOS services they may also use. Each list is replaced whole by its flag (an empty value clears it). New scopes seal it;
|
|
11651
|
+
* approved ones keep theirs. Changing it is an approver's act with a password, kept in the ledger as before → after.
|
|
11652
|
+
*/
|
|
11653
|
+
async function projectSandboxCommand(positional, flags, context) {
|
|
11654
|
+
const command = "project sandbox";
|
|
11655
|
+
const allowed = new Set(["repo", "read-deny", "mach", "as", "token", "token-file", "token-env", "db", "json"]);
|
|
11656
|
+
for (const name of flags.keys())
|
|
11657
|
+
if (!allowed.has(name))
|
|
11658
|
+
return fail(context.write, context.json, command, "usage", `--${name} is not a project sandbox option.`, EXIT.usage);
|
|
11659
|
+
const repoFlag = text(flags, "repo");
|
|
11660
|
+
if (positional.length > 1 || repoFlag === undefined)
|
|
11661
|
+
return fail(context.write, context.json, command, "usage", "Use project sandbox --repo <project path> [--read-deny <paths>] [--mach <services>] (comma-separated; empty clears).", EXIT.usage);
|
|
11662
|
+
const repo = await knownProject(context, repoFlag, { listed: true, canonical: true });
|
|
11663
|
+
if (repo === undefined)
|
|
11664
|
+
return fail(context.write, context.json, command, "not-found", "That isn't a project Toolroll knows.", EXIT.refused);
|
|
11665
|
+
const words = (one) => [`${repo}: ${readDenyWords(one.readDeny)}`,
|
|
11666
|
+
...(one.machLookup.length === 0 ? [] : [`macOS services also allowed: ${one.machLookup.join(", ")}`])];
|
|
11667
|
+
const current = context.store.projectSandbox(repo);
|
|
11668
|
+
const asked = (name) => { const value = flags.get(name); return value === undefined ? undefined : value === true ? [] : value.split(","); };
|
|
11669
|
+
const changes = { readDeny: asked("read-deny"), machLookup: asked("mach") };
|
|
11670
|
+
if (changes.readDeny === undefined && changes.machLookup === undefined)
|
|
11671
|
+
return succeed(context.write, context.json, command, { repo, sandbox: current }, () => words(current));
|
|
11672
|
+
const parsed = { readDeny: parseReadDeny(changes.readDeny ?? current.readDeny), machLookup: parseMachLookup(changes.machLookup ?? current.machLookup) };
|
|
11673
|
+
for (const [name, one] of Object.entries(parsed))
|
|
11674
|
+
if (!one.ok)
|
|
11675
|
+
return fail(context.write, context.json, command, "usage", `${name}: ${one.problem}. Nothing changed.`, EXIT.usage);
|
|
11676
|
+
const wanted = { readDeny: parsed.readDeny.ok ? parsed.readDeny.list : [], machLookup: parsed.machLookup.ok ? parsed.machLookup.list : [] };
|
|
11677
|
+
const acting = await askCredentials(flags, context);
|
|
11678
|
+
const verified = acting === null ? null : credentialApprover(context.store, acting.name, acting.token, repo);
|
|
11679
|
+
if (acting === null || !verified?.ok || !context.store.accountCanAccess(acting.name, repo))
|
|
11680
|
+
return fail(context.write, context.json, command, "refused", "An approver for this project changes its sandbox, with their password: pass --as and --token.", EXIT.refused);
|
|
11681
|
+
const changed = context.store.setProjectSandbox(repo, wanted, acting.name, context.clock());
|
|
11682
|
+
if (changed === null)
|
|
11683
|
+
return fail(context.write, context.json, command, "refused", "An approver for this project changes its sandbox. Nothing changed.", EXIT.refused);
|
|
11684
|
+
return succeed(context.write, context.json, command, { repo, before: changed.before, sandbox: changed.after }, () => [
|
|
11685
|
+
...words(changed.after),
|
|
11686
|
+
"Scopes written from now on use this; scopes already approved keep what they were approved with.",
|
|
11687
|
+
]);
|
|
11688
|
+
}
|
|
@@ -1,9 +1,4 @@
|
|
|
1
|
-
|
|
2
|
-
* Settings → Project: what Toolroll holds for a project, and deleting
|
|
3
|
-
* it. An instance operator deletes in two steps: type the project's name,
|
|
4
|
-
* then read exactly what goes and confirm with their password (or a fresh
|
|
5
|
-
* sign-in with the identity provider). The repository itself stays.
|
|
6
|
-
*/
|
|
1
|
+
import type { ProjectSandbox } from "./project-sandbox.js";
|
|
7
2
|
import type { ProjectNetwork } from "./egress.js";
|
|
8
3
|
import type { WritesMode } from "./writes-mode.js";
|
|
9
4
|
import { type CheckAccess } from "./check-access.js";
|
|
@@ -51,6 +46,12 @@ export type ProjectSettingsView = {
|
|
|
51
46
|
setting: WritesMode;
|
|
52
47
|
canChange: boolean;
|
|
53
48
|
};
|
|
49
|
+
/** What its builds and checks may not read, and the macOS services and protected files they may still use
|
|
50
|
+
* (project-sandbox.ts), and whether this person may change it. */
|
|
51
|
+
sandbox?: {
|
|
52
|
+
setting: ProjectSandbox;
|
|
53
|
+
canChange: boolean;
|
|
54
|
+
};
|
|
54
55
|
/** How far its checks reach (check-access.ts): the level, who changed it last, and whether this person may narrow it
|
|
55
56
|
* (an approver for the project) or also widen it (an instance operator). */
|
|
56
57
|
checkAccess?: {
|
|
@@ -1,3 +1,10 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Settings → Project: what Toolroll holds for a project, and deleting
|
|
3
|
+
* it. An instance operator deletes in two steps: type the project's name,
|
|
4
|
+
* then read exactly what goes and confirm with their password (or a fresh
|
|
5
|
+
* sign-in with the identity provider). The repository itself stays.
|
|
6
|
+
*/
|
|
7
|
+
import { sandboxRuntimeWords } from "./sandbox-runtime.js";
|
|
1
8
|
import { CHECK_ACCESS_WARNINGS, signInHostsMissing } from "./check-access.js";
|
|
2
9
|
import { html, postForm, reauth } from "./html.js";
|
|
3
10
|
import { holdingsWords } from "./project-delete.js";
|
|
@@ -49,6 +56,33 @@ function writesHtml(view) {
|
|
|
49
56
|
<button type="submit">Save</button>`, { hidden: { repo: view.repo } });
|
|
50
57
|
return html `<section class="build-access writes-setting" id="writes" aria-labelledby="writes-title"><h2 id="writes-title">Where builds write</h2>${said}<details><summary>Change</summary>${form}</details></section>`;
|
|
51
58
|
}
|
|
59
|
+
/** Home folder (project-sandbox.ts): what builds and checks can read there, said once; the paths they can't, and the
|
|
60
|
+
* advanced list (macOS services), changed under Change with the approver's password. */
|
|
61
|
+
function sandboxHtml(view) {
|
|
62
|
+
const sandbox = view.sandbox;
|
|
63
|
+
if (sandbox === undefined)
|
|
64
|
+
return "";
|
|
65
|
+
const { readDeny, machLookup } = sandbox.setting;
|
|
66
|
+
const said = readDeny.length === 0
|
|
67
|
+
? html `<p>Builds and checks can read your home folder, as in Anthropic's sandbox runtime. Toolroll's own folders stay out of reach; other tasks' work is read, never written.</p>`
|
|
68
|
+
: html `<p>Builds and checks can read your home folder except <strong>${readDeny.length === 1 ? readDeny[0] : `${readDeny.length} paths`}</strong>.</p>`;
|
|
69
|
+
const extra = machLookup.length === 0 ? "" : html `<p class="meta">${machLookup.length} macOS ${machLookup.length === 1 ? "service" : "services"} allowed</p>`;
|
|
70
|
+
// The engine, named where the sandbox is set (sandbox-runtime.ts).
|
|
71
|
+
const engine = html `<p class="meta" data-sandbox-engine>Builds, checks and flows run in ${sandboxRuntimeWords()}.</p>`;
|
|
72
|
+
const head = html `<h2 id="sandbox-title">Home folder</h2>${said}${extra}${engine}`;
|
|
73
|
+
if (!sandbox.canChange)
|
|
74
|
+
return html `<section class="build-access sandbox-setting" id="sandbox" aria-labelledby="sandbox-title">${head}<p class="meta">An approver for this project can change this.</p></section>`;
|
|
75
|
+
const form = postForm("/settings/project/sandbox", html `
|
|
76
|
+
<label>Paths builds can't read<textarea name="read-deny" rows="4" placeholder="~/.ssh ~/.aws">${readDeny.join("\n")}</textarea></label>
|
|
77
|
+
<p class="meta">One per line: <code>~/…</code> or a full path.</p>
|
|
78
|
+
<details><summary>Advanced</summary>
|
|
79
|
+
<label>macOS services builds may also use<textarea name="mach" rows="2">${machLookup.join("\n")}</textarea></label>
|
|
80
|
+
</details>
|
|
81
|
+
<p class="meta">Tasks scoped from now on use this; tasks already approved keep what they were approved with.</p>
|
|
82
|
+
${reauth(html `<label>Your Toolroll password<input type="password" name="password" autocomplete="current-password" required></label>`)}
|
|
83
|
+
<button type="submit">Save</button>`, { hidden: { repo: view.repo } });
|
|
84
|
+
return html `<section class="build-access sandbox-setting" id="sandbox" aria-labelledby="sandbox-title">${head}<details><summary>Change</summary>${form}</details></section>`;
|
|
85
|
+
}
|
|
52
86
|
/** Check access (check-access.ts): how far the project's checks reach, said once. Collapsed: the level, what it means
|
|
53
87
|
* when it isn't the default, and who changed it. Inside Change: a line per level, and each wider level's warning under it
|
|
54
88
|
* only while it is chosen. An instance operator widens it with a password typed now; an approver narrows it. */
|
|
@@ -83,7 +117,7 @@ function checkAccessHtml(view) {
|
|
|
83
117
|
};
|
|
84
118
|
const open = network?.mode === "open";
|
|
85
119
|
const form = postForm("/settings/project/check-access", html `
|
|
86
|
-
${option("sandboxed", "
|
|
120
|
+
${option("sandboxed", "Reads your home folder, as in Anthropic's sandbox runtime, except the folders this project hides (Home folder, above). Never Toolroll's folders or other tasks' work.", null)}
|
|
87
121
|
${unavailable !== null ? option("sign-ins", unavailable, null) : option("sign-ins", `Also reads the files your Claude, Codex and GitHub sign-ins are kept in, on this project's network (${open ? "any host" : network === null || network.hosts.length === 0 ? "registries only" : `registries and ${network.hosts.length} ${network.hosts.length === 1 ? "host" : "hosts"}`}).`, CHECK_ACCESS_WARNINGS["sign-ins"])}
|
|
88
122
|
${option("unsandboxed", "For projects whose checks create sandboxes themselves, such as Toolroll's own.", CHECK_ACCESS_WARNINGS.unsandboxed)}
|
|
89
123
|
${open && unavailable === null ? html `<label class="check"><input type="checkbox" name="confirm-open" value="yes"> The network is open: with sign-ins, checks could send them to any host</label>` : ""}
|
|
@@ -170,7 +204,7 @@ export function projectSettingsHtml(view, notice) {
|
|
|
170
204
|
const remote = html `<section class="build-access recorded-remote" aria-labelledby="recorded-remote-title"><h2 id="recorded-remote-title">Recorded remote</h2>${view.remoteUrl
|
|
171
205
|
? html `<p class="path">${view.remoteUrl}</p>`
|
|
172
206
|
: html `<p>No remote recorded. Landed work waits for your review.</p>`}<details><summary>Change remote</summary><p>An owner or instance operator can run:</p><p class="path">toolroll repos add <project> --remote <url></p></details></section>`;
|
|
173
|
-
const head = html `${note(notice)}<p class="path">${view.repo}</p>${held}${remote}${buildsHtml(view)}${accessHtml(view)}${networkHtml(view)}${writesHtml(view)}${checkAccessHtml(view)}${liveViewHtml(view)}`;
|
|
207
|
+
const head = html `${note(notice)}<p class="path">${view.repo}</p>${held}${remote}${buildsHtml(view)}${accessHtml(view)}${networkHtml(view)}${writesHtml(view)}${sandboxHtml(view)}${checkAccessHtml(view)}${liveViewHtml(view)}`;
|
|
174
208
|
if (!view.canDelete)
|
|
175
209
|
return html `<section class="project-settings">${head}<p class="meta">An instance operator can delete a project.</p></section>`;
|
|
176
210
|
const body = view.running.length > 0
|