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.
Files changed (91) hide show
  1. package/CHANGELOG.md +99 -0
  2. package/dist/agent-fence.d.ts +7 -0
  3. package/dist/agent-fence.js +10 -2
  4. package/dist/approval-authority.d.ts +20 -0
  5. package/dist/approval-authority.js +67 -0
  6. package/dist/assignment.js +26 -4
  7. package/dist/browser/workspace.css +1 -1
  8. package/dist/builder.js +48 -6
  9. package/dist/chat-decide.js +17 -2
  10. package/dist/check-report.d.ts +109 -0
  11. package/dist/check-report.js +204 -0
  12. package/dist/claim.js +1 -1
  13. package/dist/commit-extract.d.ts +94 -0
  14. package/dist/commit-extract.js +436 -0
  15. package/dist/contracts/console-api.d.ts +1 -1
  16. package/dist/contracts/console-api.js +1 -0
  17. package/dist/contracts/flow.d.ts +16 -0
  18. package/dist/contracts/flow.js +3 -0
  19. package/dist/contracts/release-check.d.ts +335 -0
  20. package/dist/contracts/release-check.js +159 -0
  21. package/dist/contracts/release-terms.d.ts +60 -0
  22. package/dist/contracts/release-terms.js +70 -0
  23. package/dist/contracts/verification-receipt.d.ts +42 -8
  24. package/dist/contracts/verification-receipt.js +24 -7
  25. package/dist/contracts/verification-view.d.ts +1 -0
  26. package/dist/deploy-gate.d.ts +17 -0
  27. package/dist/deploy-gate.js +68 -0
  28. package/dist/dispatch.js +3 -10
  29. package/dist/dispose.js +4 -1
  30. package/dist/evidence-pack.d.ts +1 -1
  31. package/dist/evidence-pack.js +20 -2
  32. package/dist/evidence.d.ts +1 -0
  33. package/dist/evidence.js +2 -1
  34. package/dist/flow-engine.js +5 -1
  35. package/dist/flow-pull-request.d.ts +5 -2
  36. package/dist/flow-pull-request.js +14 -5
  37. package/dist/flow-schedule.js +25 -6
  38. package/dist/flows-ui.js +7 -1
  39. package/dist/invoke.d.ts +19 -1
  40. package/dist/invoke.js +49 -2
  41. package/dist/lead-status.js +5 -2
  42. package/dist/live-terminal.d.ts +10 -6
  43. package/dist/live-terminal.js +55 -14
  44. package/dist/nightly-check.d.ts +104 -0
  45. package/dist/nightly-check.js +362 -0
  46. package/dist/operate.js +39 -2
  47. package/dist/person-proof.d.ts +2 -0
  48. package/dist/person-proof.js +14 -0
  49. package/dist/plan-auto.js +4 -4
  50. package/dist/planner-source.d.ts +1 -1
  51. package/dist/proof.js +2 -0
  52. package/dist/publish.js +156 -144
  53. package/dist/pull-request-flow.d.ts +2 -0
  54. package/dist/pull-request-flow.js +24 -2
  55. package/dist/release-adapters.d.ts +8 -0
  56. package/dist/release-adapters.js +212 -15
  57. package/dist/release-authority.d.ts +112 -0
  58. package/dist/release-authority.js +126 -0
  59. package/dist/release-carry.d.ts +233 -0
  60. package/dist/release-carry.js +645 -0
  61. package/dist/release-cli.d.ts +3 -0
  62. package/dist/release-cli.js +124 -5
  63. package/dist/release-coverage.js +3 -0
  64. package/dist/release-recheck.d.ts +146 -0
  65. package/dist/release-recheck.js +657 -0
  66. package/dist/release-view.d.ts +34 -0
  67. package/dist/release-view.js +27 -0
  68. package/dist/release.d.ts +131 -1
  69. package/dist/release.js +562 -244
  70. package/dist/remote-main.d.ts +2 -0
  71. package/dist/remote-main.js +1 -1
  72. package/dist/result-completion.d.ts +6 -0
  73. package/dist/result-completion.js +15 -1
  74. package/dist/result-follow-ups.js +29 -1
  75. package/dist/sandbox-runtime.d.ts +59 -1
  76. package/dist/sandbox-runtime.js +159 -5
  77. package/dist/scope.d.ts +115 -5
  78. package/dist/scope.js +219 -7
  79. package/dist/server/render-tasks.d.ts +8 -0
  80. package/dist/server/render-tasks.js +50 -9
  81. package/dist/server/route-table.js +1 -0
  82. package/dist/server/task-views.js +4 -0
  83. package/dist/server/tasks.js +12 -0
  84. package/dist/store.d.ts +227 -5
  85. package/dist/store.js +727 -37
  86. package/dist/task-outcome-cli.js +7 -3
  87. package/dist/toolroll-update.js +2 -2
  88. package/dist/verification-evidence.d.ts +25 -1
  89. package/dist/verification-evidence.js +57 -2
  90. package/dist/work-index.js +3 -6
  91. package/package.json +1 -1
@@ -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. */
@@ -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
- const completers = store.handle.prepare("SELECT DISTINCT actor FROM action_ledger WHERE run_id = ? AND action = ?").all(runId, COMPLETION_ACTION)
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
- if ((level === "full" && !batched) || (!batched && store.liveVerifyCommand(repo) === null))
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>`; on Linux the helper,
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): {
@@ -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
- * the rules themselves, their baseline and their limits are the sandbox runtime's.
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
- return withRuntimeTemp(temp, () => wrapCommandWithSandboxMacOS(params));
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>`; on Linux the helper,
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/sh", args: ["-c", command], env: {} };
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
- approvalBasis?: "password" | "mode" | "chat" | "grant" | null;
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
- proposedVia?: "mate" | "coordinator" | "scout" | null;
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?: "mate" | "coordinator" | null;
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;