toolroll 0.9.67 → 0.9.69
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +70 -0
- package/dist/acp-client.js +1 -3
- package/dist/agent-board.d.ts +0 -1
- package/dist/agent-board.js +0 -1
- package/dist/agent-fence.d.ts +58 -0
- package/dist/agent-fence.js +214 -19
- package/dist/agentconfig.d.ts +1 -1
- package/dist/agentconfig.js +6 -2
- package/dist/batch-checks.js +14 -26
- package/dist/browser/workspace.css +1 -1
- package/dist/browser/workspace.js +31 -29
- package/dist/builder.d.ts +9 -0
- package/dist/builder.js +30 -27
- package/dist/canonical-json.d.ts +14 -0
- package/dist/canonical-json.js +36 -0
- package/dist/chat-actions.js +1 -2
- package/dist/chat-admission.d.ts +19 -0
- package/dist/chat-admission.js +22 -0
- package/dist/chat-channel.js +1 -1
- package/dist/chat-delivery.js +0 -1
- package/dist/chat-follow.d.ts +17 -0
- package/dist/chat-follow.js +32 -0
- package/dist/chat-messages.js +0 -1
- package/dist/chat-settings-page.d.ts +23 -0
- package/dist/chat-settings-page.js +41 -0
- package/dist/check-access.d.ts +54 -0
- package/dist/check-access.js +80 -0
- package/dist/claim.js +3 -6
- package/dist/claude-print.d.ts +12 -0
- package/dist/claude-print.js +34 -0
- package/dist/cli-http.d.ts +0 -7
- package/dist/cli-http.js +0 -21
- package/dist/cli.d.ts +3 -10
- package/dist/cli.js +15 -24
- package/dist/coding-provider.js +2 -1
- package/dist/coding-workspace.js +1 -1
- package/dist/{team-cli.d.ts → connect-cli.d.ts} +5 -9
- package/dist/connect-cli.js +331 -0
- package/dist/containment.d.ts +0 -2
- package/dist/containment.js +2 -6
- package/dist/contracts/chat-actions.d.ts +0 -1
- package/dist/contracts/chat-content.d.ts +0 -4
- package/dist/contracts/chat-content.js +0 -2
- package/dist/contracts/chat-tables.d.ts +0 -1
- package/dist/contracts/cli.d.ts +0 -62
- package/dist/contracts/cli.js +0 -4
- package/dist/contracts/coding-workspace.js +1 -1
- package/dist/contracts/console-api.d.ts +1 -0
- package/dist/contracts/console-api.js +1 -0
- package/dist/contracts/contract.js +1 -1
- package/dist/contracts/flow-card.d.ts +0 -1
- package/dist/contracts/flow-propose.d.ts +0 -1
- package/dist/contracts/flow-send.js +1 -1
- package/dist/contracts/gateway-tools.d.ts +0 -1
- package/dist/contracts/integration-metadata.js +1 -1
- package/dist/contracts/lead-tools.d.ts +0 -1
- package/dist/contracts/memory-pass.d.ts +0 -1
- package/dist/contracts/memory-pass.js +1 -1
- package/dist/contracts/plan.js +1 -1
- package/dist/contracts/project-knowledge.js +1 -1
- package/dist/contracts/project-skills.d.ts +0 -1
- package/dist/contracts/proof.js +1 -1
- package/dist/contracts/recipes.js +1 -1
- package/dist/contracts/scope.js +1 -1
- package/dist/contracts/scout-report.js +1 -1
- package/dist/contracts/teams-callback.d.ts +0 -1
- package/dist/converse.d.ts +0 -6
- package/dist/converse.js +0 -19
- package/dist/daemon.js +3 -2
- package/dist/decision.d.ts +1 -3
- package/dist/deprecations.d.ts +12 -4
- package/dist/deprecations.js +27 -4
- package/dist/desktop-update-recovery.js +1 -1
- package/dist/desktop-update.js +3 -2
- package/dist/discord-chat.js +4 -15
- package/dist/discord-settings.d.ts +2 -6
- package/dist/discord-settings.js +10 -38
- package/dist/discord.js +12 -23
- package/dist/evidence.d.ts +0 -1
- package/dist/evidence.js +0 -3
- package/dist/exec.d.ts +0 -9
- package/dist/exec.js +3 -81
- package/dist/flow-actions.js +0 -3
- package/dist/flow-draft.d.ts +1 -3
- package/dist/flow-draft.js +15 -28
- package/dist/flow-scripts.d.ts +1 -8
- package/dist/flow-scripts.js +0 -13
- package/dist/flow-send.js +1 -1
- package/dist/flow-share.d.ts +0 -2
- package/dist/flow-share.js +1 -6
- package/dist/flow-steps.js +1 -1
- package/dist/flow-triggers.d.ts +0 -1
- package/dist/flow-triggers.js +1 -2
- package/dist/flow-unattended.js +1 -12
- package/dist/flows.js +1 -1
- package/dist/health-cli.d.ts +2 -2
- package/dist/health-cli.js +1 -1
- package/dist/invoke.d.ts +39 -5
- package/dist/invoke.js +181 -24
- package/dist/is-record.d.ts +2 -0
- package/dist/is-record.js +2 -0
- package/dist/lead-doors.d.ts +1 -5
- package/dist/lead-doors.js +0 -4
- package/dist/lead-tools.js +3 -6
- package/dist/live-detail.d.ts +0 -1
- package/dist/live-detail.js +0 -6
- package/dist/live-terminal.js +2 -1
- package/dist/live.d.ts +0 -1
- package/dist/live.js +1 -2
- package/dist/mcp-core.js +0 -2
- package/dist/memory-pass.js +1 -1
- package/dist/model-catalog.js +1 -1
- package/dist/modes.js +1 -12
- package/dist/operate.d.ts +4 -0
- package/dist/operate.js +456 -750
- package/dist/password-bearer.d.ts +0 -2
- package/dist/password-bearer.js +0 -4
- package/dist/peek-cli.js +2 -1
- package/dist/peek.d.ts +2 -26
- package/dist/peek.js +2 -168
- package/dist/phase-routing.d.ts +0 -4
- package/dist/phase-routing.js +2 -21
- package/dist/planner-source.d.ts +2 -1
- package/dist/planner-source.js +2 -12
- package/dist/planner.js +2 -14
- package/dist/process-tree.d.ts +0 -2
- package/dist/process-tree.js +0 -10
- package/dist/project-delete-ui.d.ts +16 -0
- package/dist/project-delete-ui.js +60 -1
- package/dist/project-delete.d.ts +0 -2
- package/dist/project-delete.js +1 -3
- package/dist/project-learning.d.ts +0 -14
- package/dist/project-skills.d.ts +0 -1
- package/dist/project-skills.js +0 -14
- package/dist/project-tools.js +2 -10
- package/dist/provider.d.ts +0 -17
- package/dist/provider.js +0 -26
- package/dist/pulls.d.ts +0 -2
- package/dist/pulls.js +0 -9
- package/dist/release-adapters.d.ts +3 -0
- package/dist/release-adapters.js +15 -5
- package/dist/remote-exec.d.ts +3 -2
- package/dist/remote-exec.js +3 -2
- package/dist/repos.d.ts +0 -5
- package/dist/repos.js +2 -10
- package/dist/repository-context.d.ts +0 -2
- package/dist/repository-context.js +0 -4
- package/dist/restart-certification.d.ts +0 -2
- package/dist/restart-certification.js +1 -5
- package/dist/result-follow-ups.d.ts +1 -4
- package/dist/result-follow-ups.js +11 -21
- package/dist/result-review.d.ts +0 -2
- package/dist/result-review.js +0 -2
- package/dist/retention.d.ts +0 -1
- package/dist/retention.js +0 -1
- package/dist/review-context.d.ts +0 -1
- package/dist/review-context.js +1 -12
- package/dist/reviewer.d.ts +0 -16
- package/dist/reviewer.js +0 -18
- package/dist/run-closeout.js +2 -1
- package/dist/scope.d.ts +9 -0
- package/dist/scope.js +13 -18
- package/dist/scout-browser.js +2 -1
- package/dist/serve.js +11 -2
- package/dist/server/console.css +5 -133
- package/dist/server/flows.js +2 -2
- package/dist/server/http.d.ts +4 -0
- package/dist/server/http.js +5 -0
- package/dist/server/pages.js +2 -2
- package/dist/server/people-tokens.js +3 -4
- package/dist/server/remote.js +1 -6
- package/dist/server/render-chat.d.ts +9 -2
- package/dist/server/render-chat.js +53 -7
- package/dist/server/render-results.d.ts +3 -6
- package/dist/server/render-results.js +4 -9
- package/dist/server/render-tasks.js +6 -6
- package/dist/server/route-table.d.ts +0 -4
- package/dist/server/route-table.js +1 -6
- package/dist/server/settings.js +60 -23
- package/dist/server/task-views.js +1 -1
- package/dist/server/tasks.js +5 -3
- package/dist/setup-guide.d.ts +2 -2
- package/dist/skills.d.ts +0 -6
- package/dist/skills.js +0 -8
- package/dist/slack-chat.d.ts +1 -7
- package/dist/slack-chat.js +5 -18
- package/dist/slack-settings.d.ts +2 -6
- package/dist/slack-settings.js +10 -38
- package/dist/slack.js +12 -23
- package/dist/sleep.d.ts +2 -0
- package/dist/sleep.js +2 -0
- package/dist/store.d.ts +29 -75
- package/dist/store.js +89 -180
- package/dist/structured-output.d.ts +0 -1
- package/dist/structured-output.js +0 -1
- package/dist/subagent-work.d.ts +0 -2
- package/dist/subagent-work.js +0 -2
- package/dist/subagents.d.ts +1 -3
- package/dist/subagents.js +20 -33
- package/dist/subscription-chat.d.ts +1 -3
- package/dist/subscription-chat.js +2 -3
- package/dist/surface.js +2 -1
- package/dist/task-sizing.d.ts +1 -4
- package/dist/task-sizing.js +3 -10
- package/dist/teams-chat.js +4 -11
- package/dist/teams.js +12 -19
- package/dist/telegram-lead.js +2 -2
- package/dist/telegram.js +1 -1
- package/dist/token-notices.d.ts +0 -11
- package/dist/token-notices.js +1 -5
- package/dist/tool-secret-store.d.ts +0 -3
- package/dist/tool-secret-store.js +2 -11
- package/dist/toolroll-update.d.ts +0 -2
- package/dist/toolroll-update.js +2 -2
- package/dist/verification-evidence.d.ts +0 -1
- package/dist/verification-evidence.js +0 -1
- package/dist/webhooks.d.ts +0 -1
- package/dist/webhooks.js +0 -1
- package/dist/workspace-ui.d.ts +0 -8
- package/dist/workspace-ui.js +1 -6
- package/package.json +1 -1
- package/dist/contracts/native-census.d.ts +0 -42
- package/dist/contracts/native-census.js +0 -36
- package/dist/process-recovery-anchor.d.ts +0 -75
- package/dist/process-recovery-anchor.js +0 -188
- package/dist/process-recovery-coalition.d.ts +0 -25
- package/dist/process-recovery-coalition.js +0 -105
- package/dist/process-recovery-eligibility.d.ts +0 -179
- package/dist/process-recovery-eligibility.js +0 -174
- package/dist/process-recovery-native.d.ts +0 -161
- package/dist/process-recovery-native.js +0 -595
- package/dist/process-recovery-provenance.d.ts +0 -163
- package/dist/process-recovery-provenance.js +0 -292
- package/dist/process-recovery-services.d.ts +0 -56
- package/dist/process-recovery-services.js +0 -191
- package/dist/process-recovery-settlement.d.ts +0 -32
- package/dist/process-recovery-settlement.js +0 -133
- package/dist/process-recovery.d.ts +0 -41
- package/dist/process-recovery.js +0 -91
- package/dist/session-brief.d.ts +0 -8
- package/dist/session-brief.js +0 -24
- package/dist/session-cli.d.ts +0 -42
- package/dist/session-cli.js +0 -271
- package/dist/session-contract.d.ts +0 -183
- package/dist/session-contract.js +0 -122
- package/dist/session-remote.d.ts +0 -19
- package/dist/session-remote.js +0 -86
- package/dist/session-service.d.ts +0 -22
- package/dist/session-service.js +0 -157
- package/dist/team-cli.js +0 -692
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,75 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.9.69 — 2026-10-23
|
|
4
|
+
|
|
5
|
+
- **Choose how far a project's checks reach.** Settings → Project has a new
|
|
6
|
+
Check access section, and `toolroll project check-access --repo <p>` shows
|
|
7
|
+
or sets the same thing. **Sandboxed** (the default) runs a project's setup
|
|
8
|
+
and check commands in the plane's fence, as before. **Not sandboxed** runs
|
|
9
|
+
them with your full access, as before 0.9.66: for a project whose checks
|
|
10
|
+
must create sandboxes themselves, such as Toolroll's own.
|
|
11
|
+
- Widening takes an instance operator with their password typed now (a
|
|
12
|
+
remembered sign-in or a single sign-on recheck doesn't count). Any approver
|
|
13
|
+
for the project can narrow it back. Each change is in the ledger.
|
|
14
|
+
- The level is part of what you approve. A scope seals it, the approval card
|
|
15
|
+
says "Checks: not sandboxed", and tasks already approved keep the level
|
|
16
|
+
they were approved with. Each run records the level its checks actually ran
|
|
17
|
+
at, and the result page says when they ran without a sandbox. Later checks
|
|
18
|
+
of a saved result never run wider than that run did. A mode, a grant or a
|
|
19
|
+
chat confirmation can't approve a wider level; only a person's password can.
|
|
20
|
+
- **A third level, "Sandboxed, with model and GitHub sign-ins", is built but
|
|
21
|
+
off.** It would let a check read your Claude, Codex and GitHub sign-in files.
|
|
22
|
+
Reading those files safely needs a way to open a file that a running build
|
|
23
|
+
can't redirect partway (Node can't do that yet), so the option is shown
|
|
24
|
+
disabled with the reason. Choosing it is refused, and a level saved before
|
|
25
|
+
runs sandboxed and says so.
|
|
26
|
+
- **The fence holds your home configuration by name.** `~/.codex`,
|
|
27
|
+
`~/.config/gh`, `~/.ssh` and the rest are denied whether or not they exist
|
|
28
|
+
when a build starts, so a build can't create one or swap it for a link
|
|
29
|
+
later. The folders above them can't be renamed or made during a build.
|
|
30
|
+
- A check's temporary folder on a Mac is now short (under `/private/tmp`), so
|
|
31
|
+
tests that open a unix socket there work inside the fence. Other launches'
|
|
32
|
+
folders stay out of reach.
|
|
33
|
+
- With no OS sandbox on the machine and unconfined running allowed, the
|
|
34
|
+
settings page and `project check-access` say checks run without a sandbox,
|
|
35
|
+
whatever level is saved.
|
|
36
|
+
|
|
37
|
+
## 0.9.68 — 2026-10-23
|
|
38
|
+
|
|
39
|
+
- **The lead chat is redesigned.** Your messages sit in grey bubbles and the
|
|
40
|
+
lead's replies read as plain text, with code blocks. Its work folds into one
|
|
41
|
+
"Worked …" line, live steps show a spinner and then a check, and anything
|
|
42
|
+
waiting on you carries a "Needs you" chip. The message box is one row that
|
|
43
|
+
grows as you type, with a send arrow (Queue while a reply runs).
|
|
44
|
+
- **Comment on a line of a change.** In a result's Changes tab, a pin on any
|
|
45
|
+
line opens a one-line comment. Enter sends it to the lead as its next
|
|
46
|
+
message, naming the file, line and run and quoting the line. The thread
|
|
47
|
+
shows it as a reference, the quoted line, and your words. On a phone the
|
|
48
|
+
result opens as a sheet over the chat.
|
|
49
|
+
- **A fenced check can build bundles from the shared dependencies.** On a Mac
|
|
50
|
+
the sandbox let a build look up the linked copy's folder but not list it,
|
|
51
|
+
so esbuild couldn't find node_modules ("could not resolve react"). That one
|
|
52
|
+
folder may now be listed; nothing in it is readable beyond node_modules.
|
|
53
|
+
The console's own browser build also resolves through the checkout's links.
|
|
54
|
+
- The release check passes inside the plane's macOS check sandbox: tests that
|
|
55
|
+
need to create a sandbox or watch other processes skip there and still run
|
|
56
|
+
in CI, and the browser journeys' throwaway planes run their agents inside
|
|
57
|
+
the check's own sandbox.
|
|
58
|
+
- A Claude build no longer refuses to start when a sandbox around Toolroll
|
|
59
|
+
hides your Claude settings file: it signs in from the keychain as before,
|
|
60
|
+
and a sign-in that needed the file still fails quickly and says so.
|
|
61
|
+
- The test suite reads its own home and temp folders, never yours, so it runs
|
|
62
|
+
the same on your machine and inside a check's sandbox. Tests that need a
|
|
63
|
+
socket path shorter than any folder the sandbox allows, and the one browser
|
|
64
|
+
journey that resumes a saved Claude session, skip inside the sandbox and
|
|
65
|
+
still run in CI.
|
|
66
|
+
- `toolroll release` no longer stops at merge when GitHub has already deleted
|
|
67
|
+
the merged branch: it looks a few times, 2 seconds apart, before calling the
|
|
68
|
+
delete a failure (0.9.66 and 0.9.67 both stopped there).
|
|
69
|
+
- Leaner code, with every feature kept (about 5,600 fewer lines). The
|
|
70
|
+
deprecated team and native session commands are gone, and `chat` and
|
|
71
|
+
`brief` without `--local` refuse when a central profile is saved.
|
|
72
|
+
|
|
3
73
|
## 0.9.67 — 2026-10-23
|
|
4
74
|
|
|
5
75
|
- **A project's checks read their own dependencies again.** 0.9.66 began
|
package/dist/acp-client.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { isRecord } from "./is-record.js";
|
|
1
2
|
/**
|
|
2
3
|
* The Agent Client Protocol, from the plane's side (docs/plans/acp-adapter.md).
|
|
3
4
|
*
|
|
@@ -37,9 +38,6 @@ const TEXT_CAP = 2048;
|
|
|
37
38
|
const USAGE_CAP = 8 * 1024;
|
|
38
39
|
const PROTOCOL_VERSION = 1;
|
|
39
40
|
const STOP_REASONS = new Set(["end_turn", "max_tokens", "max_turn_requests", "refusal", "cancelled"]);
|
|
40
|
-
function isRecord(value) {
|
|
41
|
-
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
42
|
-
}
|
|
43
41
|
function bounded(text, cap) {
|
|
44
42
|
return text.length <= cap ? text : text.slice(0, cap);
|
|
45
43
|
}
|
package/dist/agent-board.d.ts
CHANGED
|
@@ -1,6 +1,5 @@
|
|
|
1
1
|
import type { Store } from "./store.js";
|
|
2
2
|
export type AgentTurn = "busy" | "idle" | "crashed";
|
|
3
|
-
export declare const AGENT_TURNS: readonly AgentTurn[];
|
|
4
3
|
export type AgentSaid = "working" | "blocked" | "done";
|
|
5
4
|
/** A busy run whose transcript and activity are both this old is Stuck. */
|
|
6
5
|
export declare const STUCK_AFTER_MS: number;
|
package/dist/agent-board.js
CHANGED
|
@@ -27,7 +27,6 @@
|
|
|
27
27
|
* Display state, never evidence: nothing here changes a run's outcome.
|
|
28
28
|
*/
|
|
29
29
|
import { randomBytes } from "node:crypto";
|
|
30
|
-
export const AGENT_TURNS = ["busy", "idle", "crashed"];
|
|
31
30
|
/** A busy run whose transcript and activity are both this old is Stuck. */
|
|
32
31
|
export const STUCK_AFTER_MS = 4 * 60_000;
|
|
33
32
|
/** With a tool call still open (a long test run is silent), it is Stuck only after this long. */
|
package/dist/agent-fence.d.ts
CHANGED
|
@@ -1,6 +1,9 @@
|
|
|
1
1
|
import type { LoopbackMode } from "./egress.js";
|
|
2
2
|
/** Beside the database: where Linux builds' network bridges are made (its name reads as secret wherever it is). */
|
|
3
3
|
export declare const BRIDGE_DIR = "bridge-secrets";
|
|
4
|
+
/** Where a Linux sign-ins check's copies of its sign-in files live while it runs (check-access.ts): one folder per launch,
|
|
5
|
+
* `c-<pid>-…`, beside the database; fenced from every build by name, like the bridges. */
|
|
6
|
+
export declare const SIGN_IN_COPIES_DIR = "sign-in-copies";
|
|
4
7
|
/** How a launch was fenced, as recorded on the run and said on the task. */
|
|
5
8
|
export type FenceMethod = "codex-profile" | "macos-sandbox" | "linux-bubblewrap" | "claude-rules" | "none";
|
|
6
9
|
/** Only the plane's recorded repository may supply an own-Git exemption. */
|
|
@@ -38,6 +41,9 @@ export type TaskBoundary = {
|
|
|
38
41
|
fileTools: string[];
|
|
39
42
|
readOnly: string[];
|
|
40
43
|
lookup: string[];
|
|
44
|
+
/** Every home configuration name the launch may not reach, by name, whether or not it exists now (macOS: Seatbelt
|
|
45
|
+
* denies a path that isn't there yet, so a build can't create it, or later replace it with a link). */
|
|
46
|
+
homeNames?: string[];
|
|
41
47
|
};
|
|
42
48
|
/**
|
|
43
49
|
* Everything outside a task's own folders that a build could otherwise
|
|
@@ -54,14 +60,24 @@ export declare function taskBoundary(options: {
|
|
|
54
60
|
binding?: WorktreeBinding | null;
|
|
55
61
|
}): TaskBoundary;
|
|
56
62
|
/** Where launches keep their private folders (the system temp folder), and this launch's own tool folder. */
|
|
63
|
+
/** Where launches keep their private folders: `root` (the system temp folder) and, on a Mac, `also` the short shared one
|
|
64
|
+
* a check's launch folder is made in (/private/tmp, so its unix sockets fit); `own` is this launch's. */
|
|
57
65
|
export type RunFolders = {
|
|
58
66
|
root: string;
|
|
59
67
|
own: string | null;
|
|
68
|
+
also?: string[];
|
|
60
69
|
};
|
|
61
70
|
/** The prefixes of every launch's private folders: its tools' config, a Codex build's own home, and a Linux build's
|
|
62
71
|
* network bridge (egress-bridge.ts). */
|
|
63
72
|
export declare const RUN_FOLDER_PREFIXES: readonly ["so-tools-", "so-codex-", "so-net-", "so-cmd-"];
|
|
64
73
|
export declare function runFolders(own: string | null, root?: string): RunFolders;
|
|
74
|
+
/** Where a check's launch folder is made on a Mac: a short shared folder, so its TMPDIR leaves room for a unix socket
|
|
75
|
+
* path (about 104 bytes); every other launch folder there is denied by pattern, as under the system temp folder. */
|
|
76
|
+
export declare const CHECK_LAUNCH_ROOT_MACOS = "/private/tmp";
|
|
77
|
+
/** The folder a check's launch folder is made in: on a Mac the short one, elsewhere the system temp folder (unchanged). */
|
|
78
|
+
export declare const checkLaunchRoot: () => string;
|
|
79
|
+
/** Every root launch folders are made under (the system temp folder, and on a Mac the short shared one). */
|
|
80
|
+
export declare const runRoots: (folders: RunFolders) => string[];
|
|
65
81
|
/**
|
|
66
82
|
* What a fence gives back beside its denied paths. Each is narrow and none
|
|
67
83
|
* ever writes: `readOnly` is read, never written (other tasks' git
|
|
@@ -81,6 +97,11 @@ export type FenceGrants = {
|
|
|
81
97
|
pool?: CheckoutPool;
|
|
82
98
|
network?: NetworkFence;
|
|
83
99
|
writes?: WriteAllowlist;
|
|
100
|
+
/** macOS: the home configuration names denied by name, present or not (TaskBoundary.homeNames). */
|
|
101
|
+
homeNames?: string[];
|
|
102
|
+
/** A `sign-ins` check's exact sign-in files (read) and its checkout's own Claude transcript folder (written):
|
|
103
|
+
* check-access.ts, signInGrants. Bound back over the home fence by path, never a pattern or a parent folder. */
|
|
104
|
+
signIns?: SignInGrants;
|
|
84
105
|
};
|
|
85
106
|
export { isWritesMode, WRITES_DEFAULT, type WritesMode } from "./writes-mode.js";
|
|
86
107
|
/**
|
|
@@ -328,3 +349,40 @@ export declare const FENCE_SUMMARY: readonly {
|
|
|
328
349
|
}[];
|
|
329
350
|
/** FENCE_SUMMARY as README's markdown table. */
|
|
330
351
|
export declare function fenceSummaryMarkdown(): string;
|
|
352
|
+
/** What a `sign-ins` check reads and writes beyond its fence (check-access.ts). */
|
|
353
|
+
export type SignInGrants = {
|
|
354
|
+
read: string[];
|
|
355
|
+
transcripts: string[];
|
|
356
|
+
copies?: Record<string, string>;
|
|
357
|
+
};
|
|
358
|
+
/**
|
|
359
|
+
* A `sign-ins` check's grants: each sign-in file only as the real regular file it is (never a link, nor reached
|
|
360
|
+
* through one), and on a Mac the checkout's own Claude transcript folder (made when absent, only as the real folder it
|
|
361
|
+
* is), whose session files alone are written so a session the check starts can be resumed. Nothing else under
|
|
362
|
+
* ~/.claude, ~/.codex or ~/.config is granted.
|
|
363
|
+
*/
|
|
364
|
+
export declare function signInGrants(home: string, checkout: string, platform?: NodeJS.Platform): SignInGrants;
|
|
365
|
+
/**
|
|
366
|
+
* A sign-in file's bytes, read only as the real regular file it is: its folder reached through no link (its real path
|
|
367
|
+
* is its own), and the file itself opened without following a link and checked as a regular file through what was
|
|
368
|
+
* opened, so a link swapped in at the file's own name is never read. Null for anything else.
|
|
369
|
+
*
|
|
370
|
+
* Its folders are still resolved by name (Node has no openat), so a folder above it swapped for a link between the
|
|
371
|
+
* checks and the open would be followed. That is detected after the fact: every folder from / down to the file's own
|
|
372
|
+
* is looked at (no link, the same device and inode) before the open and again after it, the file's real path is
|
|
373
|
+
* checked again, and the name must still be the very file that was opened. Any difference reads nothing.
|
|
374
|
+
*
|
|
375
|
+
* Residual risk: a swap made and undone entirely between the two looks isn't seen. On a Mac every build's and check's
|
|
376
|
+
* fence denies the home configuration names (~/.codex among them) by name, present or not, so no build or sandboxed
|
|
377
|
+
* check can make that swap; only a check the operator set to Not sandboxed, or the person themselves, still could.
|
|
378
|
+
* `afterOpen` is for tests: it runs between the open and the second look.
|
|
379
|
+
*/
|
|
380
|
+
export declare function readGrantedFile(path: string, seams?: {
|
|
381
|
+
afterOpen?: () => void;
|
|
382
|
+
}): Buffer | null;
|
|
383
|
+
/**
|
|
384
|
+
* The sign-in copies folder in a state folder (check-access.ts; Linux checks only, and sign-ins are off for now), or null:
|
|
385
|
+
* only the real folder Toolroll made there, owned by this user, mode 0700, reached through no link. Anything else (a
|
|
386
|
+
* link planted at that name, someone else's folder, one others may open) is never used: nothing is copied into it.
|
|
387
|
+
*/
|
|
388
|
+
export declare function signInCopiesRoot(state: string, uid?: number | undefined): string | null;
|
package/dist/agent-fence.js
CHANGED
|
@@ -44,7 +44,7 @@ import { INSTANCE_DIR } from "./console-instance.js";
|
|
|
44
44
|
import { ACP_AGENTS, ACP_PROVIDER_IDS } from "./acp-agents.js";
|
|
45
45
|
import { spawnSync } from "node:child_process";
|
|
46
46
|
import { createHash } from "node:crypto";
|
|
47
|
-
import { existsSync, lstatSync, mkdirSync, readdirSync, readFileSync, realpathSync, statSync, writeFileSync } from "node:fs";
|
|
47
|
+
import { closeSync, constants, existsSync, fstatSync, lstatSync, mkdirSync, openSync, readdirSync, readFileSync, realpathSync, statSync, writeFileSync } from "node:fs";
|
|
48
48
|
import { homedir, tmpdir } from "node:os";
|
|
49
49
|
import { basename, dirname, isAbsolute, join, relative, resolve, sep } from "node:path";
|
|
50
50
|
import { readMonitoring } from "./monitoring-settings.js";
|
|
@@ -52,6 +52,9 @@ import { existingFolders } from "./names.js";
|
|
|
52
52
|
import { DEPS_FOLDER, linkedKey, readyCopy, sharedCopies } from "./shared-deps.js";
|
|
53
53
|
/** Beside the database: where Linux builds' network bridges are made (its name reads as secret wherever it is). */
|
|
54
54
|
export const BRIDGE_DIR = "bridge-secrets";
|
|
55
|
+
/** Where a Linux sign-ins check's copies of its sign-in files live while it runs (check-access.ts): one folder per launch,
|
|
56
|
+
* `c-<pid>-…`, beside the database; fenced from every build by name, like the bridges. */
|
|
57
|
+
export const SIGN_IN_COPIES_DIR = "sign-in-copies";
|
|
55
58
|
/** Names that are secrets wherever the database lives (used when its folder is shared with other things). */
|
|
56
59
|
const SENSITIVE_NAME = /(^up-login\.txt$|token|secret|login|password|credential|vapid|keys?\b|\.pem$|\.key$|^backups$|^evidence$|^remote$|^sign-in\.json$|^monitoring\.json$)/i;
|
|
57
60
|
function real(path) {
|
|
@@ -136,6 +139,8 @@ export function agentFence(options) {
|
|
|
136
139
|
let runFolder = null;
|
|
137
140
|
/** The bridges' folder, likewise. */
|
|
138
141
|
let bridgeFolder = null;
|
|
142
|
+
/** The sign-in copies folder, likewise. */
|
|
143
|
+
let copiesFolder = null;
|
|
139
144
|
const home = real(options.home ?? homedir());
|
|
140
145
|
const worktree = options.worktree === null ? null : real(options.worktree);
|
|
141
146
|
if (options.databaseFile !== null && options.databaseFile !== "" && options.databaseFile !== ":memory:") {
|
|
@@ -168,6 +173,23 @@ export function agentFence(options) {
|
|
|
168
173
|
// refuses a root that isn't a private folder: egress-bridge.ts).
|
|
169
174
|
bridgeFolder = bridges;
|
|
170
175
|
fence.add(bridges);
|
|
176
|
+
// The sign-in copies (check-access.ts): made before any build starts and fenced from every build, by its own name as
|
|
177
|
+
// well as where it leads (as the bridges and the run folder are), whether or not the state folder is Toolroll's own.
|
|
178
|
+
// A Linux check binds its own copy from outside its sandbox, so nothing here is granted back. Never swept: a copy a
|
|
179
|
+
// launch that died left behind stays in this fenced, private folder (walking a folder a build might swap is the
|
|
180
|
+
// greater risk); a root that isn't Toolroll's private folder is never used (signInCopiesRoot).
|
|
181
|
+
const copies = join(state, SIGN_IN_COPIES_DIR);
|
|
182
|
+
try {
|
|
183
|
+
lstatSync(copies);
|
|
184
|
+
}
|
|
185
|
+
catch {
|
|
186
|
+
try {
|
|
187
|
+
mkdirSync(copies, { mode: 0o700 });
|
|
188
|
+
}
|
|
189
|
+
catch { /* fenced below either way */ }
|
|
190
|
+
}
|
|
191
|
+
copiesFolder = copies;
|
|
192
|
+
fence.add(copies);
|
|
171
193
|
runFolder = join(state, INSTANCE_DIR);
|
|
172
194
|
const present = () => { try {
|
|
173
195
|
lstatSync(runFolder);
|
|
@@ -227,12 +249,12 @@ export function agentFence(options) {
|
|
|
227
249
|
fence.add(path);
|
|
228
250
|
for (const path of options.boundary?.protectedShell ?? [])
|
|
229
251
|
fence.add(real(path));
|
|
230
|
-
const protectedPaths = new Set([...fence].flatMap(path => path === runFolder || path === bridgeFolder ? [path, real(path)] : [real(path)]));
|
|
252
|
+
const protectedPaths = new Set([...fence].flatMap(path => path === runFolder || path === bridgeFolder || path === copiesFolder ? [path, real(path)] : [real(path)]));
|
|
231
253
|
for (const path of options.boundary?.shell ?? [])
|
|
232
254
|
fence.add(path);
|
|
233
255
|
const binding = options.binding;
|
|
234
256
|
const needed = binding === undefined || binding === null ? [] : [binding.gitdir, real(join(binding.main, ".git"))];
|
|
235
|
-
return [...new Set([...fence].flatMap(path => path === runFolder || path === bridgeFolder ? [path, real(path)] : [real(path)]))].filter(path => {
|
|
257
|
+
return [...new Set([...fence].flatMap(path => path === runFolder || path === bridgeFolder || path === copiesFolder ? [path, real(path)] : [real(path)]))].filter(path => {
|
|
236
258
|
if (protectedPaths.has(path) || !needed.some(target => target === path || inside(target, path)))
|
|
237
259
|
return true;
|
|
238
260
|
console.warn(`Dropped build fence entry covering this checkout's own Git data: ${path}`);
|
|
@@ -365,15 +387,28 @@ export function taskBoundary(options) {
|
|
|
365
387
|
}
|
|
366
388
|
}
|
|
367
389
|
const fileTools = options.provider === "claude" ? present(CLAUDE_FILE_TOOL_HOME).filter(path => !shell.has(path)) : [];
|
|
368
|
-
|
|
390
|
+
// By name too, present or not: a build started while ~/.codex is absent must never make it (and later swap it for a
|
|
391
|
+
// link while a sign-ins check reads through it). Only Seatbelt uses these (macosFenceProfile); bubblewrap can only
|
|
392
|
+
// mask what exists, and Codex's own sandbox takes existing paths.
|
|
393
|
+
const homeNames = HOME_CONFIGURATION.filter(name => !own.has(name)).map(name => join(home, name)).sort();
|
|
394
|
+
return { protectedShell, shell: [...shell].sort(), fileTools: fileTools.sort(), readOnly: [...readOnly].sort(), lookup: [...lookup].sort(), homeNames };
|
|
369
395
|
}
|
|
370
396
|
/** The prefixes of every launch's private folders: its tools' config, a Codex build's own home, and a Linux build's
|
|
371
397
|
* network bridge (egress-bridge.ts). */
|
|
372
398
|
export const RUN_FOLDER_PREFIXES = ["so-tools-", "so-codex-", "so-net-", "so-cmd-"];
|
|
373
|
-
export function runFolders(own, root
|
|
374
|
-
|
|
399
|
+
export function runFolders(own, root) {
|
|
400
|
+
// The system's own roots (not one a caller names): on a Mac, checks' launch folders are under the short one too.
|
|
401
|
+
const also = root === undefined && process.platform === "darwin" && existsSync(CHECK_LAUNCH_ROOT_MACOS) ? [CHECK_LAUNCH_ROOT_MACOS] : [];
|
|
402
|
+
return { root: real(root ?? tmpdir()), own: own === null ? null : real(own), ...(also.length === 0 ? {} : { also }) };
|
|
375
403
|
}
|
|
376
|
-
/**
|
|
404
|
+
/** Where a check's launch folder is made on a Mac: a short shared folder, so its TMPDIR leaves room for a unix socket
|
|
405
|
+
* path (about 104 bytes); every other launch folder there is denied by pattern, as under the system temp folder. */
|
|
406
|
+
export const CHECK_LAUNCH_ROOT_MACOS = "/private/tmp";
|
|
407
|
+
/** The folder a check's launch folder is made in: on a Mac the short one, elsewhere the system temp folder (unchanged). */
|
|
408
|
+
export const checkLaunchRoot = () => process.platform === "darwin" && existsSync(CHECK_LAUNCH_ROOT_MACOS) ? CHECK_LAUNCH_ROOT_MACOS : tmpdir();
|
|
409
|
+
/** Every other launch's private folder in the system temp folder now, each only as the real folder it is (a link planted
|
|
410
|
+
* there is never followed, nor fenced in its target's place) and this user's own. A shared root (`also`, /private/tmp on
|
|
411
|
+
* a Mac) is never listed: anyone may plant entries there, so it is fenced by name pattern alone. */
|
|
377
412
|
function otherRunFolders(folders) {
|
|
378
413
|
let entries = [];
|
|
379
414
|
try {
|
|
@@ -382,8 +417,21 @@ function otherRunFolders(folders) {
|
|
|
382
417
|
catch {
|
|
383
418
|
entries = [];
|
|
384
419
|
}
|
|
385
|
-
|
|
420
|
+
const uid = process.getuid?.();
|
|
421
|
+
return entries.filter(entry => RUN_FOLDER_PREFIXES.some(prefix => entry.startsWith(prefix))).map(entry => join(folders.root, entry)).filter(path => {
|
|
422
|
+
if (path === folders.own)
|
|
423
|
+
return false;
|
|
424
|
+
try {
|
|
425
|
+
const held = lstatSync(path);
|
|
426
|
+
return held.isDirectory() && !held.isSymbolicLink() && (uid === undefined || held.uid === uid);
|
|
427
|
+
}
|
|
428
|
+
catch {
|
|
429
|
+
return false;
|
|
430
|
+
}
|
|
431
|
+
});
|
|
386
432
|
}
|
|
433
|
+
/** Every root launch folders are made under (the system temp folder, and on a Mac the short shared one). */
|
|
434
|
+
export const runRoots = (folders) => [folders.root, ...(folders.also ?? [])];
|
|
387
435
|
export { isWritesMode, WRITES_DEFAULT } from "./writes-mode.js";
|
|
388
436
|
/** Claude's transcript folder for a working folder: its path with every character but a letter or digit made `-`
|
|
389
437
|
* (Claude Code's own naming; a name over 200 characters gets a suffix, so it is granted by prefix). */
|
|
@@ -618,6 +666,7 @@ export function fenceGrants(options) {
|
|
|
618
666
|
}
|
|
619
667
|
const pool = options.poolRoots === undefined ? undefined : checkoutPool({ roots: options.poolRoots, worktree: options.worktree, binding: options.binding ?? null, ...(options.home === undefined ? {} : { home: options.home }) });
|
|
620
668
|
return { readOnly: [...options.boundary?.readOnly ?? []], readable, lookup: [...new Set(lookup)].sort(), ...(options.runFolders === undefined ? {} : { runFolders: options.runFolders }),
|
|
669
|
+
...(options.boundary?.homeNames === undefined || options.boundary.homeNames.length === 0 ? {} : { homeNames: [...options.boundary.homeNames] }),
|
|
621
670
|
...(pool === undefined || pool.deny.length === 0 ? {} : { pool }) };
|
|
622
671
|
}
|
|
623
672
|
/** The setup a shared copy was installed by (its ready note), or null. */
|
|
@@ -668,8 +717,9 @@ export function codexFenceArgv(fence, fullAccess, grants = NO_GRANTS) {
|
|
|
668
717
|
// Codex's harness reads its own launch files outside the command sandbox.
|
|
669
718
|
// Deny the namespace, including folders created after this policy was built.
|
|
670
719
|
if (grants.runFolders !== undefined) {
|
|
671
|
-
for (const
|
|
672
|
-
|
|
720
|
+
for (const root of runRoots(grants.runFolders)) {
|
|
721
|
+
for (const prefix of RUN_FOLDER_PREFIXES)
|
|
722
|
+
deny.push(join(root, `${prefix}*`), join(root, `${prefix}*`, "**"));
|
|
673
723
|
}
|
|
674
724
|
}
|
|
675
725
|
// Launch folders can already be in the base fence when invoke appends them.
|
|
@@ -701,11 +751,11 @@ export function codexFenceArgv(fence, fullAccess, grants = NO_GRANTS) {
|
|
|
701
751
|
for (const path of held.write)
|
|
702
752
|
access.set(path, "write");
|
|
703
753
|
}
|
|
704
|
-
// Denies are keyed where they resolve; the console's run folder
|
|
705
|
-
// stays unreplaceable, not only its target.
|
|
754
|
+
// Denies are keyed where they resolve; the console's run folder and the sign-in copies folder are also denied by their
|
|
755
|
+
// own pathname, so a link there stays unreplaceable, not only its target.
|
|
706
756
|
for (const path of deny) {
|
|
707
757
|
access.set(real(path), "deny");
|
|
708
|
-
if (basename(path) === INSTANCE_DIR)
|
|
758
|
+
if (basename(path) === INSTANCE_DIR || basename(path) === SIGN_IN_COPIES_DIR)
|
|
709
759
|
access.set(path, "deny");
|
|
710
760
|
}
|
|
711
761
|
const filesystem = [...access].map(([path, mode]) => `${toml(path)}=${toml(mode)}`);
|
|
@@ -859,7 +909,16 @@ export function macosFenceProfile(fence, grants = NO_GRANTS) {
|
|
|
859
909
|
const paths = (filter, list) => list.map(path => `(${filter} ${seatbelt(path)})`).join(" ");
|
|
860
910
|
const folders = grants.runFolders;
|
|
861
911
|
const pool = grants.pool;
|
|
862
|
-
const
|
|
912
|
+
const homeNames = grants.homeNames ?? [];
|
|
913
|
+
const above = ownedAncestors([...fence, ...grants.readOnly, ...homeNames, ...pool?.deny ?? []], folders === undefined ? [real(tmpdir())] : runRoots(folders));
|
|
914
|
+
// A home name's folder that isn't there yet (~/.config for ~/.config/gh): made by the build, it could be a link to
|
|
915
|
+
// anywhere and carry the name out of its deny, so nothing may make it while the build runs.
|
|
916
|
+
const missingAbove = [...new Set(homeNames.flatMap(name => {
|
|
917
|
+
const out = [];
|
|
918
|
+
for (let folder = dirname(name); folder !== dirname(folder) && !existsSync(folder); folder = dirname(folder))
|
|
919
|
+
out.push(folder);
|
|
920
|
+
return out;
|
|
921
|
+
}))].sort();
|
|
863
922
|
return [
|
|
864
923
|
"(version 1)",
|
|
865
924
|
"(allow default)",
|
|
@@ -878,12 +937,29 @@ export function macosFenceProfile(fence, grants = NO_GRANTS) {
|
|
|
878
937
|
]),
|
|
879
938
|
...(pool === undefined || pool.lookup.length === 0 ? [] : [`(allow file-read-metadata ${paths("literal", pool.lookup)})`]),
|
|
880
939
|
...(fence.length === 0 ? [] : [`(deny file-read* file-write* ${paths("subpath", fence)})`]),
|
|
940
|
+
// The home configuration by name, there or not: nothing may create, read, replace or write one (the sign-ins grants
|
|
941
|
+
// after this give back only their exact files).
|
|
942
|
+
...(homeNames.length === 0 ? [] : [`(deny file-read* file-write* ${paths("subpath", homeNames)})`]),
|
|
943
|
+
...(missingAbove.length === 0 ? [] : [`(deny file-write* ${paths("literal", missingAbove)})`]),
|
|
881
944
|
...(grants.readOnly.length === 0 ? [] : [`(deny file-write* ${paths("subpath", grants.readOnly)})`]),
|
|
882
|
-
...(folders === undefined ? [] :
|
|
945
|
+
...(folders === undefined ? [] : runRoots(folders).map(root => `(deny file-read* file-write* (regex #"^${seatbeltRegex(root)}/(${RUN_FOLDER_PREFIXES.map(seatbeltRegex).join("|")})"))`)),
|
|
883
946
|
...(grants.lookup.length === 0 ? [] : [`(allow file-read-metadata ${paths("literal", grants.lookup)})`]),
|
|
884
947
|
// Denied whole above, granted back narrowly here (a later rule wins): an entry made after launch beside it stays denied.
|
|
885
948
|
...(grants.readable.length === 0 ? [] : [`(allow file-read* file-test-existence ${paths("subpath", grants.readable)})`]),
|
|
886
|
-
|
|
949
|
+
// A sign-ins check (check-access.ts): its exact sign-in files read, its own transcript folder written; nothing else
|
|
950
|
+
// of the home configuration comes back.
|
|
951
|
+
...(grants.signIns === undefined || grants.signIns.read.length === 0 ? [] : [`(allow file-read* ${paths("literal", grants.signIns.read)})`]),
|
|
952
|
+
// Its own transcript folder: only the session files directly in it (a session can be resumed). Nothing else there —
|
|
953
|
+
// never memory/ or any folder Claude would load into a later real session — comes back.
|
|
954
|
+
...(grants.signIns === undefined || grants.signIns.transcripts.length === 0 ? [] : [
|
|
955
|
+
`(allow file-read-data file-read-metadata ${paths("literal", grants.signIns.transcripts)})`,
|
|
956
|
+
`(allow file-read* file-write* ${grants.signIns.transcripts.map(folder => `(regex #"^${seatbeltRegex(folder)}/[^/]+\\.jsonl$")`).join(" ")})`,
|
|
957
|
+
]),
|
|
958
|
+
// Each readable copy's own folder can be listed, never read: bundlers such as esbuild find node_modules by listing
|
|
959
|
+
// its parent, which a lookup alone refuses (the Linux and Codex fences list it as read-only already).
|
|
960
|
+
...(grants.readable.length === 0 ? [] : [`(allow file-read-data ${paths("literal", [...new Set(grants.readable.map(one => dirname(one)))])})`]),
|
|
961
|
+
// Its own launch folder: what is in it, never the folder itself (it can't be renamed, removed or replaced by a link).
|
|
962
|
+
...(folders?.own == null ? [] : [`(allow file-read* file-write* ${paths("subpath", [folders.own])})`, `(deny file-write* ${paths("literal", [folders.own])})`]),
|
|
887
963
|
'(deny sysctl-read (sysctl-name-prefix "kern.procargs"))',
|
|
888
964
|
"(deny process-info-pidinfo)",
|
|
889
965
|
"(allow process-info-pidinfo (target same-sandbox))",
|
|
@@ -982,8 +1058,8 @@ const SECRET_SEGMENT = SPLIT_SECRET_PATTERNS.map(patternTest);
|
|
|
982
1058
|
*/
|
|
983
1059
|
function linuxGrants(fence, grants) {
|
|
984
1060
|
const folders = grants.runFolders;
|
|
985
|
-
const launchFolder = (path) => folders !== undefined &&
|
|
986
|
-
&& RUN_FOLDER_PREFIXES.some(prefix => relative(
|
|
1061
|
+
const launchFolder = (path) => folders !== undefined && path !== folders.own && !(folders.own != null && inside(path, folders.own))
|
|
1062
|
+
&& runRoots(folders).some(root => inside(path, root) && RUN_FOLDER_PREFIXES.some(prefix => relative(root, path).split(sep)[0].startsWith(prefix)));
|
|
987
1063
|
const secretBelow = (path) => fence.some(one => inside(path, one) && relative(one, path).split(sep).some(segment => SECRET_SEGMENT.some(test => test.test(segment))));
|
|
988
1064
|
const ok = (path, readOnlyGrant) => existsSync(path) && !fence.includes(path) && !launchFolder(path) && !secretBelow(path)
|
|
989
1065
|
&& !(readOnlyGrant && fence.some(one => inside(path, one)));
|
|
@@ -1010,6 +1086,14 @@ export function linuxFenced(file, args, fence, grants = NO_GRANTS) {
|
|
|
1010
1086
|
const pool = grants.pool?.deny ?? [];
|
|
1011
1087
|
const own = grants.pool?.own != null && existsSync(grants.pool.own) ? ["--bind", grants.pool.own, grants.pool.own] : [];
|
|
1012
1088
|
const kept = linuxGrants([...fence, ...pool], grants).flatMap(path => ["--ro-bind", path, path]);
|
|
1089
|
+
// A sign-ins check's exact files, bound back over the masks (validated when granted: signInGrants).
|
|
1090
|
+
const signIns = grants.signIns === undefined ? [] : [
|
|
1091
|
+
// Each from the launch's own copy (made from the validated, opened file), never the path itself: a link swapped in
|
|
1092
|
+
// after validation is never followed by the bind. A file with no copy isn't granted.
|
|
1093
|
+
...grants.signIns.read.flatMap(path => { const copy = grants.signIns.copies?.[path]; return copy !== undefined && existsSync(copy) ? ["--ro-bind", copy, path] : []; }),
|
|
1094
|
+
// Bubblewrap can't bind by pattern: a session transcript folder is never given back writable on Linux (a check
|
|
1095
|
+
// there can't resume a Claude session; it can't plant anything a later session loads either).
|
|
1096
|
+
];
|
|
1013
1097
|
// A confined network: a network and a process view of its own, the host's sockets out of reach (a socket in a
|
|
1014
1098
|
// mounted folder is not network, so they are masked), and the run's proxy and broker carried in by the forwarder.
|
|
1015
1099
|
const isolated = network === null ? [] : [
|
|
@@ -1034,7 +1118,7 @@ export function linuxFenced(file, args, fence, grants = NO_GRANTS) {
|
|
|
1034
1118
|
const services = writes === undefined || network !== null ? [] : linuxHostSockets(undefined, undefined, undefined, true).flatMap(mask);
|
|
1035
1119
|
const command = network === null ? [file, ...args]
|
|
1036
1120
|
: [network.bridge.node, network.bridge.forwarder, network.bridge.folder, ...[network.proxyPort, ...network.brokerPorts].map(String), "--", file, ...args];
|
|
1037
|
-
return { file: "bwrap", args: [...root, ...privateTmp, ...writable, ...pool.flatMap(mask), ...own, ...fence.flatMap(mask), ...kept, ...services, ...isolated, "--die-with-parent", "--", ...command] };
|
|
1121
|
+
return { file: "bwrap", args: [...root, ...privateTmp, ...writable, ...pool.flatMap(mask), ...own, ...fence.flatMap(mask), ...kept, ...signIns, ...services, ...isolated, "--die-with-parent", "--", ...command] };
|
|
1038
1122
|
}
|
|
1039
1123
|
/** The host's own sockets a process in a mounted filesystem could still talk to (D-Bus, the display, container
|
|
1040
1124
|
* engines), wherever they exist here. */
|
|
@@ -1161,3 +1245,114 @@ export const FENCE_SUMMARY = [
|
|
|
1161
1245
|
export function fenceSummaryMarkdown() {
|
|
1162
1246
|
return ["| | Auto | Full access |", "|---|---|---|", ...FENCE_SUMMARY.map(row => `| ${row.agent} | ${row.auto} | ${row.full} |`)].join("\n");
|
|
1163
1247
|
}
|
|
1248
|
+
/** The sign-in files a `sign-ins` check reads, by fixed name under the person's home (never from the environment, so no
|
|
1249
|
+
* caller's CLAUDE_CONFIG_DIR or GH_CONFIG_DIR points a grant at .ssh): Claude's account and settings (and, on Linux,
|
|
1250
|
+
* its credentials file; on a Mac they are in the keychain), and gh's hosts and config. Codex reads copies in a folder
|
|
1251
|
+
* of the launch's own (invoke.ts), so nothing of ~/.codex is granted. */
|
|
1252
|
+
const SIGN_IN_FILES = (platform) => [
|
|
1253
|
+
".claude.json", join(".claude", "settings.json"), ...(platform === "linux" ? [join(".claude", ".credentials.json")] : []),
|
|
1254
|
+
join(".config", "gh", "hosts.yml"), join(".config", "gh", "config.yml"),
|
|
1255
|
+
];
|
|
1256
|
+
/**
|
|
1257
|
+
* A `sign-ins` check's grants: each sign-in file only as the real regular file it is (never a link, nor reached
|
|
1258
|
+
* through one), and on a Mac the checkout's own Claude transcript folder (made when absent, only as the real folder it
|
|
1259
|
+
* is), whose session files alone are written so a session the check starts can be resumed. Nothing else under
|
|
1260
|
+
* ~/.claude, ~/.codex or ~/.config is granted.
|
|
1261
|
+
*/
|
|
1262
|
+
export function signInGrants(home, checkout, platform = process.platform) {
|
|
1263
|
+
const base = real(home);
|
|
1264
|
+
const read = SIGN_IN_FILES(platform).map(name => join(base, name)).filter(path => {
|
|
1265
|
+
try {
|
|
1266
|
+
const held = lstatSync(path);
|
|
1267
|
+
// One name only: a hard link made to another file (Toolroll's database, say) would be read through this grant.
|
|
1268
|
+
return held.isFile() && !held.isSymbolicLink() && held.nlink === 1 && realpathSync(path) === path;
|
|
1269
|
+
}
|
|
1270
|
+
catch {
|
|
1271
|
+
return false;
|
|
1272
|
+
}
|
|
1273
|
+
});
|
|
1274
|
+
const transcript = claudeProjectFolder(base, real(checkout));
|
|
1275
|
+
// A transcript folder Claude would name by prefix (a very long checkout path) isn't granted: never a prefix of others.
|
|
1276
|
+
const folder = transcript.byPrefix || !existsSync(join(base, ".claude")) ? null : grantFolder(transcript.path);
|
|
1277
|
+
return { read, transcripts: folder === null || platform !== "darwin" ? [] : [folder] };
|
|
1278
|
+
}
|
|
1279
|
+
/**
|
|
1280
|
+
* A sign-in file's bytes, read only as the real regular file it is: its folder reached through no link (its real path
|
|
1281
|
+
* is its own), and the file itself opened without following a link and checked as a regular file through what was
|
|
1282
|
+
* opened, so a link swapped in at the file's own name is never read. Null for anything else.
|
|
1283
|
+
*
|
|
1284
|
+
* Its folders are still resolved by name (Node has no openat), so a folder above it swapped for a link between the
|
|
1285
|
+
* checks and the open would be followed. That is detected after the fact: every folder from / down to the file's own
|
|
1286
|
+
* is looked at (no link, the same device and inode) before the open and again after it, the file's real path is
|
|
1287
|
+
* checked again, and the name must still be the very file that was opened. Any difference reads nothing.
|
|
1288
|
+
*
|
|
1289
|
+
* Residual risk: a swap made and undone entirely between the two looks isn't seen. On a Mac every build's and check's
|
|
1290
|
+
* fence denies the home configuration names (~/.codex among them) by name, present or not, so no build or sandboxed
|
|
1291
|
+
* check can make that swap; only a check the operator set to Not sandboxed, or the person themselves, still could.
|
|
1292
|
+
* `afterOpen` is for tests: it runs between the open and the second look.
|
|
1293
|
+
*/
|
|
1294
|
+
export function readGrantedFile(path, seams = {}) {
|
|
1295
|
+
const folder = dirname(path);
|
|
1296
|
+
const chain = [];
|
|
1297
|
+
for (let at = folder;; at = dirname(at)) {
|
|
1298
|
+
chain.push(at);
|
|
1299
|
+
if (at === dirname(at))
|
|
1300
|
+
break;
|
|
1301
|
+
}
|
|
1302
|
+
const look = () => {
|
|
1303
|
+
const held = [];
|
|
1304
|
+
for (const at of chain) {
|
|
1305
|
+
const one = lstatSync(at);
|
|
1306
|
+
if (one.isSymbolicLink() || !one.isDirectory())
|
|
1307
|
+
return null;
|
|
1308
|
+
held.push({ dev: one.dev, ino: one.ino });
|
|
1309
|
+
}
|
|
1310
|
+
return held;
|
|
1311
|
+
};
|
|
1312
|
+
const same = (a, b) => a.length === b.length && a.every((one, index) => one.dev === b[index].dev && one.ino === b[index].ino);
|
|
1313
|
+
try {
|
|
1314
|
+
const before = look();
|
|
1315
|
+
if (before === null || realpathSync(folder) !== folder)
|
|
1316
|
+
return null;
|
|
1317
|
+
const fd = openSync(path, constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK);
|
|
1318
|
+
try {
|
|
1319
|
+
const held = fstatSync(fd);
|
|
1320
|
+
if (!held.isFile() || held.nlink !== 1)
|
|
1321
|
+
return null;
|
|
1322
|
+
seams.afterOpen?.();
|
|
1323
|
+
const after = look();
|
|
1324
|
+
if (after === null || !same(before, after) || realpathSync(folder) !== folder)
|
|
1325
|
+
return null;
|
|
1326
|
+
const named = lstatSync(path);
|
|
1327
|
+
if (named.isSymbolicLink() || named.dev !== held.dev || named.ino !== held.ino)
|
|
1328
|
+
return null;
|
|
1329
|
+
const bytes = readFileSync(fd);
|
|
1330
|
+
// And once more after the read: what was read must still be what the checked name holds.
|
|
1331
|
+
const last = look();
|
|
1332
|
+
return last !== null && same(before, last) ? bytes : null;
|
|
1333
|
+
}
|
|
1334
|
+
finally {
|
|
1335
|
+
closeSync(fd);
|
|
1336
|
+
}
|
|
1337
|
+
}
|
|
1338
|
+
catch {
|
|
1339
|
+
return null;
|
|
1340
|
+
}
|
|
1341
|
+
}
|
|
1342
|
+
/**
|
|
1343
|
+
* The sign-in copies folder in a state folder (check-access.ts; Linux checks only, and sign-ins are off for now), or null:
|
|
1344
|
+
* only the real folder Toolroll made there, owned by this user, mode 0700, reached through no link. Anything else (a
|
|
1345
|
+
* link planted at that name, someone else's folder, one others may open) is never used: nothing is copied into it.
|
|
1346
|
+
*/
|
|
1347
|
+
export function signInCopiesRoot(state, uid = process.getuid?.()) {
|
|
1348
|
+
const root = join(state, SIGN_IN_COPIES_DIR);
|
|
1349
|
+
try {
|
|
1350
|
+
const held = lstatSync(root);
|
|
1351
|
+
if (held.isSymbolicLink() || !held.isDirectory() || (uid !== undefined && held.uid !== uid) || (held.mode & 0o777) !== 0o700)
|
|
1352
|
+
return null;
|
|
1353
|
+
return grantFolder(root, false) === root ? root : null;
|
|
1354
|
+
}
|
|
1355
|
+
catch {
|
|
1356
|
+
return null;
|
|
1357
|
+
}
|
|
1358
|
+
}
|
package/dist/agentconfig.d.ts
CHANGED
|
@@ -65,7 +65,7 @@ export type ProfileResolution = {
|
|
|
65
65
|
export declare function resolveScopeProfile(store: Store, repo: string | null, ref: Pick<TaskRef, "agentProvider" | "agentModel"> | undefined, flags: PhaseFlags & {
|
|
66
66
|
repairModel?: string | undefined;
|
|
67
67
|
permissionMode?: UnattendedPermissionMode | undefined;
|
|
68
|
-
}): ProfileResolution;
|
|
68
|
+
}, platform?: NodeJS.Platform): ProfileResolution;
|
|
69
69
|
/**
|
|
70
70
|
* The two candidate tiers the routing policy chooses between, per phase,
|
|
71
71
|
* read from configuration ONLY — never inferred from a model's name:
|
package/dist/agentconfig.js
CHANGED
|
@@ -21,6 +21,7 @@
|
|
|
21
21
|
*/
|
|
22
22
|
import { PROJECT_NETWORK_DEFAULT, repositoryEcosystems, resolveNetwork } from "./egress.js";
|
|
23
23
|
import { WRITES_DEFAULT } from "./writes-mode.js";
|
|
24
|
+
import { checkAccessHere } from "./check-access.js";
|
|
24
25
|
import { isProviderId, validateSpec } from "./provider.js";
|
|
25
26
|
import { ACP_LIMITS, CLAUDE_LIMITS, CODEX_SHAPED_LIMITS, GEMINI_LIMITS } from "./scope.js";
|
|
26
27
|
import { ACP_AGENTS, isAcpProvider } from "./acp-agents.js";
|
|
@@ -102,7 +103,7 @@ ref) {
|
|
|
102
103
|
const valid = validateSpec(spec);
|
|
103
104
|
return valid.ok ? { ok: true, spec, source: flags.model !== undefined ? "flag" : source } : { ok: false, problem: valid.problem };
|
|
104
105
|
}
|
|
105
|
-
export function resolveScopeProfile(store, repo, ref, flags) {
|
|
106
|
+
export function resolveScopeProfile(store, repo, ref, flags, platform = process.platform) {
|
|
106
107
|
const build = resolvePhaseAgent(store, "build", repo, flags, ref);
|
|
107
108
|
if (!build.ok)
|
|
108
109
|
return { ok: false, reason: "unknown-provider", problem: build.problem };
|
|
@@ -199,7 +200,10 @@ export function resolveScopeProfile(store, repo, ref, flags) {
|
|
|
199
200
|
const network = resolveNetwork(provider, repo === null ? PROJECT_NETWORK_DEFAULT : store.projectNetwork(repo), repo === null ? [] : repositoryEcosystems(repo));
|
|
200
201
|
// Where the build may write (writes-mode.ts): the project's setting, sealed likewise.
|
|
201
202
|
const writes = repo === null ? WRITES_DEFAULT : store.projectWrites(repo);
|
|
202
|
-
|
|
203
|
+
// How far its checks reach (check-access.ts): the project's setting as checks can get it (sign-ins, off for now,
|
|
204
|
+
// seal as sandboxed: an approval never claims what its checks won't get), sealed only when wider than sandboxed.
|
|
205
|
+
const checkAccess = repo === null ? "sandboxed" : checkAccessHere(store.projectCheckAccess(repo), platform);
|
|
206
|
+
return { ok: true, profile: { ...profile, network, writes, ...(checkAccess === "sandboxed" ? {} : { checkAccess }) }, provenance: { resolvedFrom: build.source, repairFrom } };
|
|
203
207
|
}
|
|
204
208
|
const PHASE_NOUN = { plan: "planner", build: "builder", repair: "repair", review: "reviewer" };
|
|
205
209
|
export function resolveRouteCandidates(store, repo, pins = {}) {
|