toolroll 0.9.69 → 0.9.71
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 +67 -0
- package/README.md +43 -17
- package/THIRD_PARTY_NOTICES.md +21 -1
- package/dist/agent-fence.d.ts +184 -115
- package/dist/agent-fence.js +418 -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 +8 -11
- 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 +25 -8
- package/dist/invoke.js +115 -115
- package/dist/operate.js +46 -0
- package/dist/project-delete-ui.d.ts +7 -6
- package/dist/project-delete-ui.js +37 -3
- 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 +109 -0
- package/dist/sandbox-runtime.js +281 -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,11 @@ 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
|
+
// With sign-ins, the keychain too (Claude Code keeps its macOS sign-in there), only while a person still set it.
|
|
1021
|
+
fenceGrants: signIns === null ? grants : { ...grants, signIns: { ...signIns.grants, read: signIns.grants.read.filter(path => signInIsItself(realpathSync(homedir()), path)) }, network: signIns.network,
|
|
1022
|
+
...(signInsKeychain(store, checkout === null ? null : store.getWorktree(checkout)?.repo ?? null) ? { keychain: true } : {}) },
|
|
1023
|
+
confinement: "required",
|
|
1014
1024
|
env: { ...(options?.env ?? {}), TMPDIR: temp, TMP: temp, TEMP: temp, ...(fenced.cache === undefined || fenced.grants.writes === undefined ? {} : buildCacheEnv(fenced.cache)), ...(signIns?.env ?? {}) },
|
|
1015
1025
|
...(signIns === null ? {} : { omitEnv: [...(options?.omitEnv ?? []), ...SIGN_INS_OMITTED_ENV] }),
|
|
1016
1026
|
});
|
|
@@ -1030,14 +1040,14 @@ export function fencedCommands(store, runId, writes, inner, available = () => ma
|
|
|
1030
1040
|
* project's writes setting, only that checkout, its Git data, its own temp folder, the project's caches and its check
|
|
1031
1041
|
* database writable. Where nothing can confine them, they run only when an instance operator allowed unconfined agents.
|
|
1032
1042
|
*/
|
|
1033
|
-
export function fencedCheckoutCommands(store, repo, checkout, inner, available = () => macosFenceAvailable() || linuxFenceAvailable(), access = CHECK_ACCESS_DEFAULT, network) {
|
|
1043
|
+
export function fencedCheckoutCommands(store, repo, checkout, inner, available = () => macosFenceAvailable() || linuxFenceAvailable(), access = CHECK_ACCESS_DEFAULT, network, sandbox = LEGACY_SANDBOX) {
|
|
1034
1044
|
if (inner !== runCommand)
|
|
1035
1045
|
return inner;
|
|
1036
1046
|
return async (file, args, options) => {
|
|
1037
1047
|
if (!available()) {
|
|
1038
1048
|
if (store.unfencedAgentsAllowed())
|
|
1039
1049
|
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 };
|
|
1050
|
+
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
1051
|
}
|
|
1042
1052
|
const databaseFile = store.databaseFile();
|
|
1043
1053
|
if (databaseFile === null)
|
|
@@ -1057,15 +1067,16 @@ export function fencedCheckoutCommands(store, repo, checkout, inner, available =
|
|
|
1057
1067
|
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
1068
|
}
|
|
1059
1069
|
const poolRoots = [join(dirname(databaseFile), "worktrees"), ...store.listWorktrees().map(record => poolRootOf(record.path, record.repo))];
|
|
1060
|
-
const boundary = taskBoundary({ worktree: checkout, provider: "command", binding });
|
|
1070
|
+
const boundary = taskBoundary({ worktree: checkout, provider: "command", binding, policy: sandbox.policy });
|
|
1061
1071
|
const folders = runFolders(own);
|
|
1062
1072
|
const cache = buildCacheFolder(repo);
|
|
1063
1073
|
const allowed = store.projectWrites(repo) !== "workspace" ? null : writeAllowlist({ provider: "command", home: homedir(), worktree: checkout, binding, launchFolder: own, cache });
|
|
1064
1074
|
const database = options?.env?.["TOOLROLL_DB"];
|
|
1065
1075
|
const checkData = allowed === null || database === undefined ? null : grantFolder(dirname(database), false);
|
|
1066
|
-
const
|
|
1067
|
-
|
|
1068
|
-
:
|
|
1076
|
+
const launch = fenceFor("check", { databaseFile, worktree: checkout, boundary, binding, runFolders: folders, poolRoots, extra: allowed?.deny ?? [], linked: linkedFor(store, checkout),
|
|
1077
|
+
readDeny: sandbox.readDeny.map(one => readDenyPath(one, homedir())), machLookup: sandbox.machLookup, policy: sandbox.policy,
|
|
1078
|
+
writes: allowed === null ? null : { writes: { ...allowed.writes, subpaths: [...allowed.writes.subpaths, ...(checkData === null ? [] : [checkData])] }, readOnly: allowed.readOnly } });
|
|
1079
|
+
const grants = launch.fenceGrants;
|
|
1069
1080
|
// With sign-ins, under the network its caller proved for it (the narrowest of its results' sealed check networks and
|
|
1070
1081
|
// the project's current one): none given, nothing runs.
|
|
1071
1082
|
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 +1086,12 @@ export function fencedCheckoutCommands(store, repo, checkout, inner, available =
|
|
|
1075
1086
|
try {
|
|
1076
1087
|
return await inner(file, args, {
|
|
1077
1088
|
...options,
|
|
1078
|
-
fence:
|
|
1079
|
-
|
|
1089
|
+
fence: launch.fence,
|
|
1090
|
+
// Each sign-in file looked at again right before the launch: one no longer itself isn't granted.
|
|
1091
|
+
// With sign-ins, the keychain too (Claude Code keeps its macOS sign-in there), only while a person still set it.
|
|
1092
|
+
fenceGrants: signIns === null ? grants : { ...grants, signIns: { ...signIns.grants, read: signIns.grants.read.filter(path => signInIsItself(realpathSync(homedir()), path)) }, network: signIns.network,
|
|
1093
|
+
...(signInsKeychain(store, repo) ? { keychain: true } : {}) },
|
|
1094
|
+
confinement: launch.confinement,
|
|
1080
1095
|
env: { ...(options?.env ?? {}), TMPDIR: temp, TMP: temp, TEMP: temp, ...(allowed === null ? {} : buildCacheEnv(cache)), ...(signIns?.env ?? {}) },
|
|
1081
1096
|
...(signIns === null ? {} : { omitEnv: [...(options?.omitEnv ?? []), ...SIGN_INS_OMITTED_ENV] }),
|
|
1082
1097
|
});
|
|
@@ -1156,80 +1171,40 @@ function plainLines(stdout) {
|
|
|
1156
1171
|
* keyed to its default one; check-access.ts). gh's and Codex's are set to the granted ones instead. */
|
|
1157
1172
|
const SIGN_INS_OMITTED_ENV = ["CLAUDE_CONFIG_DIR"];
|
|
1158
1173
|
/**
|
|
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.
|
|
1174
|
+
* A `sign-ins` check's launch (check-access.ts, sandbox parity C10): its exact sign-in files, read by the check itself
|
|
1175
|
+
* through its sandbox (agent-fence.ts signInGrants: nothing privileged reads or copies one), its own transcript folder
|
|
1176
|
+
* and Codex's session and log folders; Codex, gh and XDG pointed at the real folders; and the network the check runs
|
|
1177
|
+
* under: open, or its allowlist through a proxy of its own (on Linux, relayed in by the sandbox runtime). Nothing about it is
|
|
1178
|
+
* started unless all of it is.
|
|
1164
1179
|
*/
|
|
1180
|
+
/**
|
|
1181
|
+
* Whether a check with sign-ins also reaches the keychain (macOS securityd: any item every program may read, not only
|
|
1182
|
+
* the sign-ins). Only while the project's sign-ins level stands as a person set it: widening to sign-ins takes an
|
|
1183
|
+
* instance operator's password typed now (operate.ts project check-access, Settings → Project → Check access), and the
|
|
1184
|
+
* one who set it must still be an approver for the project and an instance operator now. Anything else: no keychain.
|
|
1185
|
+
*/
|
|
1186
|
+
export function signInsKeychain(store, repo) {
|
|
1187
|
+
if (repo === null || store.projectCheckAccess(repo) !== "sign-ins")
|
|
1188
|
+
return false;
|
|
1189
|
+
const change = store.projectCheckAccessChange(repo);
|
|
1190
|
+
const account = change === null ? null : store.accountOf(change.by);
|
|
1191
|
+
return account !== null && account.revokedAt === null && account.role === "approver" && store.accountCanAccess(change.by, repo) && store.isInstanceOperator(change.by);
|
|
1192
|
+
}
|
|
1165
1193
|
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
1194
|
if (!signInsAvailable(platform))
|
|
1169
|
-
return { problem: "Sign-ins can't be shared with checks
|
|
1195
|
+
return { problem: "Sign-ins can't be shared with checks here, so nothing ran." };
|
|
1170
1196
|
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
|
-
}
|
|
1197
|
+
const grants = signInGrants(home, checkout, platform);
|
|
1213
1198
|
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 }); };
|
|
1199
|
+
const close = async () => { await egress?.close(); };
|
|
1217
1200
|
try {
|
|
1218
|
-
const env = { CODEX_HOME: codex, GH_CONFIG_DIR: join(home, ".config", "gh"), XDG_CONFIG_HOME: join(home, ".config") };
|
|
1201
|
+
const env = { CODEX_HOME: join(home, ".codex"), GH_CONFIG_DIR: join(home, ".config", "gh"), XDG_CONFIG_HOME: join(home, ".config") };
|
|
1219
1202
|
if (policy.mode === "open")
|
|
1220
1203
|
return { grants, network: { mode: "open" }, env, close };
|
|
1221
1204
|
// Exactly the project's network: the services the sign-ins are for are reached only if the operator allowed their
|
|
1222
1205
|
// hosts (check-access.ts signInHostsMissing says which are missing; never added here).
|
|
1223
1206
|
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
|
-
}
|
|
1207
|
+
const network = { mode: "allowlist", proxyPort: egress.port, brokerPorts: [], loopback: policy.loopback, socketRoots: [temp, realpathSync(checkout)], token: egress.secret };
|
|
1233
1208
|
return { grants, network, env: { ...env, ...proxyEnv(egress, policy.loopback) }, close };
|
|
1234
1209
|
}
|
|
1235
1210
|
catch (error) {
|
|
@@ -1270,6 +1245,31 @@ export function checkoutRunner(store, repo, checkout, inner, runIds, ran, platfo
|
|
|
1270
1245
|
return (file, args, options) => {
|
|
1271
1246
|
const launch = checkoutLaunchOf(store, repo, runIds, platform);
|
|
1272
1247
|
ran(actualCheckAccess(store, launch.access));
|
|
1273
|
-
return fencedCheckoutCommands(store, repo, checkout, inner, undefined, launch.access, launch.network)(file, args, options);
|
|
1248
|
+
return fencedCheckoutCommands(store, repo, checkout, inner, undefined, launch.access, launch.network, checkoutSandboxOf(store, repo, runIds))(file, args, options);
|
|
1249
|
+
};
|
|
1250
|
+
}
|
|
1251
|
+
/**
|
|
1252
|
+
* A saved result's checks' sandbox terms (sandbox parity C9), never wider than any of those results' own approvals nor
|
|
1253
|
+
* the project now: v2 only when every result's approval sealed v2; every read deny any of them sealed or the project
|
|
1254
|
+
* lists now; mach services only those all of them sealed and the project still lists. A result with
|
|
1255
|
+
* no readable approval counts as v1 with nothing listed.
|
|
1256
|
+
*/
|
|
1257
|
+
export function checkoutSandboxOf(store, repo, runIds) {
|
|
1258
|
+
const sealed = runIds.map(id => {
|
|
1259
|
+
const taskRef = store.getRun(id)?.taskRef;
|
|
1260
|
+
const ref = taskRef === undefined ? null : store.refForId(taskRef);
|
|
1261
|
+
const profile = ref === null ? null : store.getScope(ref.externalId)?.approvedProfile ?? null;
|
|
1262
|
+
return profile === null ? LEGACY_SANDBOX : launchSandboxOf(profile);
|
|
1263
|
+
});
|
|
1264
|
+
const live = store.projectSandbox(repo);
|
|
1265
|
+
const common = (pick, now) => now.filter(one => sealed.length > 0 && sealed.every(terms => pick(terms).includes(one)));
|
|
1266
|
+
return {
|
|
1267
|
+
policy: sealed.length > 0 && sealed.every(one => one.policy === 2) ? 2 : 1,
|
|
1268
|
+
readDeny: [...new Set([...live.readDeny, ...sealed.flatMap(one => one.readDeny)])].sort(),
|
|
1269
|
+
machLookup: common(one => one.machLookup, live.machLookup),
|
|
1274
1270
|
};
|
|
1275
1271
|
}
|
|
1272
|
+
/** The digest of a launch's whole fence (its denied paths and grants, canonical), as stamped on its run (C11). */
|
|
1273
|
+
export function fenceDigest(fence, grants) {
|
|
1274
|
+
return createHash("sha256").update(`toolroll:fence:${canonicalJson({ fence: [...fence], grants: grants })}`).digest("hex").slice(0, 32);
|
|
1275
|
+
}
|
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?: {
|