approval-md 0.1.0 → 0.2.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 +584 -553
- package/SPEC.md +42 -13
- package/dist/src/adapters/agentmail.d.ts +426 -0
- package/dist/src/adapters/agentmail.js +2 -2
- package/dist/src/adapters/conformance.d.ts +149 -0
- package/dist/src/adapters/contract.d.ts +628 -0
- package/dist/src/adapters/contract.js +110 -16
- package/dist/src/adapters/contract.js.map +1 -1
- package/dist/src/adapters/email.d.ts +324 -0
- package/dist/src/adapters/env-passphrase.d.ts +93 -0
- package/dist/src/adapters/public.d.ts +11 -0
- package/dist/src/adapters/public.js +11 -0
- package/dist/src/adapters/public.js.map +1 -0
- package/dist/src/adapters/registry.d.ts +59 -0
- package/dist/src/adapters/registry.js +2 -1
- package/dist/src/adapters/registry.js.map +1 -1
- package/dist/src/adapters/smtp.d.ts +213 -0
- package/dist/src/adapters/vault-provider.d.ts +114 -0
- package/dist/src/adapters/vault-provider.js +3 -3
- package/dist/src/adapters/zzz.d.ts +66 -0
- package/dist/src/adapters/zzz.js +299 -0
- package/dist/src/adapters/zzz.js.map +1 -0
- package/dist/src/channels/batch.d.ts +109 -0
- package/dist/src/channels/cli.d.ts +193 -0
- package/dist/src/channels/conformance.d.ts +92 -0
- package/dist/src/channels/contract.d.ts +623 -0
- package/dist/src/channels/payload-view.d.ts +35 -0
- package/dist/src/channels/render-queue.d.ts +149 -0
- package/dist/src/channels/tagging.d.ts +196 -0
- package/dist/src/channels/telegram.d.ts +1832 -0
- package/dist/src/channels/web.d.ts +341 -0
- package/dist/src/cli/adapter.d.ts +90 -0
- package/dist/src/cli/adapter.js +25 -15
- package/dist/src/cli/adapter.js.map +1 -1
- package/dist/src/cli/amend.d.ts +59 -0
- package/dist/src/cli/args.d.ts +43 -0
- package/dist/src/cli/attest.d.ts +41 -0
- package/dist/src/cli/audit-card.d.ts +62 -0
- package/dist/src/cli/audit.d.ts +59 -0
- package/dist/src/cli/channel-telegram.d.ts +806 -0
- package/dist/src/cli/channel-web.d.ts +131 -0
- package/dist/src/cli/channel.d.ts +71 -0
- package/dist/src/cli/checkpoint-tap.d.ts +169 -0
- package/dist/src/cli/codex.d.ts +2 -0
- package/dist/src/cli/codex.js +172 -0
- package/dist/src/cli/codex.js.map +1 -0
- package/dist/src/cli/coverage.d.ts +61 -0
- package/dist/src/cli/daemon.d.ts +120 -0
- package/dist/src/cli/doctor.d.ts +129 -0
- package/dist/src/cli/doctor.js +119 -5
- package/dist/src/cli/doctor.js.map +1 -1
- package/dist/src/cli/env.d.ts +65 -0
- package/dist/src/cli/execute.d.ts +202 -0
- package/dist/src/cli/exit-codes.d.ts +73 -0
- package/dist/src/cli/feedback.d.ts +60 -0
- package/dist/src/cli/gate-window.d.ts +40 -0
- package/dist/src/cli/gate.d.ts +68 -0
- package/dist/src/cli/git-scope.d.ts +190 -0
- package/dist/src/cli/gloss-attach.d.ts +85 -0
- package/dist/src/cli/gloss-codex-child.d.ts +9 -0
- package/dist/src/cli/gloss-codex.d.ts +24 -0
- package/dist/src/cli/gloss-options.d.ts +42 -0
- package/dist/src/cli/gloss.d.ts +265 -0
- package/dist/src/cli/help.d.ts +103 -0
- package/dist/src/cli/help.js +173 -51
- package/dist/src/cli/help.js.map +1 -1
- package/dist/src/cli/hook-codex.d.ts +78 -0
- package/dist/src/cli/hook-codex.js +167 -0
- package/dist/src/cli/hook-codex.js.map +1 -0
- package/dist/src/cli/hook.d.ts +331 -0
- package/dist/src/cli/hook.js +186 -80
- package/dist/src/cli/hook.js.map +1 -1
- package/dist/src/cli/import.d.ts +35 -0
- package/dist/src/cli/init.d.ts +84 -0
- package/dist/src/cli/init.js +2 -2
- package/dist/src/cli/init.js.map +1 -1
- package/dist/src/cli/instructions.d.ts +23 -0
- package/dist/src/cli/journal.d.ts +41 -0
- package/dist/src/cli/log-advance.d.ts +287 -0
- package/dist/src/cli/log-advance.js +102 -11
- package/dist/src/cli/log-advance.js.map +1 -1
- package/dist/src/cli/log-anchor.d.ts +176 -0
- package/dist/src/cli/log-checkpoint.d.ts +22 -0
- package/dist/src/cli/log-sync.d.ts +243 -0
- package/dist/src/cli/log-verbs.d.ts +16 -0
- package/dist/src/cli/log-verbs.js +7 -1
- package/dist/src/cli/log-verbs.js.map +1 -1
- package/dist/src/cli/long-help.d.ts +70 -0
- package/dist/src/cli/main.d.ts +77 -0
- package/dist/src/cli/main.js +155 -5
- package/dist/src/cli/main.js.map +1 -1
- package/dist/src/cli/mcp.d.ts +52 -0
- package/dist/src/cli/paths.d.ts +56 -0
- package/dist/src/cli/payload.d.ts +58 -0
- package/dist/src/cli/policy.d.ts +43 -0
- package/dist/src/cli/preflight.d.ts +363 -0
- package/dist/src/cli/preflight.js +294 -7
- package/dist/src/cli/preflight.js.map +1 -1
- package/dist/src/cli/progress.d.ts +78 -0
- package/dist/src/cli/prompt.d.ts +209 -0
- package/dist/src/cli/quickstart.d.ts +46 -0
- package/dist/src/cli/quickstart.js +297 -0
- package/dist/src/cli/quickstart.js.map +1 -0
- package/dist/src/cli/records.d.ts +34 -0
- package/dist/src/cli/render.d.ts +22 -0
- package/dist/src/cli/sandbox.d.ts +51 -0
- package/dist/src/cli/scaffold.d.ts +79 -0
- package/dist/src/cli/setup-adapter.d.ts +137 -0
- package/dist/src/cli/setup-adapter.js +38 -4
- package/dist/src/cli/setup-adapter.js.map +1 -1
- package/dist/src/cli/setup-channel.d.ts +117 -0
- package/dist/src/cli/setup-checkpoint.d.ts +57 -0
- package/dist/src/cli/setup-common.d.ts +275 -0
- package/dist/src/cli/setup-flow.d.ts +287 -0
- package/dist/src/cli/setup-service.d.ts +96 -0
- package/dist/src/cli/setup.d.ts +202 -0
- package/dist/src/cli/style.d.ts +320 -0
- package/dist/src/cli/token.d.ts +39 -0
- package/dist/src/cli/up.d.ts +155 -0
- package/dist/src/cli/up.js +4 -2
- package/dist/src/cli/up.js.map +1 -1
- package/dist/src/cli/usage.d.ts +37 -0
- package/dist/src/cli/values.d.ts +40 -0
- package/dist/src/cli/vault.d.ts +59 -0
- package/dist/src/cli/vault.js +2 -2
- package/dist/src/cli/vault.js.map +1 -1
- package/dist/src/cli/verb-registry.d.ts +76 -0
- package/dist/src/cli/verb-registry.js +176 -8
- package/dist/src/cli/verb-registry.js.map +1 -1
- package/dist/src/cli/wordmark.d.ts +31 -0
- package/dist/src/cli/wordmark.js +2 -2
- package/dist/src/codex/doctor.d.ts +13 -0
- package/dist/src/codex/doctor.js +41 -0
- package/dist/src/codex/doctor.js.map +1 -0
- package/dist/src/codex/manifest.d.ts +49 -0
- package/dist/src/codex/manifest.js +103 -0
- package/dist/src/codex/manifest.js.map +1 -0
- package/dist/src/codex/templates.d.ts +41 -0
- package/dist/src/codex/templates.js +319 -0
- package/dist/src/codex/templates.js.map +1 -0
- package/dist/src/codex/trust.d.ts +19 -0
- package/dist/src/codex/trust.js +183 -0
- package/dist/src/codex/trust.js.map +1 -0
- package/dist/src/codex/workspace-plan.d.ts +131 -0
- package/dist/src/codex/workspace-plan.js +561 -0
- package/dist/src/codex/workspace-plan.js.map +1 -0
- package/dist/src/core/actor.d.ts +2 -0
- package/dist/src/core/actor.js +5 -0
- package/dist/src/core/actor.js.map +1 -0
- package/dist/src/core/advance-cycle.d.ts +170 -0
- package/dist/src/core/agents-md.d.ts +276 -0
- package/dist/src/core/apply-patch.d.ts +49 -0
- package/dist/src/core/apply-patch.js +266 -0
- package/dist/src/core/apply-patch.js.map +1 -0
- package/dist/src/core/attest.d.ts +420 -0
- package/dist/src/core/attest.js +13 -1
- package/dist/src/core/attest.js.map +1 -1
- package/dist/src/core/audit.d.ts +492 -0
- package/dist/src/core/budgets.d.ts +238 -0
- package/dist/src/core/checkpoint.d.ts +500 -0
- package/dist/src/core/child-env.d.ts +88 -0
- package/dist/src/core/clock.d.ts +52 -0
- package/dist/src/core/command-class.d.ts +543 -0
- package/dist/src/core/command-class.js +43 -8
- package/dist/src/core/command-class.js.map +1 -1
- package/dist/src/core/coverage-sources/adapter.d.ts +40 -0
- package/dist/src/core/coverage-sources/gh.d.ts +48 -0
- package/dist/src/core/coverage-sources/git.d.ts +101 -0
- package/dist/src/core/coverage.d.ts +217 -0
- package/dist/src/core/credential-spec.d.ts +72 -0
- package/dist/src/core/dark-session.d.ts +331 -0
- package/dist/src/core/decision-refusal.d.ts +185 -0
- package/dist/src/core/env-file.d.ts +450 -0
- package/dist/src/core/execute.d.ts +858 -0
- package/dist/src/core/execute.js +44 -6
- package/dist/src/core/execute.js.map +1 -1
- package/dist/src/core/frontmatter.d.ts +78 -0
- package/dist/src/core/gate-window.d.ts +312 -0
- package/dist/src/core/gate.d.ts +1364 -0
- package/dist/src/core/gate.js +68 -13
- package/dist/src/core/gate.js.map +1 -1
- package/dist/src/core/git-run.d.ts +73 -0
- package/dist/src/core/harness-version.d.ts +157 -0
- package/dist/src/core/harness-version.js +2 -1
- package/dist/src/core/harness-version.js.map +1 -1
- package/dist/src/core/harness-wait.d.ts +55 -0
- package/dist/src/core/head-retry.d.ts +107 -0
- package/dist/src/core/instance.d.ts +253 -0
- package/dist/src/core/intake-limits.d.ts +247 -0
- package/dist/src/core/jcs.d.ts +52 -0
- package/dist/src/core/journal.d.ts +144 -0
- package/dist/src/core/live-draw.d.ts +436 -0
- package/dist/src/core/log-reconcile.d.ts +89 -0
- package/dist/src/core/log-subscribe.d.ts +36 -0
- package/dist/src/core/log-subscribe.js +162 -0
- package/dist/src/core/log-subscribe.js.map +1 -0
- package/dist/src/core/log.d.ts +278 -0
- package/dist/src/core/loop.d.ts +274 -0
- package/dist/src/core/loop.js +11 -0
- package/dist/src/core/loop.js.map +1 -1
- package/dist/src/core/md-fence.d.ts +41 -0
- package/dist/src/core/money.d.ts +147 -0
- package/dist/src/core/payload-census.d.ts +74 -0
- package/dist/src/core/payload-store.d.ts +175 -0
- package/dist/src/core/payload.d.ts +71 -0
- package/dist/src/core/policy-diff.d.ts +292 -0
- package/dist/src/core/policy-diff.js +27 -4
- package/dist/src/core/policy-diff.js.map +1 -1
- package/dist/src/core/policy-expectations.d.ts +199 -0
- package/dist/src/core/policy-explain.d.ts +150 -0
- package/dist/src/core/policy-explain.js +31 -3
- package/dist/src/core/policy-explain.js.map +1 -1
- package/dist/src/core/policy-load.d.ts +527 -0
- package/dist/src/core/policy-load.js +15 -3
- package/dist/src/core/policy-load.js.map +1 -1
- package/dist/src/core/policy-match.d.ts +281 -0
- package/dist/src/core/policy-match.js +20 -9
- package/dist/src/core/policy-match.js.map +1 -1
- package/dist/src/core/policy-proposal.d.ts +265 -0
- package/dist/src/core/prompt-layout.d.ts +221 -0
- package/dist/src/core/protected-path-guard.d.ts +453 -0
- package/dist/src/core/protected-path-guard.js +514 -35
- package/dist/src/core/protected-path-guard.js.map +1 -1
- package/dist/src/core/registration.d.ts +25 -0
- package/dist/src/core/reindex.d.ts +99 -0
- package/dist/src/core/sampler.d.ts +313 -0
- package/dist/src/core/sandbox.d.ts +290 -0
- package/dist/src/core/seal.d.ts +165 -0
- package/dist/src/core/state.d.ts +505 -0
- package/dist/src/core/task-file.d.ts +185 -0
- package/dist/src/core/telegram-config.d.ts +93 -0
- package/dist/src/core/token.d.ts +409 -0
- package/dist/src/core/token.js +21 -38
- package/dist/src/core/token.js.map +1 -1
- package/dist/src/core/validate.d.ts +138 -0
- package/dist/src/core/values.d.ts +137 -0
- package/dist/src/core/vault.d.ts +291 -0
- package/dist/src/core/verified-snapshot.d.ts +204 -0
- package/dist/src/core/verify.d.ts +336 -0
- package/dist/src/core/version.d.ts +8 -0
- package/dist/src/core/wysiwys.d.ts +370 -0
- package/dist/src/daemon/advance-child.d.ts +39 -0
- package/dist/src/daemon/advance.d.ts +466 -0
- package/dist/src/daemon/audit.d.ts +87 -0
- package/dist/src/daemon/daemon.d.ts +1180 -0
- package/dist/src/daemon/dark-session.d.ts +64 -0
- package/dist/src/daemon/draw-child.d.ts +36 -0
- package/dist/src/daemon/draw.d.ts +154 -0
- package/dist/src/daemon/git-evidence.d.ts +173 -0
- package/dist/src/daemon/git-evidence.js +1 -1
- package/dist/src/daemon/projection.d.ts +180 -0
- package/dist/src/daemon/prune.d.ts +207 -0
- package/dist/src/mcp/http.d.ts +113 -0
- package/dist/src/mcp/server.d.ts +265 -0
- package/dist/src/mcp/server.js +9 -1
- package/dist/src/mcp/server.js.map +1 -1
- package/docs/adapter-api.md +106 -0
- package/docs/cli-reference.md +389 -36
- package/docs/codex-enforced-session.md +30 -0
- package/package.json +12 -2
- package/schema/codex-instance.schema.json +82 -0
- package/schema/event.schema.json +2 -1
- package/schema/fixtures/codex-instance/invalid/unpinned-codex-version.json +40 -0
- package/schema/fixtures/codex-instance/valid/canonical.json +40 -0
- package/schema/policy.schema.json +21 -1
- package/templates/codex/README.md +9 -0
|
@@ -17,20 +17,21 @@
|
|
|
17
17
|
* party under oversight to report its own oversight, which SPEC.md §11 rules
|
|
18
18
|
* out on principle. So this one never talks to a session. It takes two commits,
|
|
19
19
|
* asks git which protected paths changed between them, and requires — for each
|
|
20
|
-
* one — evidence in the committed hash-chained log that a human
|
|
21
|
-
*
|
|
22
|
-
*
|
|
20
|
+
* one — exact evidence in the committed hash-chained log that either a human
|
|
21
|
+
* granted that edit or the runtime recorded that policy authorized its
|
|
22
|
+
* execution. A change with no evidence fails the pull request. Session wiring
|
|
23
|
+
* is not an input.
|
|
23
24
|
*
|
|
24
25
|
* ## What counts as evidence
|
|
25
26
|
*
|
|
26
27
|
* Evidence is about the CHANGE, not about the path (APRV-202). The guard reads
|
|
27
28
|
* the blob at both commits, reduces the difference to the lines this pull
|
|
28
|
-
* request adds and removes, and requires every one of them to trace back to
|
|
29
|
-
* bound material
|
|
30
|
-
* again today has
|
|
31
|
-
* that fails. Naming remains necessary; it stopped being sufficient.
|
|
29
|
+
* request adds and removes, and requires every one of them to trace back to
|
|
30
|
+
* bound material in an authorization record. A path authorized last Tuesday
|
|
31
|
+
* and edited again today has evidence naming it and none covering today's
|
|
32
|
+
* lines, and that fails. Naming remains necessary; it stopped being sufficient.
|
|
32
33
|
*
|
|
33
|
-
*
|
|
34
|
+
* Four verdicts pass:
|
|
34
35
|
*
|
|
35
36
|
* 1. `attested` — the policy file, and the gate's ORGANS. `approval policy
|
|
36
37
|
* amend --commit` appends `policy.updated` carrying `{policy_path, sha256}`,
|
|
@@ -52,7 +53,14 @@
|
|
|
52
53
|
* match: a digest attested for one organ is not evidence for another, which
|
|
53
54
|
* is why the organ record carries a whole relative path where the policy
|
|
54
55
|
* record carries a basename.
|
|
55
|
-
* 2. `
|
|
56
|
+
* 2. `policy-authorized-file` — an exact Edit or Write whose verified
|
|
57
|
+
* `execution.started` is preceded by its unique matching registration and no
|
|
58
|
+
* approval request. The registration, start and recomputed stored payload
|
|
59
|
+
* must agree on task, action, class and hash. The start must precede the
|
|
60
|
+
* change and the recorded class must be the class this path is routed to.
|
|
61
|
+
* This records authorization to execute, not successful completion; the
|
|
62
|
+
* exact hunk checks below establish whether those bytes landed.
|
|
63
|
+
* 3. `granted-file` — a file-tool edit. The hook binds the CHANGE rather than
|
|
56
64
|
* the touch (APRV-124), so the bound material carries `file` plus the exact
|
|
57
65
|
* edit: `{before, after}` for an Edit, `{content}` for a Write. That is
|
|
58
66
|
* HUNK-level evidence, and it is used as such. The granted `after` bytes
|
|
@@ -62,7 +70,7 @@
|
|
|
62
70
|
* in head is a grant for something that did not land, and covers nothing.
|
|
63
71
|
* Some payloads carry the `{input}` fallback shape instead, which describes
|
|
64
72
|
* no bytes; those name the path and cover nothing.
|
|
65
|
-
*
|
|
73
|
+
* 4. `granted-command` — a shell edit. The bound material is `{command, cwd}`
|
|
66
74
|
* or `{argv, cwd}`, and the guard re-runs the runtime's own
|
|
67
75
|
* {@link classifyCommand} over it, requiring a segment that classifies as a
|
|
68
76
|
* granting class BECAUSE of a word naming this path. A mention is not a
|
|
@@ -191,7 +199,9 @@
|
|
|
191
199
|
* git plumbing and file reads live in the caller.
|
|
192
200
|
*/
|
|
193
201
|
import { organAttestationOf } from "./attest.js";
|
|
194
|
-
import {
|
|
202
|
+
import { parseApplyPatch } from "./apply-patch.js";
|
|
203
|
+
import { classifyCommand, isGateOrganPath, isProtectedPath, normalizePathSpelling, POLICY_EDIT_SUBCLASS, protectedPathClass, } from "./command-class.js";
|
|
204
|
+
import { payloadHash } from "./payload.js";
|
|
195
205
|
/**
|
|
196
206
|
* Classes whose grant authorizes a protected-path write.
|
|
197
207
|
*
|
|
@@ -288,6 +298,10 @@ export const DEFAULT_LOOKBACK_MS = 7 * 24 * 60 * 60 * 1000;
|
|
|
288
298
|
* fresh approval, which costs one tap.
|
|
289
299
|
*/
|
|
290
300
|
export const DEFAULT_COMMAND_ATTRIBUTION_MS = 6 * 60 * 60 * 1000;
|
|
301
|
+
/** Fail-closed resource bounds for exact protected-edit reconstruction. */
|
|
302
|
+
export const EXACT_REPLAY_MAX_CANDIDATES = 128;
|
|
303
|
+
export const EXACT_REPLAY_MAX_STATES = 2_048;
|
|
304
|
+
export const EXACT_REPLAY_MAX_EXAMINED_BYTES = 64 * 1024 * 1024;
|
|
291
305
|
/** Split on either separator, dropping empties and `.` noise. */
|
|
292
306
|
function segmentsOf(candidate) {
|
|
293
307
|
return candidate.split(/[/\\]+/u).filter((part) => part.length > 0 && part !== ".");
|
|
@@ -413,6 +427,31 @@ function evidenceFor(material, path, policyProtectedPaths) {
|
|
|
413
427
|
if (typeof material !== "object" || material === null || Array.isArray(material))
|
|
414
428
|
return null;
|
|
415
429
|
const map = material;
|
|
430
|
+
// Codex apply_patch is tagged explicitly. Parse it before the generic
|
|
431
|
+
// command fallback so patch text is never mistaken for a shell command and
|
|
432
|
+
// never receives time-based command attribution. Until a patch-to-blob
|
|
433
|
+
// proof exists, this is deliberately naming-only evidence and covers no
|
|
434
|
+
// bytes; it remains useful diagnosis without weakening the guard.
|
|
435
|
+
if (map["tool"] === "apply_patch" && typeof map["command"] === "string") {
|
|
436
|
+
const parsed = parseApplyPatch(map["command"]);
|
|
437
|
+
if (parsed.ok) {
|
|
438
|
+
const operation = parsed.operations.find((candidate) => endsWithSegments(candidate.path, path) ||
|
|
439
|
+
(candidate.kind === "update" &&
|
|
440
|
+
candidate.moveTo !== undefined &&
|
|
441
|
+
endsWithSegments(candidate.moveTo, path)));
|
|
442
|
+
if (operation !== undefined) {
|
|
443
|
+
return {
|
|
444
|
+
kind: "granted-file",
|
|
445
|
+
detail: `the granted material is a Codex apply_patch operation naming ${path}; patch bytes are not yet guard coverage`,
|
|
446
|
+
after: null,
|
|
447
|
+
before: null,
|
|
448
|
+
whole: false,
|
|
449
|
+
command: false,
|
|
450
|
+
};
|
|
451
|
+
}
|
|
452
|
+
}
|
|
453
|
+
return null;
|
|
454
|
+
}
|
|
416
455
|
const file = map["file"];
|
|
417
456
|
if (typeof file === "string" && endsWithSegments(file, path)) {
|
|
418
457
|
const tool = typeof map["tool"] === "string" ? map["tool"] : "a file tool";
|
|
@@ -476,6 +515,396 @@ function evidenceFor(material, path, policyProtectedPaths) {
|
|
|
476
515
|
}
|
|
477
516
|
return null;
|
|
478
517
|
}
|
|
518
|
+
/** Every declaration of this action key that existed before the start. */
|
|
519
|
+
function precedingBindings(records, start) {
|
|
520
|
+
const actionKey = start.action_key;
|
|
521
|
+
if (actionKey === undefined)
|
|
522
|
+
return [];
|
|
523
|
+
const found = [];
|
|
524
|
+
for (const record of records) {
|
|
525
|
+
if (record.seq >= start.seq || record.event !== "task.registered")
|
|
526
|
+
continue;
|
|
527
|
+
const actions = payloadOf(record)["actions"];
|
|
528
|
+
if (!Array.isArray(actions))
|
|
529
|
+
continue;
|
|
530
|
+
for (const raw of actions) {
|
|
531
|
+
if (typeof raw !== "object" || raw === null || Array.isArray(raw))
|
|
532
|
+
continue;
|
|
533
|
+
const action = raw;
|
|
534
|
+
if (action["idempotency_key"] !== actionKey)
|
|
535
|
+
continue;
|
|
536
|
+
const cls = action["class"];
|
|
537
|
+
const hash = action["payload_hash"];
|
|
538
|
+
// Count malformed declarations too. A malformed entry sharing the key is
|
|
539
|
+
// ambiguity, never something a later well-formed entry can hide.
|
|
540
|
+
found.push({
|
|
541
|
+
record,
|
|
542
|
+
task: record.task ?? null,
|
|
543
|
+
actionKey,
|
|
544
|
+
cls: typeof cls === "string" ? cls : null,
|
|
545
|
+
payloadHash: typeof hash === "string" ? hash : null,
|
|
546
|
+
});
|
|
547
|
+
}
|
|
548
|
+
}
|
|
549
|
+
return found;
|
|
550
|
+
}
|
|
551
|
+
/** Exact file bytes, excluding fallback, command and apply_patch material. */
|
|
552
|
+
function policyFileEvidence(material, path, policyProtectedPaths) {
|
|
553
|
+
if (typeof material !== "object" || material === null || Array.isArray(material))
|
|
554
|
+
return null;
|
|
555
|
+
const map = material;
|
|
556
|
+
const tool = map["tool"];
|
|
557
|
+
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("/"))
|
|
564
|
+
return null;
|
|
565
|
+
const keys = Object.keys(map);
|
|
566
|
+
const hasOnly = (allowed) => keys.every((key) => allowed.includes(key));
|
|
567
|
+
if (tool === "Edit") {
|
|
568
|
+
if (!hasOnly(["tool", "rule", "file", "before", "after", "replace_all"]) ||
|
|
569
|
+
(map["rule"] !== undefined && typeof map["rule"] !== "string") ||
|
|
570
|
+
typeof map["before"] !== "string" ||
|
|
571
|
+
typeof map["after"] !== "string" ||
|
|
572
|
+
(map["replace_all"] !== undefined && typeof map["replace_all"] !== "boolean"))
|
|
573
|
+
return null;
|
|
574
|
+
}
|
|
575
|
+
else if (tool === "Write") {
|
|
576
|
+
if (!hasOnly(["tool", "rule", "file", "content"]) ||
|
|
577
|
+
(map["rule"] !== undefined && typeof map["rule"] !== "string") ||
|
|
578
|
+
typeof map["content"] !== "string")
|
|
579
|
+
return null;
|
|
580
|
+
}
|
|
581
|
+
else {
|
|
582
|
+
return null;
|
|
583
|
+
}
|
|
584
|
+
const match = evidenceFor(material, path, policyProtectedPaths);
|
|
585
|
+
if (match === null || match.command || (match.after === null && match.before === null))
|
|
586
|
+
return null;
|
|
587
|
+
return {
|
|
588
|
+
...match,
|
|
589
|
+
kind: "policy-authorized-file",
|
|
590
|
+
detail: match.detail.replace(/^the granted material is/u, "the policy-authorized material is"),
|
|
591
|
+
};
|
|
592
|
+
}
|
|
593
|
+
/** Differing applicable routes in an unlabeled policy union carry no single class. */
|
|
594
|
+
function hasAmbiguousRoute(path, policyProtectedPaths) {
|
|
595
|
+
const candidate = segmentsOf(path);
|
|
596
|
+
const classes = new Set();
|
|
597
|
+
for (const entry of policyProtectedPaths) {
|
|
598
|
+
const raw = typeof entry === "string" ? entry : entry.path;
|
|
599
|
+
const trimmed = raw.trim();
|
|
600
|
+
if (trimmed.length === 0 || trimmed.startsWith("/"))
|
|
601
|
+
continue;
|
|
602
|
+
const wanted = segmentsOf(trimmed);
|
|
603
|
+
if (wanted.includes("..") || wanted.length === 0 || wanted.length > candidate.length)
|
|
604
|
+
continue;
|
|
605
|
+
const directory = /[/\\]$/u.test(trimmed);
|
|
606
|
+
const matches = directory
|
|
607
|
+
? candidate.some((_, start) => start + wanted.length <= candidate.length &&
|
|
608
|
+
wanted.every((segment, offset) => candidate[start + offset] === segment))
|
|
609
|
+
: wanted.every((segment, offset) => candidate[candidate.length - wanted.length + offset] === segment);
|
|
610
|
+
if (matches)
|
|
611
|
+
classes.add(typeof entry === "string" ? "policy.edit" : entry.class);
|
|
612
|
+
}
|
|
613
|
+
return classes.size > 1;
|
|
614
|
+
}
|
|
615
|
+
/**
|
|
616
|
+
* Exact-file evidence from an unattended execution authorized by policy.
|
|
617
|
+
*
|
|
618
|
+
* The start must reproduce the one declaration that preceded it, and the
|
|
619
|
+
* committed payload must hash back to that declaration. A prior request means
|
|
620
|
+
* the action entered the human-decision path, so this tier cannot carry it.
|
|
621
|
+
*/
|
|
622
|
+
function policyAuthorizedEvidence(records, start, path, policyProtectedPaths, payloadFor, anchorMs, lookbackMs) {
|
|
623
|
+
if (start.event !== "execution.started")
|
|
624
|
+
return null;
|
|
625
|
+
const task = start.task;
|
|
626
|
+
const actionKey = start.action_key;
|
|
627
|
+
if (task === undefined || actionKey === undefined)
|
|
628
|
+
return null;
|
|
629
|
+
const started = payloadOf(start);
|
|
630
|
+
const harnessStart = started["execution"] === "harness";
|
|
631
|
+
const coreStart = !Object.hasOwn(started, "execution");
|
|
632
|
+
if ((!harnessStart && !coreStart) ||
|
|
633
|
+
Object.hasOwn(started, "grant_seq") ||
|
|
634
|
+
Object.hasOwn(started, "token_sha256"))
|
|
635
|
+
return null;
|
|
636
|
+
const cls = started["class"];
|
|
637
|
+
const hash = started["payload_hash"];
|
|
638
|
+
if (typeof cls !== "string" || typeof hash !== "string")
|
|
639
|
+
return null;
|
|
640
|
+
// The exact routed class is authority. Merely living in policy.edit.* is not.
|
|
641
|
+
if (hasAmbiguousRoute(path, policyProtectedPaths))
|
|
642
|
+
return null;
|
|
643
|
+
const routed = protectedPathClass(path, policyProtectedPaths);
|
|
644
|
+
if (routed === null || routed === "policy.core" || routed === "log.mutate" || cls !== routed) {
|
|
645
|
+
return null;
|
|
646
|
+
}
|
|
647
|
+
const bindings = precedingBindings(records, start);
|
|
648
|
+
if (bindings.length !== 1)
|
|
649
|
+
return null;
|
|
650
|
+
const binding = bindings[0];
|
|
651
|
+
if (binding.task !== task ||
|
|
652
|
+
binding.actionKey !== actionKey ||
|
|
653
|
+
binding.cls !== cls ||
|
|
654
|
+
binding.payloadHash !== hash)
|
|
655
|
+
return null;
|
|
656
|
+
if (records.some((record) => record.seq < start.seq &&
|
|
657
|
+
record.action_key === actionKey &&
|
|
658
|
+
record.event === "approval.requested"))
|
|
659
|
+
return null;
|
|
660
|
+
// A start cannot authorize a change that already happened. This tier also
|
|
661
|
+
// requires a usable commit timestamp; missing temporal evidence fails closed.
|
|
662
|
+
if (anchorMs === null)
|
|
663
|
+
return null;
|
|
664
|
+
const at = Date.parse(start.ts);
|
|
665
|
+
if (Number.isNaN(at) || at > anchorMs || anchorMs - at > lookbackMs)
|
|
666
|
+
return null;
|
|
667
|
+
const material = payloadFor(hash);
|
|
668
|
+
if (material === null)
|
|
669
|
+
return null;
|
|
670
|
+
try {
|
|
671
|
+
if (payloadHash(material) !== hash)
|
|
672
|
+
return null;
|
|
673
|
+
}
|
|
674
|
+
catch {
|
|
675
|
+
return null;
|
|
676
|
+
}
|
|
677
|
+
return policyFileEvidence(material, path, policyProtectedPaths);
|
|
678
|
+
}
|
|
679
|
+
/** The closed, repository-relative Edit shape eligible for byte replay. */
|
|
680
|
+
function exactReplayEdit(material, path) {
|
|
681
|
+
if (typeof material !== "object" || material === null || Array.isArray(material))
|
|
682
|
+
return null;
|
|
683
|
+
const map = material;
|
|
684
|
+
const keys = Object.keys(map);
|
|
685
|
+
const allowed = ["tool", "rule", "file", "before", "after", "replace_all"];
|
|
686
|
+
if (keys.some((key) => !allowed.includes(key)))
|
|
687
|
+
return null;
|
|
688
|
+
if (map["tool"] !== "Edit" ||
|
|
689
|
+
typeof map["file"] !== "string" ||
|
|
690
|
+
map["file"].startsWith("/") ||
|
|
691
|
+
map["file"].includes("\\") ||
|
|
692
|
+
(map["rule"] !== undefined && typeof map["rule"] !== "string") ||
|
|
693
|
+
typeof map["before"] !== "string" ||
|
|
694
|
+
map["before"].length === 0 ||
|
|
695
|
+
typeof map["after"] !== "string" ||
|
|
696
|
+
map["before"] === map["after"] ||
|
|
697
|
+
(map["replace_all"] !== undefined && map["replace_all"] !== false))
|
|
698
|
+
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
|
+
return { before: map["before"], after: map["after"] };
|
|
706
|
+
}
|
|
707
|
+
/** The one token or harness spend that proves this manual grant actually ran. */
|
|
708
|
+
function startForReplayGrant(candidate, records, anchorMs, lookbackMs, path, policyProtectedPaths) {
|
|
709
|
+
if (anchorMs === null)
|
|
710
|
+
return null;
|
|
711
|
+
const grant = candidate.record;
|
|
712
|
+
const task = grant.task;
|
|
713
|
+
const actionKey = grant.action_key;
|
|
714
|
+
if (task === undefined || actionKey === undefined)
|
|
715
|
+
return null;
|
|
716
|
+
const granted = payloadOf(grant);
|
|
717
|
+
const cls = granted["class"];
|
|
718
|
+
const hash = granted["payload_hash"];
|
|
719
|
+
if (typeof cls !== "string" || typeof hash !== "string" || hash !== candidate.payloadHash)
|
|
720
|
+
return null;
|
|
721
|
+
if (hasAmbiguousRoute(path, policyProtectedPaths))
|
|
722
|
+
return null;
|
|
723
|
+
const routed = protectedPathClass(path, policyProtectedPaths);
|
|
724
|
+
if (routed === null || routed === "policy.core" || routed === "log.mutate" || cls !== routed) {
|
|
725
|
+
return null;
|
|
726
|
+
}
|
|
727
|
+
try {
|
|
728
|
+
if (payloadHash(candidate.material) !== hash)
|
|
729
|
+
return null;
|
|
730
|
+
}
|
|
731
|
+
catch {
|
|
732
|
+
return null;
|
|
733
|
+
}
|
|
734
|
+
const grantToken = granted["token_sha256"];
|
|
735
|
+
const starts = records.filter((record) => {
|
|
736
|
+
if (record.event !== "execution.started" ||
|
|
737
|
+
record.seq <= grant.seq ||
|
|
738
|
+
record.task !== task ||
|
|
739
|
+
record.action_key !== actionKey)
|
|
740
|
+
return false;
|
|
741
|
+
const started = payloadOf(record);
|
|
742
|
+
if (started["class"] !== cls || started["payload_hash"] !== hash)
|
|
743
|
+
return false;
|
|
744
|
+
if (Object.hasOwn(started, "execution") && started["execution"] !== "harness")
|
|
745
|
+
return false;
|
|
746
|
+
const hasGrantLink = Object.hasOwn(started, "grant_seq");
|
|
747
|
+
const hasTokenLink = Object.hasOwn(started, "token_sha256");
|
|
748
|
+
if (!hasGrantLink && !hasTokenLink)
|
|
749
|
+
return false;
|
|
750
|
+
if (hasGrantLink && started["grant_seq"] !== grant.seq)
|
|
751
|
+
return false;
|
|
752
|
+
if (hasTokenLink &&
|
|
753
|
+
(typeof grantToken !== "string" || started["token_sha256"] !== grantToken))
|
|
754
|
+
return false;
|
|
755
|
+
const at = Date.parse(record.ts);
|
|
756
|
+
return !Number.isNaN(at) && at <= anchorMs && anchorMs - at <= lookbackMs;
|
|
757
|
+
});
|
|
758
|
+
if (starts.length !== 1)
|
|
759
|
+
return null;
|
|
760
|
+
const start = starts[0];
|
|
761
|
+
const bindings = precedingBindings(records, start);
|
|
762
|
+
if (bindings.length !== 1)
|
|
763
|
+
return null;
|
|
764
|
+
const binding = bindings[0];
|
|
765
|
+
if (binding.record.seq >= grant.seq ||
|
|
766
|
+
binding.task !== task ||
|
|
767
|
+
binding.actionKey !== actionKey ||
|
|
768
|
+
binding.cls !== cls ||
|
|
769
|
+
binding.payloadHash !== hash)
|
|
770
|
+
return null;
|
|
771
|
+
const requested = records.some((record) => {
|
|
772
|
+
if (record.event !== "approval.requested" ||
|
|
773
|
+
record.seq <= binding.record.seq ||
|
|
774
|
+
record.seq >= grant.seq ||
|
|
775
|
+
record.task !== task ||
|
|
776
|
+
record.action_key !== actionKey)
|
|
777
|
+
return false;
|
|
778
|
+
const payload = payloadOf(record);
|
|
779
|
+
return payload["class"] === cls && payload["payload_hash"] === hash;
|
|
780
|
+
});
|
|
781
|
+
return requested ? start : null;
|
|
782
|
+
}
|
|
783
|
+
/**
|
|
784
|
+
* Replay authorized exact edits in execution order and demand byte equality.
|
|
785
|
+
*
|
|
786
|
+
* The log may contain older edits whose anchors survive in BASE but which did
|
|
787
|
+
* not produce this change. Search deterministic ordered subsequences: skip a
|
|
788
|
+
* candidate first, and apply it only when its anchor occurs exactly once in the
|
|
789
|
+
* current state. An ambiguous candidate cannot be applied, but it does not make
|
|
790
|
+
* an independent later proof ambiguous. Only a non-empty sequence that turns
|
|
791
|
+
* BASE into HEAD byte for byte is evidence; there is no reordering, substring
|
|
792
|
+
* credit, or partial result.
|
|
793
|
+
*
|
|
794
|
+
* Search is bounded by candidate count, distinct states, and cumulative bytes
|
|
795
|
+
* examined. Crossing any bound refuses the fallback rather than pruning a
|
|
796
|
+
* potentially valid branch and claiming the remaining search was complete.
|
|
797
|
+
*/
|
|
798
|
+
function exactEditReplay(candidates, records, base, head, anchorMs, lookbackMs, path, policyProtectedPaths) {
|
|
799
|
+
if (base === null ||
|
|
800
|
+
head === null ||
|
|
801
|
+
base.includes("\uFFFD") ||
|
|
802
|
+
head.includes("\uFFFD"))
|
|
803
|
+
return { steps: null, bound: null };
|
|
804
|
+
const gathered = [];
|
|
805
|
+
for (const candidate of candidates) {
|
|
806
|
+
try {
|
|
807
|
+
if (payloadHash(candidate.material) !== candidate.payloadHash)
|
|
808
|
+
continue;
|
|
809
|
+
}
|
|
810
|
+
catch {
|
|
811
|
+
continue;
|
|
812
|
+
}
|
|
813
|
+
const edit = exactReplayEdit(candidate.material, path);
|
|
814
|
+
if (edit === null)
|
|
815
|
+
continue;
|
|
816
|
+
const start = candidate.source === "policy"
|
|
817
|
+
? candidate.record
|
|
818
|
+
: startForReplayGrant(candidate, records, anchorMs, lookbackMs, path, policyProtectedPaths);
|
|
819
|
+
if (start !== null)
|
|
820
|
+
gathered.push({ candidate, start, ...edit });
|
|
821
|
+
}
|
|
822
|
+
gathered.sort((left, right) => left.start.seq - right.start.seq);
|
|
823
|
+
// A verified execution record can authorize at most one application. Collapse
|
|
824
|
+
// duplicate evidence views of the same binding. If two views give one start
|
|
825
|
+
// conflicting edit bytes, neither representation of that start is usable.
|
|
826
|
+
const byStart = new Map();
|
|
827
|
+
const conflictingStarts = new Set();
|
|
828
|
+
for (const step of gathered) {
|
|
829
|
+
const prior = byStart.get(step.start.seq);
|
|
830
|
+
if (prior === undefined) {
|
|
831
|
+
byStart.set(step.start.seq, step);
|
|
832
|
+
continue;
|
|
833
|
+
}
|
|
834
|
+
if (prior.before !== step.before || prior.after !== step.after) {
|
|
835
|
+
conflictingStarts.add(step.start.seq);
|
|
836
|
+
}
|
|
837
|
+
}
|
|
838
|
+
const steps = [...byStart.values()].filter((step) => !conflictingStarts.has(step.start.seq));
|
|
839
|
+
if (steps.length > EXACT_REPLAY_MAX_CANDIDATES) {
|
|
840
|
+
return { steps: null, bound: "candidate-limit" };
|
|
841
|
+
}
|
|
842
|
+
const headBytes = Buffer.byteLength(head, "utf8");
|
|
843
|
+
let examinedBytes = Buffer.byteLength(base, "utf8") + headBytes;
|
|
844
|
+
if (examinedBytes > EXACT_REPLAY_MAX_EXAMINED_BYTES) {
|
|
845
|
+
return { steps: null, bound: "byte-limit" };
|
|
846
|
+
}
|
|
847
|
+
let visitedStates = 0;
|
|
848
|
+
let exceeded = null;
|
|
849
|
+
// State equality is exact string equality, not a digest or lossy cache key.
|
|
850
|
+
// Empty/non-empty histories stay distinct because an empty replay is never
|
|
851
|
+
// evidence even when BASE already equals HEAD.
|
|
852
|
+
const visited = Array.from({ length: steps.length + 1 }, () => [new Set(), new Set()]);
|
|
853
|
+
const search = (index, replayed, applied) => {
|
|
854
|
+
if (exceeded !== null)
|
|
855
|
+
return null;
|
|
856
|
+
const replayedBytes = Buffer.byteLength(replayed, "utf8");
|
|
857
|
+
// Charge exact-state hashing/equality and the HEAD comparison before either
|
|
858
|
+
// can retain or accept this state.
|
|
859
|
+
const stateBytes = replayedBytes + headBytes;
|
|
860
|
+
if (examinedBytes + stateBytes > EXACT_REPLAY_MAX_EXAMINED_BYTES) {
|
|
861
|
+
exceeded = "byte-limit";
|
|
862
|
+
return null;
|
|
863
|
+
}
|
|
864
|
+
examinedBytes += stateBytes;
|
|
865
|
+
const seen = visited[index]?.[applied.length === 0 ? 0 : 1];
|
|
866
|
+
if (seen === undefined || seen.has(replayed))
|
|
867
|
+
return null;
|
|
868
|
+
seen.add(replayed);
|
|
869
|
+
visitedStates += 1;
|
|
870
|
+
if (visitedStates > EXACT_REPLAY_MAX_STATES) {
|
|
871
|
+
exceeded = "state-limit";
|
|
872
|
+
return null;
|
|
873
|
+
}
|
|
874
|
+
if (applied.length > 0 && replayed === head)
|
|
875
|
+
return [...applied];
|
|
876
|
+
if (index === steps.length)
|
|
877
|
+
return null;
|
|
878
|
+
// Deterministic skip-first DFS finds a late witness quickly and
|
|
879
|
+
// prevents irrelevant historical edits from polluting a valid proof.
|
|
880
|
+
const skipped = search(index + 1, replayed, applied);
|
|
881
|
+
if (skipped !== null || exceeded !== null)
|
|
882
|
+
return skipped;
|
|
883
|
+
const step = steps[index];
|
|
884
|
+
const scanBytes = replayedBytes * 2 +
|
|
885
|
+
Buffer.byteLength(step.before, "utf8");
|
|
886
|
+
if (examinedBytes + scanBytes > EXACT_REPLAY_MAX_EXAMINED_BYTES) {
|
|
887
|
+
exceeded = "byte-limit";
|
|
888
|
+
return null;
|
|
889
|
+
}
|
|
890
|
+
examinedBytes += scanBytes;
|
|
891
|
+
const first = replayed.indexOf(step.before);
|
|
892
|
+
if (first === -1 || replayed.indexOf(step.before, first + 1) !== -1)
|
|
893
|
+
return null;
|
|
894
|
+
// Bound the next allocation before constructing it. `replayed + after` is
|
|
895
|
+
// a conservative upper bound because the replacement removes `before`.
|
|
896
|
+
const prospectiveBytes = replayedBytes + Buffer.byteLength(step.after, "utf8");
|
|
897
|
+
if (examinedBytes + prospectiveBytes > EXACT_REPLAY_MAX_EXAMINED_BYTES) {
|
|
898
|
+
exceeded = "byte-limit";
|
|
899
|
+
return null;
|
|
900
|
+
}
|
|
901
|
+
examinedBytes += prospectiveBytes;
|
|
902
|
+
const next = `${replayed.slice(0, first)}${step.after}${replayed.slice(first + step.before.length)}`;
|
|
903
|
+
return search(index + 1, next, [...applied, step]);
|
|
904
|
+
};
|
|
905
|
+
const proof = search(0, base, []);
|
|
906
|
+
return { steps: proof, bound: exceeded };
|
|
907
|
+
}
|
|
479
908
|
/** A blob's lines, with the empty tail a trailing newline leaves dropped. */
|
|
480
909
|
function linesOf(text) {
|
|
481
910
|
const parts = text.split("\n");
|
|
@@ -667,7 +1096,7 @@ function attributeRun(grant, runs, anchorMs, attributionMs) {
|
|
|
667
1096
|
* spelled: two different (path, digest) pairs cannot collide into one key.
|
|
668
1097
|
*/
|
|
669
1098
|
function organKey(organPath, sha256) {
|
|
670
|
-
return `${normalizePathSpelling(organPath)}
|
|
1099
|
+
return `${normalizePathSpelling(organPath)}\0${sha256}`;
|
|
671
1100
|
}
|
|
672
1101
|
/** The ordering rule, stated identically on every failure that could be lag. */
|
|
673
1102
|
const ORDERING_RULE = "the committed log trails the primary checkout's live log, so if this edit WAS granted, " +
|
|
@@ -844,8 +1273,21 @@ export function evaluateProtectedPaths(input) {
|
|
|
844
1273
|
stale.push(grant);
|
|
845
1274
|
continue;
|
|
846
1275
|
}
|
|
847
|
-
candidates.push({ grant, match: found });
|
|
1276
|
+
candidates.push({ record: grant, match: found, source: "grant", material, payloadHash: hash });
|
|
1277
|
+
}
|
|
1278
|
+
for (const start of records) {
|
|
1279
|
+
const found = policyAuthorizedEvidence(records, start, path, input.policyProtectedPaths, input.payloadFor, anchorMs, lookbackMs);
|
|
1280
|
+
const hash = payloadOf(start)["payload_hash"];
|
|
1281
|
+
if (found !== null && typeof hash === "string") {
|
|
1282
|
+
const material = input.payloadFor(hash);
|
|
1283
|
+
if (material !== null) {
|
|
1284
|
+
candidates.push({ record: start, match: found, source: "policy", material, payloadHash: hash });
|
|
1285
|
+
}
|
|
1286
|
+
}
|
|
848
1287
|
}
|
|
1288
|
+
const evidenceNoun = candidates.every((candidate) => candidate.source === "grant")
|
|
1289
|
+
? `grant${candidates.length === 1 ? "" : "s"}`
|
|
1290
|
+
: `evidence record${candidates.length === 1 ? "" : "s"}`;
|
|
849
1291
|
/** Distance from the change commit, or 0 when there is no anchor to measure from. */
|
|
850
1292
|
const distance = (grant) => {
|
|
851
1293
|
if (anchorMs === null)
|
|
@@ -853,9 +1295,9 @@ export function evaluateProtectedPaths(input) {
|
|
|
853
1295
|
const at = Date.parse(grant.ts);
|
|
854
1296
|
return Number.isNaN(at) ? Number.POSITIVE_INFINITY : Math.abs(at - anchorMs);
|
|
855
1297
|
};
|
|
856
|
-
const rank = (kind) =>
|
|
1298
|
+
const rank = (kind) => kind === "granted-file" ? 0 : kind === "policy-authorized-file" ? 1 : 2;
|
|
857
1299
|
candidates.sort((left, right) => rank(left.match.kind) - rank(right.match.kind) ||
|
|
858
|
-
distance(left.
|
|
1300
|
+
distance(left.record) - distance(right.record));
|
|
859
1301
|
// The change this pull request makes to the path. Read before any coverage
|
|
860
1302
|
// is attempted, and a failure to read it is a failure: a change the guard
|
|
861
1303
|
// cannot see is not a change it has checked (APRV-202).
|
|
@@ -865,7 +1307,7 @@ export function evaluateProtectedPaths(input) {
|
|
|
865
1307
|
path,
|
|
866
1308
|
ok: false,
|
|
867
1309
|
code: "change-unreadable",
|
|
868
|
-
detail: `${path} is a protected path and changed between ${input.window.base} and ${input.window.head}, and its bytes could not be read at both commits (git could not show the blob, or it is binary), so no
|
|
1310
|
+
detail: `${path} is a protected path and changed between ${input.window.base} and ${input.window.head}, and its bytes could not be read at both commits (git could not show the blob, or it is binary), so no evidence could be checked against the change. ${candidates.length} ${evidenceNoun} name this path in the window, and naming is not coverage. ${ORDERING_RULE}.`,
|
|
869
1311
|
});
|
|
870
1312
|
continue;
|
|
871
1313
|
}
|
|
@@ -881,14 +1323,21 @@ export function evaluateProtectedPaths(input) {
|
|
|
881
1323
|
const removedCover = new Set();
|
|
882
1324
|
let whole = false;
|
|
883
1325
|
for (const candidate of candidates) {
|
|
884
|
-
const {
|
|
885
|
-
|
|
1326
|
+
const { record, match, source } = candidate;
|
|
1327
|
+
// An execution start says an exact file action was authorized, not that a
|
|
1328
|
+
// mode-only or whitespace-only change happened. Legacy human grants retain
|
|
1329
|
+
// their narrow no-substantive-hunk behavior; policy starts do not gain it.
|
|
1330
|
+
if (hunks.identical && source === "policy")
|
|
1331
|
+
continue;
|
|
1332
|
+
const at = source === "grant"
|
|
1333
|
+
? `the grant at seq ${record.seq}`
|
|
1334
|
+
: `the policy-authorized execution at seq ${record.seq}`;
|
|
886
1335
|
if (match.command) {
|
|
887
1336
|
if (!commandTargetsPath(match.target ?? "", match.cwd, path)) {
|
|
888
1337
|
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`);
|
|
889
1338
|
continue;
|
|
890
1339
|
}
|
|
891
|
-
const run = attributeRun(
|
|
1340
|
+
const run = attributeRun(record, runs, anchorMs, attributionMs);
|
|
892
1341
|
if (!run.ok) {
|
|
893
1342
|
rejected.push(run.why);
|
|
894
1343
|
continue;
|
|
@@ -896,7 +1345,7 @@ export function evaluateProtectedPaths(input) {
|
|
|
896
1345
|
// A command payload describes no bytes, so attribution is all or
|
|
897
1346
|
// nothing: the run that wrote this file wrote whatever is in it.
|
|
898
1347
|
whole = true;
|
|
899
|
-
contributors.push({
|
|
1348
|
+
contributors.push({ record, kind: match.kind, why: `${match.detail}, and ${run.detail}`, whole: true, source });
|
|
900
1349
|
continue;
|
|
901
1350
|
}
|
|
902
1351
|
if (match.after === null && match.before === null) {
|
|
@@ -947,8 +1396,34 @@ export function evaluateProtectedPaths(input) {
|
|
|
947
1396
|
if (parts.length > 0)
|
|
948
1397
|
contributed = `${match.detail}, and ${parts.join(" and ")}`;
|
|
949
1398
|
}
|
|
950
|
-
if (contributed !== null)
|
|
951
|
-
contributors.push({
|
|
1399
|
+
if (contributed !== null) {
|
|
1400
|
+
contributors.push({ record, kind: match.kind, why: contributed, whole: wholeHere, source });
|
|
1401
|
+
}
|
|
1402
|
+
}
|
|
1403
|
+
// Exact Edit payloads may bind fragments within a line, including several
|
|
1404
|
+
// independent fragments of the same long line. Whole-line set membership
|
|
1405
|
+
// cannot express that safely. Replay is the bounded fallback: genuine
|
|
1406
|
+
// starts, execution order, unique anchors, and final byte equality.
|
|
1407
|
+
const needsReplay = !whole &&
|
|
1408
|
+
!hunks.identical &&
|
|
1409
|
+
(hunks.reordered ||
|
|
1410
|
+
hunks.added.some((line) => !addedCover.has(line)) ||
|
|
1411
|
+
hunks.removed.some((line) => !removedCover.has(line)));
|
|
1412
|
+
const replay = needsReplay
|
|
1413
|
+
? exactEditReplay(candidates, records, blobs.base, blobs.head, anchorMs, lookbackMs, path, input.policyProtectedPaths)
|
|
1414
|
+
: { steps: null, bound: null };
|
|
1415
|
+
if (replay.bound !== null) {
|
|
1416
|
+
rejected.unshift(`exact BASE-to-HEAD replay refused after reaching its ${replay.bound.replace("-", " ")}`);
|
|
1417
|
+
}
|
|
1418
|
+
if (replay.steps !== null) {
|
|
1419
|
+
whole = true;
|
|
1420
|
+
contributors.splice(0, contributors.length, ...replay.steps.map((step) => ({
|
|
1421
|
+
record: step.candidate.record,
|
|
1422
|
+
kind: step.candidate.match.kind,
|
|
1423
|
+
why: `${step.candidate.match.detail}, applied at execution.started seq ${step.start.seq} in an exact BASE-to-HEAD replay`,
|
|
1424
|
+
whole: true,
|
|
1425
|
+
source: step.candidate.source,
|
|
1426
|
+
})));
|
|
952
1427
|
}
|
|
953
1428
|
// What the bytes alone cover, before any whole-file attribution. If this is
|
|
954
1429
|
// non-empty the change RESTS on the attribution, and the finding has to
|
|
@@ -976,12 +1451,14 @@ export function evaluateProtectedPaths(input) {
|
|
|
976
1451
|
const lead = restsOnAttribution
|
|
977
1452
|
? (contributors.find((one) => one.whole) ?? contributors[0])
|
|
978
1453
|
: contributors[0];
|
|
979
|
-
const quiet = hunks.identical
|
|
1454
|
+
const quiet = hunks.identical
|
|
1455
|
+
? candidates.find((candidate) => candidate.source === "grant")
|
|
1456
|
+
: undefined;
|
|
980
1457
|
const passing = lead !== undefined
|
|
981
|
-
? { record: lead.
|
|
1458
|
+
? { record: lead.record, kind: lead.kind, why: lead.why }
|
|
982
1459
|
: quiet !== undefined
|
|
983
1460
|
? {
|
|
984
|
-
record: quiet.
|
|
1461
|
+
record: quiet.record,
|
|
985
1462
|
kind: quiet.match.kind,
|
|
986
1463
|
why: `${quiet.match.detail}, and this change alters no substantive line (whitespace, mode or metadata only), so there is no hunk to cover`,
|
|
987
1464
|
}
|
|
@@ -997,14 +1474,16 @@ export function evaluateProtectedPaths(input) {
|
|
|
997
1474
|
seq: record.seq,
|
|
998
1475
|
ts: record.ts,
|
|
999
1476
|
actor: record.actor,
|
|
1000
|
-
coveredBy: contributors.map((one) => one.
|
|
1001
|
-
detail:
|
|
1002
|
-
? "
|
|
1003
|
-
:
|
|
1004
|
-
? "
|
|
1005
|
-
:
|
|
1006
|
-
|
|
1007
|
-
|
|
1477
|
+
coveredBy: contributors.map((one) => one.record.seq),
|
|
1478
|
+
detail: replay.steps !== null
|
|
1479
|
+
? `${path}'s full change was reconstructed byte-for-byte from ${contributors.length} replayed authorization record${contributors.length === 1 ? "" : "s"} in execution order, ${boundText}. Evidence records in that order: seq ${contributors.map((one) => one.record.seq).join(", ")}. The first record, ${record.actor} at seq ${record.seq} (${record.ts}), leads the finding: ${why}.`
|
|
1480
|
+
: `${path} was ${kind === "policy-authorized-file" ? "authorized by policy for execution" : "granted"} by ${record.actor} at seq ${record.seq} (${record.ts}), ${boundText}: ${why}. ${hunks.identical
|
|
1481
|
+
? "there are no substantive hunks to cover"
|
|
1482
|
+
: whole
|
|
1483
|
+
? "the whole change is attributed to that authorization record"
|
|
1484
|
+
: `${hunks.added.length} added and ${hunks.removed.length} removed line(s) all trace to authorized material`}${contributors.length > 1
|
|
1485
|
+
? ` (assembled from ${contributors.length} ${contributors.every((one) => one.source === "grant") ? "grants" : "evidence records"}: seq ${contributors.map((one) => one.record.seq).join(", ")}; the strongest and nearest leads)`
|
|
1486
|
+
: ""}`,
|
|
1008
1487
|
});
|
|
1009
1488
|
continue;
|
|
1010
1489
|
}
|
|
@@ -1019,8 +1498,8 @@ export function evaluateProtectedPaths(input) {
|
|
|
1019
1498
|
ok: false,
|
|
1020
1499
|
code: "uncovered-hunk",
|
|
1021
1500
|
uncovered: sample,
|
|
1022
|
-
detail: `${path} is a protected path and ${uncovered.length} line(s) of this change trace to no
|
|
1023
|
-
? `${contributors.length}
|
|
1501
|
+
detail: `${path} is a protected path and ${uncovered.length} line(s) of this change trace to no authorized material. ${candidates.length} ${evidenceNoun} name this path ${boundText}, and naming is not coverage: evidence authorizes only the bytes it binds, and a later edit to the same file is a different decision. ${contributors.length > 0
|
|
1502
|
+
? `${contributors.length} evidence record${contributors.length === 1 ? "" : "s"} covered part of it (seq ${contributors.map((one) => one.record.seq).join(", ")}); `
|
|
1024
1503
|
: ""}${rejected.length > 0
|
|
1025
1504
|
? `${rejected.slice(0, 6).join("; ")}${rejected.length > 6 ? `; … and ${rejected.length - 6} other naming grants set aside for the same kinds of reason` : ""}. `
|
|
1026
1505
|
: ""}uncovered: ${sample.map((line) => JSON.stringify(line)).join(", ")}${uncovered.length > sample.length ? `, … ${uncovered.length - sample.length} more` : ""}. ${windowText(input.window)}. ${ORDERING_RULE}.`,
|