toolroll 0.9.65 → 0.9.67
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 +57 -0
- package/README.md +20 -2
- package/dist/agent-fence.d.ts +69 -2
- package/dist/agent-fence.js +291 -11
- package/dist/agentconfig.js +4 -1
- package/dist/batch-checks.js +3 -2
- package/dist/browser/THIRD_PARTY_NOTICES.txt +46 -0
- package/dist/browser/terminal.js +134 -23
- package/dist/browser/workspace.css +1 -1
- package/dist/builder.d.ts +4 -0
- package/dist/builder.js +54 -12
- package/dist/contracts/console-api.d.ts +1 -0
- package/dist/contracts/console-api.js +1 -0
- package/dist/invoke.d.ts +18 -0
- package/dist/invoke.js +137 -14
- package/dist/live-terminal.d.ts +24 -13
- package/dist/live-terminal.js +79 -42
- package/dist/operate.d.ts +2 -0
- package/dist/operate.js +60 -2
- package/dist/project-delete-ui.d.ts +6 -0
- package/dist/project-delete-ui.js +20 -1
- package/dist/release-adapters.d.ts +6 -0
- package/dist/release-adapters.js +70 -3
- package/dist/release.d.ts +7 -1
- package/dist/release.js +20 -7
- package/dist/result-follow-ups.js +3 -2
- package/dist/scope.d.ts +13 -0
- package/dist/scope.js +10 -5
- package/dist/serve.js +1 -0
- package/dist/server/chrome.d.ts +10 -1
- package/dist/server/chrome.js +45 -4
- package/dist/server/console.css +55 -4
- package/dist/server/render-pages.d.ts +28 -3
- package/dist/server/render-pages.js +78 -9
- package/dist/server/render-tasks.js +2 -1
- package/dist/server/route-table.js +1 -0
- package/dist/server/settings.js +21 -0
- package/dist/server/tasks.js +7 -4
- package/dist/store.d.ts +11 -1
- package/dist/store.js +33 -2
- package/dist/teardown-git.d.ts +22 -0
- package/dist/teardown-git.js +136 -2
- package/dist/tool-broker.d.ts +12 -0
- package/dist/tool-broker.js +66 -2
- package/dist/unfenced-test-support.d.ts +2 -0
- package/dist/unfenced-test-support.js +11 -0
- package/dist/writes-mode.d.ts +9 -0
- package/dist/writes-mode.js +8 -0
- package/package.json +5 -1
package/dist/builder.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { legacyNetwork, repositoryEcosystems } from "./egress.js";
|
|
2
|
+
import { checkoutGit } from "./teardown-git.js";
|
|
2
3
|
import { isAcpProvider } from "./acp-agents.js";
|
|
3
4
|
import { OBSERVATION_MAILBOX, observationBrief, parseObservationCases, collectObservations } from "./observations.js";
|
|
4
5
|
import { checkCommandFor, effectiveCheckLevel, recordRunCheckLevel } from "./check-levels.js";
|
|
@@ -41,9 +42,9 @@ import { PREPARED_EVIDENCE_FILE, PREPARED_EVIDENCE_GIT, preparedScreenshotMatche
|
|
|
41
42
|
* watchdog and a high runaway-turn breaker still stop a pathological loop;
|
|
42
43
|
* repair remains narrowly time-bounded because its job is narrowly scoped.
|
|
43
44
|
*/
|
|
44
|
-
import { existsSync, unlinkSync, writeFileSync } from "node:fs";
|
|
45
|
+
import { existsSync, realpathSync, unlinkSync, writeFileSync } from "node:fs";
|
|
45
46
|
import { homedir } from "node:os";
|
|
46
|
-
import { join } from "node:path";
|
|
47
|
+
import { join, resolve } from "node:path";
|
|
47
48
|
import { run, runClaudeStreamJsonl, runOwnerTag, streamsInput } from "./exec.js";
|
|
48
49
|
import { stopRequestedFor, stopWords, underStopWatch } from "./task-control.js";
|
|
49
50
|
import { witnessedRunner } from "./process-custody.js";
|
|
@@ -58,7 +59,7 @@ import { heartbeat as runnerHeartbeat } from "./runner.js";
|
|
|
58
59
|
import { MARKER as LEASE_MARKER } from "./worktree.js";
|
|
59
60
|
import { parseDecision, parseHandoff, repairPrompt, HEADLESS_RULE, HANDOFF_CONCLUSION_CAP, HANDOFF_ITEM_CAP, HANDOFF_LIST_CAP, HANDOFF_PAYLOAD_CAP } from "./decision.js";
|
|
60
61
|
import { createHash, randomBytes, randomUUID } from "node:crypto";
|
|
61
|
-
import { invokeAgent } from "./invoke.js";
|
|
62
|
+
import { fencedCommands, invokeAgent } from "./invoke.js";
|
|
62
63
|
import { TELEGRAM_TOKEN_ENVS } from "./names.js";
|
|
63
64
|
import { OPENROUTER_ENV_KEY, auditOf, ALL_CREDENTIAL_ENV } from "./provider.js";
|
|
64
65
|
import { liveLogContext } from "./live-detail.js";
|
|
@@ -237,6 +238,43 @@ export function launchNetworkOf(store, taskRef, profile) {
|
|
|
237
238
|
const repo = store.refForId(taskRef)?.repo ?? null;
|
|
238
239
|
return { policy: legacyNetwork(profile.provider, repo === null ? [] : repositoryEcosystems(repo)) };
|
|
239
240
|
}
|
|
241
|
+
/** Asking a checkout which branch it is on: reads no file a build wrote and runs nothing. */
|
|
242
|
+
const BRANCH_QUERIES = [
|
|
243
|
+
["--no-optional-locks", "rev-parse", "--abbrev-ref", "HEAD"], ["--no-optional-locks", "symbolic-ref", "--short", "-q", "HEAD"],
|
|
244
|
+
];
|
|
245
|
+
/**
|
|
246
|
+
* The plane's git as a build runs it: hardened (teardown-git.ts checkoutGit) for every call, failing closed, but one —
|
|
247
|
+
* asking the project's own main checkout which branch it is on (its config is the operator's, and the query runs
|
|
248
|
+
* nothing). An injected runner (`request.git`: tests only; production always runs git itself) is used as given.
|
|
249
|
+
*/
|
|
250
|
+
function buildCheckoutGit(given, worktree, mainCheckout = null) {
|
|
251
|
+
if (given !== run)
|
|
252
|
+
return given;
|
|
253
|
+
const hardened = checkoutGit(given);
|
|
254
|
+
const real = (path) => { try {
|
|
255
|
+
return realpathSync(resolve(path));
|
|
256
|
+
}
|
|
257
|
+
catch {
|
|
258
|
+
return null;
|
|
259
|
+
} };
|
|
260
|
+
const trusted = mainCheckout === null ? null : real(mainCheckout);
|
|
261
|
+
const own = real(worktree);
|
|
262
|
+
return (file, args, options) => {
|
|
263
|
+
const at = options?.cwd === undefined ? null : real(options.cwd);
|
|
264
|
+
const query = BRANCH_QUERIES.some(one => one.length === args.length && one.every((word, index) => args[index] === word));
|
|
265
|
+
return trusted !== null && at === trusted && at !== own && query ? given(file, args, options) : hardened(file, args, options);
|
|
266
|
+
};
|
|
267
|
+
}
|
|
268
|
+
/** A run's setup and check commands, in its build's fence, writing where the approval this build runs under sealed
|
|
269
|
+
* (never the scope as it may since have been rewritten; invoke.ts fencedCommands). */
|
|
270
|
+
function commandRunner(store, runId, profile, inner) {
|
|
271
|
+
return fencedCommands(store, runId, launchWritesOf(profile), inner);
|
|
272
|
+
}
|
|
273
|
+
/** Where a build may write (writes-mode.ts): what its approval sealed, or — sealed before write allowlists — anywhere
|
|
274
|
+
* its fence allows, as it was approved. */
|
|
275
|
+
export function launchWritesOf(profile) {
|
|
276
|
+
return profile.writes ?? "anywhere";
|
|
277
|
+
}
|
|
240
278
|
export function proveApprovedProfile(scope, given) {
|
|
241
279
|
// The raw terms first (raw authority repair): a scope whose stored terms
|
|
242
280
|
// do not read back exactly proves nothing — not the filtered reading.
|
|
@@ -331,7 +369,9 @@ export function proveApprovedProfile(scope, given) {
|
|
|
331
369
|
* that forgot one is exactly the caller this is protecting against.
|
|
332
370
|
*/
|
|
333
371
|
export async function build(store, request) {
|
|
334
|
-
const { taskId, taskRef, runner, worktree, branch, now, permissionMode = "auto", skipPermissions = false, provider = "claude", model, maxTurns = DEFAULT_MAX_TURNS, timeoutMs = DEFAULT_BUILD_TIMEOUT_MS, agent, git = run, } = request;
|
|
372
|
+
const { taskId, taskRef, runner, worktree, branch, now, permissionMode = "auto", skipPermissions = false, provider = "claude", model, maxTurns = DEFAULT_MAX_TURNS, timeoutMs = DEFAULT_BUILD_TIMEOUT_MS, agent, git: givenGit = run, } = request;
|
|
373
|
+
// The plane's own git on this build's checkout runs hardened, and never where the checkout's config names a command.
|
|
374
|
+
const git = buildCheckoutGit(givenGit, worktree, store.getWorktree(worktree)?.repo ?? null);
|
|
335
375
|
const scope = store.getScope(taskId);
|
|
336
376
|
const approval = approvalOf(scope);
|
|
337
377
|
if (!approval.approved) {
|
|
@@ -625,7 +665,7 @@ export async function build(store, request) {
|
|
|
625
665
|
// Setup runs under the stop watch (v52), owned by this run: an
|
|
626
666
|
// operator's stop ends the setup's process group and the attempt
|
|
627
667
|
// settles as interrupted below, never as a setup failure.
|
|
628
|
-
const made = await underStopWatch(store, request.runId, () => runWithIsolatedDatabase(witnessedRunner(store, request.runId, request.clock ?? (() => now), runSetup), shell.file, shell.args, {
|
|
668
|
+
const made = await underStopWatch(store, request.runId, () => runWithIsolatedDatabase(witnessedRunner(store, request.runId, request.clock ?? (() => now), commandRunner(store, request.runId, effective.profile, runSetup)), shell.file, shell.args, {
|
|
629
669
|
cwd: worktree,
|
|
630
670
|
timeoutMs: setupWanted.timeoutMs,
|
|
631
671
|
processGroup: true,
|
|
@@ -1310,6 +1350,7 @@ export async function build(store, request) {
|
|
|
1310
1350
|
...watchdogsOf(effective.profile, effective.timeoutMs),
|
|
1311
1351
|
omitEnv: AGENT_ENV_DENYLIST,
|
|
1312
1352
|
network: launchNetworkOf(store, request.taskRef, effective.profile),
|
|
1353
|
+
writes: launchWritesOf(effective.profile),
|
|
1313
1354
|
...(agent === undefined ? {} : { runner: agent }),
|
|
1314
1355
|
onSpawn: (pid) => { spawned = true; request.onProviderSpawn?.(pid); },
|
|
1315
1356
|
onStreamEvent: (event) => liveLog.observe(event),
|
|
@@ -1774,7 +1815,7 @@ export async function settleProviderOutcome(captured, result) {
|
|
|
1774
1815
|
};
|
|
1775
1816
|
if (!eligible())
|
|
1776
1817
|
throw Error("The observation source or execution authority changed.");
|
|
1777
|
-
const execute = (file, args, options) => underStopWatch(store, request.runId, () => runWithIsolatedDatabase(witnessedRunner(store, request.runId, clock, request.verify ?? run), file, args, {
|
|
1818
|
+
const execute = (file, args, options) => underStopWatch(store, request.runId, () => runWithIsolatedDatabase(witnessedRunner(store, request.runId, clock, commandRunner(store, request.runId, captured.effective.profile, request.verify ?? run)), file, args, {
|
|
1778
1819
|
...options, processGroup: true, owner: runOwnerTag(store, request.runId), beforeSpawn: eligible,
|
|
1779
1820
|
onSpawn: pid => request.onProviderSpawn?.(pid), envAllowlist: SETUP_ENV_ALLOWLIST, omitEnv: SETUP_ENV_DENYLIST,
|
|
1780
1821
|
}));
|
|
@@ -2234,7 +2275,7 @@ async function settleProof(captured, statArtifactId, sealedHead) {
|
|
|
2234
2275
|
const runVerification = async (label) => {
|
|
2235
2276
|
const streamed = { stdout: false, stderr: false };
|
|
2236
2277
|
// The check runs under the stop watch (v52), owned by this run.
|
|
2237
|
-
const result = await underStopWatch(store, runId, () => runWithIsolatedDatabase(witnessedRunner(store, runId, now, verifyRunner), verifyShell.file, verifyShell.args, {
|
|
2278
|
+
const result = await underStopWatch(store, runId, () => runWithIsolatedDatabase(witnessedRunner(store, runId, now, commandRunner(store, runId, captured.effective.profile, verifyRunner)), verifyShell.file, verifyShell.args, {
|
|
2238
2279
|
cwd: worktree,
|
|
2239
2280
|
timeoutMs: configured.timeoutMs,
|
|
2240
2281
|
processGroup: true,
|
|
@@ -2276,7 +2317,7 @@ async function settleProof(captured, statArtifactId, sealedHead) {
|
|
|
2276
2317
|
detachShared(worktree, depsRoot);
|
|
2277
2318
|
const label = "Own install · approved project setup (package.json or lockfile changed)";
|
|
2278
2319
|
const setupShell = approvedCommandShell(live.command);
|
|
2279
|
-
const installed = await underStopWatch(store, runId, () => runWithIsolatedDatabase(witnessedRunner(store, runId, now, request.setup ?? run), setupShell.file, setupShell.args, {
|
|
2320
|
+
const installed = await underStopWatch(store, runId, () => runWithIsolatedDatabase(witnessedRunner(store, runId, now, commandRunner(store, runId, captured.effective.profile, request.setup ?? run)), setupShell.file, setupShell.args, {
|
|
2280
2321
|
cwd: worktree,
|
|
2281
2322
|
timeoutMs: live.timeoutMs,
|
|
2282
2323
|
processGroup: true,
|
|
@@ -2295,7 +2336,7 @@ async function settleProof(captured, statArtifactId, sealedHead) {
|
|
|
2295
2336
|
checkLog.push(attemptLog(label, live.command, installed));
|
|
2296
2337
|
if (installed.notFound || installed.timedOut || installed.code !== 0)
|
|
2297
2338
|
return "own-install-failed";
|
|
2298
|
-
return await sealedTreeState(request.git ?? run) === "clean" ? null : "setup-changed-files";
|
|
2339
|
+
return await sealedTreeState(buildCheckoutGit(request.git ?? run, worktree)) === "clean" ? null : "setup-changed-files";
|
|
2299
2340
|
};
|
|
2300
2341
|
const ownInstall = await ownInstallIfChanged();
|
|
2301
2342
|
const first = ownInstall === null ? await runVerification("Project check · attempt 1") : null;
|
|
@@ -2339,7 +2380,7 @@ async function settleProof(captured, statArtifactId, sealedHead) {
|
|
|
2339
2380
|
verifyCommand = { configured: true, ran: false, attemptFailed: true, failure: "custody-lost" };
|
|
2340
2381
|
}
|
|
2341
2382
|
else {
|
|
2342
|
-
const gitRunner = request.git ?? run;
|
|
2383
|
+
const gitRunner = buildCheckoutGit(request.git ?? run, worktree);
|
|
2343
2384
|
const beforeSetup = await sealedTreeState(gitRunner);
|
|
2344
2385
|
if (beforeSetup === "head-moved") {
|
|
2345
2386
|
verifyCommand = { configured: true, ran: false, attemptFailed: true, failure: "checkout-moved" };
|
|
@@ -2366,7 +2407,7 @@ async function settleProof(captured, statArtifactId, sealedHead) {
|
|
|
2366
2407
|
else {
|
|
2367
2408
|
const setupRunner = request.setup ?? run;
|
|
2368
2409
|
const setupShell = approvedCommandShell(liveBeforeSetup.command);
|
|
2369
|
-
const restored = await underStopWatch(store, runId, () => runWithIsolatedDatabase(witnessedRunner(store, runId, now, setupRunner), setupShell.file, setupShell.args, {
|
|
2410
|
+
const restored = await underStopWatch(store, runId, () => runWithIsolatedDatabase(witnessedRunner(store, runId, now, commandRunner(store, runId, captured.effective.profile, setupRunner)), setupShell.file, setupShell.args, {
|
|
2370
2411
|
cwd: worktree,
|
|
2371
2412
|
timeoutMs: liveBeforeSetup.timeoutMs,
|
|
2372
2413
|
processGroup: true,
|
|
@@ -2653,6 +2694,7 @@ async function resumeUnhandedWork(captured, sessionId) {
|
|
|
2653
2694
|
...watchdogsOf(effective.profile, effective.timeoutMs),
|
|
2654
2695
|
omitEnv: AGENT_ENV_DENYLIST,
|
|
2655
2696
|
network: launchNetworkOf(store, request.taskRef, effective.profile),
|
|
2697
|
+
writes: launchWritesOf(effective.profile),
|
|
2656
2698
|
...(agent === undefined ? {} : { runner: agent }),
|
|
2657
2699
|
...(request.onProviderSpawn === undefined ? {} : { onSpawn: request.onProviderSpawn }),
|
|
2658
2700
|
clock,
|
|
@@ -2867,7 +2909,7 @@ async function ingestPark(args) {
|
|
|
2867
2909
|
? watchdogsOf(args.profile, args.profile.repairTimeoutSeconds * 1000, true)
|
|
2868
2910
|
: { timeoutMs: args.profile !== undefined ? args.profile.repairTimeoutSeconds * 1000 : REPAIR_TIMEOUT_MS }),
|
|
2869
2911
|
omitEnv: AGENT_ENV_DENYLIST,
|
|
2870
|
-
...(args.profile === undefined ? {} : { network: launchNetworkOf(store, request.taskRef, args.profile) }),
|
|
2912
|
+
...(args.profile === undefined ? {} : { network: launchNetworkOf(store, request.taskRef, args.profile), writes: launchWritesOf(args.profile) }),
|
|
2871
2913
|
...(agent === undefined ? {} : { runner: agent }),
|
|
2872
2914
|
...(request.onProviderSpawn === undefined ? {} : { onSpawn: request.onProviderSpawn }),
|
|
2873
2915
|
clock,
|
|
@@ -83,6 +83,7 @@ export declare const CONSOLE_FORMS: {
|
|
|
83
83
|
readonly projectConcurrency: FormContract<"concurrency" | "repo">;
|
|
84
84
|
readonly projectLiveView: FormContract<"act" | "nonce" | "password" | "repo">;
|
|
85
85
|
readonly projectNetwork: FormContract<"hosts" | "local" | "mode" | "password" | "repo">;
|
|
86
|
+
readonly projectWrites: FormContract<"password" | "repo" | "writes">;
|
|
86
87
|
readonly projectDelete: FormContract<"name" | "password" | "repo" | "step">;
|
|
87
88
|
readonly policy: FormContract<"ceiling" | "models" | "password" | "provider" | "tools">;
|
|
88
89
|
readonly approval: FormContract<"not_requester" | "password" | "paths" | "protect" | "repo">;
|
|
@@ -92,6 +92,7 @@ export const CONSOLE_FORMS = {
|
|
|
92
92
|
projectConcurrency: formContract("POST /settings/project/concurrency", ["repo", "concurrency"]),
|
|
93
93
|
projectLiveView: formContract("POST /settings/project/live-view", ["repo", "act", "password", "nonce"]),
|
|
94
94
|
projectNetwork: formContract("POST /settings/project/network", ["repo", "mode", "local", "hosts", "password"]),
|
|
95
|
+
projectWrites: formContract("POST /settings/project/writes", ["repo", "writes", "password"]),
|
|
95
96
|
projectDelete: formContract("POST /settings/project/delete", ["repo", "name", "step", "password"]),
|
|
96
97
|
policy: formContract("POST /settings/policy", ["password", "provider", "models", "tools", "ceiling"]),
|
|
97
98
|
approval: formContract("POST /settings/approval", ["repo", "password", "protect", "paths", "not_requester"]),
|
package/dist/invoke.d.ts
CHANGED
|
@@ -20,6 +20,8 @@ import { type AgentSpec, type Invocation, type ProviderRunner, type AgentEnding
|
|
|
20
20
|
import { type VersionProbe } from "./attest.js";
|
|
21
21
|
import type { Store } from "./store.js";
|
|
22
22
|
import type { RunOptions } from "./exec.js";
|
|
23
|
+
import type { Runner } from "./worktree.js";
|
|
24
|
+
import { type WritesMode } from "./agent-fence.js";
|
|
23
25
|
import { type NetworkProfile } from "./egress.js";
|
|
24
26
|
export type { ProviderRunner } from "./provider.js";
|
|
25
27
|
/** The claude binary name — kept for the legacy quota rows and tests that
|
|
@@ -109,4 +111,20 @@ export declare function invokeAgent(store: Store, runId: number, spec: AgentSpec
|
|
|
109
111
|
keyHome?: string;
|
|
110
112
|
accumulateUsage?: boolean;
|
|
111
113
|
network?: LaunchNetwork;
|
|
114
|
+
writes?: WritesMode;
|
|
112
115
|
}): Promise<InvokeResult>;
|
|
116
|
+
/**
|
|
117
|
+
* A build's setup and check commands, run in its fence (arc 2): the same denies as its agent's (Toolroll's state,
|
|
118
|
+
* credentials, other projects and runs), its write allowlist when its approval sealed one (its checkout and Git data,
|
|
119
|
+
* its project's caches, and a temp folder of the command's own), and its package managers pointed at those caches.
|
|
120
|
+
* Their network stays as it was. A machine without an OS fence runs them as before; a fence that can't be prepared
|
|
121
|
+
* (a checkout whose binding no longer proves) runs nothing.
|
|
122
|
+
*/
|
|
123
|
+
export declare function fencedCommands(store: Store, runId: number, writes: WritesMode, inner: Runner, available?: () => boolean): Runner;
|
|
124
|
+
/**
|
|
125
|
+
* A project's checks run in a checkout of a result's commit made for them (result-follow-ups.ts, batch-checks.ts), in
|
|
126
|
+
* the same fence as a build's commands: Toolroll's state, credentials, other projects and runs denied; under the
|
|
127
|
+
* project's writes setting, only that checkout, its Git data, its own temp folder, the project's caches and its check
|
|
128
|
+
* database writable. Where nothing can confine them, they run only when an instance operator allowed unconfined agents.
|
|
129
|
+
*/
|
|
130
|
+
export declare function fencedCheckoutCommands(store: Store, repo: string, checkout: string, inner: Runner, available?: () => boolean): Runner;
|
package/dist/invoke.js
CHANGED
|
@@ -29,12 +29,12 @@ 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 { mkdirSync, readFileSync, realpathSync } from "node:fs";
|
|
33
|
-
import { homedir } from "node:os";
|
|
32
|
+
import { existsSync, mkdirSync, mkdtempSync, readFileSync, realpathSync, rmSync } from "node:fs";
|
|
33
|
+
import { homedir, tmpdir } from "node:os";
|
|
34
34
|
import { dirname, join } from "node:path";
|
|
35
35
|
import { poolRootOf } from "./worktree.js";
|
|
36
36
|
import { BRIDGE_DIR } from "./agent-fence.js";
|
|
37
|
-
import { agentFence, CheckoutBindingError, trustedWorktreeBinding, checkoutGitEnv, HARDENED_GIT_ARGS, codexFenceArgv, fenceGrants, fencedGitEnv, linuxFenceAvailable, linuxFenced, macosFenceAvailable, macosFenced, NO_GRANTS, runFolders, taskBoundary } from "./agent-fence.js";
|
|
37
|
+
import { agentFence, buildCacheEnv, buildCacheFolder, grantFolder, writeAllowlist, WRITES_DEFAULT, CheckoutBindingError, trustedWorktreeBinding, checkoutGitEnv, HARDENED_GIT_ARGS, codexFenceArgv, fenceGrants, fencedGitEnv, linuxFenceAvailable, linuxFenced, macosFenceAvailable, macosFenced, NO_GRANTS, runFolders, taskBoundary } from "./agent-fence.js";
|
|
38
38
|
import { proxyEnv, providerOnlyNetwork, startEgressProxy } from "./egress.js";
|
|
39
39
|
import { bridgeFits, startEgressBridge } from "./egress-bridge.js";
|
|
40
40
|
import { claudeConfigDir, claudeDefaults, codexDefaults, codexHomeDir, isolatedCodexHome } from "./provider-home.js";
|
|
@@ -239,7 +239,7 @@ export async function invokeAgent(store, runId, spec, invocation, options) {
|
|
|
239
239
|
// re-supply survives resolveChildEnv's chat-key strip); in
|
|
240
240
|
// "subscription" mode the key is STRIPPED from the child so the CLI's
|
|
241
241
|
// own login is used — its key is retained, just not handed over.
|
|
242
|
-
const { runner, clock: _clock, versionProbe: _probe, keyHome, timeoutMs: _hardTimeout, idleTimeoutMs: _idleTimeout, stdin: _callerStdin, accumulateUsage: _accumulateUsage, liveInput: _liveInput, acpCheck: _acpCheck, network: launchNetwork, ...runOptions } = options;
|
|
242
|
+
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;
|
|
243
243
|
// The live setting is read STRICTLY (atomic authority closure): a
|
|
244
244
|
// present mode file that says neither word is a value-shaped refusal
|
|
245
245
|
// before any stamp or process — never the subscription default a
|
|
@@ -300,12 +300,21 @@ export async function invokeAgent(store, runId, spec, invocation, options) {
|
|
|
300
300
|
// The project's tools (v80): exactly its MCP servers, and none of the
|
|
301
301
|
// operator's global or a repository's own, served by this launch's broker
|
|
302
302
|
// (tool-broker.ts), which alone holds their secrets. A review keeps its isolation.
|
|
303
|
+
// Set once the fence is known: a fenced build's servers never start outside a fence.
|
|
304
|
+
let serverMustBeFenced = false;
|
|
305
|
+
// What the build writes (its checkout, its project's caches): a tool server never runs from there, nor reads it at all.
|
|
306
|
+
const serverUntrusted = workspace === null ? [] : toolUntrusted(store, workspace, keyHome);
|
|
303
307
|
const launchTools = invocation.phase === "review" ? null : await runTools(store, runId, spec, options.keyHome, clock, invocation.readOnlyTools, invocation.researchWithheld, containment, {
|
|
304
308
|
env: { ...process.env, ...(runOptions.env ?? {}) },
|
|
305
309
|
omitEnv: [...(runOptions.omitEnv ?? []), ...ALL_CREDENTIAL_ENV],
|
|
306
310
|
pass: childDatabaseEnv(isolatedDb.file),
|
|
307
|
-
|
|
311
|
+
// A tool server starts in a folder the build can't write, and never runs the build's own bytes (arc 2).
|
|
312
|
+
cwd: keyHome ?? homedir(),
|
|
313
|
+
untrusted: serverUntrusted,
|
|
308
314
|
wrap: () => serverFence,
|
|
315
|
+
// A fenced build's tool servers start only in a fence too, unless an instance operator allowed unconfined agents.
|
|
316
|
+
unwrapped: () => serverMustBeFenced && !store.unfencedAgentsAllowed()
|
|
317
|
+
? "This machine can't confine the tool's server (no macOS sandbox, no working bubblewrap), so it was not started." : null,
|
|
309
318
|
});
|
|
310
319
|
if (launchTools !== null && "refused" in launchTools) {
|
|
311
320
|
removeAgentDatabase(isolatedDb.dir);
|
|
@@ -333,7 +342,7 @@ export async function invokeAgent(store, runId, spec, invocation, options) {
|
|
|
333
342
|
// tool servers outside its command sandbox, so its own launch folder is
|
|
334
343
|
// fenced too; Claude reads its config from there, inside the sandbox.
|
|
335
344
|
const ownTools = tools?.privateDir === undefined ? null : realpathSync(tools.privateDir);
|
|
336
|
-
const fenceOf = invocation.phase === "review" ? { fence: [], fileTools: [], grants: NO_GRANTS, gitPaths: [], problem: undefined } : runFence(store, runId, spec.provider, options.keyHome, codexHarness ? null : ownTools);
|
|
345
|
+
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);
|
|
337
346
|
if (fenceOf.problem !== undefined) {
|
|
338
347
|
tools?.cleanup();
|
|
339
348
|
codexHome?.cleanup();
|
|
@@ -372,12 +381,16 @@ export async function invokeAgent(store, runId, spec, invocation, options) {
|
|
|
372
381
|
// A tool server the broker starts is fenced by what this platform supports, whatever the provider: Codex's own
|
|
373
382
|
// profile fences only its agent's commands, and the broker starts servers outside it.
|
|
374
383
|
if (fence.length > 0) {
|
|
375
|
-
// A tool server keeps its own reach (it holds its tool's secrets, not the agent's code): never the agent's network
|
|
376
|
-
|
|
384
|
+
// A tool server keeps its own reach (it holds its tool's secrets, not the agent's code): never the agent's network,
|
|
385
|
+
// nor its write allowlist (a server keeps its own state where it always has).
|
|
386
|
+
const serverGrants = () => { const { network: _agentOnly, writes: _agentWrites, ...rest } = grants; return rest; };
|
|
387
|
+
// Nor read what the build wrote: however its arguments are spelled, no byte of the checkout or caches can load.
|
|
388
|
+
const serverDenies = [...fence, ...serverUntrusted.filter(path => existsSync(path)).map(path => realpathSync(path))];
|
|
377
389
|
if (macosFenceAvailable())
|
|
378
|
-
serverFence = (file, args) => macosFenced(file, args,
|
|
390
|
+
serverFence = (file, args) => macosFenced(file, args, serverDenies, serverGrants());
|
|
379
391
|
else if (linuxFenceAvailable())
|
|
380
|
-
serverFence = (file, args) => linuxFenced(file, args,
|
|
392
|
+
serverFence = (file, args) => linuxFenced(file, args, serverDenies, serverGrants());
|
|
393
|
+
serverMustBeFenced = true;
|
|
381
394
|
}
|
|
382
395
|
// No silent fallback (arc 1): a build this machine can't confine at the OS — Claude reduced to its own file-tool
|
|
383
396
|
// rules, Gemini to nothing — doesn't start unless the instance operator allowed unconfined agents here. An injected
|
|
@@ -457,6 +470,10 @@ export async function invokeAgent(store, runId, spec, invocation, options) {
|
|
|
457
470
|
...(egress === null || !("policy" in network) ? {} : proxyEnv(egress, network.policy.loopback)),
|
|
458
471
|
// Its own temp folder, where its tools' sockets may live (the only one its fence admits besides its checkout).
|
|
459
472
|
...(runTemp === null ? {} : { TMPDIR: runTemp, TMP: runTemp, TEMP: runTemp }),
|
|
473
|
+
// Claude's own working folder goes there too (it is otherwise the shared /tmp/claude-<uid>).
|
|
474
|
+
...(runTemp === null || spec.provider !== "claude" ? {} : { CLAUDE_CODE_TMPDIR: runTemp }),
|
|
475
|
+
// A workspace build's package managers keep their caches in its project's own folder (arc 2).
|
|
476
|
+
...(fenceOf.cache === undefined || grants.writes === undefined ? {} : buildCacheEnv(fenceOf.cache)),
|
|
460
477
|
// This key is deliberately last: no caller may point an agent back at
|
|
461
478
|
// the live control database through a generic RunOptions override.
|
|
462
479
|
...childDatabaseEnv(isolatedDb.file),
|
|
@@ -906,7 +923,7 @@ function linkedFor(store, worktree) {
|
|
|
906
923
|
/** The paths this run's agent may not reach: the state folder around the live database (but not its own worktree),
|
|
907
924
|
* ~/.toolroll (or its older name), its task boundary and other launches' private folders; what the fence gives back
|
|
908
925
|
* (FenceGrants); and what only Claude's file tools refuse. */
|
|
909
|
-
function runFence(store, runId, provider, keyHome, ownTools) {
|
|
926
|
+
function runFence(store, runId, provider, keyHome, ownTools, writes = WRITES_DEFAULT) {
|
|
910
927
|
try {
|
|
911
928
|
// A plane always has a database file; an in-memory store (a test) has nothing to fence.
|
|
912
929
|
const databaseFile = store.databaseFile();
|
|
@@ -921,22 +938,128 @@ function runFence(store, runId, provider, keyHome, ownTools) {
|
|
|
921
938
|
const binding = worktree === null || row === null ? null : trustedWorktreeBinding(row.repo, worktree, poolRoots);
|
|
922
939
|
const boundary = worktree === null ? null : taskBoundary({ worktree, provider, ...home, binding });
|
|
923
940
|
const folders = runFolders(ownTools);
|
|
941
|
+
// A build with a checkout writes only its workspace unless its approval said anywhere (arc 2): its checkout and
|
|
942
|
+
// Git data, its launch folder, its project's caches and the provider state its CLI must write.
|
|
943
|
+
const allowed = worktree === null || row === null || writes !== "workspace" ? null
|
|
944
|
+
: writeAllowlist({ provider, home: keyHome ?? homedir(), worktree, binding, launchFolder: ownTools, cache: buildCacheFolder(row.repo, keyHome ?? homedir()) });
|
|
945
|
+
const fence = agentFence({ databaseFile, worktree, ...home, boundary, binding, runFolders: folders, extra: allowed?.deny ?? [] });
|
|
946
|
+
const grants = fenceGrants({ databaseFile, worktree, boundary, runFolders: folders, linked: linkedFor(store, worktree), binding, ...home, poolRoots });
|
|
924
947
|
return {
|
|
925
|
-
fence
|
|
948
|
+
fence,
|
|
926
949
|
fileTools: boundary?.fileTools ?? [],
|
|
927
950
|
// The shared install is granted only as the plane linked it: this checkout's project and stamped setup.
|
|
928
951
|
// Every plane checkout pool is denied around this one: the plane's own beside the database, and the one this
|
|
929
952
|
// 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.
|
|
930
|
-
grants:
|
|
931
|
-
poolRoots }),
|
|
953
|
+
grants: allowed === null ? grants : { ...grants, writes: allowed.writes, readOnly: [...new Set([...grants.readOnly, ...allowed.readOnly])].sort() },
|
|
932
954
|
// The checkout's own Git data, which a private /tmp gives back beside the checkout (agent-fence.ts linuxFenced).
|
|
933
955
|
gitPaths: binding === null ? [] : [binding.gitdir, join(binding.main, ".git")],
|
|
956
|
+
...(allowed === null || row === null ? {} : { cache: buildCacheFolder(row.repo, keyHome ?? homedir()) }),
|
|
934
957
|
};
|
|
935
958
|
}
|
|
936
959
|
catch (error) {
|
|
937
960
|
return { fence: [], fileTools: [], grants: NO_GRANTS, problem: error instanceof CheckoutBindingError ? error.message : "The build fence could not be prepared." };
|
|
938
961
|
}
|
|
939
962
|
}
|
|
963
|
+
/**
|
|
964
|
+
* A build's setup and check commands, run in its fence (arc 2): the same denies as its agent's (Toolroll's state,
|
|
965
|
+
* credentials, other projects and runs), its write allowlist when its approval sealed one (its checkout and Git data,
|
|
966
|
+
* its project's caches, and a temp folder of the command's own), and its package managers pointed at those caches.
|
|
967
|
+
* Their network stays as it was. A machine without an OS fence runs them as before; a fence that can't be prepared
|
|
968
|
+
* (a checkout whose binding no longer proves) runs nothing.
|
|
969
|
+
*/
|
|
970
|
+
export function fencedCommands(store, runId, writes, inner, available = () => macosFenceAvailable() || linuxFenceAvailable()) {
|
|
971
|
+
// An injected runner (tests) starts no real command, as an injected agent starts no real agent (invokeAgent).
|
|
972
|
+
if (inner !== runCommand)
|
|
973
|
+
return inner;
|
|
974
|
+
return async (file, args, options) => {
|
|
975
|
+
// As a build itself: where nothing can confine them, they run only when an instance operator allowed unconfined agents.
|
|
976
|
+
if (!available()) {
|
|
977
|
+
if (store.unfencedAgentsAllowed())
|
|
978
|
+
return inner(file, args, options);
|
|
979
|
+
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 };
|
|
980
|
+
}
|
|
981
|
+
const own = realpathSync(mkdtempSync(join(tmpdir(), "so-cmd-")));
|
|
982
|
+
try {
|
|
983
|
+
const temp = join(own, "tmp");
|
|
984
|
+
mkdirSync(temp, { mode: 0o700 });
|
|
985
|
+
const fenced = runFence(store, runId, "command", undefined, own, writes);
|
|
986
|
+
if (fenced.problem !== undefined)
|
|
987
|
+
return { code: 1, stdout: "", stderr: `${fenced.problem}\n`, timedOut: false, notFound: false };
|
|
988
|
+
if (fenced.fence.length === 0)
|
|
989
|
+
return inner(file, args, options);
|
|
990
|
+
// A self-hosted check's own database (child-database.ts) is the command's to write too.
|
|
991
|
+
const database = options?.env?.["TOOLROLL_DB"];
|
|
992
|
+
const checkData = database === undefined || fenced.grants.writes === undefined ? null : grantFolder(dirname(database), false);
|
|
993
|
+
const grants = checkData === null ? fenced.grants : { ...fenced.grants, writes: { ...fenced.grants.writes, subpaths: [...fenced.grants.writes.subpaths, checkData] } };
|
|
994
|
+
return await inner(file, args, {
|
|
995
|
+
...options,
|
|
996
|
+
fence: fenced.fence,
|
|
997
|
+
fenceGrants: grants,
|
|
998
|
+
env: { ...(options?.env ?? {}), TMPDIR: temp, TMP: temp, TEMP: temp, ...(fenced.cache === undefined || fenced.grants.writes === undefined ? {} : buildCacheEnv(fenced.cache)) },
|
|
999
|
+
});
|
|
1000
|
+
}
|
|
1001
|
+
finally {
|
|
1002
|
+
rmSync(own, { recursive: true, force: true });
|
|
1003
|
+
}
|
|
1004
|
+
};
|
|
1005
|
+
}
|
|
1006
|
+
/**
|
|
1007
|
+
* A project's checks run in a checkout of a result's commit made for them (result-follow-ups.ts, batch-checks.ts), in
|
|
1008
|
+
* the same fence as a build's commands: Toolroll's state, credentials, other projects and runs denied; under the
|
|
1009
|
+
* project's writes setting, only that checkout, its Git data, its own temp folder, the project's caches and its check
|
|
1010
|
+
* database writable. Where nothing can confine them, they run only when an instance operator allowed unconfined agents.
|
|
1011
|
+
*/
|
|
1012
|
+
export function fencedCheckoutCommands(store, repo, checkout, inner, available = () => macosFenceAvailable() || linuxFenceAvailable()) {
|
|
1013
|
+
if (inner !== runCommand)
|
|
1014
|
+
return inner;
|
|
1015
|
+
return async (file, args, options) => {
|
|
1016
|
+
if (!available()) {
|
|
1017
|
+
if (store.unfencedAgentsAllowed())
|
|
1018
|
+
return inner(file, args, options);
|
|
1019
|
+
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 };
|
|
1020
|
+
}
|
|
1021
|
+
const databaseFile = store.databaseFile();
|
|
1022
|
+
if (databaseFile === null)
|
|
1023
|
+
return inner(file, args, options);
|
|
1024
|
+
const own = realpathSync(mkdtempSync(join(tmpdir(), "so-cmd-")));
|
|
1025
|
+
try {
|
|
1026
|
+
const temp = join(own, "tmp");
|
|
1027
|
+
mkdirSync(temp, { mode: 0o700 });
|
|
1028
|
+
let binding;
|
|
1029
|
+
try {
|
|
1030
|
+
binding = trustedWorktreeBinding(repo, checkout);
|
|
1031
|
+
}
|
|
1032
|
+
catch {
|
|
1033
|
+
return { code: 1, stdout: "", stderr: "The check's checkout couldn't be bound to its project, so nothing ran.\n", timedOut: false, notFound: false };
|
|
1034
|
+
}
|
|
1035
|
+
const poolRoots = [join(dirname(databaseFile), "worktrees"), ...store.listWorktrees().map(record => poolRootOf(record.path, record.repo))];
|
|
1036
|
+
const boundary = taskBoundary({ worktree: checkout, provider: "command", binding });
|
|
1037
|
+
const folders = runFolders(own);
|
|
1038
|
+
const cache = buildCacheFolder(repo);
|
|
1039
|
+
const allowed = store.projectWrites(repo) !== "workspace" ? null : writeAllowlist({ provider: "command", home: homedir(), worktree: checkout, binding, launchFolder: own, cache });
|
|
1040
|
+
const database = options?.env?.["TOOLROLL_DB"];
|
|
1041
|
+
const checkData = allowed === null || database === undefined ? null : grantFolder(dirname(database), false);
|
|
1042
|
+
const base = fenceGrants({ databaseFile, worktree: checkout, boundary, runFolders: folders, linked: linkedFor(store, checkout), binding, poolRoots });
|
|
1043
|
+
const grants = allowed === null ? base
|
|
1044
|
+
: { ...base, readOnly: [...new Set([...base.readOnly, ...allowed.readOnly])].sort(), writes: { ...allowed.writes, subpaths: [...allowed.writes.subpaths, ...(checkData === null ? [] : [checkData])] } };
|
|
1045
|
+
return await inner(file, args, {
|
|
1046
|
+
...options,
|
|
1047
|
+
fence: agentFence({ databaseFile, worktree: checkout, boundary, binding, runFolders: folders, extra: allowed?.deny ?? [] }),
|
|
1048
|
+
fenceGrants: grants,
|
|
1049
|
+
env: { ...(options?.env ?? {}), TMPDIR: temp, TMP: temp, TEMP: temp, ...(allowed === null ? {} : buildCacheEnv(cache)) },
|
|
1050
|
+
});
|
|
1051
|
+
}
|
|
1052
|
+
finally {
|
|
1053
|
+
rmSync(own, { recursive: true, force: true });
|
|
1054
|
+
}
|
|
1055
|
+
};
|
|
1056
|
+
}
|
|
1057
|
+
/** What a build writes that a tool server must never run from: its checkout and its project's caches. A checkout with
|
|
1058
|
+
* no record never gets this far: its fence refuses the launch (runFence) before any tool server can start. */
|
|
1059
|
+
function toolUntrusted(store, workspace, keyHome) {
|
|
1060
|
+
const row = store.getWorktree(workspace);
|
|
1061
|
+
return [workspace, ...(row === null ? [] : [buildCacheFolder(row.repo, keyHome ?? homedir())])];
|
|
1062
|
+
}
|
|
940
1063
|
/** The operator's own model choices a build routed with no model keeps (provider-home.ts). */
|
|
941
1064
|
function inheritedDefaults(provider, env, home) {
|
|
942
1065
|
if (provider === "claude")
|
package/dist/live-terminal.d.ts
CHANGED
|
@@ -6,8 +6,6 @@ export declare const MAX_LIVE_TERMINALS = 8;
|
|
|
6
6
|
export declare const MAX_KEPT_TERMINALS = 16;
|
|
7
7
|
/** At most this many pages attached to one session (a desktop, a phone, a spare tab or two). */
|
|
8
8
|
export declare const MAX_VIEWERS = 4;
|
|
9
|
-
/** Raw output kept per session for a page that attaches later. */
|
|
10
|
-
export declare const SCROLLBACK_BYTES: number;
|
|
11
9
|
/** After SIGHUP, the terminal's processes have this long before SIGKILL. */
|
|
12
10
|
export declare const CLOSE_GRACE_MS = 2000;
|
|
13
11
|
/** Typed input not yet read by the program, per terminal: past this, more is refused until it reads. */
|
|
@@ -54,15 +52,24 @@ export declare const ttyName: (path: unknown) => string | null;
|
|
|
54
52
|
export declare function pendingInputOf(pty: unknown): number;
|
|
55
53
|
/** node-pty, if this install has it (an optional dependency with prebuilt binaries); null when it doesn't load. */
|
|
56
54
|
export declare function loadPtySpawn(): PtySpawn | null;
|
|
57
|
-
/**
|
|
58
|
-
export declare
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
55
|
+
/** How many lines above the screen a terminal keeps for a page that attaches later. */
|
|
56
|
+
export declare const SCREEN_SCROLLBACK_LINES = 5000;
|
|
57
|
+
/**
|
|
58
|
+
* The terminal as a page would see it now: every byte the program writes is parsed by a headless xterm here, so a page
|
|
59
|
+
* that attaches later gets the screen, its scrollback and its modes (the alternate screen of a full-screen program, the
|
|
60
|
+
* cursor, colours, bracketed paste, mouse reporting) — never a replay of raw output cut at an arbitrary byte, which
|
|
61
|
+
* could split an escape sequence or redraw a full-screen program garbled.
|
|
62
|
+
*/
|
|
63
|
+
export declare class ScreenMirror {
|
|
64
|
+
private readonly lines;
|
|
65
|
+
private readonly terminal;
|
|
66
|
+
private readonly serializer;
|
|
67
|
+
constructor(cols: number, rows: number, lines?: number);
|
|
68
|
+
write(data: string): void;
|
|
69
|
+
resize(cols: number, rows: number): void;
|
|
70
|
+
/** The screen as of this call (after everything written before it), handed to `done` once parsed. */
|
|
71
|
+
snapshot(done: (screen: string) => void): void;
|
|
72
|
+
dispose(): void;
|
|
66
73
|
}
|
|
67
74
|
/** One attached page. The host never waits on it. */
|
|
68
75
|
export type TerminalViewer = {
|
|
@@ -138,7 +145,8 @@ export type TerminalHostOptions = {
|
|
|
138
145
|
/** Start, close and remove, for the event log (never input or output). */
|
|
139
146
|
record?: (event: "start" | "close" | "remove" | "exit", session: TerminalSummary) => void;
|
|
140
147
|
maxSessions?: number;
|
|
141
|
-
|
|
148
|
+
/** Lines above the screen a terminal keeps for a page that attaches later (SCREEN_SCROLLBACK_LINES). */
|
|
149
|
+
scrollbackLines?: number;
|
|
142
150
|
closeGraceMs?: number;
|
|
143
151
|
ledger?: TerminalLedger;
|
|
144
152
|
/** The process table (tests stand a failing one in); null when it can't be read. */
|
|
@@ -175,7 +183,10 @@ export declare class TerminalHost {
|
|
|
175
183
|
cols: number;
|
|
176
184
|
rows: number;
|
|
177
185
|
}, admit?: () => string | null): Promise<TerminalSummary>;
|
|
178
|
-
/**
|
|
186
|
+
/**
|
|
187
|
+
* Attach a page: it gets the screen as it is now (ScreenMirror), then `hello`, then live output — what the program
|
|
188
|
+
* writes meanwhile is held and sent after the screen, in order. Returns its detach.
|
|
189
|
+
*/
|
|
179
190
|
attach(id: string, viewer: TerminalViewer): () => void;
|
|
180
191
|
/** How many pages are attached now. */
|
|
181
192
|
viewerCount(id: string): number;
|