approval-md 0.2.0 → 0.3.0
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/README.md +63 -24
- package/SPEC.md +57 -11
- package/dist/src/channels/contract.d.ts +34 -1
- package/dist/src/channels/contract.js +200 -7
- package/dist/src/channels/contract.js.map +1 -1
- package/dist/src/channels/telegram.d.ts +123 -11
- package/dist/src/channels/telegram.js +218 -23
- package/dist/src/channels/telegram.js.map +1 -1
- package/dist/src/channels/web.d.ts +9 -0
- package/dist/src/channels/web.js +17 -0
- package/dist/src/channels/web.js.map +1 -1
- package/dist/src/cli/amend.js +214 -30
- package/dist/src/cli/amend.js.map +1 -1
- package/dist/src/cli/attest.d.ts +9 -0
- package/dist/src/cli/attest.js +134 -7
- package/dist/src/cli/attest.js.map +1 -1
- package/dist/src/cli/channel-telegram.d.ts +99 -26
- package/dist/src/cli/channel-telegram.js +311 -13
- package/dist/src/cli/channel-telegram.js.map +1 -1
- package/dist/src/cli/channel.d.ts +9 -0
- package/dist/src/cli/channel.js +9 -0
- package/dist/src/cli/channel.js.map +1 -1
- package/dist/src/cli/codex-bridge.d.ts +819 -0
- package/dist/src/cli/codex-bridge.js +1607 -0
- package/dist/src/cli/codex-bridge.js.map +1 -0
- package/dist/src/cli/codex.d.ts +1 -1
- package/dist/src/cli/codex.js +304 -7
- package/dist/src/cli/codex.js.map +1 -1
- package/dist/src/cli/daemon.js +4 -1
- package/dist/src/cli/daemon.js.map +1 -1
- package/dist/src/cli/doctor.js +467 -12
- package/dist/src/cli/doctor.js.map +1 -1
- package/dist/src/cli/execute.js +25 -2
- package/dist/src/cli/execute.js.map +1 -1
- package/dist/src/cli/help.d.ts +6 -2
- package/dist/src/cli/help.js +165 -60
- package/dist/src/cli/help.js.map +1 -1
- package/dist/src/cli/hook-codex.d.ts +49 -1
- package/dist/src/cli/hook-codex.js +60 -1
- package/dist/src/cli/hook-codex.js.map +1 -1
- package/dist/src/cli/hook.d.ts +459 -3
- package/dist/src/cli/hook.js +1062 -114
- package/dist/src/cli/hook.js.map +1 -1
- package/dist/src/cli/import.js +1 -1
- package/dist/src/cli/import.js.map +1 -1
- package/dist/src/cli/main.js +5 -3
- package/dist/src/cli/main.js.map +1 -1
- package/dist/src/cli/policy-apply.d.ts +195 -0
- package/dist/src/cli/policy-apply.js +573 -0
- package/dist/src/cli/policy-apply.js.map +1 -0
- package/dist/src/cli/policy.js +14 -1
- package/dist/src/cli/policy.js.map +1 -1
- package/dist/src/cli/preflight.d.ts +151 -13
- package/dist/src/cli/preflight.js +398 -41
- package/dist/src/cli/preflight.js.map +1 -1
- package/dist/src/cli/sandbox.js +17 -1
- package/dist/src/cli/sandbox.js.map +1 -1
- package/dist/src/cli/scaffold.d.ts +1 -1
- package/dist/src/cli/scaffold.js +1 -1
- package/dist/src/cli/setup-channel.d.ts +9 -0
- package/dist/src/cli/setup-channel.js +28 -1
- package/dist/src/cli/setup-channel.js.map +1 -1
- package/dist/src/cli/setup-common.d.ts +3 -1
- package/dist/src/cli/setup-common.js +3 -2
- package/dist/src/cli/setup-common.js.map +1 -1
- package/dist/src/cli/setup.d.ts +2 -0
- package/dist/src/cli/setup.js +94 -2
- package/dist/src/cli/setup.js.map +1 -1
- package/dist/src/cli/up.js +115 -51
- package/dist/src/cli/up.js.map +1 -1
- package/dist/src/cli/values.js +3 -4
- package/dist/src/cli/values.js.map +1 -1
- package/dist/src/cli/verb-registry.js +174 -9
- package/dist/src/cli/verb-registry.js.map +1 -1
- package/dist/src/cli/wordmark.d.ts +2 -2
- package/dist/src/cli/wordmark.js +2 -2
- package/dist/src/codex/broker.d.ts +229 -0
- package/dist/src/codex/broker.js +548 -0
- package/dist/src/codex/broker.js.map +1 -0
- package/dist/src/codex/runner.d.ts +178 -0
- package/dist/src/codex/runner.js +231 -0
- package/dist/src/codex/runner.js.map +1 -0
- package/dist/src/codex/serve.d.ts +56 -0
- package/dist/src/codex/serve.js +98 -0
- package/dist/src/codex/serve.js.map +1 -0
- package/dist/src/codex/workspace-commit.d.ts +219 -0
- package/dist/src/codex/workspace-commit.js +549 -0
- package/dist/src/codex/workspace-commit.js.map +1 -0
- package/dist/src/core/advance-cycle.d.ts +51 -0
- package/dist/src/core/advance-cycle.js +66 -2
- package/dist/src/core/advance-cycle.js.map +1 -1
- package/dist/src/core/agents-md.d.ts +20 -18
- package/dist/src/core/agents-md.js +33 -31
- package/dist/src/core/agents-md.js.map +1 -1
- package/dist/src/core/attest.d.ts +215 -0
- package/dist/src/core/attest.js +317 -7
- package/dist/src/core/attest.js.map +1 -1
- package/dist/src/core/audit.d.ts +18 -0
- package/dist/src/core/audit.js +13 -0
- package/dist/src/core/audit.js.map +1 -1
- package/dist/src/core/channel-owner.d.ts +213 -0
- package/dist/src/core/channel-owner.js +358 -0
- package/dist/src/core/channel-owner.js.map +1 -0
- package/dist/src/core/command-class.d.ts +154 -0
- package/dist/src/core/command-class.js +673 -20
- package/dist/src/core/command-class.js.map +1 -1
- package/dist/src/core/commit-guard.d.ts +272 -0
- package/dist/src/core/commit-guard.js +424 -0
- package/dist/src/core/commit-guard.js.map +1 -0
- package/dist/src/core/daemon-actor.d.ts +45 -0
- package/dist/src/core/daemon-actor.js +54 -0
- package/dist/src/core/daemon-actor.js.map +1 -0
- package/dist/src/core/dark-session.d.ts +109 -8
- package/dist/src/core/dark-session.js +266 -82
- package/dist/src/core/dark-session.js.map +1 -1
- package/dist/src/core/decision-refusal.d.ts +23 -2
- package/dist/src/core/decision-refusal.js +24 -2
- package/dist/src/core/decision-refusal.js.map +1 -1
- package/dist/src/core/env-file.d.ts +5 -0
- package/dist/src/core/env-file.js +60 -1
- package/dist/src/core/env-file.js.map +1 -1
- package/dist/src/core/execute.d.ts +15 -2
- package/dist/src/core/execute.js +15 -2
- package/dist/src/core/execute.js.map +1 -1
- package/dist/src/core/gate.d.ts +86 -1
- package/dist/src/core/gate.js +81 -1
- package/dist/src/core/gate.js.map +1 -1
- package/dist/src/core/gesture-refusal.d.ts +166 -0
- package/dist/src/core/gesture-refusal.js +188 -0
- package/dist/src/core/gesture-refusal.js.map +1 -0
- package/dist/src/core/harness-version.d.ts +1 -1
- package/dist/src/core/harness-version.js +3 -1
- package/dist/src/core/harness-version.js.map +1 -1
- package/dist/src/core/instance.d.ts +59 -2
- package/dist/src/core/instance.js +113 -0
- package/dist/src/core/instance.js.map +1 -1
- package/dist/src/core/log.d.ts +39 -1
- package/dist/src/core/log.js.map +1 -1
- package/dist/src/core/policy-explain.d.ts +10 -0
- package/dist/src/core/policy-explain.js +32 -0
- package/dist/src/core/policy-explain.js.map +1 -1
- package/dist/src/core/policy-load.d.ts +41 -1
- package/dist/src/core/policy-load.js +21 -3
- package/dist/src/core/policy-load.js.map +1 -1
- package/dist/src/core/policy-match.d.ts +43 -0
- package/dist/src/core/policy-match.js +52 -0
- package/dist/src/core/policy-match.js.map +1 -1
- package/dist/src/core/policy-proposal.d.ts +52 -0
- package/dist/src/core/policy-proposal.js +102 -2
- package/dist/src/core/policy-proposal.js.map +1 -1
- package/dist/src/core/protected-path-guard.d.ts +117 -4
- package/dist/src/core/protected-path-guard.js +362 -48
- package/dist/src/core/protected-path-guard.js.map +1 -1
- package/dist/src/core/question-preempted.d.ts +141 -0
- package/dist/src/core/question-preempted.js +152 -0
- package/dist/src/core/question-preempted.js.map +1 -0
- package/dist/src/core/read-scope.d.ts +172 -0
- package/dist/src/core/read-scope.js +252 -0
- package/dist/src/core/read-scope.js.map +1 -0
- package/dist/src/core/sandbox.d.ts +81 -0
- package/dist/src/core/sandbox.js +190 -1
- package/dist/src/core/sandbox.js.map +1 -1
- package/dist/src/core/sender-identity.d.ts +476 -0
- package/dist/src/core/sender-identity.js +572 -0
- package/dist/src/core/sender-identity.js.map +1 -0
- package/dist/src/core/shlex.d.ts +102 -0
- package/dist/src/core/shlex.js +159 -0
- package/dist/src/core/shlex.js.map +1 -0
- package/dist/src/core/values.d.ts +18 -8
- package/dist/src/core/values.js +36 -1
- package/dist/src/core/values.js.map +1 -1
- package/dist/src/daemon/advance.d.ts +10 -0
- package/dist/src/daemon/advance.js +25 -4
- package/dist/src/daemon/advance.js.map +1 -1
- package/dist/src/daemon/daemon.js +9 -0
- package/dist/src/daemon/daemon.js.map +1 -1
- package/dist/src/daemon/git-evidence.d.ts +2 -2
- package/dist/src/daemon/git-evidence.js +1 -1
- package/dist/src/mcp/server.js +8 -0
- package/dist/src/mcp/server.js.map +1 -1
- package/docs/cli-reference.md +932 -32
- package/docs/codex-enforced-session.md +75 -2
- package/docs/codex-workspace-broker.md +118 -0
- package/package.json +3 -1
- package/schema/event.schema.json +538 -9
- package/schema/fixtures/event/invalid/approval-granted-sender-hashed-false.json +20 -0
- package/schema/fixtures/event/invalid/approval-granted-sender-hashed-raw-id.json +20 -0
- package/schema/fixtures/event/invalid/audit-gesture-refused-human-actor.json +16 -0
- package/schema/fixtures/event/invalid/audit-gesture-refused-no-actor-no-sender.json +15 -0
- package/schema/fixtures/event/invalid/audit-gesture-refused-unknown-gesture.json +16 -0
- package/schema/fixtures/event/invalid/audit-question-preempted-agent-actor.json +16 -0
- package/schema/fixtures/event/invalid/audit-question-preempted-no-question-id.json +16 -0
- package/schema/fixtures/event/invalid/audit-question-preempted-unknown-source.json +15 -0
- package/schema/fixtures/event/invalid/gate-path-signed-off-absolute-path.json +14 -0
- package/schema/fixtures/event/invalid/gate-path-signed-off-agent-actor.json +14 -0
- package/schema/fixtures/event/invalid/gate-path-signed-off-missing-path.json +13 -0
- package/schema/fixtures/event/valid/approval-granted-sender-hashed.json +20 -0
- package/schema/fixtures/event/valid/audit-gesture-refused-review-note.json +21 -0
- package/schema/fixtures/event/valid/audit-gesture-refused-sender-key-unavailable.json +19 -0
- package/schema/fixtures/event/valid/audit-gesture-refused.json +19 -0
- package/schema/fixtures/event/valid/audit-question-preempted-no-verdict.json +16 -0
- package/schema/fixtures/event/valid/audit-question-preempted.json +20 -0
- package/schema/fixtures/event/valid/gate-path-signed-off.json +14 -0
- package/schema/fixtures/event/valid/harness-kind-claude-code.json +23 -0
- package/schema/fixtures/event/valid/harness-kind-codex.json +23 -0
- package/schema/fixtures/event/valid/harness-kind-cursor.json +23 -0
- package/schema/fixtures/event/valid/harness-kind-grok.json +23 -0
- package/schema/fixtures/event/valid/harness-kind-muse.json +23 -0
- package/schema/fixtures/policy/invalid/senders-half-keyed.json +20 -0
- package/schema/fixtures/policy/valid/canonical.json +1 -1
- package/schema/fixtures/policy/valid/senders-keyed.json +24 -0
- package/schema/fixtures/policy-md/valid/canonical.md +1 -1
- package/schema/fixtures/policy-md/valid/with-values.md +5 -7
- package/schema/fixtures/values/invalid/class-shaped.json +1 -1
- package/schema/fixtures/values/invalid/duplicate-entry.json +1 -1
- package/schema/fixtures/values/invalid/non-string-item.json +1 -1
- package/schema/fixtures/values/invalid/over-cap.json +1 -1
- package/schema/fixtures/values/invalid/unknown-key.json +1 -1
- package/schema/fixtures/values/invalid/version-float.json +1 -0
- package/schema/fixtures/values/invalid/version-integer.json +1 -0
- package/schema/fixtures/values/invalid/version-wrong-string.json +1 -0
- package/schema/fixtures/values/valid/empty-lists.json +2 -3
- package/schema/fixtures/values/valid/full.json +5 -7
- package/schema/fixtures/values/valid/minimal.json +1 -1
- package/schema/fixtures/values-md/invalid/schema-invalid.md +5 -3
- package/schema/fixtures/values-md/invalid/two-blocks.md +3 -3
- package/schema/fixtures/values-md/invalid/unterminated.md +2 -2
- package/schema/fixtures/values-md/invalid/version-1.md +69 -0
- package/schema/fixtures/values-md/invalid/version-unquoted.md +64 -0
- package/schema/fixtures/values-md/invalid/yaml-error.md +2 -2
- package/schema/fixtures/values-md/valid/absent.md +1 -1
- package/schema/fixtures/values-md/valid/with-values.md +5 -7
- package/schema/policy.schema.json +54 -2
- package/schema/values.schema.json +7 -11
- package/schema/fixtures/values/invalid/version-string.json +0 -1
|
@@ -22,6 +22,17 @@
|
|
|
22
22
|
* execution. A change with no evidence fails the pull request. Session wiring
|
|
23
23
|
* is not an input.
|
|
24
24
|
*
|
|
25
|
+
* ## The unit of judgment: one commit (APRV-375)
|
|
26
|
+
*
|
|
27
|
+
* This file answers about ONE PAIR OF BLOBS, whichever pair the caller hands
|
|
28
|
+
* it, so the caller's choice of pair is where the question is really settled.
|
|
29
|
+
* Both callers choose the same one: for every commit of the range, base is that
|
|
30
|
+
* commit's first parent and head is the commit, because a grant binds one edit
|
|
31
|
+
* and the combined diff of a branch is a change nobody made. `core/commit-guard.ts`
|
|
32
|
+
* enumerates the commits, builds the per-commit inputs, states the argument
|
|
33
|
+
* that nothing goes unjudged, and is shared by the CI script and by
|
|
34
|
+
* `approval doctor`'s dark-session arm A.
|
|
35
|
+
*
|
|
25
36
|
* ## What counts as evidence
|
|
26
37
|
*
|
|
27
38
|
* Evidence is about the CHANGE, not about the path (APRV-202). The guard reads
|
|
@@ -53,6 +64,18 @@
|
|
|
53
64
|
* match: a digest attested for one organ is not evidence for another, which
|
|
54
65
|
* is why the organ record carries a whole relative path where the policy
|
|
55
66
|
* record carries a basename.
|
|
67
|
+
*
|
|
68
|
+
* Since APRV-338 the verdict ALSO covers an ordinary `policy.edit` path on a
|
|
69
|
+
* `gate.path.signed_off` record carrying that path and the digest at head —
|
|
70
|
+
* and it is reached LAST, after every grant search below has failed. That
|
|
71
|
+
* ordering is the whole design. A sign-off is whole-file evidence: it says a
|
|
72
|
+
* human read this file at these bytes, not that they saw a particular line
|
|
73
|
+
* change. A grant binds the hunk. So a change a grant covers passes on the
|
|
74
|
+
* grant and prints the grant as its reason, and the sign-off answers only
|
|
75
|
+
* the case the pending-sign-off suffix was invented for — text a human has
|
|
76
|
+
* read and agrees with, for which no grant was ever taken. The organ's
|
|
77
|
+
* verdict stays first because an organ can have no grant at all, so for one
|
|
78
|
+
* there is nothing weaker to prefer.
|
|
56
79
|
* 2. `policy-authorized-file` — an exact Edit or Write whose verified
|
|
57
80
|
* `execution.started` is preceded by its unique matching registration and no
|
|
58
81
|
* approval request. The registration, start and recomputed stored payload
|
|
@@ -60,6 +83,21 @@
|
|
|
60
83
|
* change and the recorded class must be the class this path is routed to.
|
|
61
84
|
* This records authorization to execute, not successful completion; the
|
|
62
85
|
* exact hunk checks below establish whether those bytes landed.
|
|
86
|
+
*
|
|
87
|
+
* The payload's `file` is the path the hook bound, and the hook binds an
|
|
88
|
+
* ABSOLUTE one: `fileToolGate` resolves the declared target against the
|
|
89
|
+
* session's `cwd`, so an Edit of SPEC.md in a worktree is recorded as
|
|
90
|
+
* `/Users/carter/dev/approval-md/.claude/worktrees/<name>/SPEC.md`. This
|
|
91
|
+
* tier accepts that shape and the bare repository-relative path, matching
|
|
92
|
+
* an absolute one by its trailing segments (see
|
|
93
|
+
* {@link namesProtectedFile}). Until APRV-337 it accepted only the relative
|
|
94
|
+
* shape, which no hook has ever written, so every unsampled
|
|
95
|
+
* supervised-live edit to SPEC.md failed CI (PR #393). Tail matching is
|
|
96
|
+
* sound HERE because the bytes carry the proof: `before` has to occur in
|
|
97
|
+
* the blob at base, `after` in the blob at head, and the replay has to
|
|
98
|
+
* reach HEAD byte-identical, so a scratch copy holding other bytes covers
|
|
99
|
+
* nothing whatever its path says. Verdict 4 below keeps the stricter
|
|
100
|
+
* cwd-join rule, because a command payload describes no bytes.
|
|
63
101
|
* 3. `granted-file` — a file-tool edit. The hook binds the CHANGE rather than
|
|
64
102
|
* the touch (APRV-124), so the bound material carries `file` plus the exact
|
|
65
103
|
* edit: `{before, after}` for an Edit, `{content}` for a Write. That is
|
|
@@ -134,6 +172,22 @@
|
|
|
134
172
|
* against the head commit would have been theatre. The finding says which of
|
|
135
173
|
* the two it got, every time.
|
|
136
174
|
*
|
|
175
|
+
* ## Two anchors, because git keeps two dates (APRV-339)
|
|
176
|
+
*
|
|
177
|
+
* A commit carries an author date and a committer date, and they answer
|
|
178
|
+
* different questions. ORDERING — could this record have written these bytes,
|
|
179
|
+
* or does it come after them — is measured against the COMMITTER date, the
|
|
180
|
+
* moment those bytes were committed: an amend or a rebase only ever moves it
|
|
181
|
+
* later, so it cannot turn a genuine earlier start into a post-hoc one.
|
|
182
|
+
* STALENESS — is this evidence about this change or about some edit months ago
|
|
183
|
+
* — is measured against the AUTHOR date, which a rebase does not move, so
|
|
184
|
+
* replaying history does not expire evidence. Measuring both against the author
|
|
185
|
+
* date is what refused PR #393: `git commit --amend` kept the first author date
|
|
186
|
+
* while the bytes were committed 98 seconds later, and the unattended start
|
|
187
|
+
* that wrote them sat between the two and was read as having happened after the
|
|
188
|
+
* change. A caller that has only one date supplies it alone, and it answers
|
|
189
|
+
* both questions, which is where every caller stood before this.
|
|
190
|
+
*
|
|
137
191
|
* ## How a hunk is decided to be covered
|
|
138
192
|
*
|
|
139
193
|
* The unit is a line of text. `added` is the multiset of lines the head blob
|
|
@@ -145,6 +199,18 @@
|
|
|
145
199
|
* several approved edits to one file; the finding names every contributing
|
|
146
200
|
* grant and puts the strongest and nearest at the head.
|
|
147
201
|
*
|
|
202
|
+
* A bound Edit may describe LESS than a line, because the hook binds exactly
|
|
203
|
+
* what the tool replaced: rewriting part of a long paragraph binds a fragment
|
|
204
|
+
* and covers neither the line it removed nor the line it added. So before the
|
|
205
|
+
* replay below there is a line-local step (APRV-340). A fragment that occurs
|
|
206
|
+
* exactly once in exactly one line of the blob at base has one possible
|
|
207
|
+
* effect — that line, rewritten — and when the line it rewrites is one this
|
|
208
|
+
* change removes and the line it produces is one this change adds, both lines
|
|
209
|
+
* are credited to it. It is a one-step replay that needs no search, and it
|
|
210
|
+
* carries every eligibility condition the replay carries; what it does not
|
|
211
|
+
* carry is the replay's budget, which a 200 KB file with forty naming
|
|
212
|
+
* candidates exhausts before reaching a proof (PR #393).
|
|
213
|
+
*
|
|
148
214
|
* Three properties of that choice are worth stating, because each is a limit:
|
|
149
215
|
*
|
|
150
216
|
* - A blob that differs while its line multiset does not is a REORDERING, and
|
|
@@ -198,7 +264,7 @@
|
|
|
198
264
|
* This module appends nothing, reads no clock, and performs no IO of its own —
|
|
199
265
|
* git plumbing and file reads live in the caller.
|
|
200
266
|
*/
|
|
201
|
-
import { organAttestationOf } from "./attest.js";
|
|
267
|
+
import { organAttestationOf, pathSignOffOf } from "./attest.js";
|
|
202
268
|
import { parseApplyPatch } from "./apply-patch.js";
|
|
203
269
|
import { classifyCommand, isGateOrganPath, isProtectedPath, normalizePathSpelling, POLICY_EDIT_SUBCLASS, protectedPathClass, } from "./command-class.js";
|
|
204
270
|
import { payloadHash } from "./payload.js";
|
|
@@ -315,6 +381,50 @@ function endsWithSegments(candidate, want) {
|
|
|
315
381
|
const offset = have.length - tail.length;
|
|
316
382
|
return tail.every((segment, index) => segment === have[offset + index]);
|
|
317
383
|
}
|
|
384
|
+
/**
|
|
385
|
+
* Is this changed path the POLICY FILE, whichever end carries the longer
|
|
386
|
+
* spelling?
|
|
387
|
+
*
|
|
388
|
+
* The guard is handed a repository-relative path from git and a policy location
|
|
389
|
+
* from its caller, and either may be the more qualified of the two, so the test
|
|
390
|
+
* is run both ways. One named predicate rather than the comparison written out
|
|
391
|
+
* at each of the two sites that ask it.
|
|
392
|
+
*/
|
|
393
|
+
function namesPolicyFile(path, policyPath) {
|
|
394
|
+
return endsWithSegments(policyPath, path) || endsWithSegments(path, policyPath);
|
|
395
|
+
}
|
|
396
|
+
/**
|
|
397
|
+
* Does a bound payload's `file` name this checkout's copy of `path`?
|
|
398
|
+
*
|
|
399
|
+
* The hook writes the ABSOLUTE path: `fileToolGate` resolves the declared
|
|
400
|
+
* target against the session's `cwd`, so a real Edit of SPEC.md in a worktree
|
|
401
|
+
* binds `/Users/carter/dev/approval-md/.claude/worktrees/<name>/SPEC.md`. Two
|
|
402
|
+
* shapes pass: the repository-relative path itself, and an absolute path whose
|
|
403
|
+
* TRAILING segments are that path. A relative path carrying extra leading
|
|
404
|
+
* directories (`dry/SPEC.md`) is refused, as is any `..` segment or backslash,
|
|
405
|
+
* either of which would make the tail test claim something it cannot know.
|
|
406
|
+
*
|
|
407
|
+
* Matching an absolute path by its tail is safe for Edit and Write material,
|
|
408
|
+
* and only for that material, because there the bytes are the proof: `before`
|
|
409
|
+
* has to occur in the blob at base, `after` in the blob at head, and the
|
|
410
|
+
* replay has to reach HEAD byte-identical. A payload naming another checkout's
|
|
411
|
+
* copy of SPEC.md carries that copy's bytes, which are not these, so it covers
|
|
412
|
+
* nothing. Command material describes no bytes at all, which is why
|
|
413
|
+
* {@link commandTargetsPath} keeps the stricter cwd-join rule for it.
|
|
414
|
+
*/
|
|
415
|
+
function namesProtectedFile(file, path) {
|
|
416
|
+
if (file.includes("\\"))
|
|
417
|
+
return false;
|
|
418
|
+
const named = segmentsOf(file);
|
|
419
|
+
if (named.includes(".."))
|
|
420
|
+
return false;
|
|
421
|
+
const wanted = segmentsOf(path);
|
|
422
|
+
if (wanted.length === 0)
|
|
423
|
+
return false;
|
|
424
|
+
if (file.startsWith("/"))
|
|
425
|
+
return endsWithSegments(file, path);
|
|
426
|
+
return named.length === wanted.length && named.every((part, index) => part === wanted[index]);
|
|
427
|
+
}
|
|
318
428
|
/**
|
|
319
429
|
* Does this command line WRITE `path`, as the runtime's own classifier reads it?
|
|
320
430
|
*
|
|
@@ -555,12 +665,7 @@ function policyFileEvidence(material, path, policyProtectedPaths) {
|
|
|
555
665
|
const map = material;
|
|
556
666
|
const tool = map["tool"];
|
|
557
667
|
const file = map["file"];
|
|
558
|
-
if (typeof file !== "string" ||
|
|
559
|
-
file.startsWith("/") ||
|
|
560
|
-
file.includes("\\"))
|
|
561
|
-
return null;
|
|
562
|
-
const named = segmentsOf(file);
|
|
563
|
-
if (named.includes("..") || named.join("/") !== segmentsOf(path).join("/"))
|
|
668
|
+
if (typeof file !== "string" || !namesProtectedFile(file, path))
|
|
564
669
|
return null;
|
|
565
670
|
const keys = Object.keys(map);
|
|
566
671
|
const hasOnly = (allowed) => keys.every((key) => allowed.includes(key));
|
|
@@ -612,6 +717,28 @@ function hasAmbiguousRoute(path, policyProtectedPaths) {
|
|
|
612
717
|
}
|
|
613
718
|
return classes.size > 1;
|
|
614
719
|
}
|
|
720
|
+
/** Normalize whatever the caller supplied into one {@link ChangeAnchor}. */
|
|
721
|
+
function changeAnchorOf(supplied) {
|
|
722
|
+
const pair = supplied === null || typeof supplied === "string"
|
|
723
|
+
? { author: supplied, committer: supplied }
|
|
724
|
+
: supplied;
|
|
725
|
+
const authorTs = pair.author ?? pair.committer;
|
|
726
|
+
const committerTs = pair.committer ?? pair.author;
|
|
727
|
+
const parse = (ts) => {
|
|
728
|
+
if (ts === null)
|
|
729
|
+
return null;
|
|
730
|
+
const ms = Date.parse(ts);
|
|
731
|
+
return Number.isNaN(ms) ? null : ms;
|
|
732
|
+
};
|
|
733
|
+
const authorMs = parse(authorTs);
|
|
734
|
+
const committerMs = parse(committerTs);
|
|
735
|
+
return {
|
|
736
|
+
authorTs,
|
|
737
|
+
committerTs,
|
|
738
|
+
authorMs: authorMs ?? committerMs,
|
|
739
|
+
committerMs: committerMs ?? authorMs,
|
|
740
|
+
};
|
|
741
|
+
}
|
|
615
742
|
/**
|
|
616
743
|
* Exact-file evidence from an unattended execution authorized by policy.
|
|
617
744
|
*
|
|
@@ -619,7 +746,7 @@ function hasAmbiguousRoute(path, policyProtectedPaths) {
|
|
|
619
746
|
* committed payload must hash back to that declaration. A prior request means
|
|
620
747
|
* the action entered the human-decision path, so this tier cannot carry it.
|
|
621
748
|
*/
|
|
622
|
-
function policyAuthorizedEvidence(records, start, path, policyProtectedPaths, payloadFor,
|
|
749
|
+
function policyAuthorizedEvidence(records, start, path, policyProtectedPaths, payloadFor, anchor, lookbackMs) {
|
|
623
750
|
if (start.event !== "execution.started")
|
|
624
751
|
return null;
|
|
625
752
|
const task = start.task;
|
|
@@ -657,12 +784,15 @@ function policyAuthorizedEvidence(records, start, path, policyProtectedPaths, pa
|
|
|
657
784
|
record.action_key === actionKey &&
|
|
658
785
|
record.event === "approval.requested"))
|
|
659
786
|
return null;
|
|
660
|
-
// A start cannot authorize a change that already happened
|
|
661
|
-
//
|
|
662
|
-
|
|
787
|
+
// A start cannot authorize a change that already happened, and a start from
|
|
788
|
+
// months ago is about some earlier edit. Ordering is asked of the COMMITTER
|
|
789
|
+
// date and staleness of the AUTHOR date (APRV-339). This tier also requires a
|
|
790
|
+
// usable commit timestamp; missing temporal evidence fails closed.
|
|
791
|
+
const { authorMs, committerMs } = anchor;
|
|
792
|
+
if (authorMs === null || committerMs === null)
|
|
663
793
|
return null;
|
|
664
794
|
const at = Date.parse(start.ts);
|
|
665
|
-
if (Number.isNaN(at) || at >
|
|
795
|
+
if (Number.isNaN(at) || at > committerMs || authorMs - at > lookbackMs)
|
|
666
796
|
return null;
|
|
667
797
|
const material = payloadFor(hash);
|
|
668
798
|
if (material === null)
|
|
@@ -676,7 +806,7 @@ function policyAuthorizedEvidence(records, start, path, policyProtectedPaths, pa
|
|
|
676
806
|
}
|
|
677
807
|
return policyFileEvidence(material, path, policyProtectedPaths);
|
|
678
808
|
}
|
|
679
|
-
/** The closed
|
|
809
|
+
/** The closed Edit shape, naming this path, eligible for byte replay. */
|
|
680
810
|
function exactReplayEdit(material, path) {
|
|
681
811
|
if (typeof material !== "object" || material === null || Array.isArray(material))
|
|
682
812
|
return null;
|
|
@@ -687,8 +817,7 @@ function exactReplayEdit(material, path) {
|
|
|
687
817
|
return null;
|
|
688
818
|
if (map["tool"] !== "Edit" ||
|
|
689
819
|
typeof map["file"] !== "string" ||
|
|
690
|
-
map["file"]
|
|
691
|
-
map["file"].includes("\\") ||
|
|
820
|
+
!namesProtectedFile(map["file"], path) ||
|
|
692
821
|
(map["rule"] !== undefined && typeof map["rule"] !== "string") ||
|
|
693
822
|
typeof map["before"] !== "string" ||
|
|
694
823
|
map["before"].length === 0 ||
|
|
@@ -696,17 +825,12 @@ function exactReplayEdit(material, path) {
|
|
|
696
825
|
map["before"] === map["after"] ||
|
|
697
826
|
(map["replace_all"] !== undefined && map["replace_all"] !== false))
|
|
698
827
|
return null;
|
|
699
|
-
const named = segmentsOf(map["file"]);
|
|
700
|
-
const changed = segmentsOf(path);
|
|
701
|
-
if (named.includes("..") ||
|
|
702
|
-
named.length !== changed.length ||
|
|
703
|
-
!named.every((segment, index) => segment === changed[index]))
|
|
704
|
-
return null;
|
|
705
828
|
return { before: map["before"], after: map["after"] };
|
|
706
829
|
}
|
|
707
830
|
/** The one token or harness spend that proves this manual grant actually ran. */
|
|
708
|
-
function startForReplayGrant(candidate, records,
|
|
709
|
-
|
|
831
|
+
function startForReplayGrant(candidate, records, anchor, lookbackMs, path, policyProtectedPaths) {
|
|
832
|
+
const { authorMs, committerMs } = anchor;
|
|
833
|
+
if (authorMs === null || committerMs === null)
|
|
710
834
|
return null;
|
|
711
835
|
const grant = candidate.record;
|
|
712
836
|
const task = grant.task;
|
|
@@ -752,8 +876,10 @@ function startForReplayGrant(candidate, records, anchorMs, lookbackMs, path, pol
|
|
|
752
876
|
if (hasTokenLink &&
|
|
753
877
|
(typeof grantToken !== "string" || started["token_sha256"] !== grantToken))
|
|
754
878
|
return false;
|
|
879
|
+
// Ordering against the committer date, staleness against the author date
|
|
880
|
+
// (APRV-339), exactly as the policy-authorized tier asks them.
|
|
755
881
|
const at = Date.parse(record.ts);
|
|
756
|
-
return !Number.isNaN(at) && at <=
|
|
882
|
+
return !Number.isNaN(at) && at <= committerMs && authorMs - at <= lookbackMs;
|
|
757
883
|
});
|
|
758
884
|
if (starts.length !== 1)
|
|
759
885
|
return null;
|
|
@@ -795,7 +921,7 @@ function startForReplayGrant(candidate, records, anchorMs, lookbackMs, path, pol
|
|
|
795
921
|
* examined. Crossing any bound refuses the fallback rather than pruning a
|
|
796
922
|
* potentially valid branch and claiming the remaining search was complete.
|
|
797
923
|
*/
|
|
798
|
-
function exactEditReplay(candidates, records, base, head,
|
|
924
|
+
function exactEditReplay(candidates, records, base, head, anchor, lookbackMs, path, policyProtectedPaths) {
|
|
799
925
|
if (base === null ||
|
|
800
926
|
head === null ||
|
|
801
927
|
base.includes("\uFFFD") ||
|
|
@@ -815,7 +941,7 @@ function exactEditReplay(candidates, records, base, head, anchorMs, lookbackMs,
|
|
|
815
941
|
continue;
|
|
816
942
|
const start = candidate.source === "policy"
|
|
817
943
|
? candidate.record
|
|
818
|
-
: startForReplayGrant(candidate, records,
|
|
944
|
+
: startForReplayGrant(candidate, records, anchor, lookbackMs, path, policyProtectedPaths);
|
|
819
945
|
if (start !== null)
|
|
820
946
|
gathered.push({ candidate, start, ...edit });
|
|
821
947
|
}
|
|
@@ -912,6 +1038,38 @@ function linesOf(text) {
|
|
|
912
1038
|
parts.pop();
|
|
913
1039
|
return parts;
|
|
914
1040
|
}
|
|
1041
|
+
/**
|
|
1042
|
+
* Where a fragment anchors in the blob at base, and what replacing it yields.
|
|
1043
|
+
*
|
|
1044
|
+
* `null` unless the fragment sits inside ONE line and occurs exactly once in
|
|
1045
|
+
* the whole file: a fragment with two homes does not say which occurrence the
|
|
1046
|
+
* human approved, and a guess is not evidence. A fragment carrying a newline is
|
|
1047
|
+
* not line-local at all and belongs to the global replay.
|
|
1048
|
+
*
|
|
1049
|
+
* Applying a uniquely-anchored edit to base can only produce base with that one
|
|
1050
|
+
* line rewritten, so this is a one-step replay stated as the line it changes —
|
|
1051
|
+
* the arithmetic the caller then checks against the hunks (APRV-340).
|
|
1052
|
+
*/
|
|
1053
|
+
function lineLocalReplacement(baseLines, edit) {
|
|
1054
|
+
if (edit.before.includes("\n"))
|
|
1055
|
+
return null;
|
|
1056
|
+
let found = null;
|
|
1057
|
+
for (const line of baseLines) {
|
|
1058
|
+
const first = line.indexOf(edit.before);
|
|
1059
|
+
if (first === -1)
|
|
1060
|
+
continue;
|
|
1061
|
+
if (found !== null || line.indexOf(edit.before, first + 1) !== -1)
|
|
1062
|
+
return null;
|
|
1063
|
+
found = { line, at: first };
|
|
1064
|
+
}
|
|
1065
|
+
if (found === null)
|
|
1066
|
+
return null;
|
|
1067
|
+
const { line, at } = found;
|
|
1068
|
+
return {
|
|
1069
|
+
removed: line,
|
|
1070
|
+
added: `${line.slice(0, at)}${edit.after}${line.slice(at + edit.before.length)}`,
|
|
1071
|
+
};
|
|
1072
|
+
}
|
|
915
1073
|
/** A line that carries content. Blank lines neither need coverage nor give it. */
|
|
916
1074
|
function substantive(line) {
|
|
917
1075
|
return line.trim().length > 0;
|
|
@@ -984,8 +1142,8 @@ function windowText(window) {
|
|
|
984
1142
|
}
|
|
985
1143
|
/**
|
|
986
1144
|
* How far past the change commit a run may still start and be its cause: five
|
|
987
|
-
* minutes, for clock disagreement between the log and git's
|
|
988
|
-
* a skew allowance, not an ordering allowance.
|
|
1145
|
+
* minutes, for clock disagreement between the log and git's committer date. It
|
|
1146
|
+
* is a skew allowance, not an ordering allowance.
|
|
989
1147
|
*/
|
|
990
1148
|
const SKEW_GRACE_MS = 5 * 60 * 1000;
|
|
991
1149
|
/** Milliseconds as something a failure message can say out loud. */
|
|
@@ -1009,7 +1167,12 @@ function spanText(ms) {
|
|
|
1009
1167
|
* interval it actually occupied: a batch that started before the commit and
|
|
1010
1168
|
* finished after it brackets the commit, which is the strongest form of this.
|
|
1011
1169
|
*/
|
|
1012
|
-
function attributeRun(grant, runs,
|
|
1170
|
+
function attributeRun(grant, runs, anchor, attributionMs) {
|
|
1171
|
+
// The same split the other two tiers make (APRV-339): "did this run start
|
|
1172
|
+
// after the bytes were committed" is asked of the COMMITTER date, which an
|
|
1173
|
+
// amend may only move later, and "how far from the change is it" of the
|
|
1174
|
+
// AUTHOR date, which a rebase does not move.
|
|
1175
|
+
const { authorMs, committerMs } = anchor;
|
|
1013
1176
|
const key = grant.action_key;
|
|
1014
1177
|
if (key === undefined) {
|
|
1015
1178
|
return {
|
|
@@ -1027,7 +1190,7 @@ function attributeRun(grant, runs, anchorMs, attributionMs) {
|
|
|
1027
1190
|
}
|
|
1028
1191
|
const completed = tied.filter((record) => record.event === "execution.completed");
|
|
1029
1192
|
const endOf = (record) => completed.find((done) => done.seq > record.seq);
|
|
1030
|
-
if (
|
|
1193
|
+
if (authorMs === null || committerMs === null) {
|
|
1031
1194
|
const first = started[0];
|
|
1032
1195
|
return {
|
|
1033
1196
|
ok: true,
|
|
@@ -1040,7 +1203,7 @@ function attributeRun(grant, runs, anchorMs, attributionMs) {
|
|
|
1040
1203
|
// the record is appended before the process is spawned. The real log shows
|
|
1041
1204
|
// what the symmetric window costs — a SPEC.md batch run four hours AFTER
|
|
1042
1205
|
// PR #187's commit would otherwise have carried that commit's changes.
|
|
1043
|
-
// `SKEW_GRACE_MS` is for the two clocks (the log's and git's
|
|
1206
|
+
// `SKEW_GRACE_MS` is for the two clocks (the log's and git's committer date)
|
|
1044
1207
|
// disagreeing, not for ordering.
|
|
1045
1208
|
let best = null;
|
|
1046
1209
|
let laterOnly = null;
|
|
@@ -1048,7 +1211,7 @@ function attributeRun(grant, runs, anchorMs, attributionMs) {
|
|
|
1048
1211
|
const from = Date.parse(record.ts);
|
|
1049
1212
|
if (Number.isNaN(from))
|
|
1050
1213
|
continue;
|
|
1051
|
-
if (from >
|
|
1214
|
+
if (from > committerMs + SKEW_GRACE_MS) {
|
|
1052
1215
|
if (laterOnly === null)
|
|
1053
1216
|
laterOnly = record;
|
|
1054
1217
|
continue;
|
|
@@ -1056,9 +1219,9 @@ function attributeRun(grant, runs, anchorMs, attributionMs) {
|
|
|
1056
1219
|
const end = endOf(record);
|
|
1057
1220
|
const to = end === undefined ? from : Date.parse(end.ts);
|
|
1058
1221
|
const upper = Number.isNaN(to) ? from : to;
|
|
1059
|
-
const distance =
|
|
1222
|
+
const distance = authorMs >= Math.min(from, upper) && authorMs <= Math.max(from, upper)
|
|
1060
1223
|
? 0
|
|
1061
|
-
: Math.min(Math.abs(from -
|
|
1224
|
+
: Math.min(Math.abs(from - authorMs), Math.abs(upper - authorMs));
|
|
1062
1225
|
if (best === null || distance < best.distance)
|
|
1063
1226
|
best = { record, end, distance };
|
|
1064
1227
|
}
|
|
@@ -1098,6 +1261,40 @@ function attributeRun(grant, runs, anchorMs, attributionMs) {
|
|
|
1098
1261
|
function organKey(organPath, sha256) {
|
|
1099
1262
|
return `${normalizePathSpelling(organPath)}\0${sha256}`;
|
|
1100
1263
|
}
|
|
1264
|
+
/**
|
|
1265
|
+
* May this path be evidenced by a `gate.path.signed_off` record? (APRV-338.)
|
|
1266
|
+
*
|
|
1267
|
+
* `policy.edit` and its sub-classes, and nothing else. The organs and the
|
|
1268
|
+
* approval home are `policy.core` and answered by their own record; the log
|
|
1269
|
+
* directory is `log.mutate` and answered by nobody. Asked with the policy's own
|
|
1270
|
+
* entries, so a path a project routed to `policy.edit.design` is signable while
|
|
1271
|
+
* a path no list protects is not.
|
|
1272
|
+
*/
|
|
1273
|
+
function signOffEligible(path, extra) {
|
|
1274
|
+
const routed = protectedPathClass(path, extra);
|
|
1275
|
+
return routed === "policy.edit" || (routed !== null && POLICY_EDIT_SUBCLASS.test(routed));
|
|
1276
|
+
}
|
|
1277
|
+
/**
|
|
1278
|
+
* The sentence a failing `policy.edit` path gets about the sign-off route, or
|
|
1279
|
+
* nothing at all.
|
|
1280
|
+
*
|
|
1281
|
+
* Deliberately last in every message it appears in, and deliberately hedged.
|
|
1282
|
+
* The first repair for an uncovered change is to take it to the gate, which
|
|
1283
|
+
* binds the hunk; a sign-off stands for the whole file and is what the
|
|
1284
|
+
* pending-sign-off suffix was invented for — text a human has read at this
|
|
1285
|
+
* commit and agrees with. Printing it first would read as an invitation to
|
|
1286
|
+
* route around the gate, which is the one thing this guard exists to notice.
|
|
1287
|
+
*/
|
|
1288
|
+
function signOffRepair(path, digest) {
|
|
1289
|
+
if (digest === null)
|
|
1290
|
+
return "";
|
|
1291
|
+
// The two flags are named because the digest is the thing that has to match,
|
|
1292
|
+
// and the bytes under review are on the BRANCH while the log lives in the
|
|
1293
|
+
// primary checkout. `--dir` says which bytes to hash and `--log` says where
|
|
1294
|
+
// the record goes, so one command can span both without the log ever being
|
|
1295
|
+
// written from a worktree.
|
|
1296
|
+
return ` If a human has READ this change at this commit and stands behind the file as it now is, they may ratify it with \`approval policy attest --path ${path} --dir <a checkout at this commit> --log <the primary checkout's log> --as human:<id>\`, which must hash to ${digest}, followed by a log advance carrying that record; that is whole-file evidence and weaker than a grant, so prefer the gate wherever the edit can still go through it.`;
|
|
1297
|
+
}
|
|
1101
1298
|
/** The ordering rule, stated identically on every failure that could be lag. */
|
|
1102
1299
|
const ORDERING_RULE = "the committed log trails the primary checkout's live log, so if this edit WAS granted, " +
|
|
1103
1300
|
"the log advance carrying the grant must be pushed to a records branch or merged to main " +
|
|
@@ -1162,6 +1359,18 @@ export function evaluateProtectedPaths(input) {
|
|
|
1162
1359
|
continue;
|
|
1163
1360
|
organAttestations.set(organKey(fields.organPath, fields.sha256), record);
|
|
1164
1361
|
}
|
|
1362
|
+
// The SIGN-OFF index (APRV-338), keyed the same way and fed by its own event
|
|
1363
|
+
// type, so a sign-off can never answer an organ's question or the policy
|
|
1364
|
+
// file's and neither can answer a sign-off's. Built here and consulted at the
|
|
1365
|
+
// END of each path's evidence search: see the module note on why hunk
|
|
1366
|
+
// evidence has to be preferred over whole-file evidence.
|
|
1367
|
+
const signOffs = new Map();
|
|
1368
|
+
for (const record of records) {
|
|
1369
|
+
const fields = pathSignOffOf(record);
|
|
1370
|
+
if (fields === null)
|
|
1371
|
+
continue;
|
|
1372
|
+
signOffs.set(organKey(fields.path, fields.sha256), record);
|
|
1373
|
+
}
|
|
1165
1374
|
// The grants of a class that can authorize a protected write.
|
|
1166
1375
|
const grants = records.filter((record) => record.event === "approval.granted" &&
|
|
1167
1376
|
isGrantingClass(String(payloadOf(record)["class"] ?? "")));
|
|
@@ -1186,8 +1395,7 @@ export function evaluateProtectedPaths(input) {
|
|
|
1186
1395
|
const findings = [];
|
|
1187
1396
|
for (const path of guarded) {
|
|
1188
1397
|
// 1. The policy file, by attestation.
|
|
1189
|
-
if (
|
|
1190
|
-
endsWithSegments(path, input.policyPath)) {
|
|
1398
|
+
if (namesPolicyFile(path, input.policyPath)) {
|
|
1191
1399
|
if (input.policySha256AtHead !== null) {
|
|
1192
1400
|
const attested = attestations.get(input.policySha256AtHead);
|
|
1193
1401
|
if (attested !== undefined) {
|
|
@@ -1235,18 +1443,24 @@ export function evaluateProtectedPaths(input) {
|
|
|
1235
1443
|
const lookbackMs = input.lookbackMs ?? DEFAULT_LOOKBACK_MS;
|
|
1236
1444
|
// ONE derivation of the anchor, so the bound that is enforced and the bound
|
|
1237
1445
|
// that is reported cannot disagree. An unparseable timestamp is no anchor,
|
|
1238
|
-
// exactly as a missing one is not: both land in `anchorMs === null`.
|
|
1239
|
-
|
|
1240
|
-
|
|
1446
|
+
// exactly as a missing one is not: both land in `anchorMs === null`. Two
|
|
1447
|
+
// instants since APRV-339, and the one asked here is the AUTHOR date: this
|
|
1448
|
+
// is the staleness pre-filter, and ordering belongs to the tiers that can
|
|
1449
|
+
// say which record wrote which bytes.
|
|
1450
|
+
const anchor = changeAnchorOf(changeTs);
|
|
1451
|
+
const anchorMs = anchor.authorMs;
|
|
1241
1452
|
const inWindow = (ts) => {
|
|
1242
1453
|
if (anchorMs === null)
|
|
1243
1454
|
return true;
|
|
1244
1455
|
const at = Date.parse(ts);
|
|
1245
1456
|
return !Number.isNaN(at) && Math.abs(at - anchorMs) <= lookbackMs;
|
|
1246
1457
|
};
|
|
1458
|
+
const datedText = anchor.committerTs === null || anchor.committerTs === anchor.authorTs
|
|
1459
|
+
? `${anchor.authorTs}`
|
|
1460
|
+
: `authored ${anchor.authorTs}, committed ${anchor.committerTs}`;
|
|
1247
1461
|
const boundText = anchorMs === null
|
|
1248
|
-
? `no usable commit timestamp for this path (${
|
|
1249
|
-
: `within ${Math.round(lookbackMs / 86_400_000)}d of the commit that changed it (${
|
|
1462
|
+
? `no usable commit timestamp for this path (${anchor.authorTs === null ? "git named none" : `git named ${JSON.stringify(datedText)}, which does not parse`}), so NO recency bound was applied and this evidence rests on the path match alone`
|
|
1463
|
+
: `within ${Math.round(lookbackMs / 86_400_000)}d of the commit that changed it (${datedText})`;
|
|
1250
1464
|
// EVERY qualifying grant is collected and the best one is reported, rather
|
|
1251
1465
|
// than the first one found. The first-match version passed commit 41d2c9f
|
|
1252
1466
|
// on a `cp SPEC.md <dir>/` granted four days earlier while the grant that
|
|
@@ -1276,7 +1490,7 @@ export function evaluateProtectedPaths(input) {
|
|
|
1276
1490
|
candidates.push({ record: grant, match: found, source: "grant", material, payloadHash: hash });
|
|
1277
1491
|
}
|
|
1278
1492
|
for (const start of records) {
|
|
1279
|
-
const found = policyAuthorizedEvidence(records, start, path, input.policyProtectedPaths, input.payloadFor,
|
|
1493
|
+
const found = policyAuthorizedEvidence(records, start, path, input.policyProtectedPaths, input.payloadFor, anchor, lookbackMs);
|
|
1280
1494
|
const hash = payloadOf(start)["payload_hash"];
|
|
1281
1495
|
if (found !== null && typeof hash === "string") {
|
|
1282
1496
|
const material = input.payloadFor(hash);
|
|
@@ -1337,7 +1551,7 @@ export function evaluateProtectedPaths(input) {
|
|
|
1337
1551
|
rejected.push(`${at} writes ${JSON.stringify(match.target ?? "")} from ${JSON.stringify(match.cwd ?? "(no cwd recorded)")}, which is not this checkout's ${path}: a granted write to a copy of the file elsewhere (a dry run into a scratch directory, another worktree) authorizes nothing here`);
|
|
1338
1552
|
continue;
|
|
1339
1553
|
}
|
|
1340
|
-
const run = attributeRun(record, runs,
|
|
1554
|
+
const run = attributeRun(record, runs, anchor, attributionMs);
|
|
1341
1555
|
if (!run.ok) {
|
|
1342
1556
|
rejected.push(run.why);
|
|
1343
1557
|
continue;
|
|
@@ -1400,6 +1614,71 @@ export function evaluateProtectedPaths(input) {
|
|
|
1400
1614
|
contributors.push({ record, kind: match.kind, why: contributed, whole: wholeHere, source });
|
|
1401
1615
|
}
|
|
1402
1616
|
}
|
|
1617
|
+
// One fragment inside one line, credited by bytes alone (APRV-340).
|
|
1618
|
+
//
|
|
1619
|
+
// The hook binds exactly what the Edit tool replaced, so an edit that
|
|
1620
|
+
// rewrites part of a long paragraph binds a fragment and whole-line set
|
|
1621
|
+
// membership cannot credit either line. That used to leave only the global
|
|
1622
|
+
// replay, which on a 200 KB file with forty naming candidates refuses on
|
|
1623
|
+
// its byte limit before it can reach a proof (PR #393, the fragment inside
|
|
1624
|
+
// SPEC.md line 139). A uniquely-anchored fragment needs no search: applying
|
|
1625
|
+
// it to base can only produce base with that ONE line rewritten, so if the
|
|
1626
|
+
// line it rewrites is a line this change removes and the line it produces
|
|
1627
|
+
// is a line this change adds, the bytes have proved both lines at the cost
|
|
1628
|
+
// of one scan. Every eligibility condition is the global replay's own — the
|
|
1629
|
+
// material rehashed, the Edit shape naming this path, and the same start
|
|
1630
|
+
// resolution, which for a grant is the registration, the request, the
|
|
1631
|
+
// class, the spend and the timing. A line-local step substitutes for none
|
|
1632
|
+
// of them; it only spends less to ask the same question.
|
|
1633
|
+
//
|
|
1634
|
+
// Bytes that did not decode are not bytes anyone can prove anything about,
|
|
1635
|
+
// so a blob carrying U+FFFD is refused here exactly as the replay refuses
|
|
1636
|
+
// it: byte equality against a lossy decoding says nothing.
|
|
1637
|
+
if (!whole &&
|
|
1638
|
+
!hunks.identical &&
|
|
1639
|
+
!baseText.includes("\uFFFD") &&
|
|
1640
|
+
!headText.includes("\uFFFD")) {
|
|
1641
|
+
const baseLines = linesOf(baseText);
|
|
1642
|
+
const addsLine = new Set(hunks.added);
|
|
1643
|
+
const removesLine = new Set(hunks.removed);
|
|
1644
|
+
for (const candidate of candidates) {
|
|
1645
|
+
const edit = exactReplayEdit(candidate.material, path);
|
|
1646
|
+
if (edit === null)
|
|
1647
|
+
continue;
|
|
1648
|
+
try {
|
|
1649
|
+
if (payloadHash(candidate.material) !== candidate.payloadHash)
|
|
1650
|
+
continue;
|
|
1651
|
+
}
|
|
1652
|
+
catch {
|
|
1653
|
+
continue;
|
|
1654
|
+
}
|
|
1655
|
+
const start = candidate.source === "policy"
|
|
1656
|
+
? candidate.record
|
|
1657
|
+
: startForReplayGrant(candidate, records, anchor, lookbackMs, path, input.policyProtectedPaths);
|
|
1658
|
+
if (start === null)
|
|
1659
|
+
continue;
|
|
1660
|
+
const local = lineLocalReplacement(baseLines, edit);
|
|
1661
|
+
if (local === null)
|
|
1662
|
+
continue;
|
|
1663
|
+
if (!removesLine.has(local.removed) || !addsLine.has(local.added))
|
|
1664
|
+
continue;
|
|
1665
|
+
addedCover.add(local.added);
|
|
1666
|
+
removedCover.add(local.removed);
|
|
1667
|
+
const why = `replacing that fragment in the one line of the blob at base that carries it yields a line this change adds, and that base line is one this change removes (line-local replay at execution.started seq ${start.seq})`;
|
|
1668
|
+
const already = contributors.find((one) => one.record.seq === candidate.record.seq);
|
|
1669
|
+
if (already === undefined) {
|
|
1670
|
+
contributors.push({
|
|
1671
|
+
record: candidate.record,
|
|
1672
|
+
kind: candidate.match.kind,
|
|
1673
|
+
why: `${candidate.match.detail}, and ${why}`,
|
|
1674
|
+
whole: false,
|
|
1675
|
+
source: candidate.source,
|
|
1676
|
+
});
|
|
1677
|
+
continue;
|
|
1678
|
+
}
|
|
1679
|
+
already.why = `${already.why}, and ${why}`;
|
|
1680
|
+
}
|
|
1681
|
+
}
|
|
1403
1682
|
// Exact Edit payloads may bind fragments within a line, including several
|
|
1404
1683
|
// independent fragments of the same long line. Whole-line set membership
|
|
1405
1684
|
// cannot express that safely. Replay is the bounded fallback: genuine
|
|
@@ -1410,7 +1689,7 @@ export function evaluateProtectedPaths(input) {
|
|
|
1410
1689
|
hunks.added.some((line) => !addedCover.has(line)) ||
|
|
1411
1690
|
hunks.removed.some((line) => !removedCover.has(line)));
|
|
1412
1691
|
const replay = needsReplay
|
|
1413
|
-
? exactEditReplay(candidates, records, blobs.base, blobs.head,
|
|
1692
|
+
? exactEditReplay(candidates, records, blobs.base, blobs.head, anchor, lookbackMs, path, input.policyProtectedPaths)
|
|
1414
1693
|
: { steps: null, bound: null };
|
|
1415
1694
|
if (replay.bound !== null) {
|
|
1416
1695
|
rejected.unshift(`exact BASE-to-HEAD replay refused after reaching its ${replay.bound.replace("-", " ")}`);
|
|
@@ -1487,6 +1766,35 @@ export function evaluateProtectedPaths(input) {
|
|
|
1487
1766
|
});
|
|
1488
1767
|
continue;
|
|
1489
1768
|
}
|
|
1769
|
+
// The LAST thing tried for this path, and deliberately last: a human's
|
|
1770
|
+
// whole-file sign-off (APRV-338).
|
|
1771
|
+
//
|
|
1772
|
+
// Everything above is hunk evidence, and everything above has now failed
|
|
1773
|
+
// to cover this change. Only here does the guard ask the weaker question —
|
|
1774
|
+
// did a human read this file at exactly these bytes and say so — and the
|
|
1775
|
+
// finding says in words that this is what it rests on, so a reader can
|
|
1776
|
+
// never mistake it for a grant. Running it here rather than beside the
|
|
1777
|
+
// organ verdict is what keeps that true: consulted first, a sign-off would
|
|
1778
|
+
// have silently answered for every change a grant already covered, and the
|
|
1779
|
+
// reasons this guard prints are half its value.
|
|
1780
|
+
const signOffDigest = signOffEligible(path, input.policyProtectedPaths)
|
|
1781
|
+
? (input.pathSha256AtHead?.(path) ?? null)
|
|
1782
|
+
: null;
|
|
1783
|
+
if (signOffDigest !== null) {
|
|
1784
|
+
const signed = signOffs.get(organKey(path, signOffDigest));
|
|
1785
|
+
if (signed !== undefined) {
|
|
1786
|
+
findings.push({
|
|
1787
|
+
path,
|
|
1788
|
+
ok: true,
|
|
1789
|
+
evidence: "attested",
|
|
1790
|
+
seq: signed.seq,
|
|
1791
|
+
ts: signed.ts,
|
|
1792
|
+
actor: signed.actor,
|
|
1793
|
+
detail: `${path} at ${input.window.head} hashes to ${signOffDigest}, which ${signed.actor} signed off FOR THAT PATH at seq ${signed.seq}. This is WHOLE-FILE evidence and weaker than a grant: it says a human read this file at these exact bytes, not that they saw this hunk. It was read only because no grant covers this change — ${candidates.length} ${evidenceNoun} name this path ${boundText}${contributors.length > 0 ? `, and ${contributors.length} of them covered part of it (seq ${contributors.map((one) => one.record.seq).join(", ")})` : ""}`,
|
|
1794
|
+
});
|
|
1795
|
+
continue;
|
|
1796
|
+
}
|
|
1797
|
+
}
|
|
1490
1798
|
// Naming grants exist and the change is not made of them: the repeat-edit
|
|
1491
1799
|
// shape. Its own code, because the reader's next move differs from
|
|
1492
1800
|
// `no-evidence` — take THIS change to the gate, rather than hunt for a
|
|
@@ -1502,7 +1810,7 @@ export function evaluateProtectedPaths(input) {
|
|
|
1502
1810
|
? `${contributors.length} evidence record${contributors.length === 1 ? "" : "s"} covered part of it (seq ${contributors.map((one) => one.record.seq).join(", ")}); `
|
|
1503
1811
|
: ""}${rejected.length > 0
|
|
1504
1812
|
? `${rejected.slice(0, 6).join("; ")}${rejected.length > 6 ? `; … and ${rejected.length - 6} other naming grants set aside for the same kinds of reason` : ""}. `
|
|
1505
|
-
: ""}uncovered: ${sample.map((line) => JSON.stringify(line)).join(", ")}${uncovered.length > sample.length ? `, … ${uncovered.length - sample.length} more` : ""}. ${windowText(input.window)}. ${ORDERING_RULE}
|
|
1813
|
+
: ""}uncovered: ${sample.map((line) => JSON.stringify(line)).join(", ")}${uncovered.length > sample.length ? `, … ${uncovered.length - sample.length} more` : ""}. ${windowText(input.window)}. ${ORDERING_RULE}.${signOffRepair(path, signOffDigest)}`,
|
|
1506
1814
|
});
|
|
1507
1815
|
continue;
|
|
1508
1816
|
}
|
|
@@ -1516,10 +1824,16 @@ export function evaluateProtectedPaths(input) {
|
|
|
1516
1824
|
if (unresolved.length > 0) {
|
|
1517
1825
|
diagnosis.push(`${unresolved.length} grant payload${unresolved.length === 1 ? "" : "s"} could not be resolved from the committed payload store (${unresolved.slice(0, 3).join(", ")}${unresolved.length > 3 ? ", …" : ""}), and a grant whose bytes cannot be read is not evidence for any path`);
|
|
1518
1826
|
}
|
|
1519
|
-
if ((
|
|
1520
|
-
input.policySha256AtHead !== null) {
|
|
1827
|
+
if (namesPolicyFile(path, input.policyPath) && input.policySha256AtHead !== null) {
|
|
1521
1828
|
diagnosis.push(`no policy.updated record attests the bytes this pull request would install (${input.policySha256AtHead}); an amendment lands through \`approval policy amend --commit\`, whose attestation record is the evidence`);
|
|
1522
1829
|
}
|
|
1830
|
+
if (signOffDigest !== null) {
|
|
1831
|
+
// A protected path that COULD have been signed off and was not. Named
|
|
1832
|
+
// second to the grant advice above, not first: taking the edit to the
|
|
1833
|
+
// gate binds the hunk, and a sign-off stands for the whole file
|
|
1834
|
+
// (APRV-338).
|
|
1835
|
+
diagnosis.push(`no gate.path.signed_off record signs off ${path} at ${signOffDigest}, and a digest signed for some OTHER path is not evidence for this one`);
|
|
1836
|
+
}
|
|
1523
1837
|
if (isGateOrganPath(path)) {
|
|
1524
1838
|
// The one failure in this guard whose repair is NOT "take the change to
|
|
1525
1839
|
// the gate": there is no gate for it. `policy.core` is human-only, the
|
|
@@ -1535,7 +1849,7 @@ export function evaluateProtectedPaths(input) {
|
|
|
1535
1849
|
path,
|
|
1536
1850
|
ok: false,
|
|
1537
1851
|
code: "no-evidence",
|
|
1538
|
-
detail: `${path} is a protected path (edits classify policy.edit) and changed between ${input.window.base} and ${input.window.head}, and the committed log carries no evidence that a human decided it. ${windowText(input.window)}. ${diagnosis.join("; ")}. ${ORDERING_RULE}
|
|
1852
|
+
detail: `${path} is a protected path (edits classify policy.edit) and changed between ${input.window.base} and ${input.window.head}, and the committed log carries no evidence that a human decided it. ${windowText(input.window)}. ${diagnosis.join("; ")}. ${ORDERING_RULE}.${signOffRepair(path, signOffDigest)}`,
|
|
1539
1853
|
});
|
|
1540
1854
|
}
|
|
1541
1855
|
return {
|