toolroll 0.9.70 → 0.9.72
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 +99 -0
- package/dist/agent-fence.d.ts +7 -0
- package/dist/agent-fence.js +10 -2
- package/dist/approval-authority.d.ts +20 -0
- package/dist/approval-authority.js +67 -0
- package/dist/assignment.js +26 -4
- package/dist/browser/workspace.css +1 -1
- package/dist/builder.js +48 -6
- package/dist/chat-decide.js +17 -2
- package/dist/check-report.d.ts +109 -0
- package/dist/check-report.js +204 -0
- package/dist/claim.js +1 -1
- package/dist/commit-extract.d.ts +94 -0
- package/dist/commit-extract.js +436 -0
- package/dist/contracts/console-api.d.ts +1 -1
- package/dist/contracts/console-api.js +1 -0
- package/dist/contracts/flow.d.ts +16 -0
- package/dist/contracts/flow.js +3 -0
- package/dist/contracts/release-check.d.ts +335 -0
- package/dist/contracts/release-check.js +159 -0
- package/dist/contracts/release-terms.d.ts +60 -0
- package/dist/contracts/release-terms.js +70 -0
- package/dist/contracts/verification-receipt.d.ts +42 -8
- package/dist/contracts/verification-receipt.js +24 -7
- package/dist/contracts/verification-view.d.ts +1 -0
- package/dist/deploy-gate.d.ts +17 -0
- package/dist/deploy-gate.js +68 -0
- package/dist/dispatch.js +3 -10
- package/dist/dispose.js +4 -1
- package/dist/evidence-pack.d.ts +1 -1
- package/dist/evidence-pack.js +20 -2
- package/dist/evidence.d.ts +1 -0
- package/dist/evidence.js +2 -1
- package/dist/flow-engine.js +5 -1
- package/dist/flow-pull-request.d.ts +5 -2
- package/dist/flow-pull-request.js +14 -5
- package/dist/flow-schedule.js +25 -6
- package/dist/flows-ui.js +7 -1
- package/dist/invoke.d.ts +19 -1
- package/dist/invoke.js +49 -2
- package/dist/lead-status.js +5 -2
- package/dist/live-terminal.d.ts +10 -6
- package/dist/live-terminal.js +55 -14
- package/dist/nightly-check.d.ts +104 -0
- package/dist/nightly-check.js +362 -0
- package/dist/operate.js +39 -2
- package/dist/person-proof.d.ts +2 -0
- package/dist/person-proof.js +14 -0
- package/dist/plan-auto.js +4 -4
- package/dist/planner-source.d.ts +1 -1
- package/dist/proof.js +2 -0
- package/dist/publish.js +156 -144
- package/dist/pull-request-flow.d.ts +2 -0
- package/dist/pull-request-flow.js +24 -2
- package/dist/release-adapters.d.ts +8 -0
- package/dist/release-adapters.js +212 -15
- package/dist/release-authority.d.ts +112 -0
- package/dist/release-authority.js +126 -0
- package/dist/release-carry.d.ts +233 -0
- package/dist/release-carry.js +645 -0
- package/dist/release-cli.d.ts +3 -0
- package/dist/release-cli.js +124 -5
- package/dist/release-coverage.js +3 -0
- package/dist/release-recheck.d.ts +146 -0
- package/dist/release-recheck.js +657 -0
- package/dist/release-view.d.ts +34 -0
- package/dist/release-view.js +27 -0
- package/dist/release.d.ts +131 -1
- package/dist/release.js +562 -244
- package/dist/remote-main.d.ts +2 -0
- package/dist/remote-main.js +1 -1
- package/dist/result-completion.d.ts +6 -0
- package/dist/result-completion.js +15 -1
- package/dist/result-follow-ups.js +29 -1
- package/dist/sandbox-runtime.d.ts +59 -1
- package/dist/sandbox-runtime.js +159 -5
- package/dist/scope.d.ts +115 -5
- package/dist/scope.js +219 -7
- package/dist/server/render-tasks.d.ts +8 -0
- package/dist/server/render-tasks.js +50 -9
- package/dist/server/route-table.js +1 -0
- package/dist/server/task-views.js +4 -0
- package/dist/server/tasks.js +12 -0
- package/dist/store.d.ts +227 -5
- package/dist/store.js +727 -37
- package/dist/task-outcome-cli.js +7 -3
- package/dist/toolroll-update.js +2 -2
- package/dist/verification-evidence.d.ts +25 -1
- package/dist/verification-evidence.js +57 -2
- package/dist/work-index.js +3 -6
- package/package.json +1 -1
package/dist/remote-main.d.ts
CHANGED
|
@@ -25,6 +25,8 @@ export declare function remoteUrlProblem(url: string): string | null;
|
|
|
25
25
|
export type RemoteGit = (args: readonly string[]) => Promise<ExecResult>;
|
|
26
26
|
export type RemoteMain = {
|
|
27
27
|
url: string;
|
|
28
|
+
/** The private Git directory the read fetched into (plane-owned; gone at `close`). */
|
|
29
|
+
gitDir: string;
|
|
28
30
|
/** The commit the recorded remote's main is at, as fetched just now. */
|
|
29
31
|
main: string;
|
|
30
32
|
/** Git in the private directory, hardened as above. */
|
package/dist/remote-main.js
CHANGED
|
@@ -165,7 +165,7 @@ export class RemoteMains {
|
|
|
165
165
|
const got = await this.exec(dir, ["fetch", "--quiet", "--no-tags", "--no-write-fetch-head", "--", from, `+${head}:refs/toolroll/results/${head}`], "always");
|
|
166
166
|
return got.code === 0 && this.clean(dir) && (await git(["cat-file", "-e", `${head}^{commit}`])).code === 0;
|
|
167
167
|
};
|
|
168
|
-
return { ok: true, remote: { url, main, git, bring } };
|
|
168
|
+
return { ok: true, remote: { url, gitDir: dir, main, git, bring } };
|
|
169
169
|
}
|
|
170
170
|
close() {
|
|
171
171
|
for (const dir of this.dirs.values()) {
|
|
@@ -27,3 +27,9 @@ export declare function familyChangedFiles(store: Store, taskId: string, runId:
|
|
|
27
27
|
* revises) changed protected files on a one-person approval, and nobody but its approver and requesters
|
|
28
28
|
* has marked it complete. */
|
|
29
29
|
export declare function protectedPublicationHold(store: Store, runId: number, root: string): string | null;
|
|
30
|
+
/** A completion's mark (v134, approval policy §1.7a): whether a person or a machine credential made it. */
|
|
31
|
+
export declare const COMPLETION_BY_PERSON = "proof person";
|
|
32
|
+
export declare const COMPLETION_BY_MACHINE = "proof machine";
|
|
33
|
+
/** Whether this person completing an already-complete result would release a protected publication hold: protected
|
|
34
|
+
* work needs someone other than its approver, no person's completion on record provides it, and this person can. */
|
|
35
|
+
export declare function personCompletionOwed(store: Store, taskId: string, runId: number, receiptDigest: string, person: string, root: string): boolean;
|
|
@@ -78,9 +78,23 @@ export function protectedPublicationHold(store, runId, root) {
|
|
|
78
78
|
const changed = familyChangedFiles(store, taskId, runId, root);
|
|
79
79
|
if (store.protectedResultProblem(taskId, changed, null) === null)
|
|
80
80
|
return null;
|
|
81
|
-
|
|
81
|
+
// Only a person's completion counts here (approval policy §1.7a): one a machine credential made, or one from before
|
|
82
|
+
// completions said who made them, never stands as the second person.
|
|
83
|
+
const completers = store.handle.prepare("SELECT DISTINCT actor FROM action_ledger WHERE run_id = ? AND action = ? AND detail = ?").all(runId, COMPLETION_ACTION, COMPLETION_BY_PERSON)
|
|
82
84
|
.map(row => String(row["actor"])).filter(actor => actor.startsWith("operator:")).map(actor => actor.slice("operator:".length));
|
|
83
85
|
if (completers.some(name => store.protectedResultProblem(taskId, changed, name) === null))
|
|
84
86
|
return null;
|
|
85
87
|
return "held: it changed protected files on a one-person approval, so it publishes once someone other than its approver marks it complete";
|
|
86
88
|
}
|
|
89
|
+
/** A completion's mark (v134, approval policy §1.7a): whether a person or a machine credential made it. */
|
|
90
|
+
export const COMPLETION_BY_PERSON = "proof person";
|
|
91
|
+
export const COMPLETION_BY_MACHINE = "proof machine";
|
|
92
|
+
/** Whether this person completing an already-complete result would release a protected publication hold: protected
|
|
93
|
+
* work needs someone other than its approver, no person's completion on record provides it, and this person can. */
|
|
94
|
+
export function personCompletionOwed(store, taskId, runId, receiptDigest, person, root) {
|
|
95
|
+
const changed = familyChangedFiles(store, taskId, runId, root);
|
|
96
|
+
if (store.protectedResultProblem(taskId, changed, null) === null || store.protectedResultProblem(taskId, changed, person) !== null)
|
|
97
|
+
return false;
|
|
98
|
+
return store.handle.prepare("SELECT 1 FROM action_ledger WHERE run_id = ? AND action = ? AND outcome = ? AND actor = ? AND detail = ?")
|
|
99
|
+
.get(runId, COMPLETION_ACTION, receiptDigest, `operator:${person}`, COMPLETION_BY_PERSON) === undefined;
|
|
100
|
+
}
|
|
@@ -2,6 +2,7 @@ import { checkoutRunner, ranAtLine, widestRan } from "./invoke.js";
|
|
|
2
2
|
import { mkdtempSync, rmSync } from "node:fs";
|
|
3
3
|
import { tmpdir } from "node:os";
|
|
4
4
|
import { join } from "node:path";
|
|
5
|
+
import { belongsToRelease } from "./release-authority.js";
|
|
5
6
|
import { run as execRun } from "./exec.js";
|
|
6
7
|
import { approvedCommandShell, SETUP_ENV_ALLOWLIST, SETUP_ENV_DENYLIST } from "./builder.js";
|
|
7
8
|
import { runWithIsolatedDatabase } from "./child-database.js";
|
|
@@ -288,7 +289,34 @@ export function fullCheckGate(store, runId, now) {
|
|
|
288
289
|
const fulls = followUpChecksOf(store, runId, now).filter(one => one.level === "full" && one.head === run.headRevision);
|
|
289
290
|
// A Full build that waited for a batch check has had no check of its own: Merge waits for the batch, like Quick does.
|
|
290
291
|
const batched = level === "full" && fulls.some(one => one.why === "batch");
|
|
291
|
-
|
|
292
|
+
// A recorded Full level is not a pass (review round 1): the build's own check must have passed, and a release
|
|
293
|
+
// check that re-ran only what failed is proved by its release alone, never cleared here.
|
|
294
|
+
if (level === "full" && !batched) {
|
|
295
|
+
const targeted = store.handle.prepare("SELECT 1 AS hit FROM release_check_attempt WHERE run = ? AND mode = 'targeted'").get(runId) !== undefined;
|
|
296
|
+
if (targeted)
|
|
297
|
+
return { state: "failed", message: "This result's check re-ran only what failed; it merges only through its release." };
|
|
298
|
+
const own = store.handle.prepare("SELECT status FROM run_check WHERE run = ?").get(runId);
|
|
299
|
+
if (own !== undefined && own["status"] === "passed")
|
|
300
|
+
return { state: "clear" };
|
|
301
|
+
// An ordinary task's own check didn't pass (or has no row): a Full check run again on this same commit decides
|
|
302
|
+
// (review round 2). A release's task never: it merges only through its release.
|
|
303
|
+
const taskId = store.refById(run.taskRef)?.externalId ?? null;
|
|
304
|
+
const last = fulls.at(-1);
|
|
305
|
+
if (last !== undefined && (taskId === null || !belongsToRelease(store, taskId))) {
|
|
306
|
+
if (last.state === "passed")
|
|
307
|
+
return { state: "clear" };
|
|
308
|
+
if (last.state === "failed")
|
|
309
|
+
return { state: "failed", message: `The full check failed on this commit${last.exitCode === null ? "" : ` (exit ${last.exitCode})`}. Fix it, or merge anyway.` };
|
|
310
|
+
if (last.state === "not-run")
|
|
311
|
+
return { state: "not-run", message: `The full check didn't finish on this commit${last.note === null ? "" : `: ${last.note}`} Run it again, or merge anyway.` };
|
|
312
|
+
return { state: "waiting", message: "The full check is still running on this commit. Merge once it passes, or merge anyway." };
|
|
313
|
+
}
|
|
314
|
+
// No row is a check pending or never requested, never a pass (security review).
|
|
315
|
+
if (own === undefined)
|
|
316
|
+
return { state: "not-requested", message: "The full check hasn't run on this commit yet. Merge once it passes, or merge anyway." };
|
|
317
|
+
return { state: "failed", message: "The full check didn't pass on this commit. Fix it, or merge anyway." };
|
|
318
|
+
}
|
|
319
|
+
if (!batched && store.liveVerifyCommand(repo) === null)
|
|
292
320
|
return { state: "clear" };
|
|
293
321
|
const last = fulls.at(-1);
|
|
294
322
|
if (last === undefined)
|
|
@@ -15,6 +15,11 @@ export type SrtTerms = {
|
|
|
15
15
|
readConfig: FsReadRestrictionConfig;
|
|
16
16
|
writeConfig: FsWriteRestrictionConfig;
|
|
17
17
|
allowMachLookup: string[];
|
|
18
|
+
/** macOS: the launch may reach the keychain's services (securityd). Only a build's agent: every other launch is
|
|
19
|
+
* denied them (SEATBELT_NO_KEYCHAIN). */
|
|
20
|
+
keychain?: boolean;
|
|
21
|
+
/** macOS: a project check's launch, whose Chromium may register and reach its own service (SEATBELT_CHROMIUM). */
|
|
22
|
+
browser?: boolean;
|
|
18
23
|
network: {
|
|
19
24
|
kind: "open";
|
|
20
25
|
} | {
|
|
@@ -38,8 +43,60 @@ export declare function shellQuote(words: readonly string[]): string;
|
|
|
38
43
|
* the launch's own temp folder when it has one, so it keeps it; otherwise /tmp/claude, made if missing.
|
|
39
44
|
*/
|
|
40
45
|
export declare function withRuntimeTemp<T>(temp: string | undefined, make: () => T): T;
|
|
46
|
+
/**
|
|
47
|
+
* Toolroll's own Seatbelt rules, appended to the end of the runtime's profile (Seatbelt takes the last rule that
|
|
48
|
+
* matches). Static: constants here, never made from a launch's data.
|
|
49
|
+
*
|
|
50
|
+
* Terminals: a pseudo-terminal of the launch's own (node-pty, `script`, a test that drives a shell), and no other. The
|
|
51
|
+
* runtime's `allowPty` opens reads and writes on every /dev/ttys device, the operator's own open terminals among them,
|
|
52
|
+
* and its baseline already reads them (a read steals what is typed there). macOS marks a terminal the sandboxed process
|
|
53
|
+
* made with the `com.apple.sandbox.pty` extension (for that terminal only: a device number used again later outside is
|
|
54
|
+
* not covered), so only those are opened by name. A terminal handed down open (a live terminal's own) stays usable.
|
|
55
|
+
*/
|
|
56
|
+
export declare const SEATBELT_PTY: string;
|
|
57
|
+
/** Last in every profile: no other terminal opened by name, and no keystrokes pushed into any terminal (TIOCSTI,
|
|
58
|
+
* _IOW('t', 114, char)), its own controlling terminal included. */
|
|
59
|
+
export declare const SEATBELT_NO_OTHER_TERMINAL: string;
|
|
60
|
+
/**
|
|
61
|
+
* Last in every launch's profile but a build agent's: the keychain's services. The runtime's
|
|
62
|
+
* baseline lets every sandboxed process ask securityd, so it reads the login keychain's items whose access list lets
|
|
63
|
+
* any program read them (anthropics/sandbox-runtime#755). Build agents keep it (Claude Code signs in from the keychain).
|
|
64
|
+
*/
|
|
65
|
+
export declare const SEATBELT_NO_KEYCHAIN: string;
|
|
66
|
+
/**
|
|
67
|
+
* Chromium, a project check's launch only: Playwright's default browser (its Chromium headless shell) registers its
|
|
68
|
+
* port rendezvous service as org.chromium.Chromium.MachPortRendezvousServer.<its pid> at start, and its helpers look
|
|
69
|
+
* it up. Exactly that name. Chromium takes the prefix from its bundle id with no switch or variable to change it, and
|
|
70
|
+
* Seatbelt can't tie the pid to the caller, so the residual (another pid's name registered or looked up) is written
|
|
71
|
+
* up in SECURITY.md. Chrome for Testing (`channel: "chromium"`, headed runs) gets nothing: such a check runs Not
|
|
72
|
+
* sandboxed.
|
|
73
|
+
*/
|
|
74
|
+
export declare const SEATBELT_CHROMIUM: string;
|
|
75
|
+
/** Toolroll's rules for a launch's terms, in order: its own terminal (unless its read denies cover the devices), a
|
|
76
|
+
* check's Chromium service, then the denies that must come last. */
|
|
77
|
+
export declare function seatbeltRulesFor(terms: SrtTerms): string;
|
|
78
|
+
/** The runtime's own quoting (its utils/shell-quote.js): a word bare when nothing in it is special, otherwise in single
|
|
79
|
+
* quotes with a quote inside as '"'"'. */
|
|
80
|
+
export declare function srtQuote(words: readonly string[]): string;
|
|
81
|
+
/** The words of a line srtQuote made, or null for any other line (an operator, a second command, other quoting). */
|
|
82
|
+
export declare function srtWords(line: string): string[] | null;
|
|
83
|
+
/**
|
|
84
|
+
* `command` (the runtime's macOS invocation) with `rules` appended to its profile, made again from its words. The
|
|
85
|
+
* runtime's invocation is exactly `env [-u NAME]… [NAME=value]… /usr/bin/sandbox-exec -p <profile> <shell> -c
|
|
86
|
+
* <command>`, each word quoted its way, and `<command>` is `expected` when given. Any other line (an operator, a word
|
|
87
|
+
* before `env` or after the command, another program) is refused, so the launch doesn't run.
|
|
88
|
+
*/
|
|
89
|
+
export declare function withSeatbeltRules(command: string, rules: string, expected?: string): string;
|
|
41
90
|
/** The runtime's macOS invocation for `file args` run from `cwd`: a command line for /bin/sh. */
|
|
42
91
|
export declare function srtMacosCommand(file: string, args: readonly string[], terms: SrtTerms, cwd?: string, temp?: string): string;
|
|
92
|
+
/**
|
|
93
|
+
* macOS: what every sandboxed launch runs first, outside the sandbox, before it execs the runtime's command: a session of
|
|
94
|
+
* its own (setsid, syscall 147), so no terminal of whoever started Toolroll is its controlling terminal to reach. A
|
|
95
|
+
* launch that is a session leader already (a live terminal's program, its own terminal made for it) keeps that one; any
|
|
96
|
+
* other that still has a controlling terminal (getsid, syscall 310) is refused. Perl with -t reads neither PERL5OPT nor
|
|
97
|
+
* PERL5LIB, and -X keeps its taint warnings off the launch's output.
|
|
98
|
+
*/
|
|
99
|
+
export declare const DETACH: readonly string[];
|
|
43
100
|
/** The Linux helper (sandbox-launch.ts) and the environment variable its launch is handed in. */
|
|
44
101
|
export declare const SANDBOX_LAUNCH_SPEC = "TOOLROLL_SANDBOX_LAUNCH";
|
|
45
102
|
export declare const sandboxLaunchHelper: () => string;
|
|
@@ -52,7 +109,8 @@ export type SandboxLaunchSpec = {
|
|
|
52
109
|
temp?: string;
|
|
53
110
|
};
|
|
54
111
|
/**
|
|
55
|
-
* `file args` inside the runtime's sandbox, as a spawn: on macOS `/bin/sh -c <its command
|
|
112
|
+
* `file args` inside the runtime's sandbox, as a spawn: on macOS `/bin/sh -c <its command>` in a session of its own
|
|
113
|
+
* (DETACH); on Linux the helper,
|
|
56
114
|
* handed the launch in its environment (never its arguments, which other users can list).
|
|
57
115
|
*/
|
|
58
116
|
export declare function srtSpawn(file: string, args: readonly string[], terms: SrtTerms, cwd?: string, temp?: string, platform?: NodeJS.Platform): {
|
package/dist/sandbox-runtime.js
CHANGED
|
@@ -2,8 +2,10 @@
|
|
|
2
2
|
* The sandbox engine: Anthropic's sandbox-runtime (`@anthropic-ai/sandbox-runtime`, pinned in package.json). Every
|
|
3
3
|
* macOS Seatbelt profile and every Linux bubblewrap + seccomp invocation a fenced launch runs under is generated by
|
|
4
4
|
* its own code, per launch, from a small static config (read denies with exact re-allows, write roots with denies
|
|
5
|
-
* inside them, mach services, the run's proxy). Toolroll adds only what that config says (agent-fence.ts srtTermsOf)
|
|
6
|
-
*
|
|
5
|
+
* inside them, mach services, the run's proxy). Toolroll adds only what that config says (agent-fence.ts srtTermsOf),
|
|
6
|
+
* plus, on macOS, a few static rules of its own appended to the end of the profile (seatbeltRulesFor: a terminal of the
|
|
7
|
+
* launch's own and no other, no keystrokes pushed into a terminal, a check's Chromium service, the keychain's services
|
|
8
|
+
* only for builds); the rest of the rules, their baseline and their limits are the runtime's.
|
|
7
9
|
*
|
|
8
10
|
* macOS: its profile builder runs in this process (it is synchronous), with this process's working folder set to the
|
|
9
11
|
* launch's for the call, because the runtime anchors its mandatory write denies (.git/hooks, .vscode, shell profiles)
|
|
@@ -90,6 +92,146 @@ export function withRuntimeTemp(temp, make) {
|
|
|
90
92
|
process.env["CLAUDE_TMPDIR"] = before.old;
|
|
91
93
|
}
|
|
92
94
|
}
|
|
95
|
+
/**
|
|
96
|
+
* Toolroll's own Seatbelt rules, appended to the end of the runtime's profile (Seatbelt takes the last rule that
|
|
97
|
+
* matches). Static: constants here, never made from a launch's data.
|
|
98
|
+
*
|
|
99
|
+
* Terminals: a pseudo-terminal of the launch's own (node-pty, `script`, a test that drives a shell), and no other. The
|
|
100
|
+
* runtime's `allowPty` opens reads and writes on every /dev/ttys device, the operator's own open terminals among them,
|
|
101
|
+
* and its baseline already reads them (a read steals what is typed there). macOS marks a terminal the sandboxed process
|
|
102
|
+
* made with the `com.apple.sandbox.pty` extension (for that terminal only: a device number used again later outside is
|
|
103
|
+
* not covered), so only those are opened by name. A terminal handed down open (a live terminal's own) stays usable.
|
|
104
|
+
*/
|
|
105
|
+
export const SEATBELT_PTY = [
|
|
106
|
+
"; Toolroll: a pseudo-terminal the launch made itself",
|
|
107
|
+
"(allow pseudo-tty)",
|
|
108
|
+
"(allow file-read* file-write* file-ioctl (literal \"/dev/ptmx\"))",
|
|
109
|
+
"(allow file-read* file-write* file-ioctl (require-all (regex #\"^/dev/ttys[0-9]+$\") (extension \"com.apple.sandbox.pty\")))",
|
|
110
|
+
].join("\n");
|
|
111
|
+
/** Last in every profile: no other terminal opened by name, and no keystrokes pushed into any terminal (TIOCSTI,
|
|
112
|
+
* _IOW('t', 114, char)), its own controlling terminal included. */
|
|
113
|
+
export const SEATBELT_NO_OTHER_TERMINAL = [
|
|
114
|
+
"; Toolroll: no other terminal, and nothing typed into one",
|
|
115
|
+
"(deny file-read-data file-write* (require-all (regex #\"^/dev/ttys[0-9]+$\") (require-not (extension \"com.apple.sandbox.pty\"))))",
|
|
116
|
+
"(deny file-ioctl (ioctl-command 2147578994))",
|
|
117
|
+
].join("\n");
|
|
118
|
+
/**
|
|
119
|
+
* Last in every launch's profile but a build agent's: the keychain's services. The runtime's
|
|
120
|
+
* baseline lets every sandboxed process ask securityd, so it reads the login keychain's items whose access list lets
|
|
121
|
+
* any program read them (anthropics/sandbox-runtime#755). Build agents keep it (Claude Code signs in from the keychain).
|
|
122
|
+
*/
|
|
123
|
+
export const SEATBELT_NO_KEYCHAIN = [
|
|
124
|
+
"; Toolroll: no keychain",
|
|
125
|
+
"(deny mach-lookup (global-name \"com.apple.SecurityServer\") (global-name \"com.apple.securityd.xpc\"))",
|
|
126
|
+
].join("\n");
|
|
127
|
+
/**
|
|
128
|
+
* Chromium, a project check's launch only: Playwright's default browser (its Chromium headless shell) registers its
|
|
129
|
+
* port rendezvous service as org.chromium.Chromium.MachPortRendezvousServer.<its pid> at start, and its helpers look
|
|
130
|
+
* it up. Exactly that name. Chromium takes the prefix from its bundle id with no switch or variable to change it, and
|
|
131
|
+
* Seatbelt can't tie the pid to the caller, so the residual (another pid's name registered or looked up) is written
|
|
132
|
+
* up in SECURITY.md. Chrome for Testing (`channel: "chromium"`, headed runs) gets nothing: such a check runs Not
|
|
133
|
+
* sandboxed.
|
|
134
|
+
*/
|
|
135
|
+
export const SEATBELT_CHROMIUM = [
|
|
136
|
+
"; Toolroll: the Chromium headless shell port rendezvous service",
|
|
137
|
+
"(allow mach-register mach-lookup (global-name-regex #\"^org\\.chromium\\.Chromium\\.MachPortRendezvousServer\\.[0-9]+$\"))",
|
|
138
|
+
].join("\n");
|
|
139
|
+
/** Whether a read deny covers the launch's own terminal devices (/dev/ptmx, /dev/ttys*): then it gets none. */
|
|
140
|
+
function deniesTerminals(deny) {
|
|
141
|
+
const glob = deny.search(/[*?[]/);
|
|
142
|
+
const path = (glob < 0 ? deny : deny.slice(0, glob)).replace(/\/+$/, "");
|
|
143
|
+
const devices = ["/dev/ptmx", "/dev/ttys0"];
|
|
144
|
+
if (glob >= 0)
|
|
145
|
+
return devices.some(device => device.startsWith(path));
|
|
146
|
+
return path === "" || path === "/dev" || devices.includes(path) || /^\/dev\/ttys[0-9]*$/.test(path);
|
|
147
|
+
}
|
|
148
|
+
/** Toolroll's rules for a launch's terms, in order: its own terminal (unless its read denies cover the devices), a
|
|
149
|
+
* check's Chromium service, then the denies that must come last. */
|
|
150
|
+
export function seatbeltRulesFor(terms) {
|
|
151
|
+
const denied = [...terms.readConfig.denyOnly, ...terms.readConfig.literalDenyOnly ?? []].some(one => deniesTerminals(typeof one === "string" ? one : String(one)));
|
|
152
|
+
return [...denied ? [] : [SEATBELT_PTY], ...terms.browser === true ? [SEATBELT_CHROMIUM] : [], SEATBELT_NO_OTHER_TERMINAL,
|
|
153
|
+
...terms.keychain === true ? [] : [SEATBELT_NO_KEYCHAIN]].join("\n");
|
|
154
|
+
}
|
|
155
|
+
const BARE_WORD = /^[A-Za-z0-9_./:@+,-][A-Za-z0-9_./:=@+,-]*$/;
|
|
156
|
+
/** The runtime's own quoting (its utils/shell-quote.js): a word bare when nothing in it is special, otherwise in single
|
|
157
|
+
* quotes with a quote inside as '"'"'. */
|
|
158
|
+
export function srtQuote(words) {
|
|
159
|
+
return words.map(word => word === "" ? "''" : BARE_WORD.test(word) ? word : `'${word.replace(/'/g, `'"'"'`)}'`).join(" ");
|
|
160
|
+
}
|
|
161
|
+
/** The words of a line srtQuote made, or null for any other line (an operator, a second command, other quoting). */
|
|
162
|
+
export function srtWords(line) {
|
|
163
|
+
const words = [];
|
|
164
|
+
let at = 0;
|
|
165
|
+
while (at < line.length) {
|
|
166
|
+
let word = "";
|
|
167
|
+
if (line[at] === "'") {
|
|
168
|
+
for (;;) {
|
|
169
|
+
const close = line.indexOf("'", at + 1);
|
|
170
|
+
if (close < 0)
|
|
171
|
+
return null;
|
|
172
|
+
word += line.slice(at + 1, close);
|
|
173
|
+
at = close + 1;
|
|
174
|
+
if (line.startsWith(`"'"'`, at)) {
|
|
175
|
+
word += "'";
|
|
176
|
+
at += 3;
|
|
177
|
+
continue;
|
|
178
|
+
}
|
|
179
|
+
break;
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
else {
|
|
183
|
+
const end = line.indexOf(" ", at);
|
|
184
|
+
word = line.slice(at, end < 0 ? line.length : end);
|
|
185
|
+
if (!BARE_WORD.test(word))
|
|
186
|
+
return null;
|
|
187
|
+
at += word.length;
|
|
188
|
+
}
|
|
189
|
+
words.push(word);
|
|
190
|
+
if (at === line.length)
|
|
191
|
+
break;
|
|
192
|
+
if (line[at] !== " " || at + 1 === line.length)
|
|
193
|
+
return null;
|
|
194
|
+
at += 1;
|
|
195
|
+
}
|
|
196
|
+
return words.length > 0 && srtQuote(words) === line ? words : null;
|
|
197
|
+
}
|
|
198
|
+
const ENV_NAME = /^[A-Za-z_][A-Za-z0-9_]*$/;
|
|
199
|
+
/**
|
|
200
|
+
* `command` (the runtime's macOS invocation) with `rules` appended to its profile, made again from its words. The
|
|
201
|
+
* runtime's invocation is exactly `env [-u NAME]… [NAME=value]… /usr/bin/sandbox-exec -p <profile> <shell> -c
|
|
202
|
+
* <command>`, each word quoted its way, and `<command>` is `expected` when given. Any other line (an operator, a word
|
|
203
|
+
* before `env` or after the command, another program) is refused, so the launch doesn't run.
|
|
204
|
+
*/
|
|
205
|
+
export function withSeatbeltRules(command, rules, expected) {
|
|
206
|
+
if (rules.includes("'"))
|
|
207
|
+
throw new Error("a Seatbelt rule Toolroll adds can't hold a quote");
|
|
208
|
+
const shape = () => new Error("the sandbox runtime's command isn't the shape Toolroll knows");
|
|
209
|
+
const words = srtWords(command);
|
|
210
|
+
if (words === null || words[0] !== "env")
|
|
211
|
+
throw shape();
|
|
212
|
+
const exec = words.indexOf(SANDBOX_EXEC);
|
|
213
|
+
if (exec < 1 || words.length !== exec + 6 || words[exec + 1] !== "-p" || words[exec + 4] !== "-c")
|
|
214
|
+
throw shape();
|
|
215
|
+
for (let index = 1; index < exec; index += 1) {
|
|
216
|
+
if (words[index] === "-u") {
|
|
217
|
+
if (!ENV_NAME.test(words[index + 1] ?? ""))
|
|
218
|
+
throw shape();
|
|
219
|
+
index += 1;
|
|
220
|
+
continue;
|
|
221
|
+
}
|
|
222
|
+
const name = words[index].split("=", 1)[0];
|
|
223
|
+
if (!words[index].includes("=") || !ENV_NAME.test(name))
|
|
224
|
+
throw shape();
|
|
225
|
+
}
|
|
226
|
+
const profile = words[exec + 2];
|
|
227
|
+
if (!profile.startsWith("(version 1)\n(deny default"))
|
|
228
|
+
throw shape();
|
|
229
|
+
if (!/^\/[A-Za-z0-9_./-]+$/.test(words[exec + 3]))
|
|
230
|
+
throw shape();
|
|
231
|
+
if (expected !== undefined && words[exec + 5] !== expected)
|
|
232
|
+
throw shape();
|
|
233
|
+
return srtQuote([...words.slice(0, exec + 2), `${profile}\n${rules}\n`, ...words.slice(exec + 3)]);
|
|
234
|
+
}
|
|
93
235
|
/** The runtime's macOS invocation for `file args` run from `cwd`: a command line for /bin/sh. */
|
|
94
236
|
export function srtMacosCommand(file, args, terms, cwd = process.cwd(), temp) {
|
|
95
237
|
const network = terms.network;
|
|
@@ -110,13 +252,24 @@ export function srtMacosCommand(file, args, terms, cwd = process.cwd(), temp) {
|
|
|
110
252
|
// The runtime anchors its mandatory write denies at this process's working folder: the launch's, for this call.
|
|
111
253
|
const before = process.cwd();
|
|
112
254
|
process.chdir(cwd);
|
|
255
|
+
let command;
|
|
113
256
|
try {
|
|
114
|
-
|
|
257
|
+
command = withRuntimeTemp(temp, () => wrapCommandWithSandboxMacOS(params));
|
|
115
258
|
}
|
|
116
259
|
finally {
|
|
117
260
|
process.chdir(before);
|
|
118
261
|
}
|
|
262
|
+
return withSeatbeltRules(command, seatbeltRulesFor(terms), params.command);
|
|
119
263
|
}
|
|
264
|
+
/**
|
|
265
|
+
* macOS: what every sandboxed launch runs first, outside the sandbox, before it execs the runtime's command: a session of
|
|
266
|
+
* its own (setsid, syscall 147), so no terminal of whoever started Toolroll is its controlling terminal to reach. A
|
|
267
|
+
* launch that is a session leader already (a live terminal's program, its own terminal made for it) keeps that one; any
|
|
268
|
+
* other that still has a controlling terminal (getsid, syscall 310) is refused. Perl with -t reads neither PERL5OPT nor
|
|
269
|
+
* PERL5LIB, and -X keeps its taint warnings off the launch's output.
|
|
270
|
+
*/
|
|
271
|
+
export const DETACH = ["-t", "-X", "-e",
|
|
272
|
+
'syscall(147) != -1 or syscall(310, 0) == $$ or !open(my $t, "<", "/dev/tty") or die "toolroll: the sandboxed launch kept a terminal\\n"; exec { $ARGV[0] } @ARGV; die "toolroll: the sandbox could not start: $!\\n";'];
|
|
120
273
|
/** The Linux helper (sandbox-launch.ts) and the environment variable its launch is handed in. */
|
|
121
274
|
export const SANDBOX_LAUNCH_SPEC = "TOOLROLL_SANDBOX_LAUNCH";
|
|
122
275
|
export const sandboxLaunchHelper = () => {
|
|
@@ -125,7 +278,8 @@ export const sandboxLaunchHelper = () => {
|
|
|
125
278
|
return existsSync(beside) ? beside : fileURLToPath(new URL("../dist/sandbox-launch.js", import.meta.url));
|
|
126
279
|
};
|
|
127
280
|
/**
|
|
128
|
-
* `file args` inside the runtime's sandbox, as a spawn: on macOS `/bin/sh -c <its command
|
|
281
|
+
* `file args` inside the runtime's sandbox, as a spawn: on macOS `/bin/sh -c <its command>` in a session of its own
|
|
282
|
+
* (DETACH); on Linux the helper,
|
|
129
283
|
* handed the launch in its environment (never its arguments, which other users can list).
|
|
130
284
|
*/
|
|
131
285
|
export function srtSpawn(file, args, terms, cwd = process.cwd(), temp, platform = process.platform) {
|
|
@@ -133,7 +287,7 @@ export function srtSpawn(file, args, terms, cwd = process.cwd(), temp, platform
|
|
|
133
287
|
const command = srtMacosCommand(file, args, terms, cwd, temp);
|
|
134
288
|
if (!command.includes(SANDBOX_EXEC))
|
|
135
289
|
throw new Error("the sandbox runtime returned the command unsandboxed");
|
|
136
|
-
return { file: "/bin/
|
|
290
|
+
return { file: "/usr/bin/perl", args: [...DETACH, "/bin/sh", "-c", command], env: {} };
|
|
137
291
|
}
|
|
138
292
|
const spec = { file, args: [...args], cwd, terms, ...(temp === undefined ? {} : { temp }) };
|
|
139
293
|
return { file: process.execPath, args: [sandboxLaunchHelper()], env: { [SANDBOX_LAUNCH_SPEC]: JSON.stringify(spec) } };
|
package/dist/scope.d.ts
CHANGED
|
@@ -353,8 +353,10 @@ export type Scope = SavedScopeTerms & {
|
|
|
353
353
|
/** How the approval happened (v29): 'password' = the ceremony;
|
|
354
354
|
* 'mode' = sealed by the signer's live mode (modeDigest names it);
|
|
355
355
|
* 'chat' = the owner's own two taps in their paired chat, under their chat-approval setting or a signed term
|
|
356
|
-
* (modeDigest names that authority; the yes is the person's and doesn't lapse with it).
|
|
357
|
-
|
|
356
|
+
* (modeDigest names that authority; the yes is the person's and doesn't lapse with it).
|
|
357
|
+
* 'schedule' = a standing order's firing (modeDigest names the order); 'lead' = the owner's lead (never a person);
|
|
358
|
+
* 'unknown' = a stored word this code doesn't write, which no reader treats as authority (approval-policy plan). */
|
|
359
|
+
approvalBasis?: ApprovalBasis | "unknown" | null;
|
|
358
360
|
modeDigest?: string | null;
|
|
359
361
|
/** v24 (optional so hand-built scopes in tests stay valid): the working
|
|
360
362
|
* execution profile, its resolution state, and the immutable snapshot
|
|
@@ -403,9 +405,21 @@ export type Scope = SavedScopeTerms & {
|
|
|
403
405
|
/** Who wrote the text (mate arc, ruling 2): a confirmed lead proposal,
|
|
404
406
|
* the coordinator, a scout, or a person (null). Read raw and proved by
|
|
405
407
|
* `termsProblem` — a word outside these is a stated problem, never a
|
|
406
|
-
* value that slips past the lead quarantine at a mode seal.
|
|
407
|
-
|
|
408
|
+
* value that slips past the lead quarantine at a mode seal. `lead`: the lead wrote it through its token (CLI); it is
|
|
409
|
+
* the lead's own text, not another machine's, so it isn't quarantined (`quarantinedAuthor`). */
|
|
410
|
+
proposedVia?: ScopeAuthor | null;
|
|
411
|
+
/** v135 (docs/plans/release-once.md): a release scope's terms — repository, version, pull request, approved commit,
|
|
412
|
+
* carry policy, grant, check access, expiry. Part of the digest when present; a person's seal of them opens the
|
|
413
|
+
* release's authority record. */
|
|
414
|
+
releaseTerms?: ReleaseTerms | null;
|
|
408
415
|
};
|
|
416
|
+
/** Who wrote a scope's text, when not a person (approval-policy plan §1.4). */
|
|
417
|
+
export type ScopeAuthor = "mate" | "coordinator" | "scout" | "lead";
|
|
418
|
+
/** The authorities a seal can stand on (approval-policy plan §1.5). Anything else stands on nothing. */
|
|
419
|
+
/** `carried`: a later commit of a release carried under a person's release approval (release-once.md); only the carry
|
|
420
|
+
* transaction ever writes it. */
|
|
421
|
+
export type ApprovalBasis = "password" | "mode" | "chat" | "grant" | "schedule" | "lead" | "carried";
|
|
422
|
+
export declare const APPROVAL_BASES: readonly ApprovalBasis[];
|
|
409
423
|
export type Approval = {
|
|
410
424
|
approved: true;
|
|
411
425
|
at: string;
|
|
@@ -439,11 +453,14 @@ export type ScopeInput = {
|
|
|
439
453
|
posture?: "escalated";
|
|
440
454
|
/** Who wrote this text (mate arc, ruling 2): `mate` for a confirmed mate
|
|
441
455
|
* proposal — mode coverage then never seals it; a human rewrite clears it. */
|
|
442
|
-
proposedVia?:
|
|
456
|
+
proposedVia?: ScopeAuthor | null;
|
|
443
457
|
/** v39: the signed acceptance rubric. Absent digests exactly like `[]` —
|
|
444
458
|
* see `Scope.acceptance`. `propose` itself never requires this to be
|
|
445
459
|
* non-empty; the roads that call it do. */
|
|
446
460
|
acceptance?: readonly AcceptanceCriterion[];
|
|
461
|
+
/** v135: release terms (release-carry.ts releaseTermsFor), from the plane's own records. Undefined keeps any terms
|
|
462
|
+
* the scope already carries; null removes them. */
|
|
463
|
+
releaseTerms?: ReleaseTerms | null;
|
|
447
464
|
now: Date;
|
|
448
465
|
mutation?: Mutation;
|
|
449
466
|
};
|
|
@@ -466,6 +483,8 @@ export declare function digestOf(scope: Pick<Scope, "goal" | "outOfScope" | "tou
|
|
|
466
483
|
/** v69: a prepared commit, folded in only when present so every earlier
|
|
467
484
|
* digest is untouched. */
|
|
468
485
|
candidate?: string | null;
|
|
486
|
+
/** v135: release terms, folded in only when present. */
|
|
487
|
+
releaseTerms?: ReleaseTerms | null;
|
|
469
488
|
}, target?: ExecutionProfile | null | {
|
|
470
489
|
chain: readonly ChainEntry[];
|
|
471
490
|
}, route?: PhaseRoute | null): string;
|
|
@@ -562,6 +581,11 @@ export type ApproveResult = {
|
|
|
562
581
|
| {
|
|
563
582
|
ok: false;
|
|
564
583
|
reason: "password-now";
|
|
584
|
+
}
|
|
585
|
+
/** Approval policy, phase 1: a machine credential (the lead's token, a remote API token) is not a person. */
|
|
586
|
+
| {
|
|
587
|
+
ok: false;
|
|
588
|
+
reason: "machine-proof";
|
|
565
589
|
};
|
|
566
590
|
/**
|
|
567
591
|
* Whether this name-and-token pair is a person the store knows. Shared by
|
|
@@ -835,3 +859,89 @@ export declare function acceptanceWords(criteria: readonly AcceptanceCriterion[]
|
|
|
835
859
|
export declare function scopeRouteWords(scope: Pick<Scope, "proposedRouteJson">, readiness?: ReadinessLookup): string[];
|
|
836
860
|
/** The scope, in the words an operator has to be able to agree or disagree with. */
|
|
837
861
|
export declare function describeScope(scope: Scope, readiness?: ReadinessLookup): string[];
|
|
862
|
+
/** The approval sheet's release lines (release-once.md): the release in one sentence, then what asks again. */
|
|
863
|
+
export declare function releaseTermsSentence(terms: ReleaseTerms): string;
|
|
864
|
+
/** What asks again, in full: the terms behind the sentence. */
|
|
865
|
+
export declare function releaseAsksAgainWords(terms: ReleaseTerms): string[];
|
|
866
|
+
export { personProof } from "./person-proof.js";
|
|
867
|
+
import { type ReleaseTerms } from "./contracts/release-terms.js";
|
|
868
|
+
import { type ReleaseAuthority, type ReleaseCarry } from "./release-authority.js";
|
|
869
|
+
/** Whether the command running now is a machine's: the lead (its token) or a remote API run. Trusted context only. */
|
|
870
|
+
export declare const machineActing: () => boolean;
|
|
871
|
+
/** A proof row's detail, either form: the legacy text (`<at> · generation <n> · digest <d>`) or the versioned record
|
|
872
|
+
* (`{"v":1,"at":…,"digest":…}`, more fields allowed). Any other shape is no proof. */
|
|
873
|
+
export declare function proofDetailOf(detail: unknown): {
|
|
874
|
+
at: string;
|
|
875
|
+
digest: string;
|
|
876
|
+
} | null;
|
|
877
|
+
/**
|
|
878
|
+
* A person's approval of this task as it stands: approved, on the current digest, by the ceremony (`password`) or a
|
|
879
|
+
* paired-chat tap (`chat`), with a person's proof recorded for that approver and those exact bytes. Release, deploy
|
|
880
|
+
* and the protected-work counters ask this; a lead, a mode, a grant, a schedule or an approval with no proof is not
|
|
881
|
+
* a person's.
|
|
882
|
+
*/
|
|
883
|
+
export declare function personApprovalOf(store: Store, taskId: string): {
|
|
884
|
+
ok: true;
|
|
885
|
+
by: string;
|
|
886
|
+
at: string;
|
|
887
|
+
method: string;
|
|
888
|
+
} | {
|
|
889
|
+
ok: false;
|
|
890
|
+
why: string;
|
|
891
|
+
};
|
|
892
|
+
/**
|
|
893
|
+
* Who made a seal that says it came through a person's road (`password`, `chat`), from the acts recorded AT THAT SEAL
|
|
894
|
+
* (approval policy §1.5): the proof rows its approver left at the seal's own instant, and the lead's task act then.
|
|
895
|
+
* - `machine`: any proof there that isn't a person's (a lead or remote token, or a word this code doesn't know), or the
|
|
896
|
+
* lead's act — fail closed, whatever its digest says;
|
|
897
|
+
* - `person`: a person's proof there for these exact bytes (parsed, either form);
|
|
898
|
+
* - `automation`: a standing order's firing that no person's proof stands behind;
|
|
899
|
+
* - `unproven`: none of these (a seal from before proof rows): it dispatches as before, and is never a person's yes.
|
|
900
|
+
* An older proof for the same bytes never vouches for a later seal. The dispatch belts ask the same in SQL
|
|
901
|
+
* (approval-authority.ts).
|
|
902
|
+
*/
|
|
903
|
+
export declare function sealOriginOf(store: Store, taskId: string, by: string, digest: string, at: string): {
|
|
904
|
+
origin: "machine";
|
|
905
|
+
lead: boolean;
|
|
906
|
+
} | {
|
|
907
|
+
origin: "person";
|
|
908
|
+
method: string;
|
|
909
|
+
} | {
|
|
910
|
+
origin: "automation";
|
|
911
|
+
} | {
|
|
912
|
+
origin: "unproven";
|
|
913
|
+
};
|
|
914
|
+
/** Text another machine wrote (a confirmed chat proposal, a coordinator, a scout): no mode, grant, remembered password
|
|
915
|
+
* or chat tap seals it. The lead's own CLI text (`lead`) is not quarantined. */
|
|
916
|
+
export declare const quarantinedAuthor: (via: string | null | undefined) => boolean;
|
|
917
|
+
/** What a release task is about to do. */
|
|
918
|
+
export type ReleaseAction = "dispatch" | "deploy" | "merge" | "complete";
|
|
919
|
+
export type LiveRelease = {
|
|
920
|
+
applies: false;
|
|
921
|
+
} | {
|
|
922
|
+
applies: true;
|
|
923
|
+
ok: true;
|
|
924
|
+
authority: ReleaseAuthority;
|
|
925
|
+
carry: ReleaseCarry | null;
|
|
926
|
+
} | {
|
|
927
|
+
applies: true;
|
|
928
|
+
ok: false;
|
|
929
|
+
why: string;
|
|
930
|
+
authority: ReleaseAuthority | null;
|
|
931
|
+
};
|
|
932
|
+
/**
|
|
933
|
+
* Whether a task that belongs to a release authority may do `action` now. It applies to EVERY such task, whether its
|
|
934
|
+
* seal is the person's own or `carried`, and requires all of: the authority's current generation (never superseded,
|
|
935
|
+
* revoked or expired before it started); a state that fits the action; the task is the authority's current tip; the
|
|
936
|
+
* anchor's seal is a person's (personApprovalOf, unchanged) on the exact sealed anchor digest; the anchor's approver is
|
|
937
|
+
* still an active approver with access to the project; for a carried task, a carry row for exactly this task, scope
|
|
938
|
+
* digest, candidate and generation; the release check grant and check access as sealed (access only ever the fence);
|
|
939
|
+
* a sealed policy this runtime knows; and, for deploy and merge, no open main-check failure blocking a composed check.
|
|
940
|
+
* A task that belongs to no release reads `applies: false` — the ordinary rules decide it.
|
|
941
|
+
*/
|
|
942
|
+
export declare function liveReleaseOf(store: Store, taskId: string, action: ReleaseAction, now?: Date): LiveRelease;
|
|
943
|
+
/**
|
|
944
|
+
* Open main-check failures (the nightly full check, part 3) block the deploy and the merge of a release whose gate
|
|
945
|
+
* check was composed from a targeted run: a full pass is needed first. Null when nothing blocks.
|
|
946
|
+
*/
|
|
947
|
+
export declare function mainDebtProblem(store: Store, repo: string, taskId: string): string | null;
|