toolroll 0.9.71 → 0.9.73
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 +155 -0
- package/dist/agent-fence.d.ts +10 -2
- package/dist/agent-fence.js +47 -9
- 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/browser/workspace.js +28 -28
- package/dist/builder.js +48 -6
- package/dist/chat-actions.js +13 -8
- package/dist/chat-decide.d.ts +47 -1
- package/dist/chat-decide.js +186 -5
- package/dist/check-access.js +2 -2
- package/dist/check-report.d.ts +109 -0
- package/dist/check-report.js +205 -0
- package/dist/claim.js +1 -1
- package/dist/coding-provider.js +4 -2
- package/dist/commit-extract.d.ts +111 -0
- package/dist/commit-extract.js +514 -0
- package/dist/contracts/console-api.d.ts +4 -4
- package/dist/contracts/console-api.js +5 -4
- 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 +66 -0
- package/dist/contracts/release-terms.js +74 -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/exec.d.ts +1 -1
- package/dist/exec.js +7 -3
- package/dist/flow-engine.js +5 -1
- package/dist/flow-gallery-ui.js +4 -2
- package/dist/flow-gallery.d.ts +3 -0
- package/dist/flow-gallery.js +6 -2
- 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/flow-triggers.d.ts +8 -0
- package/dist/flow-triggers.js +15 -0
- package/dist/flows-cli.js +13 -3
- package/dist/flows-ui.js +7 -1
- package/dist/guides.js +10 -1
- package/dist/invoke.d.ts +17 -6
- package/dist/invoke.js +48 -18
- package/dist/kits-ui.js +4 -2
- package/dist/kits.d.ts +6 -4
- package/dist/kits.js +9 -11
- package/dist/lead-status.js +5 -2
- package/dist/live-terminal.d.ts +10 -6
- package/dist/live-terminal.js +55 -14
- package/dist/mcp-connect.d.ts +10 -0
- package/dist/mcp-connect.js +2 -0
- 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/project-delete-ui.js +1 -1
- package/dist/project-tools.js +3 -1
- package/dist/proof.js +2 -0
- package/dist/protected-storage.d.ts +67 -0
- package/dist/protected-storage.js +398 -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 +16 -1
- package/dist/release-adapters.js +292 -29
- package/dist/release-authority.d.ts +118 -0
- package/dist/release-authority.js +135 -0
- package/dist/release-card.d.ts +76 -0
- package/dist/release-card.js +252 -0
- package/dist/release-carry.d.ts +236 -0
- package/dist/release-carry.js +664 -0
- package/dist/release-cli.d.ts +3 -0
- package/dist/release-cli.js +158 -6
- package/dist/release-coverage.js +3 -0
- package/dist/release-git.d.ts +43 -0
- package/dist/release-git.js +106 -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 +139 -3
- package/dist/release.js +641 -250
- 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 +16 -5
- package/dist/sandbox-runtime.js +19 -6
- package/dist/scope.d.ts +122 -5
- package/dist/scope.js +236 -7
- package/dist/server/flows.js +40 -10
- package/dist/server/render-settings.js +1 -1
- 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/settings.js +26 -5
- package/dist/server/task-views.js +4 -0
- package/dist/server/tasks.js +12 -0
- package/dist/setup-guide.d.ts +2 -2
- package/dist/store.d.ts +230 -5
- package/dist/store.js +733 -37
- package/dist/subagent-memory.d.ts +1 -0
- package/dist/subagent-memory.js +1 -1
- package/dist/subagent-tools.d.ts +22 -6
- package/dist/subagent-tools.js +44 -8
- package/dist/subagent-work.d.ts +2 -0
- package/dist/subagent-work.js +5 -2
- package/dist/subagents-ui.js +6 -4
- package/dist/task-outcome-cli.js +7 -3
- package/dist/telegram.d.ts +6 -1
- package/dist/telegram.js +58 -6
- 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/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,160 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.9.73 — 2026-10-25
|
|
4
|
+
|
|
5
|
+
- **Carry release approvals when checks aren't sandboxed.** A project whose
|
|
6
|
+
checks run outside the sandbox (like Toolroll's own release project) can
|
|
7
|
+
let later test-only commits carry a release approval:
|
|
8
|
+
`toolroll release unsandboxed-carry on --repo <path> --as <you> --token
|
|
9
|
+
<password>`. It is off by default; only a person turns it on, and it counts
|
|
10
|
+
only for releases approved while it is on. The approval sheet says plainly
|
|
11
|
+
that those later commits will run outside the sandbox without being shown
|
|
12
|
+
to you first. Every other carry rule stays: tests and docs only, the
|
|
13
|
+
400-line, 20-file and 800-line-churn limits, an identical production build,
|
|
14
|
+
and main's open nightly failures. Turning it off, or checks that reach
|
|
15
|
+
further than at the approval, stops carrying.
|
|
16
|
+
- **`toolroll release` stops at the gate when the pull request's checks
|
|
17
|
+
fail.** Before it files the release task or asks for your approval, it
|
|
18
|
+
reads the required checks on the exact commit. One that failed or was
|
|
19
|
+
cancelled stops the release with the failing job names, so you aren't
|
|
20
|
+
asked to approve a commit that can't merge. Checks not started or still
|
|
21
|
+
running are named and waited for; if they don't finish within the gate's
|
|
22
|
+
limit (60 minutes, `--limit gate=<min>`), it stops there too. The ci step
|
|
23
|
+
still checks every one again before merging.
|
|
24
|
+
- **Chat apps, browsers and password managers are off limits to every
|
|
25
|
+
launch.** Builds, checks, flows, tool servers and coding sessions can't
|
|
26
|
+
read or write where Slack, Telegram, Discord, Teams, WhatsApp, 1Password,
|
|
27
|
+
Bitwarden, or the Chrome, Chromium, Brave, Edge, Arc, Firefox, Zen,
|
|
28
|
+
Vivaldi, Opera, Orion and Safari profiles keep their data, on macOS and
|
|
29
|
+
Linux, under both sandbox versions and in Codex's sandbox. This covers the
|
|
30
|
+
folders by name and by real path, links inside them, and a moved profile
|
|
31
|
+
or config folder named in the environment. No project setting reopens
|
|
32
|
+
them, and those apps' variables (SLACK_, TELEGRAM_, DISCORD_, TEAMS_, OP_,
|
|
33
|
+
BW_, BITWARDENCLI_) are removed from every launch; a tool server keeps the
|
|
34
|
+
secrets it names.
|
|
35
|
+
- **`toolroll release` runs Git safely on the release checkout.** The release
|
|
36
|
+
used to run plain Git there, so a setting planted in the checkout's Git
|
|
37
|
+
config (an include, a hook folder, an ssh command, a proxy, a URL rewrite
|
|
38
|
+
and the like) could run a command or send the gh token elsewhere. Now it
|
|
39
|
+
uses the repository and GitHub URL from Toolroll's own records, runs Git,
|
|
40
|
+
gh and ssh by absolute path with a fixed environment, pins HTTPS settings
|
|
41
|
+
on the command line, and allows only the plain settings Git writes itself.
|
|
42
|
+
Any other setting stops the release and names it. The checkout must be a
|
|
43
|
+
project Toolroll records, with a recorded remote.
|
|
44
|
+
- **Widening actions take your password.** Setting the permission default to
|
|
45
|
+
Full access, giving a subagent a tool, loosening a tool rule, accepting a
|
|
46
|
+
subagent's suggested looser rule, and adding a trigger that brings in work
|
|
47
|
+
from anyone (GitHub from anyone, mail from any sender, from the Triggers
|
|
48
|
+
panel, a gallery template or a starter kit) now ask for your password (a
|
|
49
|
+
signed-in session does under Passwordless). Narrowing needs none. A chat
|
|
50
|
+
card, import or template can no longer do any of these; it points to the
|
|
51
|
+
page that can.
|
|
52
|
+
- **Approving a release from chat shows the whole release first.** A tap
|
|
53
|
+
approves a deploy only through the release card: every signed term and
|
|
54
|
+
release term, one line each (it says so when later test-only commits
|
|
55
|
+
will run outside the sandbox), and the commits since the last release
|
|
56
|
+
Toolroll recorded as merged. Continue sends the terms (as a file where the
|
|
57
|
+
chat app allows), and Approve is bound to exactly what was shown;
|
|
58
|
+
anything changed in between refuses with the new card. It works only in
|
|
59
|
+
your own private chat (Telegram or a Slack direct message); a group,
|
|
60
|
+
someone else's chat, Discord and Teams are refused. Each such approval is
|
|
61
|
+
announced to you, naming the chat app.
|
|
62
|
+
- **Network, writes, check access and sandbox settings stay on the typed
|
|
63
|
+
password under Passwordless,** and a test now fails if one moves to the
|
|
64
|
+
ordinary signed-in step-up.
|
|
65
|
+
- **Launches don't wait on macOS's "access data from other apps" prompt.**
|
|
66
|
+
Toolroll looks inside other apps' containers only after a 3-second look in
|
|
67
|
+
a separate process shows macOS lets it, so a service run by launchd no
|
|
68
|
+
longer hangs every fenced launch. While macOS is still asking, the
|
|
69
|
+
containers stay denied by name and real path, and the links found by the
|
|
70
|
+
last full look stay denied; a look that fails for any other reason refuses
|
|
71
|
+
the launch.
|
|
72
|
+
- **The release check ends itself before its time limit,** stopping the
|
|
73
|
+
parts still running and keeping every log and its report, and a failed
|
|
74
|
+
part's log reaches the run's output as soon as it fails.
|
|
75
|
+
- **`toolroll release` rides out short GitHub network timeouts** while a
|
|
76
|
+
step waits, asking again until that step's own limit.
|
|
77
|
+
- No database change: the schema stays at v135.
|
|
78
|
+
|
|
79
|
+
## 0.9.72 — 2026-10-25
|
|
80
|
+
|
|
81
|
+
- **One approval per release.** A person approves a release once. Later
|
|
82
|
+
commits on the same pull request carry that approval only when every change
|
|
83
|
+
since is unit tests, journey scripts, Markdown under docs/, the README or
|
|
84
|
+
the changelog, within 400 changed lines, 20 files and 800 lines of churn,
|
|
85
|
+
and the production build is byte-for-byte the approved one. Anything else
|
|
86
|
+
asks again, and so does any change after the deploy has started. The
|
|
87
|
+
approval sheet states the release and what asks again; the release summary
|
|
88
|
+
and the squash message list every commit that landed after the approval.
|
|
89
|
+
**Stop carrying later commits** on the task page (or `toolroll release
|
|
90
|
+
revoke`) ends it, and `toolroll release ask-every-commit on` makes a
|
|
91
|
+
project's releases ask for every commit. A release approval expires after
|
|
92
|
+
3 days.
|
|
93
|
+
- **Re-checks of a release re-run only what failed.** On the second and later
|
|
94
|
+
release checks, only the failed unit files, journey groups and upgrade
|
|
95
|
+
versions run again, with every test changed since and a short sanity pass.
|
|
96
|
+
A check that can't prove it covered everything counts as failed, and a
|
|
97
|
+
test that disappears or is newly skipped stops carrying.
|
|
98
|
+
- **A nightly full check of main.** `toolroll release nightly --repo <path>`
|
|
99
|
+
sets up a daily full check of main (03:00 local by default). A failure
|
|
100
|
+
becomes an attention notice naming what failed, holds re-checks that run
|
|
101
|
+
only part of the suite until a full check passes it again, and a nightly
|
|
102
|
+
that hasn't run for 26 hours says so.
|
|
103
|
+
- **Sandboxed checks can start Chromium on macOS.** Playwright's default
|
|
104
|
+
browser (its Chromium headless shell) now starts inside the check sandbox:
|
|
105
|
+
the sandbox may register and look up Chromium's port rendezvous service,
|
|
106
|
+
`org.chromium.Chromium.MachPortRendezvousServer.<pid>`, and nothing else.
|
|
107
|
+
Builds, flows and tool servers don't get this; SECURITY.md says what a
|
|
108
|
+
hostile check could do with it. A project set to **Not sandboxed** only for
|
|
109
|
+
a headless browser check can go back to **Sandboxed** in Settings → Project
|
|
110
|
+
→ Check access. Chrome for Testing (`channel: "chromium"`, headed runs)
|
|
111
|
+
still needs **Not sandboxed**.
|
|
112
|
+
- **Checks with sign-ins no longer reach your keychain.** Toolroll reads
|
|
113
|
+
Claude Code's and gh's sign-ins from the keychain itself, outside the
|
|
114
|
+
sandbox, and hands the check just those tokens in its environment. A check
|
|
115
|
+
gets Claude's current access token and can't renew it.
|
|
116
|
+
- **A wrong password on an approval says "That password didn't match."**
|
|
117
|
+
instead of "not approved: not-an-approver". Someone who isn't an approver
|
|
118
|
+
gets the same answer.
|
|
119
|
+
- **Closing a live terminal signals only its own processes.** It stops them
|
|
120
|
+
before it kills them, so a child one starts at that moment can't slip out
|
|
121
|
+
from under the close; each signal goes only to a process still the one it
|
|
122
|
+
saw (same pid, same start); and after the program ends, its terminal's
|
|
123
|
+
name no longer counts as proof, since the device may already be someone
|
|
124
|
+
else's. A process it can't prove is its own is never signalled, and the
|
|
125
|
+
close says it's uncertain (the worktree stays).
|
|
126
|
+
- **Only a person approves work.** The lead's token used to approve a task's
|
|
127
|
+
scope and be recorded as its owner's password approval, including on a
|
|
128
|
+
release. Now `task approve` (and `task repair --yes`) with a lead token, or
|
|
129
|
+
any approval made with a remote API token, is refused: "Only a person can
|
|
130
|
+
approve this." Approve on the task page or from your paired chat
|
|
131
|
+
(docs/plans/approval-policy.md, phase 1; the setting that lets the lead
|
|
132
|
+
approve, recorded as the lead's, comes next).
|
|
133
|
+
- **Only a person merges by hand.** `task merge` (including `--anyway`) and
|
|
134
|
+
the merge button refuse a lead or API token. A flow's Pull request zone
|
|
135
|
+
merges only after a person's recorded decision; a subagent's approval, or
|
|
136
|
+
a decision from before decisions recorded who made them, waits.
|
|
137
|
+
- **Protected work counts only people.** A vote a machine credential left, or
|
|
138
|
+
one with no proof on record, doesn't count as one of the two people, and a
|
|
139
|
+
lead or API token's completion is never the second person. A person can
|
|
140
|
+
mark an already-complete result complete again to release a held push.
|
|
141
|
+
- **Releases and deployments need a person's approval with its proof on
|
|
142
|
+
record.** The release gate and `deploy-browser.mjs` (every phase, resumed
|
|
143
|
+
ones included) refuse an approval a lead token made, a mode, a flow, a
|
|
144
|
+
standing order, or an approval with no recorded proof. `toolroll release` proves the
|
|
145
|
+
candidate in its own runtime, and that the checkout is exactly that commit,
|
|
146
|
+
before it runs anything from the candidate's checkout. Paired-chat
|
|
147
|
+
approvals now record their own proof, so they still count. Update the
|
|
148
|
+
plane before running `toolroll release`: an older plane can't say who
|
|
149
|
+
approved, and the release waits.
|
|
150
|
+
- Approvals a lead token made before this change are read for what they were:
|
|
151
|
+
they no longer dispatch, and the task asks for a person's approval. Seals
|
|
152
|
+
with no recorded authority stand on nothing. A standing order's firing is
|
|
153
|
+
approved by its own authority (`schedule`), which stands only while its
|
|
154
|
+
approver can still approve in the project.
|
|
155
|
+
- Schema v134 and v135 only add columns and tables; `toolroll update` runs
|
|
156
|
+
them in place.
|
|
157
|
+
|
|
3
158
|
## 0.9.71 — 2026-10-25
|
|
4
159
|
|
|
5
160
|
- **Sandboxed launches can open a terminal of their own on macOS.** A check
|
package/dist/agent-fence.d.ts
CHANGED
|
@@ -29,6 +29,8 @@ export declare function agentFence(options: {
|
|
|
29
29
|
boundary?: TaskBoundary | null;
|
|
30
30
|
binding?: WorktreeBinding | null;
|
|
31
31
|
runFolders?: RunFolders;
|
|
32
|
+
/** Protected storage as this launch resolved it (protected-storage.ts); resolved here when not given. */
|
|
33
|
+
protectedStorage?: readonly string[];
|
|
32
34
|
}): string[];
|
|
33
35
|
/** The task's boundary: `shell` joins the OS fence; `fileTools` only Claude's file-tool deny rules; `readOnly` and
|
|
34
36
|
* `lookup` are other tasks' git metadata and checkout roots (see FenceGrants). */
|
|
@@ -93,13 +95,19 @@ export type FenceGrants = {
|
|
|
93
95
|
/** macOS: the project's sealed mach services beyond the runtime's baseline (its `allowMachLookup`). */
|
|
94
96
|
machLookup?: string[];
|
|
95
97
|
/** macOS: the keychain's services (securityd), denied to every launch without it (sandbox-runtime.ts
|
|
96
|
-
* SEATBELT_NO_KEYCHAIN). Only fenceFor gives it, to a build's agent
|
|
97
|
-
*
|
|
98
|
+
* SEATBELT_NO_KEYCHAIN). Only fenceFor gives it, to a build's agent. Never a check's (one with sign-ins gets those in
|
|
99
|
+
* its environment, invoke.ts keychainSignIns), never a tool server's (serverGrantsOf). */
|
|
98
100
|
keychain?: true;
|
|
101
|
+
/** macOS: a project check's launch, whose Chromium may start (sandbox-runtime.ts SEATBELT_CHROMIUM). Only fenceFor
|
|
102
|
+
* sets it, only for kind "check". */
|
|
103
|
+
browser?: true;
|
|
99
104
|
/** A `sign-ins` check's exact sign-in files, read again inside the home denies (check-access.ts, signInGrants). */
|
|
100
105
|
signIns?: SignInGrants;
|
|
101
106
|
/** Never written, wherever the launch writes (homeWriteDenies): what runs for the operator next or signs them in. */
|
|
102
107
|
writeDenies?: string[];
|
|
108
|
+
/** Protected storage (protected-storage.ts): denied to read and write by every backend, and no grant equal to or
|
|
109
|
+
* inside one survives (a sandbox that lets the more specific path win can't reopen it). */
|
|
110
|
+
protectedStorage?: string[];
|
|
103
111
|
};
|
|
104
112
|
export { isWritesMode, WRITES_DEFAULT, type WritesMode } from "./writes-mode.js";
|
|
105
113
|
/**
|
package/dist/agent-fence.js
CHANGED
|
@@ -12,7 +12,7 @@
|
|
|
12
12
|
* checkout writable; other tasks' checkouts read, never written; the project's read-deny list; under policy v1 (an
|
|
13
13
|
* approval sealed before 0.9.70) the home configuration denied by name; a sign-ins check's exact files read again; the
|
|
14
14
|
* run's proxy as its only way out when its network is confined. On macOS every launch also gets a pseudo-terminal of its
|
|
15
|
-
* own and no other terminal,
|
|
15
|
+
* own and no other terminal, a project check's launch Chromium's own service, and only a build's agent the keychain
|
|
16
16
|
* (sandbox-runtime.ts seatbeltRulesFor, static rules).
|
|
17
17
|
*
|
|
18
18
|
* Codex (every platform) fences its own agent's commands with its own sandbox: a permission profile with the same
|
|
@@ -34,6 +34,7 @@ import { readMonitoring } from "./monitoring-settings.js";
|
|
|
34
34
|
import { existingFolders } from "./names.js";
|
|
35
35
|
import { DEPS_FOLDER, linkedKey, readyCopy, sharedCopies } from "./shared-deps.js";
|
|
36
36
|
import { SEATBELT_PROBE, sandboxRuntimeProblem, srtSpawn } from "./sandbox-runtime.js";
|
|
37
|
+
import { protectedStorageDenies, protectedStorageNames } from "./protected-storage.js";
|
|
37
38
|
import { getDefaultWritePaths } from "@anthropic-ai/sandbox-runtime";
|
|
38
39
|
/** Names that are secrets wherever the database lives (used when its folder is shared with other things). */
|
|
39
40
|
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;
|
|
@@ -188,11 +189,18 @@ export function agentFence(options) {
|
|
|
188
189
|
for (const path of options.boundary?.protectedShell ?? [])
|
|
189
190
|
fence.add(real(path));
|
|
190
191
|
const protectedPaths = new Set([...fence].flatMap(path => path === runFolder ? [path, real(path)] : [real(path)]));
|
|
192
|
+
// Protected storage (chat apps, browser profiles, password managers): every launch, every policy, never exemptible,
|
|
193
|
+
// kept by the name it was resolved under as well as where it leads.
|
|
194
|
+
const storage = options.protectedStorage ?? protectedStorageDenies(home);
|
|
195
|
+
for (const path of storage) {
|
|
196
|
+
protectedPaths.add(path);
|
|
197
|
+
protectedPaths.add(real(path));
|
|
198
|
+
}
|
|
191
199
|
for (const path of options.boundary?.shell ?? [])
|
|
192
200
|
fence.add(path);
|
|
193
201
|
const binding = options.binding;
|
|
194
202
|
const needed = binding === undefined || binding === null ? [] : [binding.gitdir, real(join(binding.main, ".git"))];
|
|
195
|
-
return [...new Set([...fence].flatMap(path => path === runFolder ? [path, real(path)] : [real(path)]))].filter(path => {
|
|
203
|
+
return [...new Set([...[...fence].flatMap(path => path === runFolder ? [path, real(path)] : [real(path)]), ...storage.flatMap(path => [path, real(path)])])].filter(path => {
|
|
196
204
|
if (protectedPaths.has(path) || !needed.some(target => target === path || inside(target, path)))
|
|
197
205
|
return true;
|
|
198
206
|
console.warn(`Dropped build fence entry covering this checkout's own Git data: ${path}`);
|
|
@@ -237,10 +245,12 @@ export function taskBoundary(options) {
|
|
|
237
245
|
const present = (names) => names.map(name => join(home, name)).filter(path => existsSync(path)).map(real);
|
|
238
246
|
// Policy v2 (docs/plans/sandbox-parity.md §1.2): the home folder, the repositories beside this one and the project's
|
|
239
247
|
// primary checkout are read as Anthropic's sandbox runtime reads them; only writes to the primary checkout stay denied.
|
|
248
|
+
// Protected storage by name, under both policies (protected-storage.ts): never part of the v1-only home configuration.
|
|
249
|
+
const storage = protectedStorageNames(home);
|
|
240
250
|
if ((options.policy ?? 1) === 2)
|
|
241
|
-
return v2Boundary(worktree, main, gitdir);
|
|
251
|
+
return v2Boundary(worktree, main, gitdir, storage);
|
|
242
252
|
const shell = new Set(present(HOME_CONFIGURATION.filter(name => !own.has(name))));
|
|
243
|
-
const protectedShell = [...shell];
|
|
253
|
+
const protectedShell = [...shell, ...storage];
|
|
244
254
|
const repository = (path) => existsSync(join(path, ".git"));
|
|
245
255
|
for (const parent of new Set([dirname(worktree), ...(main === null ? [] : [dirname(main)])])) {
|
|
246
256
|
// Home contains toolchain clones such as .nvm and .oh-my-zsh. The
|
|
@@ -290,7 +300,7 @@ export function taskBoundary(options) {
|
|
|
290
300
|
/** A v2 boundary: nothing of the home folder, the repositories beside this one or the primary checkout is denied to
|
|
291
301
|
* read; the primary checkout (all but its `.git`, which this worktree's Git needs) is never written; other tasks' Git
|
|
292
302
|
* metadata is read, never written, as under v1. */
|
|
293
|
-
function v2Boundary(worktree, main, gitdir) {
|
|
303
|
+
function v2Boundary(worktree, main, gitdir, storage) {
|
|
294
304
|
const noWrite = new Set();
|
|
295
305
|
if (main !== null && main !== worktree) {
|
|
296
306
|
let entries = [];
|
|
@@ -309,7 +319,7 @@ function v2Boundary(worktree, main, gitdir) {
|
|
|
309
319
|
const { readOnly, lookup } = siblingGitData(main, gitdir);
|
|
310
320
|
for (const path of noWrite)
|
|
311
321
|
readOnly.add(path);
|
|
312
|
-
return { protectedShell: [], shell: [], fileTools: [], readOnly: [...readOnly].sort(), lookup: [...lookup].sort(), homeNames: [] };
|
|
322
|
+
return { protectedShell: [...storage], shell: [], fileTools: [], readOnly: [...readOnly].sort(), lookup: [...lookup].sort(), homeNames: [] };
|
|
313
323
|
}
|
|
314
324
|
/**
|
|
315
325
|
* Git needs this worktree's metadata and the shared objects/refs, but must not prune or rewrite the metadata of another
|
|
@@ -765,6 +775,18 @@ function codexFilesystem(fence, fullAccess, grants) {
|
|
|
765
775
|
if (basename(path) === INSTANCE_DIR)
|
|
766
776
|
access.set(path, "deny");
|
|
767
777
|
}
|
|
778
|
+
// Protected storage: denied by name and real path, and nothing granted inside it (Codex lets the more specific path
|
|
779
|
+
// win, so a grant there would reopen it).
|
|
780
|
+
const storage = grants.protectedStorage ?? [];
|
|
781
|
+
if (storage.length > 0) {
|
|
782
|
+
for (const [path, mode] of [...access])
|
|
783
|
+
if (mode !== "deny" && storage.some(one => path === one || inside(path, one)))
|
|
784
|
+
access.delete(path);
|
|
785
|
+
for (const path of storage) {
|
|
786
|
+
access.set(path, "deny");
|
|
787
|
+
access.set(real(path), "deny");
|
|
788
|
+
}
|
|
789
|
+
}
|
|
768
790
|
return access;
|
|
769
791
|
}
|
|
770
792
|
/**
|
|
@@ -971,6 +993,16 @@ export function srtTermsOf(fence, grants = NO_GRANTS) {
|
|
|
971
993
|
denyWrite.add(sealed);
|
|
972
994
|
}
|
|
973
995
|
}
|
|
996
|
+
// Protected storage last: denied to read and write by name and real path, and no read grant inside it survives.
|
|
997
|
+
const storage = grants.protectedStorage ?? [];
|
|
998
|
+
const isProtected = (path) => storage.some(one => path === one || inside(path, one) || real(path) === one || inside(real(path), one));
|
|
999
|
+
for (const path of storage) {
|
|
1000
|
+
denyRead.add(path);
|
|
1001
|
+
denyWrite.add(path);
|
|
1002
|
+
}
|
|
1003
|
+
for (const path of [...allowRead])
|
|
1004
|
+
if (isProtected(path))
|
|
1005
|
+
allowRead.delete(path);
|
|
974
1006
|
const readConfig = { denyOnly: [...denyRead].sort(), allowWithinDeny: [...allowRead].sort() };
|
|
975
1007
|
const writes = grants.writes;
|
|
976
1008
|
// A sign-ins check also writes Codex's session and log folders and its own Claude transcripts (never inside a
|
|
@@ -983,8 +1015,9 @@ export function srtTermsOf(fence, grants = NO_GRANTS) {
|
|
|
983
1015
|
const network = grants.network;
|
|
984
1016
|
return {
|
|
985
1017
|
readConfig,
|
|
986
|
-
writeConfig: { allowOnly: [...new Set(allowOnly)].sort(), denyWithinAllow: [...denyWrite].sort() },
|
|
1018
|
+
writeConfig: { allowOnly: [...new Set(allowOnly)].filter(path => path === "/" || !isProtected(path.replace(/\*$/, ""))).sort(), denyWithinAllow: [...denyWrite].sort() },
|
|
987
1019
|
allowMachLookup: [...grants.machLookup ?? [], ...grants.signIns?.mach ?? []],
|
|
1020
|
+
...(grants.browser === true ? { browser: true } : {}),
|
|
988
1021
|
...(grants.keychain === true ? { keychain: true } : {}),
|
|
989
1022
|
network: network === undefined || network.mode === "open" ? { kind: "open" } : {
|
|
990
1023
|
kind: "proxy", httpPort: network.proxyPort, ...(network.brokerPorts[0] === undefined ? {} : { socksPort: network.brokerPorts[0] }),
|
|
@@ -1077,7 +1110,9 @@ export function launchSandboxOf(profile) {
|
|
|
1077
1110
|
export function fenceFor(kind, ctx) {
|
|
1078
1111
|
const home = ctx.home === undefined ? {} : { home: ctx.home };
|
|
1079
1112
|
// The project's read denies join the fence by name, there or not (the runtime denies a path that isn't there yet).
|
|
1080
|
-
|
|
1113
|
+
// Protected storage, resolved once for this launch, from the plane's own environment (protected-storage.ts).
|
|
1114
|
+
const storage = protectedStorageDenies(ctx.home ?? homedir());
|
|
1115
|
+
const fence = agentFence({ databaseFile: ctx.databaseFile, worktree: ctx.worktree, ...home, boundary: ctx.boundary, binding: ctx.binding, runFolders: ctx.runFolders, extra: [...ctx.extra ?? [], ...ctx.readDeny ?? []], protectedStorage: storage });
|
|
1081
1116
|
const found = fenceGrants({ databaseFile: ctx.databaseFile, worktree: ctx.worktree, boundary: ctx.boundary, runFolders: ctx.runFolders, linked: ctx.linked ?? null, binding: ctx.binding, ...home, poolRoots: ctx.poolRoots });
|
|
1082
1117
|
// Toolroll's own install, the sandbox runtime's package and the Node running them, never written by anything it
|
|
1083
1118
|
// launches: rewriting the code that fences a build would switch the fence off.
|
|
@@ -1088,10 +1123,13 @@ export function fenceFor(kind, ctx) {
|
|
|
1088
1123
|
...(written === null ? {} : { writes: written.writes, readOnly: [...new Set([...made.readOnly, ...written.readOnly])].sort() }),
|
|
1089
1124
|
...(ctx.network === undefined ? {} : { network: ctx.network }),
|
|
1090
1125
|
...(ctx.machLookup === undefined || ctx.machLookup.length === 0 ? {} : { machLookup: [...ctx.machLookup] }),
|
|
1126
|
+
// A project's checks may drive a browser (Playwright's Chromium); builds, flows and tool servers don't get it.
|
|
1127
|
+
...(kind === "check" ? { browser: true } : {}),
|
|
1091
1128
|
// A build's agent signs in from the keychain (Claude Code does); nothing else it launches reaches it.
|
|
1092
1129
|
...(kind === "build" ? { keychain: true } : {}),
|
|
1093
1130
|
// Under a write allowlist these are out of reach already; where the launch writes anywhere, they are denied by name.
|
|
1094
1131
|
writeDenies: homeWriteDenies(ctx.home ?? homedir(), ctx.binding, ctx.provider ?? null),
|
|
1132
|
+
protectedStorage: storage,
|
|
1095
1133
|
...(made.pool === undefined ? {} : { pool: { ...made.pool, ...(ctx.policy === 2 ? { readable: true } : {}),
|
|
1096
1134
|
// Flows' scratch folders in the pool (and any the caller names) stay denied whole, at every policy version.
|
|
1097
1135
|
sealed: sealedScratch(ctx.poolRoots, ctx.scratchRoots ?? []) } }),
|
|
@@ -1232,7 +1270,7 @@ export function homeWriteDenies(home, binding, provider, platform = process.plat
|
|
|
1232
1270
|
* where it always has), and never the build's own checkout or launch folder read back (nothing a build wrote loads in a
|
|
1233
1271
|
* process that holds a tool's secrets). */
|
|
1234
1272
|
export function serverGrantsOf(grants) {
|
|
1235
|
-
const { network: _agentOnly, writes: _agentWrites, keychain: _agentKeychain, ...rest } = grants;
|
|
1273
|
+
const { network: _agentOnly, writes: _agentWrites, keychain: _agentKeychain, browser: _checkOnly, ...rest } = grants;
|
|
1236
1274
|
return {
|
|
1237
1275
|
...rest,
|
|
1238
1276
|
...(rest.pool === undefined ? {} : { pool: { ...rest.pool, own: null } }),
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
/** SQL over a `task_scope` alias and a repo expression: whether that row's approval authority stands at `now` (an SQL
|
|
2
|
+
* expression, used exactly three times: the mode and grant clauses, and the release's expiry). It never asks whether the row is approved.
|
|
3
|
+
* COALESCE: a NULL basis would make the OR chain NULL, and NOT NULL reads as "not dead"; NULL stands on nothing. */
|
|
4
|
+
export declare const authorityStandsSql: (scope: string, repo: string, now: string) => string;
|
|
5
|
+
/** SQL: a `carried` seal stands only on its own carry row — this task, this approved digest, this candidate, and the
|
|
6
|
+
* reference `<authority>:<carry>` the carry transaction wrote (release-once.md). */
|
|
7
|
+
export declare const carrySealSql: (scope: string) => string;
|
|
8
|
+
/**
|
|
9
|
+
* SQL: a task that belongs to a release authority (its anchor, or a carried commit) dispatches only as the current
|
|
10
|
+
* generation's tip while the release is open and unexpired, with the anchor's approver still an active approver who
|
|
11
|
+
* can reach the project and the anchor's approval on its exact sealed digest. A task that belongs to no release passes.
|
|
12
|
+
* The claim and build belts read the whole predicate (scope.ts liveReleaseOf) besides.
|
|
13
|
+
*/
|
|
14
|
+
export declare const releaseStandsSql: (scope: string, now: string) => string;
|
|
15
|
+
/** SQL: a machine made this seal — any proof at the seal's instant that isn't a person's (whatever its detail says,
|
|
16
|
+
* parsed or not: fail closed), or the lead's own approval act then. */
|
|
17
|
+
export declare const machineSealSql: (scope: string) => string;
|
|
18
|
+
/** SQL: a person's proof at the seal's instant for these exact bytes, its detail parsed (the versioned JSON record,
|
|
19
|
+
* whitespace and all, or the legacy text). */
|
|
20
|
+
export declare const personSealSql: (scope: string) => string;
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Which approval authorities stand, as ONE SQL predicate every dispatch belt shares (docs/plans/approval-policy.md
|
|
3
|
+
* §1.5): the queue's dispatch query, the work index, and the claim and build belts (`Store.modeApprovalLive`).
|
|
4
|
+
*
|
|
5
|
+
* An allowlist, never a deny-list: a person's yes (`password`, `chat`) stands unless a machine made that seal; a mode
|
|
6
|
+
* or a flow grant stands while its signature does; a standing order's firing (`schedule`, or an old firing stored as
|
|
7
|
+
* `password`) stands while its approver can still approve in the project. Everything else — the lead (`lead`, which
|
|
8
|
+
* phase 1 never lets stand), a null basis, a word this code doesn't write — stands on nothing.
|
|
9
|
+
*
|
|
10
|
+
* The seal's origin is read from the acts recorded AT the seal's own instant, exactly as scope.ts sealOriginOf reads
|
|
11
|
+
* it: an older proof for the same bytes never vouches for a later seal.
|
|
12
|
+
*/
|
|
13
|
+
import { grantStandsSql } from "./flow-unattended.js";
|
|
14
|
+
import { accountReachesSql } from "./project-access.js";
|
|
15
|
+
/** The proofs a person gives (src/person-proof.ts), as an SQL list. */
|
|
16
|
+
const PERSON_PROOFS = "('password-now', 'sso-now', 'fresh-password', 'fresh-sso', 'session', 'cli-login', 'chat', 'passkey')";
|
|
17
|
+
/** SQL over a `task_scope` alias and a repo expression: whether that row's approval authority stands at `now` (an SQL
|
|
18
|
+
* expression, used exactly three times: the mode and grant clauses, and the release's expiry). It never asks whether the row is approved.
|
|
19
|
+
* COALESCE: a NULL basis would make the OR chain NULL, and NOT NULL reads as "not dead"; NULL stands on nothing. */
|
|
20
|
+
export const authorityStandsSql = (scope, repo, now) => `COALESCE((((COALESCE(${scope}.approval_basis, '') IN ('password', 'chat') AND NOT ${machineSealSql(scope)}
|
|
21
|
+
AND (${personSealSql(scope)} OR NOT ${automationSql(scope)} OR ${approverStandsSql(scope, repo)}))
|
|
22
|
+
OR (${scope}.approval_basis = 'carried' AND ${carrySealSql(scope)})
|
|
23
|
+
OR (${scope}.approval_basis = 'mode' AND EXISTS (SELECT 1 FROM operating_mode om
|
|
24
|
+
JOIN approver signer ON signer.name = om.signed_by
|
|
25
|
+
WHERE om.repo = ${repo} AND om.revoked_at IS NULL AND om.absolute_expiry > ${now} AND om.digest = ${scope}.mode_digest
|
|
26
|
+
AND signer.revoked_at IS NULL AND signer.role = 'approver' AND ${accountReachesSql("signer", repo)}))
|
|
27
|
+
OR (${scope}.approval_basis = 'grant' AND ${grantStandsSql(`${scope}.mode_digest`, now, repo)})
|
|
28
|
+
OR (${scope}.approval_basis = 'schedule' AND ${approverStandsSql(scope, repo)}))
|
|
29
|
+
AND ${releaseStandsSql(scope, now)}), 0)`;
|
|
30
|
+
/** SQL: a `carried` seal stands only on its own carry row — this task, this approved digest, this candidate, and the
|
|
31
|
+
* reference `<authority>:<carry>` the carry transaction wrote (release-once.md). */
|
|
32
|
+
export const carrySealSql = (scope) => `EXISTS (SELECT 1 FROM release_carry rc2 JOIN release_authority ra2 ON ra2.id = rc2.authority
|
|
33
|
+
WHERE rc2.task_id = ${scope}.task_id AND rc2.scope_digest = ${scope}.approved_digest AND rc2.to_sha = ${scope}.candidate
|
|
34
|
+
AND ${scope}.mode_digest = ra2.id || ':' || rc2.id)`;
|
|
35
|
+
/**
|
|
36
|
+
* SQL: a task that belongs to a release authority (its anchor, or a carried commit) dispatches only as the current
|
|
37
|
+
* generation's tip while the release is open and unexpired, with the anchor's approver still an active approver who
|
|
38
|
+
* can reach the project and the anchor's approval on its exact sealed digest. A task that belongs to no release passes.
|
|
39
|
+
* The claim and build belts read the whole predicate (scope.ts liveReleaseOf) besides.
|
|
40
|
+
*/
|
|
41
|
+
export const releaseStandsSql = (scope, now) => `((NOT EXISTS (SELECT 1 FROM release_authority rx WHERE rx.anchor_task = ${scope}.task_id)
|
|
42
|
+
AND NOT EXISTS (SELECT 1 FROM release_carry cx WHERE cx.task_id = ${scope}.task_id))
|
|
43
|
+
OR EXISTS (SELECT 1 FROM release_authority ra WHERE ra.tip_task = ${scope}.task_id AND ra.state = 'open' AND ra.expires_at > ${now}
|
|
44
|
+
AND (ra.anchor_task = ${scope}.task_id OR EXISTS (SELECT 1 FROM release_carry rc WHERE rc.authority = ra.id AND rc.task_id = ${scope}.task_id))
|
|
45
|
+
AND NOT EXISTS (SELECT 1 FROM release_authority rn WHERE rn.repo = ra.repo AND rn.version = ra.version AND rn.pr = ra.pr AND rn.generation > ra.generation)
|
|
46
|
+
AND EXISTS (SELECT 1 FROM task_scope an JOIN approver ap ON ap.name = an.approved_by
|
|
47
|
+
WHERE an.task_id = ra.anchor_task AND an.approved_digest = ra.anchor_digest AND an.digest = ra.anchor_digest
|
|
48
|
+
AND ap.revoked_at IS NULL AND ap.role = 'approver' AND ${accountReachesSql("ap", "ra.repo")})))`;
|
|
49
|
+
/** SQL: a machine made this seal — any proof at the seal's instant that isn't a person's (whatever its detail says,
|
|
50
|
+
* parsed or not: fail closed), or the lead's own approval act then. */
|
|
51
|
+
export const machineSealSql = (scope) => `(EXISTS (SELECT 1 FROM action_ledger mp WHERE mp.task_id = ${scope}.task_id AND mp.action = 'approval proved'
|
|
52
|
+
AND mp.actor = ${scope}.approved_by AND mp.at = ${scope}.approved_at AND mp.outcome NOT IN ${PERSON_PROOFS})
|
|
53
|
+
OR EXISTS (SELECT 1 FROM task_act la JOIN task_ref lr ON lr.id = la.task_ref AND lr.backend = 'built-in'
|
|
54
|
+
WHERE lr.external_id = ${scope}.task_id AND la.act = 'approved' AND la.lead = 1
|
|
55
|
+
AND la.account = ${scope}.approved_by AND la.at = ${scope}.approved_at))`;
|
|
56
|
+
/** SQL: a person's proof at the seal's instant for these exact bytes, its detail parsed (the versioned JSON record,
|
|
57
|
+
* whitespace and all, or the legacy text). */
|
|
58
|
+
export const personSealSql = (scope) => `EXISTS (SELECT 1 FROM action_ledger pp WHERE pp.task_id = ${scope}.task_id AND pp.action = 'approval proved'
|
|
59
|
+
AND pp.actor = ${scope}.approved_by AND pp.at = ${scope}.approved_at AND pp.outcome IN ${PERSON_PROOFS}
|
|
60
|
+
AND CASE WHEN json_valid(pp.detail) THEN json_extract(pp.detail, '$.v') = 1 AND json_type(pp.detail, '$.digest') = 'text'
|
|
61
|
+
AND json_extract(pp.detail, '$.digest') = ${scope}.approved_digest
|
|
62
|
+
ELSE pp.detail LIKE '% · generation % · digest ' || ${scope}.approved_digest END)`;
|
|
63
|
+
/** SQL: the task a standing order's firing filed (its own record of the firing). */
|
|
64
|
+
const automationSql = (scope) => `EXISTS (SELECT 1 FROM flow_trigger_event fe WHERE fe.task = ${scope}.task_id)`;
|
|
65
|
+
/** SQL: the seal's approver can still approve in the project. */
|
|
66
|
+
const approverStandsSql = (scope, repo) => `EXISTS (SELECT 1 FROM approver sched WHERE sched.name = ${scope}.approved_by AND sched.revoked_at IS NULL AND sched.role = 'approver'
|
|
67
|
+
AND ${accountReachesSql("sched", repo)})`;
|
package/dist/assignment.js
CHANGED
|
@@ -4,10 +4,10 @@
|
|
|
4
4
|
import { createHash } from "node:crypto";
|
|
5
5
|
import { currentActor, withActor } from "./actor.js";
|
|
6
6
|
import { replacedWords } from "./task-status.js";
|
|
7
|
-
import { COMPLETER_ACTOR, COMPLETION_ACTION, familyChangedFiles, landedReasonOf } from "./result-completion.js";
|
|
7
|
+
import { COMPLETER_ACTOR, COMPLETION_ACTION, COMPLETION_BY_MACHINE, COMPLETION_BY_PERSON, familyChangedFiles, landedReasonOf, personCompletionOwed } from "./result-completion.js";
|
|
8
8
|
import { homedir } from "node:os";
|
|
9
9
|
import { readSchemaVersion, stopFactOf } from "./store.js";
|
|
10
|
-
import { approvalOf } from "./scope.js";
|
|
10
|
+
import { approvalOf, liveReleaseOf, machineActing } from "./scope.js";
|
|
11
11
|
import { openWorkDecisionOf, taskWorkSummaryOf, workDecisionAction } from "./work-summary.js";
|
|
12
12
|
import { evidenceRoot, readVerifiedArtifact, readVerifiedReport, verifyArtifact } from "./evidence.js";
|
|
13
13
|
import { sweepIfMoved } from "./sweep-gate.js";
|
|
@@ -119,6 +119,10 @@ function buildChecks(store, root, runId) {
|
|
|
119
119
|
if (receipt === null)
|
|
120
120
|
return { ...unavailable(level === "off" ? "Checks were off for this build." : "No machine check is recorded."), status: "not-run" };
|
|
121
121
|
const ran = receipt.result.ran === true, exitCode = ran ? receipt.result.exitCode : null;
|
|
122
|
+
// A release check whose coverage fell short (release-once.md) failed, whatever its command exited with.
|
|
123
|
+
if (receipt.result.failure === "coverage-incomplete")
|
|
124
|
+
return { status: "failed", exitCode: null, command: receipt.command.command, logArtifactId: receipt.log.artifactId, level,
|
|
125
|
+
detail: "Checks didn't cover everything this release owed, or a required test went missing." };
|
|
122
126
|
// The sealed receipt names the grant that ran: the quick one, or the full one.
|
|
123
127
|
const sealedLevel = typeof receipt.command?.repo === "string" && receipt.command.repo.startsWith("quick:") ? "quick" : "full";
|
|
124
128
|
const words = sealedLevel === "quick" ? "Quick checks" : "Checks";
|
|
@@ -450,8 +454,19 @@ function acknowledgeCurrent(store, current, receiptDigest, actor, now, root, acc
|
|
|
450
454
|
if (!/^[a-f0-9]{64}$/.test(receiptDigest) || current.receipt?.digest !== receiptDigest)
|
|
451
455
|
return { ok: false, reason: "stale", message: "The result changed. Read the current assignment before checking it." };
|
|
452
456
|
const receipt = current.receipt;
|
|
457
|
+
// The nightly check of main is a check only (release-once.md, part 3): nobody completes it.
|
|
458
|
+
if (store.handle.prepare("SELECT 1 AS hit FROM nightly_check WHERE task = ?").get(current.receipt.taskId) !== undefined)
|
|
459
|
+
return { ok: false, reason: "check-only", message: "The nightly check of main is only a check; it isn't completed." };
|
|
453
460
|
if (current.state !== "ready-to-check" && current.state !== "complete")
|
|
454
461
|
return { ok: false, reason: "not-ready", message: "This assignment still has unresolved execution, scope or decisions." };
|
|
462
|
+
// A release task completes only while its release approval stands for it (release-once.md, liveReleaseOf).
|
|
463
|
+
const release = liveReleaseOf(store, current.receipt.taskId, "complete", now);
|
|
464
|
+
if (release.applies && !release.ok)
|
|
465
|
+
return { ok: false, reason: "release", message: `This release can't be completed: ${release.why}.` };
|
|
466
|
+
// Who is completing, from trusted context (approval policy §1.7a): a person, or a machine credential (the lead's
|
|
467
|
+
// token, a remote API run) that may complete ordinary work but is never the second person on protected work.
|
|
468
|
+
const person = actor.startsWith("operator:") && !machineActing() ? actor.slice("operator:".length) : null;
|
|
469
|
+
const marked = actor.startsWith("operator:") ? (person === null ? COMPLETION_BY_MACHINE : COMPLETION_BY_PERSON) : null;
|
|
455
470
|
if (current.state !== "complete") {
|
|
456
471
|
// The one readiness guard: a failed or missing required check, an unresolved requirement or HIGH finding refuses.
|
|
457
472
|
const blockers = current.readiness?.blockers ?? [];
|
|
@@ -459,11 +474,11 @@ function acknowledgeCurrent(store, current, receiptDigest, actor, now, root, acc
|
|
|
459
474
|
return { ok: false, reason: "not-ready", message: blockers.map(one => one.message).join(" ") };
|
|
460
475
|
// v102: declared paths are only a promise. A result whose actual diff reaches protected files, on a
|
|
461
476
|
// scope one person approved, completes only by someone else — two people have then seen the work.
|
|
462
|
-
const problem = store.protectedResultProblem(receipt.taskId, familyChangedFiles(store, receipt.taskId, receipt.runId, root),
|
|
477
|
+
const problem = store.protectedResultProblem(receipt.taskId, familyChangedFiles(store, receipt.taskId, receipt.runId, root), person);
|
|
463
478
|
if (problem !== null)
|
|
464
479
|
return { ok: false, reason: "approval-rules", message: problem };
|
|
465
480
|
store.recordAction({ at: now.toISOString(), actor, repo: current.repo,
|
|
466
|
-
taskId: current.rootId, runId: receipt.runId, action: CHECK_ACTION, outcome: receiptDigest, source: "work" });
|
|
481
|
+
taskId: current.rootId, runId: receipt.runId, action: CHECK_ACTION, outcome: receiptDigest, source: "work", detail: marked });
|
|
467
482
|
const ref = store.lookupRef(current.rootId);
|
|
468
483
|
// Whose act this is, even outside a command that named its person: the operator who marked it.
|
|
469
484
|
const by = currentActor() ?? (actor.startsWith("operator:") ? { account: actor.slice("operator:".length), lead: false } : null);
|
|
@@ -471,6 +486,13 @@ function acknowledgeCurrent(store, current, receiptDigest, actor, now, root, acc
|
|
|
471
486
|
store.noteTaskAct(ref.id, "completed", now, undefined, by);
|
|
472
487
|
store.bumpWake();
|
|
473
488
|
}
|
|
489
|
+
else if (person !== null && personCompletionOwed(store, receipt.taskId, receipt.runId, receiptDigest, person, root)) {
|
|
490
|
+
// Already complete, but no person's completion is on record that lets protected work publish (a machine's, or one
|
|
491
|
+
// from before completions said who made them): this person's completion is recorded now (approval policy §1.7a).
|
|
492
|
+
store.recordAction({ at: now.toISOString(), actor, repo: current.repo,
|
|
493
|
+
taskId: current.rootId, runId: receipt.runId, action: CHECK_ACTION, outcome: receiptDigest, source: "work", detail: COMPLETION_BY_PERSON });
|
|
494
|
+
store.bumpWake();
|
|
495
|
+
}
|
|
474
496
|
const assignment = assignmentOf(store, current.rootId, now, access, root);
|
|
475
497
|
noteAssignmentStatus(store, assignment, now);
|
|
476
498
|
return { ok: true, assignment };
|