approval-md 0.0.1 → 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/LICENSE +176 -0
- package/NOTICE +5 -0
- package/README.md +940 -4
- package/SPEC.md +476 -34
- package/cli.js +29 -3
- package/dist/src/adapters/agentmail.d.ts +426 -0
- package/dist/src/adapters/agentmail.js +1200 -0
- package/dist/src/adapters/agentmail.js.map +1 -0
- package/dist/src/adapters/conformance.d.ts +149 -0
- package/dist/src/adapters/conformance.js +461 -0
- package/dist/src/adapters/conformance.js.map +1 -0
- package/dist/src/adapters/contract.d.ts +628 -0
- package/dist/src/adapters/contract.js +1035 -0
- package/dist/src/adapters/contract.js.map +1 -0
- package/dist/src/adapters/email.d.ts +324 -0
- package/dist/src/adapters/email.js +749 -0
- package/dist/src/adapters/email.js.map +1 -0
- package/dist/src/adapters/env-passphrase.d.ts +93 -0
- package/dist/src/adapters/env-passphrase.js +132 -0
- package/dist/src/adapters/env-passphrase.js.map +1 -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 +77 -0
- package/dist/src/adapters/registry.js.map +1 -0
- package/dist/src/adapters/smtp.d.ts +213 -0
- package/dist/src/adapters/smtp.js +499 -0
- package/dist/src/adapters/smtp.js.map +1 -0
- package/dist/src/adapters/vault-provider.d.ts +114 -0
- package/dist/src/adapters/vault-provider.js +161 -0
- package/dist/src/adapters/vault-provider.js.map +1 -0
- 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/batch.js +121 -0
- package/dist/src/channels/batch.js.map +1 -0
- package/dist/src/channels/cli.d.ts +193 -0
- package/dist/src/channels/cli.js +468 -0
- package/dist/src/channels/cli.js.map +1 -0
- package/dist/src/channels/conformance.d.ts +92 -0
- package/dist/src/channels/conformance.js +445 -0
- package/dist/src/channels/conformance.js.map +1 -0
- package/dist/src/channels/contract.d.ts +623 -0
- package/dist/src/channels/contract.js +494 -0
- package/dist/src/channels/contract.js.map +1 -0
- package/dist/src/channels/payload-view.d.ts +35 -0
- package/dist/src/channels/payload-view.js +43 -0
- package/dist/src/channels/payload-view.js.map +1 -0
- package/dist/src/channels/render-queue.d.ts +149 -0
- package/dist/src/channels/render-queue.js +564 -0
- package/dist/src/channels/render-queue.js.map +1 -0
- package/dist/src/channels/tagging.d.ts +196 -0
- package/dist/src/channels/tagging.js +723 -0
- package/dist/src/channels/tagging.js.map +1 -0
- package/dist/src/channels/telegram.d.ts +1832 -0
- package/dist/src/channels/telegram.js +3190 -0
- package/dist/src/channels/telegram.js.map +1 -0
- package/dist/src/channels/web.d.ts +341 -0
- package/dist/src/channels/web.js +903 -0
- package/dist/src/channels/web.js.map +1 -0
- package/dist/src/cli/adapter.d.ts +90 -0
- package/dist/src/cli/adapter.js +288 -0
- package/dist/src/cli/adapter.js.map +1 -0
- package/dist/src/cli/amend.d.ts +59 -0
- package/dist/src/cli/amend.js +2171 -0
- package/dist/src/cli/amend.js.map +1 -0
- package/dist/src/cli/args.d.ts +43 -0
- package/dist/src/cli/args.js +86 -0
- package/dist/src/cli/args.js.map +1 -0
- package/dist/src/cli/attest.d.ts +41 -0
- package/dist/src/cli/attest.js +307 -0
- package/dist/src/cli/attest.js.map +1 -0
- package/dist/src/cli/audit-card.d.ts +62 -0
- package/dist/src/cli/audit-card.js +201 -0
- package/dist/src/cli/audit-card.js.map +1 -0
- package/dist/src/cli/audit.d.ts +59 -0
- package/dist/src/cli/audit.js +460 -0
- package/dist/src/cli/audit.js.map +1 -0
- package/dist/src/cli/channel-telegram.d.ts +806 -0
- package/dist/src/cli/channel-telegram.js +2063 -0
- package/dist/src/cli/channel-telegram.js.map +1 -0
- package/dist/src/cli/channel-web.d.ts +131 -0
- package/dist/src/cli/channel-web.js +357 -0
- package/dist/src/cli/channel-web.js.map +1 -0
- package/dist/src/cli/channel.d.ts +71 -0
- package/dist/src/cli/channel.js +438 -0
- package/dist/src/cli/channel.js.map +1 -0
- package/dist/src/cli/checkpoint-tap.d.ts +169 -0
- package/dist/src/cli/checkpoint-tap.js +238 -0
- package/dist/src/cli/checkpoint-tap.js.map +1 -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/coverage.js +343 -0
- package/dist/src/cli/coverage.js.map +1 -0
- package/dist/src/cli/daemon.d.ts +120 -0
- package/dist/src/cli/daemon.js +631 -0
- package/dist/src/cli/daemon.js.map +1 -0
- package/dist/src/cli/doctor.d.ts +129 -0
- package/dist/src/cli/doctor.js +2762 -0
- package/dist/src/cli/doctor.js.map +1 -0
- package/dist/src/cli/env.d.ts +65 -0
- package/dist/src/cli/env.js +302 -0
- package/dist/src/cli/env.js.map +1 -0
- package/dist/src/cli/execute.d.ts +202 -0
- package/dist/src/cli/execute.js +1682 -0
- package/dist/src/cli/execute.js.map +1 -0
- package/dist/src/cli/exit-codes.d.ts +73 -0
- package/dist/src/cli/exit-codes.js +82 -0
- package/dist/src/cli/exit-codes.js.map +1 -0
- package/dist/src/cli/feedback.d.ts +60 -0
- package/dist/src/cli/feedback.js +205 -0
- package/dist/src/cli/feedback.js.map +1 -0
- package/dist/src/cli/gate-window.d.ts +40 -0
- package/dist/src/cli/gate-window.js +294 -0
- package/dist/src/cli/gate-window.js.map +1 -0
- package/dist/src/cli/gate.d.ts +68 -0
- package/dist/src/cli/gate.js +557 -0
- package/dist/src/cli/gate.js.map +1 -0
- package/dist/src/cli/git-scope.d.ts +190 -0
- package/dist/src/cli/git-scope.js +295 -0
- package/dist/src/cli/git-scope.js.map +1 -0
- package/dist/src/cli/gloss-attach.d.ts +85 -0
- package/dist/src/cli/gloss-attach.js +107 -0
- package/dist/src/cli/gloss-attach.js.map +1 -0
- package/dist/src/cli/gloss-codex-child.d.ts +9 -0
- package/dist/src/cli/gloss-codex-child.js +149 -0
- package/dist/src/cli/gloss-codex-child.js.map +1 -0
- package/dist/src/cli/gloss-codex.d.ts +24 -0
- package/dist/src/cli/gloss-codex.js +255 -0
- package/dist/src/cli/gloss-codex.js.map +1 -0
- package/dist/src/cli/gloss-options.d.ts +42 -0
- package/dist/src/cli/gloss-options.js +79 -0
- package/dist/src/cli/gloss-options.js.map +1 -0
- package/dist/src/cli/gloss.d.ts +265 -0
- package/dist/src/cli/gloss.js +362 -0
- package/dist/src/cli/gloss.js.map +1 -0
- package/dist/src/cli/help.d.ts +103 -0
- package/dist/src/cli/help.js +2339 -0
- package/dist/src/cli/help.js.map +1 -0
- 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 +2849 -0
- package/dist/src/cli/hook.js.map +1 -0
- package/dist/src/cli/import.d.ts +35 -0
- package/dist/src/cli/import.js +175 -0
- package/dist/src/cli/import.js.map +1 -0
- package/dist/src/cli/init.d.ts +84 -0
- package/dist/src/cli/init.js +336 -0
- package/dist/src/cli/init.js.map +1 -0
- package/dist/src/cli/instructions.d.ts +23 -0
- package/dist/src/cli/instructions.js +262 -0
- package/dist/src/cli/instructions.js.map +1 -0
- package/dist/src/cli/journal.d.ts +41 -0
- package/dist/src/cli/journal.js +238 -0
- package/dist/src/cli/journal.js.map +1 -0
- package/dist/src/cli/log-advance.d.ts +287 -0
- package/dist/src/cli/log-advance.js +840 -0
- package/dist/src/cli/log-advance.js.map +1 -0
- package/dist/src/cli/log-anchor.d.ts +176 -0
- package/dist/src/cli/log-anchor.js +387 -0
- package/dist/src/cli/log-anchor.js.map +1 -0
- package/dist/src/cli/log-checkpoint.d.ts +22 -0
- package/dist/src/cli/log-checkpoint.js +128 -0
- package/dist/src/cli/log-checkpoint.js.map +1 -0
- package/dist/src/cli/log-sync.d.ts +243 -0
- package/dist/src/cli/log-sync.js +849 -0
- package/dist/src/cli/log-sync.js.map +1 -0
- package/dist/src/cli/log-verbs.d.ts +16 -0
- package/dist/src/cli/log-verbs.js +360 -0
- package/dist/src/cli/log-verbs.js.map +1 -0
- package/dist/src/cli/long-help.d.ts +70 -0
- package/dist/src/cli/long-help.js +148 -0
- package/dist/src/cli/long-help.js.map +1 -0
- package/dist/src/cli/main.d.ts +77 -0
- package/dist/src/cli/main.js +1206 -0
- package/dist/src/cli/main.js.map +1 -0
- package/dist/src/cli/mcp.d.ts +52 -0
- package/dist/src/cli/mcp.js +306 -0
- package/dist/src/cli/mcp.js.map +1 -0
- package/dist/src/cli/paths.d.ts +56 -0
- package/dist/src/cli/paths.js +79 -0
- package/dist/src/cli/paths.js.map +1 -0
- package/dist/src/cli/payload.d.ts +58 -0
- package/dist/src/cli/payload.js +253 -0
- package/dist/src/cli/payload.js.map +1 -0
- package/dist/src/cli/policy.d.ts +43 -0
- package/dist/src/cli/policy.js +229 -0
- package/dist/src/cli/policy.js.map +1 -0
- package/dist/src/cli/preflight.d.ts +363 -0
- package/dist/src/cli/preflight.js +1175 -0
- package/dist/src/cli/preflight.js.map +1 -0
- package/dist/src/cli/progress.d.ts +78 -0
- package/dist/src/cli/progress.js +112 -0
- package/dist/src/cli/progress.js.map +1 -0
- package/dist/src/cli/prompt.d.ts +209 -0
- package/dist/src/cli/prompt.js +312 -0
- package/dist/src/cli/prompt.js.map +1 -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/records.js +66 -0
- package/dist/src/cli/records.js.map +1 -0
- package/dist/src/cli/render.d.ts +22 -0
- package/dist/src/cli/render.js +132 -0
- package/dist/src/cli/render.js.map +1 -0
- package/dist/src/cli/sandbox.d.ts +51 -0
- package/dist/src/cli/sandbox.js +150 -0
- package/dist/src/cli/sandbox.js.map +1 -0
- package/dist/src/cli/scaffold.d.ts +79 -0
- package/dist/src/cli/scaffold.js +137 -0
- package/dist/src/cli/scaffold.js.map +1 -0
- package/dist/src/cli/setup-adapter.d.ts +137 -0
- package/dist/src/cli/setup-adapter.js +509 -0
- package/dist/src/cli/setup-adapter.js.map +1 -0
- package/dist/src/cli/setup-channel.d.ts +117 -0
- package/dist/src/cli/setup-channel.js +635 -0
- package/dist/src/cli/setup-channel.js.map +1 -0
- package/dist/src/cli/setup-checkpoint.d.ts +57 -0
- package/dist/src/cli/setup-checkpoint.js +196 -0
- package/dist/src/cli/setup-checkpoint.js.map +1 -0
- package/dist/src/cli/setup-common.d.ts +275 -0
- package/dist/src/cli/setup-common.js +376 -0
- package/dist/src/cli/setup-common.js.map +1 -0
- package/dist/src/cli/setup-flow.d.ts +287 -0
- package/dist/src/cli/setup-flow.js +476 -0
- package/dist/src/cli/setup-flow.js.map +1 -0
- package/dist/src/cli/setup-service.d.ts +96 -0
- package/dist/src/cli/setup-service.js +308 -0
- package/dist/src/cli/setup-service.js.map +1 -0
- package/dist/src/cli/setup.d.ts +202 -0
- package/dist/src/cli/setup.js +473 -0
- package/dist/src/cli/setup.js.map +1 -0
- package/dist/src/cli/style.d.ts +320 -0
- package/dist/src/cli/style.js +469 -0
- package/dist/src/cli/style.js.map +1 -0
- package/dist/src/cli/token.d.ts +39 -0
- package/dist/src/cli/token.js +274 -0
- package/dist/src/cli/token.js.map +1 -0
- package/dist/src/cli/up.d.ts +155 -0
- package/dist/src/cli/up.js +849 -0
- package/dist/src/cli/up.js.map +1 -0
- package/dist/src/cli/usage.d.ts +37 -0
- package/dist/src/cli/usage.js +91 -0
- package/dist/src/cli/usage.js.map +1 -0
- package/dist/src/cli/values.d.ts +40 -0
- package/dist/src/cli/values.js +189 -0
- package/dist/src/cli/values.js.map +1 -0
- package/dist/src/cli/vault.d.ts +59 -0
- package/dist/src/cli/vault.js +362 -0
- package/dist/src/cli/vault.js.map +1 -0
- package/dist/src/cli/verb-registry.d.ts +76 -0
- package/dist/src/cli/verb-registry.js +2341 -0
- package/dist/src/cli/verb-registry.js.map +1 -0
- package/dist/src/cli/wordmark.d.ts +31 -0
- package/dist/src/cli/wordmark.js +52 -0
- package/dist/src/cli/wordmark.js.map +1 -0
- 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/advance-cycle.js +200 -0
- package/dist/src/core/advance-cycle.js.map +1 -0
- package/dist/src/core/agents-md.d.ts +276 -0
- package/dist/src/core/agents-md.js +747 -0
- package/dist/src/core/agents-md.js.map +1 -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 +589 -0
- package/dist/src/core/attest.js.map +1 -0
- package/dist/src/core/audit.d.ts +492 -0
- package/dist/src/core/audit.js +882 -0
- package/dist/src/core/audit.js.map +1 -0
- package/dist/src/core/budgets.d.ts +238 -0
- package/dist/src/core/budgets.js +449 -0
- package/dist/src/core/budgets.js.map +1 -0
- package/dist/src/core/checkpoint.d.ts +500 -0
- package/dist/src/core/checkpoint.js +738 -0
- package/dist/src/core/checkpoint.js.map +1 -0
- package/dist/src/core/child-env.d.ts +88 -0
- package/dist/src/core/child-env.js +86 -0
- package/dist/src/core/child-env.js.map +1 -0
- package/dist/src/core/clock.d.ts +52 -0
- package/dist/src/core/clock.js +43 -0
- package/dist/src/core/clock.js.map +1 -0
- package/dist/src/core/command-class.d.ts +543 -0
- package/dist/src/core/command-class.js +2356 -0
- package/dist/src/core/command-class.js.map +1 -0
- package/dist/src/core/coverage-sources/adapter.d.ts +40 -0
- package/dist/src/core/coverage-sources/adapter.js +71 -0
- package/dist/src/core/coverage-sources/adapter.js.map +1 -0
- package/dist/src/core/coverage-sources/gh.d.ts +48 -0
- package/dist/src/core/coverage-sources/gh.js +136 -0
- package/dist/src/core/coverage-sources/gh.js.map +1 -0
- package/dist/src/core/coverage-sources/git.d.ts +101 -0
- package/dist/src/core/coverage-sources/git.js +269 -0
- package/dist/src/core/coverage-sources/git.js.map +1 -0
- package/dist/src/core/coverage.d.ts +217 -0
- package/dist/src/core/coverage.js +337 -0
- package/dist/src/core/coverage.js.map +1 -0
- package/dist/src/core/credential-spec.d.ts +72 -0
- package/dist/src/core/credential-spec.js +23 -0
- package/dist/src/core/credential-spec.js.map +1 -0
- package/dist/src/core/dark-session.d.ts +331 -0
- package/dist/src/core/dark-session.js +714 -0
- package/dist/src/core/dark-session.js.map +1 -0
- package/dist/src/core/decision-refusal.d.ts +185 -0
- package/dist/src/core/decision-refusal.js +265 -0
- package/dist/src/core/decision-refusal.js.map +1 -0
- package/dist/src/core/env-file.d.ts +450 -0
- package/dist/src/core/env-file.js +837 -0
- package/dist/src/core/env-file.js.map +1 -0
- package/dist/src/core/execute.d.ts +858 -0
- package/dist/src/core/execute.js +1271 -0
- package/dist/src/core/execute.js.map +1 -0
- package/dist/src/core/frontmatter.d.ts +78 -0
- package/dist/src/core/frontmatter.js +100 -0
- package/dist/src/core/frontmatter.js.map +1 -0
- package/dist/src/core/gate-window.d.ts +312 -0
- package/dist/src/core/gate-window.js +506 -0
- package/dist/src/core/gate-window.js.map +1 -0
- package/dist/src/core/gate.d.ts +1364 -0
- package/dist/src/core/gate.js +3002 -0
- package/dist/src/core/gate.js.map +1 -0
- package/dist/src/core/git-run.d.ts +73 -0
- package/dist/src/core/git-run.js +93 -0
- package/dist/src/core/git-run.js.map +1 -0
- package/dist/src/core/harness-version.d.ts +157 -0
- package/dist/src/core/harness-version.js +211 -0
- package/dist/src/core/harness-version.js.map +1 -0
- package/dist/src/core/harness-wait.d.ts +55 -0
- package/dist/src/core/harness-wait.js +58 -0
- package/dist/src/core/harness-wait.js.map +1 -0
- package/dist/src/core/head-retry.d.ts +107 -0
- package/dist/src/core/head-retry.js +121 -0
- package/dist/src/core/head-retry.js.map +1 -0
- package/dist/src/core/instance.d.ts +253 -0
- package/dist/src/core/instance.js +319 -0
- package/dist/src/core/instance.js.map +1 -0
- package/dist/src/core/intake-limits.d.ts +247 -0
- package/dist/src/core/intake-limits.js +350 -0
- package/dist/src/core/intake-limits.js.map +1 -0
- package/dist/src/core/jcs.d.ts +52 -0
- package/dist/src/core/jcs.js +132 -0
- package/dist/src/core/jcs.js.map +1 -0
- package/dist/src/core/journal.d.ts +144 -0
- package/dist/src/core/journal.js +200 -0
- package/dist/src/core/journal.js.map +1 -0
- package/dist/src/core/live-draw.d.ts +436 -0
- package/dist/src/core/live-draw.js +703 -0
- package/dist/src/core/live-draw.js.map +1 -0
- package/dist/src/core/log-reconcile.d.ts +89 -0
- package/dist/src/core/log-reconcile.js +136 -0
- package/dist/src/core/log-reconcile.js.map +1 -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/log.js +546 -0
- package/dist/src/core/log.js.map +1 -0
- package/dist/src/core/loop.d.ts +274 -0
- package/dist/src/core/loop.js +487 -0
- package/dist/src/core/loop.js.map +1 -0
- package/dist/src/core/md-fence.d.ts +41 -0
- package/dist/src/core/md-fence.js +74 -0
- package/dist/src/core/md-fence.js.map +1 -0
- package/dist/src/core/money.d.ts +147 -0
- package/dist/src/core/money.js +195 -0
- package/dist/src/core/money.js.map +1 -0
- package/dist/src/core/payload-census.d.ts +74 -0
- package/dist/src/core/payload-census.js +146 -0
- package/dist/src/core/payload-census.js.map +1 -0
- package/dist/src/core/payload-store.d.ts +175 -0
- package/dist/src/core/payload-store.js +340 -0
- package/dist/src/core/payload-store.js.map +1 -0
- package/dist/src/core/payload.d.ts +71 -0
- package/dist/src/core/payload.js +80 -0
- package/dist/src/core/payload.js.map +1 -0
- package/dist/src/core/policy-diff.d.ts +292 -0
- package/dist/src/core/policy-diff.js +588 -0
- package/dist/src/core/policy-diff.js.map +1 -0
- package/dist/src/core/policy-expectations.d.ts +199 -0
- package/dist/src/core/policy-expectations.js +394 -0
- package/dist/src/core/policy-expectations.js.map +1 -0
- package/dist/src/core/policy-explain.d.ts +150 -0
- package/dist/src/core/policy-explain.js +258 -0
- package/dist/src/core/policy-explain.js.map +1 -0
- package/dist/src/core/policy-load.d.ts +527 -0
- package/dist/src/core/policy-load.js +536 -0
- package/dist/src/core/policy-load.js.map +1 -0
- package/dist/src/core/policy-match.d.ts +281 -0
- package/dist/src/core/policy-match.js +478 -0
- package/dist/src/core/policy-match.js.map +1 -0
- package/dist/src/core/policy-proposal.d.ts +265 -0
- package/dist/src/core/policy-proposal.js +458 -0
- package/dist/src/core/policy-proposal.js.map +1 -0
- package/dist/src/core/prompt-layout.d.ts +221 -0
- package/dist/src/core/prompt-layout.js +422 -0
- package/dist/src/core/prompt-layout.js.map +1 -0
- package/dist/src/core/protected-path-guard.d.ts +453 -0
- package/dist/src/core/protected-path-guard.js +1566 -0
- package/dist/src/core/protected-path-guard.js.map +1 -0
- package/dist/src/core/registration.d.ts +25 -0
- package/dist/src/core/registration.js +39 -0
- package/dist/src/core/registration.js.map +1 -0
- package/dist/src/core/reindex.d.ts +99 -0
- package/dist/src/core/reindex.js +336 -0
- package/dist/src/core/reindex.js.map +1 -0
- package/dist/src/core/sampler.d.ts +313 -0
- package/dist/src/core/sampler.js +388 -0
- package/dist/src/core/sampler.js.map +1 -0
- package/dist/src/core/sandbox.d.ts +290 -0
- package/dist/src/core/sandbox.js +424 -0
- package/dist/src/core/sandbox.js.map +1 -0
- package/dist/src/core/seal.d.ts +165 -0
- package/dist/src/core/seal.js +290 -0
- package/dist/src/core/seal.js.map +1 -0
- package/dist/src/core/state.d.ts +505 -0
- package/dist/src/core/state.js +1009 -0
- package/dist/src/core/state.js.map +1 -0
- package/dist/src/core/task-file.d.ts +185 -0
- package/dist/src/core/task-file.js +464 -0
- package/dist/src/core/task-file.js.map +1 -0
- package/dist/src/core/telegram-config.d.ts +93 -0
- package/dist/src/core/telegram-config.js +114 -0
- package/dist/src/core/telegram-config.js.map +1 -0
- package/dist/src/core/token.d.ts +409 -0
- package/dist/src/core/token.js +561 -0
- package/dist/src/core/token.js.map +1 -0
- package/dist/src/core/validate.d.ts +138 -0
- package/dist/src/core/validate.js +0 -0
- package/dist/src/core/validate.js.map +1 -0
- package/dist/src/core/values.d.ts +137 -0
- package/dist/src/core/values.js +153 -0
- package/dist/src/core/values.js.map +1 -0
- package/dist/src/core/vault.d.ts +291 -0
- package/dist/src/core/vault.js +612 -0
- package/dist/src/core/vault.js.map +1 -0
- package/dist/src/core/verified-snapshot.d.ts +204 -0
- package/dist/src/core/verified-snapshot.js +506 -0
- package/dist/src/core/verified-snapshot.js.map +1 -0
- package/dist/src/core/verify.d.ts +336 -0
- package/dist/src/core/verify.js +549 -0
- package/dist/src/core/verify.js.map +1 -0
- package/dist/src/core/version.d.ts +8 -0
- package/dist/src/core/version.js +9 -0
- package/dist/src/core/version.js.map +1 -0
- package/dist/src/core/wysiwys.d.ts +370 -0
- package/dist/src/core/wysiwys.js +728 -0
- package/dist/src/core/wysiwys.js.map +1 -0
- package/dist/src/daemon/advance-child.d.ts +39 -0
- package/dist/src/daemon/advance-child.js +78 -0
- package/dist/src/daemon/advance-child.js.map +1 -0
- package/dist/src/daemon/advance.d.ts +466 -0
- package/dist/src/daemon/advance.js +849 -0
- package/dist/src/daemon/advance.js.map +1 -0
- package/dist/src/daemon/audit.d.ts +87 -0
- package/dist/src/daemon/audit.js +90 -0
- package/dist/src/daemon/audit.js.map +1 -0
- package/dist/src/daemon/daemon.d.ts +1180 -0
- package/dist/src/daemon/daemon.js +1988 -0
- package/dist/src/daemon/daemon.js.map +1 -0
- package/dist/src/daemon/dark-session.d.ts +64 -0
- package/dist/src/daemon/dark-session.js +119 -0
- package/dist/src/daemon/dark-session.js.map +1 -0
- package/dist/src/daemon/draw-child.d.ts +36 -0
- package/dist/src/daemon/draw-child.js +132 -0
- package/dist/src/daemon/draw-child.js.map +1 -0
- package/dist/src/daemon/draw.d.ts +154 -0
- package/dist/src/daemon/draw.js +458 -0
- package/dist/src/daemon/draw.js.map +1 -0
- package/dist/src/daemon/git-evidence.d.ts +173 -0
- package/dist/src/daemon/git-evidence.js +345 -0
- package/dist/src/daemon/git-evidence.js.map +1 -0
- package/dist/src/daemon/projection.d.ts +180 -0
- package/dist/src/daemon/projection.js +233 -0
- package/dist/src/daemon/projection.js.map +1 -0
- package/dist/src/daemon/prune.d.ts +207 -0
- package/dist/src/daemon/prune.js +376 -0
- package/dist/src/daemon/prune.js.map +1 -0
- package/dist/src/mcp/http.d.ts +113 -0
- package/dist/src/mcp/http.js +343 -0
- package/dist/src/mcp/http.js.map +1 -0
- package/dist/src/mcp/server.d.ts +265 -0
- package/dist/src/mcp/server.js +602 -0
- package/dist/src/mcp/server.js.map +1 -0
- package/docs/adapter-api.md +106 -0
- package/docs/cli-reference.md +5716 -0
- package/docs/codex-enforced-session.md +30 -0
- package/package.json +53 -4
- package/schema/.gitkeep +0 -0
- package/schema/LICENSE +117 -0
- package/schema/codex-instance.schema.json +82 -0
- package/schema/envelope.schema.json +137 -0
- package/schema/event.schema.json +1811 -0
- package/schema/fixtures/codex-instance/invalid/unpinned-codex-version.json +40 -0
- package/schema/fixtures/codex-instance/valid/canonical.json +40 -0
- package/schema/fixtures/envelope/invalid/action-missing-idempotency-key.json +15 -0
- package/schema/fixtures/envelope/invalid/action-unknown-class-format.json +14 -0
- package/schema/fixtures/envelope/invalid/confidence-out-of-range.json +11 -0
- package/schema/fixtures/envelope/invalid/est-cost-bare-number.json +14 -0
- package/schema/fixtures/envelope/invalid/est-cost-noncanonical-string.json +14 -0
- package/schema/fixtures/envelope/invalid/malformed-assignee.json +10 -0
- package/schema/fixtures/envelope/invalid/malformed-created-by.json +7 -0
- package/schema/fixtures/envelope/invalid/malformed-max-latency.json +11 -0
- package/schema/fixtures/envelope/invalid/malformed-payload-hash.json +14 -0
- package/schema/fixtures/envelope/invalid/max-cost-bare-number.json +11 -0
- package/schema/fixtures/envelope/invalid/missing-origin.json +3 -0
- package/schema/fixtures/envelope/invalid/negative-est-cost.json +14 -0
- package/schema/fixtures/envelope/invalid/unknown-state.json +7 -0
- package/schema/fixtures/envelope/invalid/unknown-top-level-field.json +8 -0
- package/schema/fixtures/envelope/valid/action-payload-hash.json +17 -0
- package/schema/fixtures/envelope/valid/actions-without-budget.json +18 -0
- package/schema/fixtures/envelope/valid/canonical.json +25 -0
- package/schema/fixtures/envelope/valid/minimal.json +7 -0
- package/schema/fixtures/envelope/valid/multi-action-executed.json +30 -0
- package/schema/fixtures/envelope/valid/record-write-stage.json +22 -0
- package/schema/fixtures/event/invalid/approval-granted-agent-actor.json +15 -0
- package/schema/fixtures/event/invalid/approval-granted-empty-batch-delivery-id.json +17 -0
- package/schema/fixtures/event/invalid/approval-granted-fifth-reaction.json +16 -0
- package/schema/fixtures/event/invalid/approval-granted-missing-actor.json +14 -0
- package/schema/fixtures/event/invalid/approval-requested-missing-action-key.json +14 -0
- package/schema/fixtures/event/invalid/approval-withdrawn-agent-policy-drift.json +15 -0
- package/schema/fixtures/event/invalid/approval-withdrawn-missing-reason.json +15 -0
- package/schema/fixtures/event/invalid/approval-withdrawn-system-actor.json +15 -0
- package/schema/fixtures/event/invalid/audit-decision-refused-human-actor.json +17 -0
- package/schema/fixtures/event/invalid/audit-decision-refused-missing-code.json +16 -0
- package/schema/fixtures/event/invalid/audit-reviewed-agent-actor.json +15 -0
- package/schema/fixtures/event/invalid/audit-reviewed-loved-no-note.json +16 -0
- package/schema/fixtures/event/invalid/audit-reviewed-system-actor.json +15 -0
- package/schema/fixtures/event/invalid/bad-actor-prefix.json +15 -0
- package/schema/fixtures/event/invalid/est-cost-bare-number.json +17 -0
- package/schema/fixtures/event/invalid/execution-completed-fabricated-exit-code.json +16 -0
- package/schema/fixtures/event/invalid/execution-completed-provider-ref-empty-id.json +18 -0
- package/schema/fixtures/event/invalid/execution-completed-provider-ref-extra-field.json +19 -0
- package/schema/fixtures/event/invalid/execution-completed-provider-ref-id-not-string.json +18 -0
- package/schema/fixtures/event/invalid/execution-completed-provider-ref-missing-adapter.json +17 -0
- package/schema/fixtures/event/invalid/execution-failed-open-reported-by.json +16 -0
- package/schema/fixtures/event/invalid/execution-indeterminate-open-reason.json +14 -0
- package/schema/fixtures/event/invalid/execution-reconciled-agent-actor.json +17 -0
- package/schema/fixtures/event/invalid/execution-started-negative-env-stripped.json +16 -0
- package/schema/fixtures/event/invalid/gate-bypassed-missing-opened-seq.json +15 -0
- package/schema/fixtures/event/invalid/gate-closed-non-integer-opened-seq.json +13 -0
- package/schema/fixtures/event/invalid/gate-opened-agent-actor.json +16 -0
- package/schema/fixtures/event/invalid/gate-organ-attested-absolute-path.json +14 -0
- package/schema/fixtures/event/invalid/gate-organ-attested-agent-actor.json +14 -0
- package/schema/fixtures/event/invalid/gate-organ-attested-missing-organ-path.json +13 -0
- package/schema/fixtures/event/invalid/harness-unknown-kind.json +18 -0
- package/schema/fixtures/event/invalid/harness-version-multiline.json +16 -0
- package/schema/fixtures/event/invalid/log-checkpoint-agent-actor.json +17 -0
- package/schema/fixtures/event/invalid/log-checkpoint-missing-signature.json +16 -0
- package/schema/fixtures/event/invalid/log-checkpoint-short-signed-hash.json +17 -0
- package/schema/fixtures/event/invalid/log-checkpoint-unknown-signature-alg.json +17 -0
- package/schema/fixtures/event/invalid/malformed-ts.json +15 -0
- package/schema/fixtures/event/invalid/missing-alg.json +14 -0
- package/schema/fixtures/event/invalid/missing-hash.json +14 -0
- package/schema/fixtures/event/invalid/non-integer-seq.json +15 -0
- package/schema/fixtures/event/invalid/payload-pruned-human-actor.json +14 -0
- package/schema/fixtures/event/invalid/payload-pruned-missing-hash.json +14 -0
- package/schema/fixtures/event/invalid/policy-declined-agent-actor.json +15 -0
- package/schema/fixtures/event/invalid/policy-proposed-missing-diff.json +19 -0
- package/schema/fixtures/event/invalid/policy-proposed-system-actor.json +26 -0
- package/schema/fixtures/event/invalid/short-hash.json +15 -0
- package/schema/fixtures/event/invalid/unknown-alg.json +15 -0
- package/schema/fixtures/event/invalid/unknown-event-type.json +15 -0
- package/schema/fixtures/event/invalid/unknown-top-level-field.json +16 -0
- package/schema/fixtures/event/valid/approval-expired.json +15 -0
- package/schema/fixtures/event/valid/approval-granted-batch.json +19 -0
- package/schema/fixtures/event/valid/approval-granted-reaction.json +16 -0
- package/schema/fixtures/event/valid/approval-granted.json +15 -0
- package/schema/fixtures/event/valid/approval-rejected.json +15 -0
- package/schema/fixtures/event/valid/approval-requested.json +19 -0
- package/schema/fixtures/event/valid/approval-revoked.json +15 -0
- package/schema/fixtures/event/valid/approval-withdrawn-policy-drift.json +17 -0
- package/schema/fixtures/event/valid/approval-withdrawn.json +16 -0
- package/schema/fixtures/event/valid/audit-decision-refused.json +20 -0
- package/schema/fixtures/event/valid/audit-reviewed-reaction.json +17 -0
- package/schema/fixtures/event/valid/audit-reviewed.json +15 -0
- package/schema/fixtures/event/valid/audit-sampled.json +14 -0
- package/schema/fixtures/event/valid/budget-exceeded.json +21 -0
- package/schema/fixtures/event/valid/envelope-drift.json +16 -0
- package/schema/fixtures/event/valid/execution-completed-harness-report.json +16 -0
- package/schema/fixtures/event/valid/execution-completed-provider-ref.json +18 -0
- package/schema/fixtures/event/valid/execution-completed.json +15 -0
- package/schema/fixtures/event/valid/execution-failed-harness-report.json +16 -0
- package/schema/fixtures/event/valid/execution-failed.json +15 -0
- package/schema/fixtures/event/valid/execution-indeterminate.json +15 -0
- package/schema/fixtures/event/valid/execution-reconciled.json +17 -0
- package/schema/fixtures/event/valid/execution-started-env-stripped.json +17 -0
- package/schema/fixtures/event/valid/execution-started.json +14 -0
- package/schema/fixtures/event/valid/gate-bypassed-harness-version.json +18 -0
- package/schema/fixtures/event/valid/gate-bypassed.json +19 -0
- package/schema/fixtures/event/valid/gate-closed.json +14 -0
- package/schema/fixtures/event/valid/gate-opened.json +16 -0
- package/schema/fixtures/event/valid/gate-organ-attested.json +14 -0
- package/schema/fixtures/event/valid/genesis-null-prev.json +14 -0
- package/schema/fixtures/event/valid/log-checkpoint.json +17 -0
- package/schema/fixtures/event/valid/payload-pruned-orphan.json +13 -0
- package/schema/fixtures/event/valid/payload-pruned.json +17 -0
- package/schema/fixtures/event/valid/policy-declined.json +16 -0
- package/schema/fixtures/event/valid/policy-proposed.json +35 -0
- package/schema/fixtures/event/valid/policy-updated.json +14 -0
- package/schema/fixtures/event/valid/reconciliation-required.json +18 -0
- package/schema/fixtures/event/valid/reconciliation-satisfied.json +17 -0
- package/schema/fixtures/event/valid/route-accepted.json +15 -0
- package/schema/fixtures/event/valid/route-proposed.json +16 -0
- package/schema/fixtures/event/valid/spec-example.json +15 -0
- package/schema/fixtures/event/valid/task-registered-harness-version.json +23 -0
- package/schema/fixtures/event/valid/task-registered.json +14 -0
- package/schema/fixtures/hash/known-answer-pre-121.json +74 -0
- package/schema/fixtures/hash/known-answer.json +74 -0
- package/schema/fixtures/policy/invalid/bad-approval-ttl.json +7 -0
- package/schema/fixtures/policy/invalid/bad-web-port.json +4 -0
- package/schema/fixtures/policy/invalid/checkpoint-key-not-base64.json +6 -0
- package/schema/fixtures/policy/invalid/class-rule-missing-autonomy.json +9 -0
- package/schema/fixtures/policy/invalid/empty-class-key.json +6 -0
- package/schema/fixtures/policy/invalid/live-rate-on-human-only.json +7 -0
- package/schema/fixtures/policy/invalid/malformed-class-key.json +6 -0
- package/schema/fixtures/policy/invalid/missing-version.json +8 -0
- package/schema/fixtures/policy/invalid/negative-limit.json +9 -0
- package/schema/fixtures/policy/invalid/non-numeric-limit.json +9 -0
- package/schema/fixtures/policy/invalid/non-positive-max-pending.json +9 -0
- package/schema/fixtures/policy/invalid/on-expiry-grant.json +8 -0
- package/schema/fixtures/policy/invalid/payload-retention-bare-number.json +4 -0
- package/schema/fixtures/policy/invalid/payload-retention-compound.json +4 -0
- package/schema/fixtures/policy/invalid/payload-retention-fractional.json +4 -0
- package/schema/fixtures/policy/invalid/payload-retention-zero.json +4 -0
- package/schema/fixtures/policy/invalid/protected-paths-escape.json +4 -0
- package/schema/fixtures/policy/invalid/protected-paths-glob.json +4 -0
- package/schema/fixtures/policy/invalid/retro-rate-on-human-only.json +7 -0
- package/schema/fixtures/policy/invalid/retro-rate-on-manual.json +7 -0
- package/schema/fixtures/policy/invalid/retro-rate-zero.json +7 -0
- package/schema/fixtures/policy/invalid/sample-rate-too-high.json +5 -0
- package/schema/fixtures/policy/invalid/sampling-secret-env-empty.json +7 -0
- package/schema/fixtures/policy/invalid/sampling-secret-env-not-string.json +6 -0
- package/schema/fixtures/policy/invalid/skew-tolerance-compound.json +6 -0
- package/schema/fixtures/policy/invalid/unknown-autonomy.json +7 -0
- package/schema/fixtures/policy/invalid/unknown-class-rule-key.json +6 -0
- package/schema/fixtures/policy/invalid/unknown-top-level-key.json +7 -0
- package/schema/fixtures/policy/invalid/vault-passphrase-env-empty.json +6 -0
- package/schema/fixtures/policy/invalid/vault-passphrase-literal.json +6 -0
- package/schema/fixtures/policy/invalid/version-not-string.json +4 -0
- package/schema/fixtures/policy/valid/canonical.json +47 -0
- package/schema/fixtures/policy/valid/checkpoint-keys.json +18 -0
- package/schema/fixtures/policy/valid/class-approvers-limits.json +25 -0
- package/schema/fixtures/policy/valid/class-retro-rate.json +17 -0
- package/schema/fixtures/policy/valid/global-budgets.json +19 -0
- package/schema/fixtures/policy/valid/human-only.json +9 -0
- package/schema/fixtures/policy/valid/minimal.json +6 -0
- package/schema/fixtures/policy/valid/protected-paths.json +10 -0
- package/schema/fixtures/policy/valid/record-namespace.json +13 -0
- package/schema/fixtures/policy/valid/request-volume-limits.json +26 -0
- package/schema/fixtures/policy/valid/retention-and-sampling-secret.json +16 -0
- package/schema/fixtures/policy/valid/skew-tolerance.json +15 -0
- package/schema/fixtures/policy/valid/vault-passphrase-env.json +14 -0
- package/schema/fixtures/policy/valid/wildcards.json +15 -0
- package/schema/fixtures/policy-md/invalid/alias-bomb.md +15 -0
- package/schema/fixtures/policy-md/invalid/no-fence.md +7 -0
- package/schema/fixtures/policy-md/invalid/protected-route-not-a-subclass.md +16 -0
- package/schema/fixtures/policy-md/invalid/schema-invalid-autonomy.md +16 -0
- package/schema/fixtures/policy-md/invalid/schema-invalid-read-proof.md +17 -0
- package/schema/fixtures/policy-md/invalid/two-fences.md +19 -0
- package/schema/fixtures/policy-md/invalid/unclosed-fence.md +11 -0
- package/schema/fixtures/policy-md/invalid/wrong-info-string.md +11 -0
- package/schema/fixtures/policy-md/invalid/yaml-syntax-error.md +13 -0
- package/schema/fixtures/policy-md/precedence/both/APPROVAL.md +7 -0
- package/schema/fixtures/policy-md/precedence/both/APPROVALS.md +7 -0
- package/schema/fixtures/policy-md/precedence/fallback-only/APPROVALS.md +7 -0
- package/schema/fixtures/policy-md/valid/canonical.md +50 -0
- package/schema/fixtures/policy-md/valid/daemon-read-proof.md +18 -0
- package/schema/fixtures/policy-md/valid/minimal.md +3 -0
- package/schema/fixtures/policy-md/valid/prose-lookalikes.md +54 -0
- package/schema/fixtures/policy-md/valid/routed-protected-paths.md +49 -0
- package/schema/fixtures/policy-md/valid/with-values.md +79 -0
- package/schema/fixtures/sample-record/invalid/bad-date-time.json +4 -0
- package/schema/fixtures/sample-record/invalid/missing-required-field.json +3 -0
- package/schema/fixtures/sample-record/invalid/unknown-top-level-field.json +5 -0
- package/schema/fixtures/sample-record/invalid/wrong-type.json +4 -0
- package/schema/fixtures/sample-record/valid/minimal.json +4 -0
- package/schema/fixtures/sample-record/valid/with-note.json +5 -0
- package/schema/fixtures/values/invalid/class-shaped.json +9 -0
- package/schema/fixtures/values/invalid/duplicate-entry.json +4 -0
- package/schema/fixtures/values/invalid/non-string-item.json +4 -0
- package/schema/fixtures/values/invalid/over-cap.json +26 -0
- package/schema/fixtures/values/invalid/unknown-key.json +5 -0
- package/schema/fixtures/values/invalid/version-string.json +1 -0
- package/schema/fixtures/values/valid/empty-lists.json +7 -0
- package/schema/fixtures/values/valid/full.json +20 -0
- package/schema/fixtures/values/valid/minimal.json +1 -0
- package/schema/fixtures/values-md/invalid/schema-invalid.md +62 -0
- package/schema/fixtures/values-md/invalid/two-blocks.md +69 -0
- package/schema/fixtures/values-md/invalid/unterminated.md +61 -0
- package/schema/fixtures/values-md/invalid/yaml-error.md +63 -0
- package/schema/fixtures/values-md/valid/absent.md +50 -0
- package/schema/fixtures/values-md/valid/with-values.md +79 -0
- package/schema/policy.schema.json +501 -0
- package/schema/sample-record.schema.json +26 -0
- package/schema/values.schema.json +55 -0
- package/templates/codex/README.md +9 -0
|
@@ -0,0 +1,2171 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `approval policy amend` (APRV-30) — the one verb that owns the whole
|
|
3
|
+
* amendment ceremony: diff, advise, confirm, attest, commit.
|
|
4
|
+
*
|
|
5
|
+
* ## The two incidents this verb exists to prevent
|
|
6
|
+
*
|
|
7
|
+
* **seq 2 of this repository's own log — the seven-minute amendment.** A
|
|
8
|
+
* policy edit was attested and superseded seven minutes later (seq 2 at
|
|
9
|
+
* 11:56:07Z, seq 3 at 12:03:35Z), because the
|
|
10
|
+
* edit broke a pinned dogfood assertion and nobody found out until the test
|
|
11
|
+
* suite ran. The operator attested bytes whose *consequences* had never been
|
|
12
|
+
* shown to them. `amend` shows the semantic diff and the load advisory BEFORE
|
|
13
|
+
* asking for the sign-off, so the failure that superseded seq 2 would have been
|
|
14
|
+
* on screen while the human was deciding — and with `--require-load` it would
|
|
15
|
+
* have refused to attest at all.
|
|
16
|
+
*
|
|
17
|
+
* **The unsigned interregnum — commit `f829e6c` and its attestation.** The
|
|
18
|
+
* policy-editing commit and the attestation that made the edit operative landed
|
|
19
|
+
* as two separate commits. In between, the repository carried an inoperative
|
|
20
|
+
* policy: `checkAttestation` said `hash-mismatch` and every gate operation
|
|
21
|
+
* refused. `amend` closes that window by making the attestation and the git
|
|
22
|
+
* commit one ceremony — it prints, or with `--commit` runs, the exact two-file
|
|
23
|
+
* `git add` + `git commit` that lands the policy edit and its attestation
|
|
24
|
+
* together, and it validates the `--commit` preconditions BEFORE it attests, so
|
|
25
|
+
* a refusal can never leave a half-finished amendment behind.
|
|
26
|
+
*
|
|
27
|
+
* ## The baseline problem, stated plainly (FLAGGED FOR HUMAN REVIEW)
|
|
28
|
+
*
|
|
29
|
+
* A semantic diff needs the previously-attested policy *text*. The log does not
|
|
30
|
+
* have it. An attestation records only the SHA-256 of the bytes — deliberately,
|
|
31
|
+
* since the log is meant to be exported and copied and a policy body in it
|
|
32
|
+
* would be a second source of truth. So the attested bytes are **not
|
|
33
|
+
* recoverable from the log**, and this verb does not pretend otherwise.
|
|
34
|
+
*
|
|
35
|
+
* What it does instead, at v0.1: when the policy file lives in a git
|
|
36
|
+
* repository, it recovers `HEAD:<path>` and hashes it. **Only if that blob's
|
|
37
|
+
* hash equals the attested hash** is it used as the diff baseline — the point
|
|
38
|
+
* being that we can then prove the text we are diffing against is exactly the
|
|
39
|
+
* text that was signed for. Anything else (not a git repo, no such blob, or a
|
|
40
|
+
* blob whose hash differs from the attestation) drops to **hash-only mode**: a
|
|
41
|
+
* loud notice that the semantic diff is unavailable, followed by the load
|
|
42
|
+
* advisory and the attestation, which still work. No `--baseline` flag is
|
|
43
|
+
* offered; a baseline the operator supplies by hand is a baseline nobody can
|
|
44
|
+
* verify, which is exactly the assurance this design refuses to fake.
|
|
45
|
+
*
|
|
46
|
+
* The limitation is real and worth a human's judgment: an amendment made
|
|
47
|
+
* outside git, or one made on top of an unattested working-tree edit, gets no
|
|
48
|
+
* semantic diff. Flagged rather than smoothed over.
|
|
49
|
+
*
|
|
50
|
+
* ## What this file does and does not decide
|
|
51
|
+
*
|
|
52
|
+
* As everywhere else in the CLI: the diff is `core/policy-diff.ts`, loading is
|
|
53
|
+
* `core/policy-load.ts`, matching is `core/policy-match.ts`, hashing and the
|
|
54
|
+
* append are `core/attest.ts`. This file resolves paths and identity, shells out
|
|
55
|
+
* to git, decides exit codes, and formats output.
|
|
56
|
+
*/
|
|
57
|
+
import { spawnSync } from "node:child_process";
|
|
58
|
+
import { createHash } from "node:crypto";
|
|
59
|
+
import { accessSync, constants, existsSync, mkdtempSync, readFileSync, rmSync, statSync, writeFileSync, } from "node:fs";
|
|
60
|
+
import { tmpdir } from "node:os";
|
|
61
|
+
import { basename, dirname, isAbsolute, join, relative, resolve as resolvePathSegments, sep } from "node:path";
|
|
62
|
+
import { HUMAN_ACTOR_ENV, appendAttestation, checkAttestation, policyFileHash, resolveHumanActor, } from "../core/attest.js";
|
|
63
|
+
import { compareChains } from "../core/log-reconcile.js";
|
|
64
|
+
import { diffPolicies, renderDiff, SPEC_NAMESPACES } from "../core/policy-diff.js";
|
|
65
|
+
import { checkPolicyExpectations, describeFailure, describePinChange, diffPinSources, expectationsFor, DOGFOOD_SUITE_BUILT, DOGFOOD_SUITE_SOURCE, EXPECTATIONS_MODULE, } from "../core/policy-expectations.js";
|
|
66
|
+
import { loadPolicy, parseDuration, POLICY_FILENAMES, } from "../core/policy-load.js";
|
|
67
|
+
import { proposalState, proposeAttestation, } from "../core/policy-proposal.js";
|
|
68
|
+
import { readVerifiedRecords } from "../core/state.js";
|
|
69
|
+
import { boolFlag, parseFlags, stringFlag } from "./args.js";
|
|
70
|
+
import { EXIT_INTEGRITY, EXIT_IO, EXIT_OK, EXIT_TORN_TAIL, EXIT_USAGE, } from "./exit-codes.js";
|
|
71
|
+
import { POLICY_AMEND_HELP } from "./help.js";
|
|
72
|
+
import { DEFAULT_LOG_PATH, resolvePath } from "./paths.js";
|
|
73
|
+
import { GIT_OUTPUT_LIMIT_BYTES, commitOnBase, fetchBase, showBlob } from "./git-scope.js";
|
|
74
|
+
import { createProgress, silentProgress } from "./progress.js";
|
|
75
|
+
import { readLineFromStdin } from "./prompt.js";
|
|
76
|
+
import { refusal as renderRefusal, relPath, runbook, shortHash, style, } from "./style.js";
|
|
77
|
+
import { usageErrorText } from "./usage.js";
|
|
78
|
+
const FLAGS = {
|
|
79
|
+
"--policy": "string",
|
|
80
|
+
"--dir": "string",
|
|
81
|
+
"--log": "string",
|
|
82
|
+
"--as": "string",
|
|
83
|
+
"--require-load": "boolean",
|
|
84
|
+
"--dry-run": "boolean",
|
|
85
|
+
"--commit": "boolean",
|
|
86
|
+
"--no-publish": "boolean",
|
|
87
|
+
"--branch": "string",
|
|
88
|
+
"--direct": "boolean",
|
|
89
|
+
"--yes": "boolean",
|
|
90
|
+
// APRV-109: the agent path's two knobs. `--wait` is how long this process
|
|
91
|
+
// holds the ceremony open for the approver's tap, and `--interval` how often
|
|
92
|
+
// it re-reads the log. Both are ignored under a human identity, where the
|
|
93
|
+
// human act is the confirmation this process already asks for.
|
|
94
|
+
"--wait": "string",
|
|
95
|
+
"--interval": "string",
|
|
96
|
+
"--note": "string",
|
|
97
|
+
"--json": "boolean",
|
|
98
|
+
"--help": "boolean",
|
|
99
|
+
"-h": "boolean",
|
|
100
|
+
};
|
|
101
|
+
/**
|
|
102
|
+
* How long the agent path waits for a tap when `--wait` is not given
|
|
103
|
+
* (APRV-109).
|
|
104
|
+
*
|
|
105
|
+
* Long enough that a phone left face-down through a meeting still collects the
|
|
106
|
+
* decision, short enough that a forgotten `amend` does not hold a worktree
|
|
107
|
+
* open overnight. A lapse attests nothing, so the cost of the timeout being
|
|
108
|
+
* too short is a re-run.
|
|
109
|
+
*/
|
|
110
|
+
const DEFAULT_ATTESTATION_WAIT_MS = 15 * 60 * 1000;
|
|
111
|
+
/** How often the agent path re-reads the log while waiting. */
|
|
112
|
+
const DEFAULT_ATTESTATION_INTERVAL_MS = 2000;
|
|
113
|
+
/** An agent identity, the one `--as` form that routes to the channel path. */
|
|
114
|
+
const AGENT_ACTOR = /^agent:.+/u;
|
|
115
|
+
/** Synchronous sleep with no dependency and no busy-spin (as `cli/execute.ts`). */
|
|
116
|
+
function sleepSync(ms) {
|
|
117
|
+
if (ms <= 0)
|
|
118
|
+
return;
|
|
119
|
+
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
|
|
120
|
+
}
|
|
121
|
+
function detail(cause) {
|
|
122
|
+
return cause instanceof Error ? cause.message : String(cause);
|
|
123
|
+
}
|
|
124
|
+
function absolute(value, cwd) {
|
|
125
|
+
return isAbsolute(value) ? value : resolvePathSegments(cwd, value);
|
|
126
|
+
}
|
|
127
|
+
function usageError(streams, json, message) {
|
|
128
|
+
if (json)
|
|
129
|
+
streams.err(`${JSON.stringify({ ok: false, error: { code: "usage", message } })}\n`);
|
|
130
|
+
else
|
|
131
|
+
streams.err(usageErrorText(message, POLICY_AMEND_HELP));
|
|
132
|
+
return EXIT_USAGE;
|
|
133
|
+
}
|
|
134
|
+
/**
|
|
135
|
+
* A refusal, on both surfaces.
|
|
136
|
+
*
|
|
137
|
+
* `message` is the FROZEN machine surface: it is what `--json` carries and what
|
|
138
|
+
* the tests pin. `human` (APRV-129) is an alternative rendering of the same
|
|
139
|
+
* facts for a terminal, the runbook shape, for the refusals a human has to act
|
|
140
|
+
* on step by step. Passing it changes nothing a machine reads.
|
|
141
|
+
*/
|
|
142
|
+
function refuse(streams, json, code, message, exitCode, human,
|
|
143
|
+
/**
|
|
144
|
+
* The ceremony's own outcome, ADDITIVE (APRV-130). A refusal that arrives
|
|
145
|
+
* AFTER the attestation is a refusal of a sub-step, and a machine caller has
|
|
146
|
+
* to be able to see that split without parsing the message: `ceremony` and
|
|
147
|
+
* `publishing` ride alongside the frozen `{ok:false,error:{…}}`, never
|
|
148
|
+
* inside it.
|
|
149
|
+
*/
|
|
150
|
+
extra) {
|
|
151
|
+
if (json) {
|
|
152
|
+
streams.err(`${JSON.stringify({ ok: false, error: { code, message }, ...extra })}\n`);
|
|
153
|
+
}
|
|
154
|
+
else if (human !== undefined)
|
|
155
|
+
streams.err(`${human}\n`);
|
|
156
|
+
else
|
|
157
|
+
streams.err(`approval: ${message}\n`);
|
|
158
|
+
return exitCode;
|
|
159
|
+
}
|
|
160
|
+
// ---------------------------------------------------------------------------
|
|
161
|
+
// Policy path resolution (mirrors `cli/attest.ts`, deliberately)
|
|
162
|
+
// ---------------------------------------------------------------------------
|
|
163
|
+
/**
|
|
164
|
+
* Is this path a readable regular file? As in `policy attest` — and unlike
|
|
165
|
+
* `policy check` — an absent policy file is an I/O error and not an answer:
|
|
166
|
+
* there is no fail-closed reading of "amend a file that is not there".
|
|
167
|
+
*/
|
|
168
|
+
function readableFile(path) {
|
|
169
|
+
let stats;
|
|
170
|
+
try {
|
|
171
|
+
stats = statSync(path);
|
|
172
|
+
}
|
|
173
|
+
catch (cause) {
|
|
174
|
+
return { ok: false, message: `policy ${path} could not be opened: ${detail(cause)}` };
|
|
175
|
+
}
|
|
176
|
+
if (stats.isDirectory()) {
|
|
177
|
+
return { ok: false, message: `policy ${path} is a directory, not a policy file` };
|
|
178
|
+
}
|
|
179
|
+
try {
|
|
180
|
+
accessSync(path, constants.R_OK);
|
|
181
|
+
}
|
|
182
|
+
catch (cause) {
|
|
183
|
+
return { ok: false, message: `policy ${path} is not readable: ${detail(cause)}` };
|
|
184
|
+
}
|
|
185
|
+
return { ok: true };
|
|
186
|
+
}
|
|
187
|
+
/**
|
|
188
|
+
* `--policy` wins outright; otherwise discovery walks `POLICY_FILENAMES` in
|
|
189
|
+
* `dir` and amends whichever file discovery would have loaded — the same
|
|
190
|
+
* precedence as `loadPolicy` and `policy attest`, so the amended file, the
|
|
191
|
+
* attested file, and the enforced file are never three different files.
|
|
192
|
+
*/
|
|
193
|
+
function resolvePolicyPath(policyFlag, dir, cwd) {
|
|
194
|
+
if (policyFlag !== null) {
|
|
195
|
+
const path = absolute(policyFlag, cwd);
|
|
196
|
+
const check = readableFile(path);
|
|
197
|
+
return check.ok ? { ok: true, path } : { ok: false, message: check.message };
|
|
198
|
+
}
|
|
199
|
+
for (const filename of POLICY_FILENAMES) {
|
|
200
|
+
const candidate = join(dir, filename);
|
|
201
|
+
try {
|
|
202
|
+
statSync(candidate);
|
|
203
|
+
}
|
|
204
|
+
catch {
|
|
205
|
+
continue;
|
|
206
|
+
}
|
|
207
|
+
const check = readableFile(candidate);
|
|
208
|
+
return check.ok ? { ok: true, path: candidate } : { ok: false, message: check.message };
|
|
209
|
+
}
|
|
210
|
+
return {
|
|
211
|
+
ok: false,
|
|
212
|
+
message: `no policy file found in ${dir} (looked for ${POLICY_FILENAMES.join(", ")}); an amendment needs a file to hash`,
|
|
213
|
+
};
|
|
214
|
+
}
|
|
215
|
+
function git(args, cwd) {
|
|
216
|
+
const result = spawnSync("git", args, { cwd, encoding: "utf8" });
|
|
217
|
+
if (result.error !== undefined || result.status === null) {
|
|
218
|
+
return { ok: false, stdout: "", stderr: detail(result.error ?? "git did not run") };
|
|
219
|
+
}
|
|
220
|
+
return { ok: result.status === 0, stdout: result.stdout, stderr: result.stderr };
|
|
221
|
+
}
|
|
222
|
+
/** The repository root containing `dir`, or `null` when there is none. */
|
|
223
|
+
function repoRoot(dir) {
|
|
224
|
+
const result = git(["rev-parse", "--show-toplevel"], dir);
|
|
225
|
+
if (!result.ok)
|
|
226
|
+
return null;
|
|
227
|
+
const root = result.stdout.trim();
|
|
228
|
+
return root.length === 0 ? null : root;
|
|
229
|
+
}
|
|
230
|
+
/** A repo-relative, forward-slashed path, as git spells it. */
|
|
231
|
+
function repoPath(root, path) {
|
|
232
|
+
return relative(root, path).split(sep).join("/");
|
|
233
|
+
}
|
|
234
|
+
/**
|
|
235
|
+
* The bytes of `HEAD:<relative>`, or `null` when git has no such blob.
|
|
236
|
+
*
|
|
237
|
+
* Read as a Buffer, never as text: the baseline is compared by SHA-256 against
|
|
238
|
+
* an attestation over exact bytes, and an encoding round-trip would silently
|
|
239
|
+
* change what is being compared.
|
|
240
|
+
*
|
|
241
|
+
* The explicit `maxBuffer` is the second half of the same lesson the anchor
|
|
242
|
+
* check learned the hard way: `spawnSync`'s one-mebibyte default KILLS the
|
|
243
|
+
* child, and a check that reads `null` as "HEAD has no such file" then reports
|
|
244
|
+
* a missing baseline for a file git is holding. This one reads the policy file,
|
|
245
|
+
* which is nowhere near the ceiling today — and neither was the log.
|
|
246
|
+
*/
|
|
247
|
+
function showHead(root, relative_) {
|
|
248
|
+
const result = spawnSync("git", ["show", `HEAD:${relative_}`], {
|
|
249
|
+
cwd: root,
|
|
250
|
+
maxBuffer: GIT_OUTPUT_LIMIT_BYTES,
|
|
251
|
+
});
|
|
252
|
+
if (result.error !== undefined || result.status !== 0)
|
|
253
|
+
return null;
|
|
254
|
+
return result.stdout;
|
|
255
|
+
}
|
|
256
|
+
/** The checked-out branch, or `null` on a detached HEAD. */
|
|
257
|
+
function currentBranch(root) {
|
|
258
|
+
const result = git(["symbolic-ref", "--quiet", "--short", "HEAD"], root);
|
|
259
|
+
if (!result.ok)
|
|
260
|
+
return null;
|
|
261
|
+
const name = result.stdout.trim();
|
|
262
|
+
return name.length === 0 ? null : name;
|
|
263
|
+
}
|
|
264
|
+
/**
|
|
265
|
+
* The remote's default branch, from `refs/remotes/origin/HEAD` when the clone
|
|
266
|
+
* recorded one, else from `gh`. Both are read-only lookups.
|
|
267
|
+
*/
|
|
268
|
+
function defaultBranchOf(root) {
|
|
269
|
+
const symbolic = git(["symbolic-ref", "--quiet", "--short", "refs/remotes/origin/HEAD"], root);
|
|
270
|
+
if (symbolic.ok) {
|
|
271
|
+
const name = symbolic.stdout.trim().replace(/^origin\//u, "");
|
|
272
|
+
if (name.length > 0)
|
|
273
|
+
return name;
|
|
274
|
+
}
|
|
275
|
+
const view = spawnSync("gh", ["repo", "view", "--json", "defaultBranchRef", "-q", ".defaultBranchRef.name"], {
|
|
276
|
+
cwd: root,
|
|
277
|
+
encoding: "utf8",
|
|
278
|
+
});
|
|
279
|
+
if (view.error === undefined && view.status === 0) {
|
|
280
|
+
const name = view.stdout.trim();
|
|
281
|
+
if (name.length > 0)
|
|
282
|
+
return name;
|
|
283
|
+
}
|
|
284
|
+
return null;
|
|
285
|
+
}
|
|
286
|
+
/** One `gh api <endpoint>` read, classified. `--silent` is NOT passed: the rulesets read needs the body. */
|
|
287
|
+
function ghApi(root, endpoint) {
|
|
288
|
+
const run = spawnSync("gh", ["api", endpoint], { cwd: root, encoding: "utf8" });
|
|
289
|
+
if (run.error !== undefined || run.status === null)
|
|
290
|
+
return { kind: "absent" };
|
|
291
|
+
if (run.status === 0)
|
|
292
|
+
return { kind: "ok", stdout: `${run.stdout}` };
|
|
293
|
+
const stderr = `${run.stderr}`;
|
|
294
|
+
if (/404|Branch not protected|Not Found/iu.test(stderr))
|
|
295
|
+
return { kind: "not-found" };
|
|
296
|
+
return { kind: "error", detail: stderr.trim().split("\n")[0] ?? "no detail" };
|
|
297
|
+
}
|
|
298
|
+
/**
|
|
299
|
+
* The rule types a rulesets answer lists, or `null` when the body is not a JSON
|
|
300
|
+
* array. An empty array is the honest "no rules" and answers `[]`.
|
|
301
|
+
*/
|
|
302
|
+
function ruleTypes(body) {
|
|
303
|
+
let parsed;
|
|
304
|
+
try {
|
|
305
|
+
parsed = JSON.parse(body);
|
|
306
|
+
}
|
|
307
|
+
catch {
|
|
308
|
+
return null;
|
|
309
|
+
}
|
|
310
|
+
if (!Array.isArray(parsed))
|
|
311
|
+
return null;
|
|
312
|
+
return parsed.map((rule) => {
|
|
313
|
+
if (typeof rule === "object" && rule !== null && typeof rule.type === "string") {
|
|
314
|
+
return rule.type;
|
|
315
|
+
}
|
|
316
|
+
return "rule";
|
|
317
|
+
});
|
|
318
|
+
}
|
|
319
|
+
/**
|
|
320
|
+
* Ask GitHub whether the default branch is protected.
|
|
321
|
+
*
|
|
322
|
+
* Two read-only probes, because GitHub has two ways to protect a branch and
|
|
323
|
+
* answers each from its own endpoint (APRV-232). Classic branch protection
|
|
324
|
+
* lives at `repos/{owner}/{repo}/branches/<branch>/protection`, which answers
|
|
325
|
+
* 200 when it is set and 404 when it is not. Repository rulesets (the merge
|
|
326
|
+
* queue, required checks and the rest of what governs this project's own main)
|
|
327
|
+
* are invisible there: the classic endpoint answers 404 for a branch a ruleset
|
|
328
|
+
* governs, and a probe that stopped at that answer concluded "unprotected",
|
|
329
|
+
* pushed, and printed the remote's GH013 rejection at the human's one hands-on
|
|
330
|
+
* moment. The rules that apply to a branch are listed at
|
|
331
|
+
* `repos/{owner}/{repo}/rules/branches/<branch>`: a non-empty array is
|
|
332
|
+
* protected, an empty array (or a 404) is no rules.
|
|
333
|
+
*
|
|
334
|
+
* Resolution: either probe protected => protected. Classic 404 AND rulesets
|
|
335
|
+
* empty => unprotected. Anything else (gh absent, not a GitHub remote, an
|
|
336
|
+
* unauthenticated or under-scoped token, a body that is not JSON) is `unknown`.
|
|
337
|
+
* The classic probe answering protected ends the lookup; the rulesets endpoint
|
|
338
|
+
* is read only when classic could not prove protection.
|
|
339
|
+
*/
|
|
340
|
+
function probeProtection(root) {
|
|
341
|
+
const branch = currentBranch(root);
|
|
342
|
+
const target = defaultBranchOf(root);
|
|
343
|
+
if (target === null) {
|
|
344
|
+
return {
|
|
345
|
+
protection: "unknown",
|
|
346
|
+
defaultBranch: null,
|
|
347
|
+
currentBranch: branch,
|
|
348
|
+
reason: "no default branch could be resolved (no origin/HEAD and no gh answer)",
|
|
349
|
+
};
|
|
350
|
+
}
|
|
351
|
+
const answer = (protection, reason) => ({
|
|
352
|
+
protection,
|
|
353
|
+
defaultBranch: target,
|
|
354
|
+
currentBranch: branch,
|
|
355
|
+
reason,
|
|
356
|
+
});
|
|
357
|
+
const classic = ghApi(root, `repos/{owner}/{repo}/branches/${target}/protection`);
|
|
358
|
+
if (classic.kind === "absent") {
|
|
359
|
+
return answer("unknown", "gh is not on PATH, so branch protection could not be read");
|
|
360
|
+
}
|
|
361
|
+
if (classic.kind === "ok") {
|
|
362
|
+
return answer("protected", `gh reports branch protection on ${target}`);
|
|
363
|
+
}
|
|
364
|
+
const rules = ghApi(root, `repos/{owner}/{repo}/rules/branches/${target}`);
|
|
365
|
+
const types = rules.kind === "ok" ? ruleTypes(rules.stdout) : null;
|
|
366
|
+
if (types !== null && types.length > 0) {
|
|
367
|
+
return answer("protected", `gh reports a ruleset on ${target} (${[...new Set(types)].join(", ")})`);
|
|
368
|
+
}
|
|
369
|
+
const noRules = rules.kind === "not-found" || (types !== null && types.length === 0);
|
|
370
|
+
if (classic.kind === "not-found" && noRules) {
|
|
371
|
+
return answer("unprotected", `gh reports no branch protection and no ruleset on ${target}`);
|
|
372
|
+
}
|
|
373
|
+
const classicDetail = classic.kind === "not-found" ? "no classic branch protection" : `classic protection unreadable (${classic.detail})`;
|
|
374
|
+
const rulesDetail = rules.kind === "absent"
|
|
375
|
+
? "gh did not run"
|
|
376
|
+
: rules.kind === "error"
|
|
377
|
+
? `rulesets unreadable (${rules.detail})`
|
|
378
|
+
: rules.kind === "ok" && types === null
|
|
379
|
+
? "rulesets answer was not a JSON array"
|
|
380
|
+
: "no ruleset";
|
|
381
|
+
return answer("unknown", `gh could not settle protection on ${target}: ${classicDetail}; ${rulesDetail}`);
|
|
382
|
+
}
|
|
383
|
+
/** Does this repository have an `origin` to push to? */
|
|
384
|
+
function hasOrigin(root) {
|
|
385
|
+
return git(["remote", "get-url", "origin"], root).ok;
|
|
386
|
+
}
|
|
387
|
+
/**
|
|
388
|
+
* Everything git said about a failed push, on one line (APRV-111).
|
|
389
|
+
*
|
|
390
|
+
* A rejection's useful text is spread over four lines — the remote's own
|
|
391
|
+
* message, the `! [remote rejected]` line, and git's summary — and the refusal
|
|
392
|
+
* that carries it is a single message string. They are joined rather than
|
|
393
|
+
* trimmed to the first line, because "which ref, rejected by what" lives in
|
|
394
|
+
* different lines depending on who did the rejecting.
|
|
395
|
+
*/
|
|
396
|
+
function pushFailureText(run) {
|
|
397
|
+
const lines = commandOutputLines(run.stderr, run.stdout);
|
|
398
|
+
return lines.length === 0 ? "git printed nothing" : lines.join(" | ");
|
|
399
|
+
}
|
|
400
|
+
/**
|
|
401
|
+
* The same output, kept as LINES (APRV-129).
|
|
402
|
+
*
|
|
403
|
+
* The joined form above exists because a `--json` message is one string. A
|
|
404
|
+
* terminal has no such constraint, and the remote's own four lines are exactly
|
|
405
|
+
* the part the reader needs to see as the remote wrote them, indented under the
|
|
406
|
+
* headline rather than folded into a sentence.
|
|
407
|
+
*/
|
|
408
|
+
function commandOutputLines(...texts) {
|
|
409
|
+
return texts
|
|
410
|
+
.join("\n")
|
|
411
|
+
.split("\n")
|
|
412
|
+
.map((line) => line.trim())
|
|
413
|
+
.filter((line) => line.length > 0);
|
|
414
|
+
}
|
|
415
|
+
/**
|
|
416
|
+
* Why the pull request is merged with a merge commit, in one line (APRV-129).
|
|
417
|
+
*
|
|
418
|
+
* It used to be an inline essay in the middle of the recovery commands. The
|
|
419
|
+
* reasoning did not get shorter; it moved to the reference, and what stays here
|
|
420
|
+
* is the rule and where to read about it.
|
|
421
|
+
*/
|
|
422
|
+
const MERGE_COMMIT_LINE = "why a MERGE COMMIT: the policy edit and its attestation stay one commit on main (docs/cli-reference.md, `policy amend`)";
|
|
423
|
+
/**
|
|
424
|
+
* How a local branch gets back onto its remote, safely (APRV-129).
|
|
425
|
+
*
|
|
426
|
+
* This line replaces a `git reset --hard origin/<branch>` that the recovery
|
|
427
|
+
* used to end on. With an uncommitted working log, a hard reset rewinds
|
|
428
|
+
* `events.jsonl` underneath the daemon that is appending to it: the fork
|
|
429
|
+
* mechanism, printed as advice. APRV-125 turned the safe sequence into a verb,
|
|
430
|
+
* so what this points at is a command now rather than a runbook.
|
|
431
|
+
*/
|
|
432
|
+
const LOG_SAFE_PULL_LINE = "then `approval log sync` rather than a pull: it holds the append lock, snapshots the log, fast-forwards and reconciles the chain (a hard reset would rewind the working log under the daemon)";
|
|
433
|
+
/** Is `gh` runnable at all? Used to decide whether the PR is opened or printed. */
|
|
434
|
+
function ghAvailable(root) {
|
|
435
|
+
const probe = spawnSync("gh", ["--version"], { cwd: root, encoding: "utf8" });
|
|
436
|
+
return probe.error === undefined && probe.status === 0;
|
|
437
|
+
}
|
|
438
|
+
/** `gh`, run the way {@link git} runs git: never throws, always answers. */
|
|
439
|
+
function gh(args, cwd) {
|
|
440
|
+
const result = spawnSync("gh", args, { cwd, encoding: "utf8" });
|
|
441
|
+
if (result.error !== undefined || result.status === null) {
|
|
442
|
+
return { ok: false, stdout: "", stderr: detail(result.error ?? "gh did not run") };
|
|
443
|
+
}
|
|
444
|
+
return { ok: result.status === 0, stdout: result.stdout, stderr: result.stderr };
|
|
445
|
+
}
|
|
446
|
+
/** The last URL `gh` printed, which is where `gh pr create` puts the PR. */
|
|
447
|
+
function lastUrl(text) {
|
|
448
|
+
const urls = text
|
|
449
|
+
.split("\n")
|
|
450
|
+
.map((line) => line.trim())
|
|
451
|
+
.filter((line) => line.startsWith("http"));
|
|
452
|
+
return urls[urls.length - 1] ?? null;
|
|
453
|
+
}
|
|
454
|
+
/**
|
|
455
|
+
* How a pull request is named to a human: `#7` when the URL carries a number,
|
|
456
|
+
* the URL itself otherwise. "PR #7 opened" is the sentence the operator repeats
|
|
457
|
+
* back; a bare URL is not.
|
|
458
|
+
*/
|
|
459
|
+
function prLabel(url) {
|
|
460
|
+
if (url === null)
|
|
461
|
+
return "the pull request";
|
|
462
|
+
const number = /\/pull\/(\d+)/u.exec(url)?.[1];
|
|
463
|
+
return number === undefined ? url : `PR #${number}`;
|
|
464
|
+
}
|
|
465
|
+
/** The pull request title. It names the seq, so the PR is findable from the log. */
|
|
466
|
+
function prTitle(summary, seq) {
|
|
467
|
+
return `Policy: ${summary} (attested seq ${seq})`;
|
|
468
|
+
}
|
|
469
|
+
/**
|
|
470
|
+
* The pull request body: the one-commit rule, and the merge instruction.
|
|
471
|
+
*
|
|
472
|
+
* One line on purpose. It is printed inside a `gh pr create --body "…"` command
|
|
473
|
+
* the human may copy, and a body with embedded newlines does not survive that
|
|
474
|
+
* copy intact.
|
|
475
|
+
*/
|
|
476
|
+
function prBody(seq) {
|
|
477
|
+
return (`This branch carries exactly one commit: the policy edit and the attestation (seq ${seq}) that names its hash. ` +
|
|
478
|
+
"They have to stay together on main, because a main that carries the policy without its attestation is a main where every gate operation refuses. " +
|
|
479
|
+
"Merge with a MERGE COMMIT so the policy edit and its attestation stay one commit on main. " +
|
|
480
|
+
"A squash or a rebase would also keep the two files together; a merge commit is the convention here, because it puts the attested commit itself on main with the hash the attestation names.");
|
|
481
|
+
}
|
|
482
|
+
/**
|
|
483
|
+
* Recover the last-attested policy text, or say honestly that we cannot.
|
|
484
|
+
*
|
|
485
|
+
* The only accepted baseline is a `HEAD` blob whose SHA-256 equals the attested
|
|
486
|
+
* hash. See the module header: an unverifiable baseline would produce a diff
|
|
487
|
+
* that looks authoritative and is not.
|
|
488
|
+
*/
|
|
489
|
+
function recoverBaseline(policyPath, attestedSha256) {
|
|
490
|
+
const unavailable = (reason) => ({
|
|
491
|
+
baseline: { mode: "unavailable", reason },
|
|
492
|
+
load: null,
|
|
493
|
+
bytes: null,
|
|
494
|
+
scratch: null,
|
|
495
|
+
});
|
|
496
|
+
if (attestedSha256 === null) {
|
|
497
|
+
return unavailable("the policy has never been attested, so there is no previous state to diff against");
|
|
498
|
+
}
|
|
499
|
+
const root = repoRoot(dirname(policyPath));
|
|
500
|
+
if (root === null) {
|
|
501
|
+
return unavailable("the policy file is not inside a git repository, and the attested BYTES are not recoverable from the log (an attestation records only their SHA-256)");
|
|
502
|
+
}
|
|
503
|
+
const blob = showHead(root, repoPath(root, policyPath));
|
|
504
|
+
if (blob === null) {
|
|
505
|
+
return unavailable(`git has no HEAD:${repoPath(root, policyPath)} blob to recover`);
|
|
506
|
+
}
|
|
507
|
+
const blobSha256 = createHash("sha256").update(blob).digest("hex");
|
|
508
|
+
if (blobSha256 !== attestedSha256) {
|
|
509
|
+
return unavailable(`HEAD:${repoPath(root, policyPath)} hashes ${blobSha256}, which is not the attested ${attestedSha256}; a baseline nobody can verify is not a baseline`);
|
|
510
|
+
}
|
|
511
|
+
const scratchDir = mkdtempSync(join(tmpdir(), "approval-amend-"));
|
|
512
|
+
const scratch = join(scratchDir, basename(policyPath));
|
|
513
|
+
writeFileSync(scratch, blob);
|
|
514
|
+
return {
|
|
515
|
+
baseline: { mode: "git-head", reason: null },
|
|
516
|
+
load: loadPolicy({ file: scratch }),
|
|
517
|
+
bytes: blob,
|
|
518
|
+
scratch: scratchDir,
|
|
519
|
+
};
|
|
520
|
+
}
|
|
521
|
+
/**
|
|
522
|
+
* Did the pins file move between `rev` and the working tree (APRV-274)?
|
|
523
|
+
*
|
|
524
|
+
* The seq 23351 ceremony is the reason this exists. The pins in
|
|
525
|
+
* `src/core/policy-expectations.ts` are part of an amendment's contract: CI's
|
|
526
|
+
* dogfood suite resolves the amended policy against them, and it reads both out
|
|
527
|
+
* of the same commit. The verb used to refuse a commit carrying anything but
|
|
528
|
+
* the policy and the log, so the pins had to be unstaged before the ceremony
|
|
529
|
+
* and cherry-picked onto the amendment branch after the push, which is four
|
|
530
|
+
* hand steps and two red CI runs for one policy edit.
|
|
531
|
+
*
|
|
532
|
+
* `rev` is the commit the amendment is being BUILT ON, not `HEAD`: the commit is
|
|
533
|
+
* assembled on the remote's tip, so the remote's tip is the thing this file is
|
|
534
|
+
* moving away from. Where a ceremony has no base yet (the report-only and
|
|
535
|
+
* `--dry-run` paths, which build nothing) `HEAD` stands in for it.
|
|
536
|
+
*
|
|
537
|
+
* `null` is "the pins are not part of this ceremony", for all three of its
|
|
538
|
+
* reasons: the policy is not one these pins govern, the file is not there, and
|
|
539
|
+
* the file is byte-for-byte what `rev` carries.
|
|
540
|
+
*/
|
|
541
|
+
function pinsChangeIn(root, policyPath, rev) {
|
|
542
|
+
if (expectationsFor(policyPath) === null)
|
|
543
|
+
return null;
|
|
544
|
+
const path = join(root, EXPECTATIONS_MODULE);
|
|
545
|
+
let working;
|
|
546
|
+
try {
|
|
547
|
+
working = readFileSync(path, "utf8");
|
|
548
|
+
}
|
|
549
|
+
catch {
|
|
550
|
+
return null;
|
|
551
|
+
}
|
|
552
|
+
const based = showBlob(root, rev, EXPECTATIONS_MODULE);
|
|
553
|
+
const before = based === null ? "" : based.toString("utf8");
|
|
554
|
+
if (before === working)
|
|
555
|
+
return null;
|
|
556
|
+
return { arg: EXPECTATIONS_MODULE, path, changes: diffPinSources(before, working) };
|
|
557
|
+
}
|
|
558
|
+
/**
|
|
559
|
+
* How long the ceremony waits for the dogfood suite before giving up on it.
|
|
560
|
+
*
|
|
561
|
+
* Generous, because the cost of being wrong in each direction is not
|
|
562
|
+
* symmetrical: a suite that needed one more second reports `dogfood-suite-failed`
|
|
563
|
+
* and the operator re-runs a ceremony that has attested nothing, while a
|
|
564
|
+
* ceremony that gave up early and called it green is the outcome this whole
|
|
565
|
+
* check exists to prevent. The suite reads one policy file and resolves a few
|
|
566
|
+
* dozen classes; it does not go near this ceiling.
|
|
567
|
+
*/
|
|
568
|
+
const DOGFOOD_SUITE_TIMEOUT_MS = 5 * 60 * 1000;
|
|
569
|
+
/** At most this many failing test names ride in a refusal; the rest are counted. */
|
|
570
|
+
const DOGFOOD_FAILURES_SHOWN = 5;
|
|
571
|
+
/**
|
|
572
|
+
* The names of the tests a TAP stream reported as failing.
|
|
573
|
+
*
|
|
574
|
+
* `not ok <n> - <name>`, at any indent, because a failing subtest is a failing
|
|
575
|
+
* test and its name is the one that says what broke. A trailing TAP directive
|
|
576
|
+
* (`# SKIP`, `# TODO`) is not part of the name. Duplicates collapse: one test
|
|
577
|
+
* can be reported at its own level and again in its parent's summary, and a
|
|
578
|
+
* refusal naming the same test twice reads as two failures. A name that is an
|
|
579
|
+
* absolute path is the runner's own FILE-level line, which some versions emit
|
|
580
|
+
* beside the failing test and which tells an operator nothing they did not
|
|
581
|
+
* already know from the code they are reading.
|
|
582
|
+
*/
|
|
583
|
+
function failedTapTests(output) {
|
|
584
|
+
const names = new Set();
|
|
585
|
+
for (const line of output.split("\n")) {
|
|
586
|
+
const matched = /^\s*not ok\s+\d+\s*-?\s*(.*)$/u.exec(line);
|
|
587
|
+
if (matched === null)
|
|
588
|
+
continue;
|
|
589
|
+
const name = (matched[1] ?? "").replace(/\s+#\s.*$/u, "").trim();
|
|
590
|
+
if (name.length > 0 && !name.startsWith("/"))
|
|
591
|
+
names.add(name);
|
|
592
|
+
}
|
|
593
|
+
return [...names];
|
|
594
|
+
}
|
|
595
|
+
/**
|
|
596
|
+
* How many tests a TAP stream says it ran, or `null` when it did not say.
|
|
597
|
+
*
|
|
598
|
+
* The counterpart to {@link failedTapTests} and the reason a green exit is not
|
|
599
|
+
* taken at face value: a runner that ran NOTHING also exits 0, and a ceremony
|
|
600
|
+
* that read that as a passing suite would be reporting the strongest possible
|
|
601
|
+
* evidence from the weakest possible run.
|
|
602
|
+
*/
|
|
603
|
+
function tapTestCount(output) {
|
|
604
|
+
const matched = /^#\s+tests\s+(\d+)\s*$/mu.exec(output);
|
|
605
|
+
return matched === null ? null : Number(matched[1]);
|
|
606
|
+
}
|
|
607
|
+
/** The failing tests as one clause, capped so a refusal stays readable. */
|
|
608
|
+
function summarizeFailures(tests) {
|
|
609
|
+
if (tests.length === 0)
|
|
610
|
+
return "the suite exited non-zero and named no test";
|
|
611
|
+
const shown = tests.slice(0, DOGFOOD_FAILURES_SHOWN);
|
|
612
|
+
const rest = tests.length - shown.length;
|
|
613
|
+
return `${shown.join("; ")}${rest > 0 ? ` (and ${String(rest)} more)` : ""}`;
|
|
614
|
+
}
|
|
615
|
+
/**
|
|
616
|
+
* Run this repository's dogfood suite against the amended policy (APRV-274).
|
|
617
|
+
*
|
|
618
|
+
* It is run rather than reimplemented: `tests/dogfood.test.ts` reads the live
|
|
619
|
+
* `APPROVAL.md` off disk and imports the BUILT pins, so running the built suite
|
|
620
|
+
* from the repository root asks exactly the question CI asks, of exactly the
|
|
621
|
+
* bytes this ceremony is about to attest. A second copy of those assertions
|
|
622
|
+
* inside the verb would be a second thing to keep in step with the first.
|
|
623
|
+
*
|
|
624
|
+
* Fail closed in four directions. A build output that is missing beside a
|
|
625
|
+
* present source is refused rather than skipped, because the suite would
|
|
626
|
+
* otherwise read the PREVIOUS build's pins and answer for an edit nobody made.
|
|
627
|
+
* A suite that could not be spawned, or that ran past its timeout, is refused
|
|
628
|
+
* for the same reason: nothing was established. A suite that exits 0 having
|
|
629
|
+
* reported no tests is refused too, because a green exit over an empty run is
|
|
630
|
+
* the strongest-looking evidence this check could report and the weakest there
|
|
631
|
+
* is. And a repository with no dogfood suite in source is skipped outright,
|
|
632
|
+
* which is what keeps a shipped CLI from running this repository's tests inside
|
|
633
|
+
* somebody else's checkout.
|
|
634
|
+
*
|
|
635
|
+
* `NODE_TEST_CONTEXT` is stripped from the child's environment, and that line is
|
|
636
|
+
* load-bearing rather than tidy. `node:test` responds to that variable by
|
|
637
|
+
* declining to run files recursively: it prints a warning, runs nothing, and
|
|
638
|
+
* EXITS 0. The variable is set for anything the test runner itself spawned, so
|
|
639
|
+
* a ceremony run from inside a test (which is how this code is exercised) read a
|
|
640
|
+
* red suite as green and published the whole amendment. The empty-run guard
|
|
641
|
+
* above does not catch that case on its own, because the runner still counts the
|
|
642
|
+
* file it declined to run as one passing test.
|
|
643
|
+
*/
|
|
644
|
+
function runDogfoodSuite(root) {
|
|
645
|
+
if (!existsSync(join(root, DOGFOOD_SUITE_SOURCE)))
|
|
646
|
+
return { kind: "skipped" };
|
|
647
|
+
const built = join(root, DOGFOOD_SUITE_BUILT);
|
|
648
|
+
if (!existsSync(built))
|
|
649
|
+
return { kind: "not-built" };
|
|
650
|
+
const env = { ...process.env };
|
|
651
|
+
// See the header: with this set, `node:test` runs nothing and exits 0.
|
|
652
|
+
delete env["NODE_TEST_CONTEXT"];
|
|
653
|
+
const run = spawnSync(process.execPath, ["--test", "--test-reporter=tap", built], {
|
|
654
|
+
cwd: root,
|
|
655
|
+
encoding: "utf8",
|
|
656
|
+
env,
|
|
657
|
+
timeout: DOGFOOD_SUITE_TIMEOUT_MS,
|
|
658
|
+
maxBuffer: GIT_OUTPUT_LIMIT_BYTES,
|
|
659
|
+
});
|
|
660
|
+
if (run.error !== undefined || run.status === null) {
|
|
661
|
+
return {
|
|
662
|
+
kind: "unrunnable",
|
|
663
|
+
detail: run.error === undefined
|
|
664
|
+
? `the suite produced no exit status (it may have run past its ${String(DOGFOOD_SUITE_TIMEOUT_MS / 1000)}s limit)`
|
|
665
|
+
: run.error.message,
|
|
666
|
+
};
|
|
667
|
+
}
|
|
668
|
+
const output = `${run.stdout}\n${run.stderr}`;
|
|
669
|
+
if (run.status === 0) {
|
|
670
|
+
const count = tapTestCount(output);
|
|
671
|
+
if (count === null || count === 0) {
|
|
672
|
+
return {
|
|
673
|
+
kind: "unrunnable",
|
|
674
|
+
detail: count === null
|
|
675
|
+
? "the suite exited 0 and reported no test count at all"
|
|
676
|
+
: "the suite exited 0 having run 0 tests",
|
|
677
|
+
};
|
|
678
|
+
}
|
|
679
|
+
return { kind: "passed" };
|
|
680
|
+
}
|
|
681
|
+
return { kind: "failed", tests: failedTapTests(output) };
|
|
682
|
+
}
|
|
683
|
+
/**
|
|
684
|
+
* A `dogfood-suite-failed` refusal, on both surfaces.
|
|
685
|
+
*
|
|
686
|
+
* One function for the three arms so the machine message and the runbook can
|
|
687
|
+
* never drift into telling an operator two different stories about the same
|
|
688
|
+
* outcome. Every arm ends the same way: nothing was attested.
|
|
689
|
+
*/
|
|
690
|
+
function dogfoodRefusal(suite, st) {
|
|
691
|
+
const rerun = `node --test ${DOGFOOD_SUITE_BUILT}`;
|
|
692
|
+
const shared = {
|
|
693
|
+
state: [
|
|
694
|
+
"nothing was attested: the policy edit is still only a working-tree change",
|
|
695
|
+
"nothing was committed and nothing was pushed",
|
|
696
|
+
],
|
|
697
|
+
footer: [
|
|
698
|
+
"this is the suite CI runs: failing it here costs a minute, failing it there costs a red pull request carrying an attestation",
|
|
699
|
+
],
|
|
700
|
+
};
|
|
701
|
+
if (suite.kind === "not-built") {
|
|
702
|
+
return {
|
|
703
|
+
message: `the dogfood suite ${DOGFOOD_SUITE_SOURCE} is not built: there is no ${DOGFOOD_SUITE_BUILT} to run, so the pins a ceremony would check are the ones the last build compiled`,
|
|
704
|
+
human: runbook(st, "dogfood-suite-failed", "the dogfood suite is not built", {
|
|
705
|
+
...shared,
|
|
706
|
+
steps: [
|
|
707
|
+
{ command: "npm run build", note: "the ceremony runs the BUILT suite and the built pins" },
|
|
708
|
+
{ command: "approval policy amend --commit", note: "re-run; it starts over cleanly" },
|
|
709
|
+
],
|
|
710
|
+
}),
|
|
711
|
+
};
|
|
712
|
+
}
|
|
713
|
+
if (suite.kind === "unrunnable") {
|
|
714
|
+
return {
|
|
715
|
+
message: `the dogfood suite ${DOGFOOD_SUITE_SOURCE} could not be run (${suite.detail}), so nothing about the amended policy was established`,
|
|
716
|
+
human: runbook(st, "dogfood-suite-failed", "the dogfood suite could not be run", {
|
|
717
|
+
...shared,
|
|
718
|
+
quote: [suite.detail],
|
|
719
|
+
steps: [
|
|
720
|
+
{ command: rerun, note: "run it yourself and see what it says" },
|
|
721
|
+
{ command: "approval policy amend --commit", note: "re-run once it runs" },
|
|
722
|
+
],
|
|
723
|
+
}),
|
|
724
|
+
};
|
|
725
|
+
}
|
|
726
|
+
return {
|
|
727
|
+
message: `the dogfood suite ${DOGFOOD_SUITE_SOURCE} is RED against the amended policy: ${summarizeFailures(suite.tests)}`,
|
|
728
|
+
human: runbook(st, "dogfood-suite-failed", "the dogfood suite is red against the amended policy", {
|
|
729
|
+
...shared,
|
|
730
|
+
state: [...shared.state, ...suite.tests.slice(0, DOGFOOD_FAILURES_SHOWN)],
|
|
731
|
+
steps: [
|
|
732
|
+
{ command: rerun, note: "the same run, with the whole output" },
|
|
733
|
+
{
|
|
734
|
+
command: `$EDITOR ${EXPECTATIONS_MODULE}`,
|
|
735
|
+
note: "when it is the pins that have to move with this amendment",
|
|
736
|
+
},
|
|
737
|
+
{ command: "npm run build", note: "the ceremony runs the BUILT suite and the built pins" },
|
|
738
|
+
{ command: "approval policy amend --commit", note: "re-run; it starts over cleanly" },
|
|
739
|
+
],
|
|
740
|
+
}),
|
|
741
|
+
};
|
|
742
|
+
}
|
|
743
|
+
/**
|
|
744
|
+
* The one-line summary that becomes the commit subject.
|
|
745
|
+
*
|
|
746
|
+
* The pins ride in it on the same footing as the class resolutions (APRV-274):
|
|
747
|
+
* they are part of the amendment's contract, they are in the commit, and a
|
|
748
|
+
* subject that named only the policy would describe a commit carrying more than
|
|
749
|
+
* it said. A pins file that moved without moving any pin (a comment, a note) is
|
|
750
|
+
* still named, because it is still in the commit.
|
|
751
|
+
*/
|
|
752
|
+
function summarize(policyPath, diff, pins) {
|
|
753
|
+
const name = basename(policyPath);
|
|
754
|
+
const pinPart = pins === null
|
|
755
|
+
? null
|
|
756
|
+
: pins.changes.length === 0
|
|
757
|
+
? "pins file"
|
|
758
|
+
: `${pins.changes.length} pin(s)`;
|
|
759
|
+
if (diff === null) {
|
|
760
|
+
return `amend ${name} (semantic diff unavailable${pinPart === null ? "" : `, ${pinPart}`})`;
|
|
761
|
+
}
|
|
762
|
+
const parts = [];
|
|
763
|
+
if (diff.classes.length > 0)
|
|
764
|
+
parts.push(`${diff.classes.length} class resolution(s)`);
|
|
765
|
+
if (diff.approvers.length > 0)
|
|
766
|
+
parts.push(`${diff.approvers.length} approver change(s)`);
|
|
767
|
+
if (diff.defaults.length > 0)
|
|
768
|
+
parts.push(`${diff.defaults.length} default(s)`);
|
|
769
|
+
if (diff.budgets.length > 0)
|
|
770
|
+
parts.push(`${diff.budgets.length} limit(s)`);
|
|
771
|
+
if (diff.vocabulary.length > 0)
|
|
772
|
+
parts.push(`${diff.vocabulary.length} policy key(s)`);
|
|
773
|
+
if (pinPart !== null)
|
|
774
|
+
parts.push(pinPart);
|
|
775
|
+
if (parts.length === 0)
|
|
776
|
+
return `amend ${name} (no semantic change)`;
|
|
777
|
+
return `amend ${name}: ${parts.join(", ")}`;
|
|
778
|
+
}
|
|
779
|
+
/**
|
|
780
|
+
* Is there a channel that could carry an attestation prompt to a human?
|
|
781
|
+
*
|
|
782
|
+
* FAIL CLOSED, and this is the check that makes the agent path safe to offer at
|
|
783
|
+
* all. A proposal appended into a repository with no configured channel is a
|
|
784
|
+
* question nobody will ever be asked, and the verb would then sit through its
|
|
785
|
+
* whole `--wait` before reporting a timeout that was decidable at the start.
|
|
786
|
+
* Worse, the proposal would stay in the log looking like an outstanding ask.
|
|
787
|
+
*
|
|
788
|
+
* A policy that does not LOAD is the same answer for a stronger reason: the
|
|
789
|
+
* channel table is in the policy, so an unloadable policy is one whose channel
|
|
790
|
+
* configuration is unknown, and "unknown" resolves to the stricter path here
|
|
791
|
+
* exactly as it does everywhere else.
|
|
792
|
+
*/
|
|
793
|
+
function channelConfigured(load) {
|
|
794
|
+
if (!load.ok)
|
|
795
|
+
return false;
|
|
796
|
+
const channels = load.policy.channels;
|
|
797
|
+
return channels !== undefined && Object.keys(channels).length > 0;
|
|
798
|
+
}
|
|
799
|
+
/**
|
|
800
|
+
* Ask a human to attest the prepared bytes, and wait for the answer.
|
|
801
|
+
*
|
|
802
|
+
* The whole of what APRV-109 adds to this verb. It appends a `policy.proposed`
|
|
803
|
+
* through `core/policy-proposal.ts` — which computes the hash, the semantic
|
|
804
|
+
* diff and the load advisory from the bytes, and refuses `diff-too-large`
|
|
805
|
+
* rather than truncating — and then polls the VERIFIED log until the proposal
|
|
806
|
+
* reaches a terminal state.
|
|
807
|
+
*
|
|
808
|
+
* Only one of those states continues the ceremony. `attested` returns the seq of
|
|
809
|
+
* the `policy.updated` the tap appended, and the caller's git half proceeds
|
|
810
|
+
* unchanged, citing that seq in the commit exactly as it cites a terminal
|
|
811
|
+
* attestation's today. `declined`, `expired`, `superseded` and a lapsed `--wait`
|
|
812
|
+
* all attest nothing and commit nothing: the policy edit stays in the working
|
|
813
|
+
* tree, as unattested as it was before the verb ran.
|
|
814
|
+
*
|
|
815
|
+
* This process never appends the attestation and never holds a human identity.
|
|
816
|
+
* The tap does both, in the channel listener, under the human identity that
|
|
817
|
+
* listener is configured with — the same identity, from the same configuration,
|
|
818
|
+
* that every grant already lands under (SPEC.md §11, unchanged).
|
|
819
|
+
*/
|
|
820
|
+
function collectAttestation(input) {
|
|
821
|
+
const { streams, json, st, logPath, policyPath, cwd } = input;
|
|
822
|
+
if (!channelConfigured(input.liveLoad)) {
|
|
823
|
+
return {
|
|
824
|
+
ok: false,
|
|
825
|
+
code: "no-channel",
|
|
826
|
+
exitCode: EXIT_USAGE,
|
|
827
|
+
message: input.liveLoad.ok
|
|
828
|
+
? `no channel is configured in ${basename(policyPath)}, so an attestation prompt has nowhere to go; nothing was proposed and nothing was attested. Configure a channel, or attest at a terminal with --as human:<id>`
|
|
829
|
+
: `the policy does not load (${input.liveLoad.code}), so which channel would carry the attestation prompt is unknown; nothing was proposed and nothing was attested. Fix the policy, or attest at a terminal with --as human:<id>`,
|
|
830
|
+
};
|
|
831
|
+
}
|
|
832
|
+
const waitUntil = new Date(Date.now() + input.waitMs).toISOString();
|
|
833
|
+
const proposed = proposeAttestation(logPath, {
|
|
834
|
+
policyPath,
|
|
835
|
+
baseline: input.baseline,
|
|
836
|
+
waitUntil,
|
|
837
|
+
...(input.note === null ? {} : { note: input.note }),
|
|
838
|
+
}, input.actor);
|
|
839
|
+
if (!proposed.ok) {
|
|
840
|
+
// A gate refusal, so exit 1 rather than 4: the command was well-formed and
|
|
841
|
+
// the runtime said no. `diff-too-large` is the one a caller acts on — read
|
|
842
|
+
// the diff at a terminal — and it rides in the message with its own code.
|
|
843
|
+
return {
|
|
844
|
+
ok: false,
|
|
845
|
+
code: "propose-failed",
|
|
846
|
+
exitCode: proposed.code === "append-failed" ? EXIT_IO : EXIT_INTEGRITY,
|
|
847
|
+
message: `${proposed.code}: ${proposed.message}`,
|
|
848
|
+
};
|
|
849
|
+
}
|
|
850
|
+
const proposedSeq = proposed.record.seq;
|
|
851
|
+
if (!json) {
|
|
852
|
+
streams.out(`${st.glyph("ok")} proposed seq ${String(proposedSeq)} — an approver has been asked to attest ${relPath(policyPath, cwd)}\n`);
|
|
853
|
+
for (const line of st
|
|
854
|
+
.table([
|
|
855
|
+
{ left: "sha256", right: shortHash(proposed.sha256) },
|
|
856
|
+
{ left: "changes", right: proposed.diff.headline },
|
|
857
|
+
{ left: "loads", right: proposed.load.ok ? "clean" : `NO (${proposed.load.code ?? "?"})` },
|
|
858
|
+
{ left: "waiting until", right: waitUntil },
|
|
859
|
+
])
|
|
860
|
+
.split("\n")) {
|
|
861
|
+
streams.out(` ${line}\n`);
|
|
862
|
+
}
|
|
863
|
+
streams.out("\n");
|
|
864
|
+
}
|
|
865
|
+
const deadline = Date.now() + input.waitMs;
|
|
866
|
+
for (;;) {
|
|
867
|
+
const read = readVerifiedRecords(logPath);
|
|
868
|
+
if (!read.ok) {
|
|
869
|
+
return {
|
|
870
|
+
ok: false,
|
|
871
|
+
code: read.code === "log-torn-tail" ? "log-torn-tail" : "log-unreadable",
|
|
872
|
+
exitCode: read.code === "log-torn-tail" ? EXIT_TORN_TAIL : EXIT_IO,
|
|
873
|
+
message: `${read.message}; the attestation prompt at seq ${String(proposedSeq)} is unanswered and nothing was attested`,
|
|
874
|
+
};
|
|
875
|
+
}
|
|
876
|
+
const derived = proposalState(read.records, proposedSeq, new Date().toISOString());
|
|
877
|
+
const state = derived?.state ?? "open";
|
|
878
|
+
if (state === "attested") {
|
|
879
|
+
// The tap's own record, found by the hash it names. `proposalState` proved
|
|
880
|
+
// one exists; this recovers its seq, which is what the commit cites.
|
|
881
|
+
const attestation = read.records.find((entry) => entry.seq > proposedSeq &&
|
|
882
|
+
entry.event === "policy.updated" &&
|
|
883
|
+
typeof entry.payload === "object" &&
|
|
884
|
+
entry.payload !== null &&
|
|
885
|
+
entry.payload["sha256"] === proposed.sha256);
|
|
886
|
+
if (attestation === undefined) {
|
|
887
|
+
return {
|
|
888
|
+
ok: false,
|
|
889
|
+
code: "log-unreadable",
|
|
890
|
+
exitCode: EXIT_IO,
|
|
891
|
+
message: `the attestation prompt at seq ${String(proposedSeq)} derives as attested and no policy.updated naming ${proposed.sha256} could be found; nothing was committed`,
|
|
892
|
+
};
|
|
893
|
+
}
|
|
894
|
+
return {
|
|
895
|
+
ok: true,
|
|
896
|
+
seq: attestation.seq,
|
|
897
|
+
proposedSeq,
|
|
898
|
+
sha256: proposed.sha256,
|
|
899
|
+
diff: proposed.diff,
|
|
900
|
+
load: proposed.load,
|
|
901
|
+
};
|
|
902
|
+
}
|
|
903
|
+
if (state === "declined") {
|
|
904
|
+
return {
|
|
905
|
+
ok: false,
|
|
906
|
+
code: "attestation-declined",
|
|
907
|
+
exitCode: EXIT_INTEGRITY,
|
|
908
|
+
message: `the approver DECLINED the attestation prompt at seq ${String(proposedSeq)}; nothing was attested and nothing was committed. The policy edit is still in the working tree, and the policy in force is the one that was in force before this ran`,
|
|
909
|
+
};
|
|
910
|
+
}
|
|
911
|
+
if (state === "superseded") {
|
|
912
|
+
return {
|
|
913
|
+
ok: false,
|
|
914
|
+
code: "attestation-timeout",
|
|
915
|
+
exitCode: EXIT_INTEGRITY,
|
|
916
|
+
message: `the attestation prompt at seq ${String(proposedSeq)} was SUPERSEDED by a later proposal for the same policy; nothing was attested here. Re-run the amendment against the bytes now on disk`,
|
|
917
|
+
};
|
|
918
|
+
}
|
|
919
|
+
if (state === "expired" || Date.now() >= deadline) {
|
|
920
|
+
return {
|
|
921
|
+
ok: false,
|
|
922
|
+
code: "attestation-timeout",
|
|
923
|
+
exitCode: EXIT_INTEGRITY,
|
|
924
|
+
message: `no answer arrived for the attestation prompt at seq ${String(proposedSeq)} before its deadline (${waitUntil}); nothing was attested and nothing was committed. The prompt retires itself: it leaves every channel queue by derivation, so no stale question is left in front of the approver`,
|
|
925
|
+
};
|
|
926
|
+
}
|
|
927
|
+
sleepSync(Math.min(input.intervalMs, Math.max(0, deadline - Date.now())));
|
|
928
|
+
}
|
|
929
|
+
}
|
|
930
|
+
/** `approval policy amend …` — the whole ceremony, in one verb. */
|
|
931
|
+
export function commandPolicyAmend(argv, streams, cwd) {
|
|
932
|
+
const json = argv.includes("--json");
|
|
933
|
+
// Asked BEFORE anything is printed, which is what makes `--json` an absolute
|
|
934
|
+
// veto on colour for this process (see `style.ts`'s header).
|
|
935
|
+
const st = style({ json });
|
|
936
|
+
const parsed = parseFlags(argv, FLAGS);
|
|
937
|
+
if (!parsed.ok)
|
|
938
|
+
return usageError(streams, json, parsed.message);
|
|
939
|
+
if (boolFlag(parsed.flags, "--help") || boolFlag(parsed.flags, "-h")) {
|
|
940
|
+
streams.out(`${POLICY_AMEND_HELP}\n`);
|
|
941
|
+
return EXIT_OK;
|
|
942
|
+
}
|
|
943
|
+
const extra = parsed.positionals[0];
|
|
944
|
+
if (extra !== undefined) {
|
|
945
|
+
return usageError(streams, json, `unexpected argument ${JSON.stringify(extra)}`);
|
|
946
|
+
}
|
|
947
|
+
const dryRun = boolFlag(parsed.flags, "--dry-run");
|
|
948
|
+
const requireLoad = boolFlag(parsed.flags, "--require-load");
|
|
949
|
+
const wantCommit = boolFlag(parsed.flags, "--commit");
|
|
950
|
+
// APRV-130: the ceremony publishes by default (push, and on a protected main
|
|
951
|
+
// branch + push + PR). `--no-publish` is the operator who wants it to stop at
|
|
952
|
+
// the commit, which is what `--commit` did before the publishing half existed.
|
|
953
|
+
const noPublish = boolFlag(parsed.flags, "--no-publish");
|
|
954
|
+
const assumeYes = boolFlag(parsed.flags, "--yes");
|
|
955
|
+
const branchFlag = stringFlag(parsed.flags, "--branch");
|
|
956
|
+
const forceDirect = boolFlag(parsed.flags, "--direct");
|
|
957
|
+
if (branchFlag !== null && forceDirect) {
|
|
958
|
+
return usageError(streams, json, "--branch and --direct ask for opposite ceremonies; pass one of them, or neither and let the protection probe decide");
|
|
959
|
+
}
|
|
960
|
+
if (branchFlag !== null && branchFlag.trim().length === 0) {
|
|
961
|
+
return usageError(streams, json, "--branch expects a branch name");
|
|
962
|
+
}
|
|
963
|
+
// Identity first, before a byte is read. Asking a human to read a diff and
|
|
964
|
+
// only then telling them their sign-off cannot be attributed wastes the one
|
|
965
|
+
// resource this system spends.
|
|
966
|
+
//
|
|
967
|
+
// APRV-109 widens WHO may run the verb without widening who may attest. Under
|
|
968
|
+
// a human identity everything below is byte-for-byte what it was: the diff,
|
|
969
|
+
// the advisory, the terminal confirmation, `appendAttestation`. Under an
|
|
970
|
+
// AGENT identity the same preparation runs and then stops at the one act an
|
|
971
|
+
// agent must not perform — instead of attesting, it appends a `policy.proposed`
|
|
972
|
+
// and waits for a human's tap to append the attestation under the human
|
|
973
|
+
// identity the channel listener holds. The agent never holds that identity and
|
|
974
|
+
// never writes a `policy.updated`; `core/policy-proposal.ts` refuses it in
|
|
975
|
+
// code and `schema/event.schema.json` refuses it at the write boundary.
|
|
976
|
+
const asFlag = stringFlag(parsed.flags, "--as");
|
|
977
|
+
const agentActor = asFlag !== null && AGENT_ACTOR.test(asFlag) ? asFlag : null;
|
|
978
|
+
const actor = agentActor ?? resolveHumanActor(asFlag === null ? {} : { actor: asFlag });
|
|
979
|
+
if (actor === null) {
|
|
980
|
+
return usageError(streams, json, asFlag === null
|
|
981
|
+
? `no identity: set ${HUMAN_ACTOR_ENV}=human:<id>, or pass --as human:<id> to attest here or --as agent:<id> to ask an approver to attest through a channel`
|
|
982
|
+
: `--as expects human:<id> or agent:<id>, got ${JSON.stringify(asFlag)}; an amendment is attested, and under an agent identity the attestation is collected as a tap rather than performed here`);
|
|
983
|
+
}
|
|
984
|
+
// APRV-109. The wait knobs are refused outright under a human identity rather
|
|
985
|
+
// than quietly ignored: an operator who passed `--wait` believes they asked
|
|
986
|
+
// for the channel ceremony, and a verb that attested on the spot instead would
|
|
987
|
+
// be answering a question they did not ask.
|
|
988
|
+
const waitText = stringFlag(parsed.flags, "--wait");
|
|
989
|
+
const intervalText = stringFlag(parsed.flags, "--interval");
|
|
990
|
+
const proposalNote = stringFlag(parsed.flags, "--note");
|
|
991
|
+
if (agentActor === null && (waitText !== null || intervalText !== null)) {
|
|
992
|
+
return usageError(streams, json, "--wait and --interval belong to the channel ceremony, which runs under --as agent:<id>; under a human identity the amendment is attested here and there is no tap to wait for");
|
|
993
|
+
}
|
|
994
|
+
const waitMs = waitText === null ? DEFAULT_ATTESTATION_WAIT_MS : parseDuration(waitText);
|
|
995
|
+
if (waitMs === null) {
|
|
996
|
+
return usageError(streams, json, `--wait expects a duration like 30s, 10m, 6h, got ${JSON.stringify(waitText)}`);
|
|
997
|
+
}
|
|
998
|
+
const intervalMs = intervalText === null ? DEFAULT_ATTESTATION_INTERVAL_MS : parseDuration(intervalText);
|
|
999
|
+
if (intervalMs === null) {
|
|
1000
|
+
return usageError(streams, json, `--interval expects a duration like 500ms, 2s, got ${JSON.stringify(intervalText)}`);
|
|
1001
|
+
}
|
|
1002
|
+
const dirFlag = stringFlag(parsed.flags, "--dir");
|
|
1003
|
+
const dir = dirFlag === null ? cwd : absolute(dirFlag, cwd);
|
|
1004
|
+
const policy = resolvePolicyPath(stringFlag(parsed.flags, "--policy"), dir, cwd);
|
|
1005
|
+
if (!policy.ok)
|
|
1006
|
+
return refuse(streams, json, "io", policy.message, EXIT_IO);
|
|
1007
|
+
const policyPath = policy.path;
|
|
1008
|
+
let liveSha256;
|
|
1009
|
+
try {
|
|
1010
|
+
liveSha256 = policyFileHash(policyPath);
|
|
1011
|
+
}
|
|
1012
|
+
catch (cause) {
|
|
1013
|
+
return refuse(streams, json, "io", `policy ${policyPath} could not be read: ${detail(cause)}`, EXIT_IO);
|
|
1014
|
+
}
|
|
1015
|
+
const logPath = resolvePath(stringFlag(parsed.flags, "--log"), DEFAULT_LOG_PATH, cwd);
|
|
1016
|
+
// (a-pre) The thirty-three seconds of silence, ended (APRV-167).
|
|
1017
|
+
//
|
|
1018
|
+
// Everything from here to the `Policy` block below is work the operator could
|
|
1019
|
+
// not see: a full chain re-verification, then a baseline recovery that shells
|
|
1020
|
+
// out to git. The verb said nothing until all of it was done, which read as a
|
|
1021
|
+
// hang — one ceremony was abandoned mid-run over it and left this repository's
|
|
1022
|
+
// gate fail-closed for every agent session until the next attempt.
|
|
1023
|
+
//
|
|
1024
|
+
// SILENT UNDER `--json`, and this is not a stylistic choice. This verb's
|
|
1025
|
+
// machine surface is not stdout alone: a refusal under `--json` emits its
|
|
1026
|
+
// error OBJECT on stderr (see `refuse`), and every caller parses that stream
|
|
1027
|
+
// whole. Narration mixed into it would be a parse error in every machine
|
|
1028
|
+
// consumer of a refusal — the progress meter would have broken the thing it
|
|
1029
|
+
// was added beside. A human is the only reader who benefits from these lines,
|
|
1030
|
+
// and `--json` is exactly the flag that says there is no human.
|
|
1031
|
+
const progress = json ? silentProgress : createProgress(streams);
|
|
1032
|
+
progress.phase("verifying the log chain before anything is read from it");
|
|
1033
|
+
const read = readVerifiedRecords(logPath, {
|
|
1034
|
+
onProgress: ({ done, total }) => {
|
|
1035
|
+
progress.step(done, total);
|
|
1036
|
+
},
|
|
1037
|
+
});
|
|
1038
|
+
progress.done();
|
|
1039
|
+
if (!read.ok) {
|
|
1040
|
+
const exitCode = read.code === "log-torn-tail"
|
|
1041
|
+
? EXIT_TORN_TAIL
|
|
1042
|
+
: read.code === "log-corrupt"
|
|
1043
|
+
? EXIT_INTEGRITY
|
|
1044
|
+
: EXIT_IO;
|
|
1045
|
+
return refuse(streams, json, read.code, read.message, exitCode);
|
|
1046
|
+
}
|
|
1047
|
+
const status = checkAttestation(read.records, policyPath);
|
|
1048
|
+
const attested = status.status === "attested"
|
|
1049
|
+
? { sha256: status.sha256, seq: status.seq }
|
|
1050
|
+
: status.status === "hash-mismatch"
|
|
1051
|
+
? { sha256: status.attestedSha256, seq: status.seq }
|
|
1052
|
+
: null;
|
|
1053
|
+
// (a) Nothing to amend. A no-op ceremony is a SUCCESS, not an error: an
|
|
1054
|
+
// operator (or a script) that runs `amend` on an already-attested policy has
|
|
1055
|
+
// established exactly what they wanted to establish.
|
|
1056
|
+
if (status.status === "attested") {
|
|
1057
|
+
if (json) {
|
|
1058
|
+
emitReport(streams, {
|
|
1059
|
+
policyPath,
|
|
1060
|
+
liveSha256,
|
|
1061
|
+
attested,
|
|
1062
|
+
baseline: { mode: "unavailable", reason: "the live policy already matches its attestation" },
|
|
1063
|
+
diff: null,
|
|
1064
|
+
load: null,
|
|
1065
|
+
attestation: null,
|
|
1066
|
+
git: null,
|
|
1067
|
+
noop: true,
|
|
1068
|
+
dryRun,
|
|
1069
|
+
aborted: false,
|
|
1070
|
+
});
|
|
1071
|
+
}
|
|
1072
|
+
else {
|
|
1073
|
+
streams.out(`nothing to amend: ${relPath(policyPath, cwd)} already matches its attestation at seq ${status.seq} (sha256 ${shortHash(liveSha256)})\n`);
|
|
1074
|
+
}
|
|
1075
|
+
return EXIT_OK;
|
|
1076
|
+
}
|
|
1077
|
+
// (b) The baseline, and only a verifiable one. See the module header.
|
|
1078
|
+
progress.phase("recovering the attested baseline and diffing it against the live policy");
|
|
1079
|
+
const recovered = recoverBaseline(policyPath, attested?.sha256 ?? null);
|
|
1080
|
+
const liveLoad = loadPolicy({ file: policyPath });
|
|
1081
|
+
const diff = recovered.load === null
|
|
1082
|
+
? null
|
|
1083
|
+
: diffPolicies(recovered.load, liveLoad, SPEC_NAMESPACES);
|
|
1084
|
+
if (recovered.scratch !== null)
|
|
1085
|
+
rmSync(recovered.scratch, { recursive: true, force: true });
|
|
1086
|
+
// Closed here, so the report below starts on a line of its own on a terminal
|
|
1087
|
+
// and after the last phase line everywhere else.
|
|
1088
|
+
progress.done();
|
|
1089
|
+
// (e-pre) Which ceremony this is: the direct one (commit on the branch you
|
|
1090
|
+
// are standing on and push it) or the branch one (branch, commit, push, PR).
|
|
1091
|
+
//
|
|
1092
|
+
// PRECEDENCE, stated once and documented in the help: --branch <name> forces
|
|
1093
|
+
// the branch flow and names the branch; --direct forces the direct flow; the
|
|
1094
|
+
// two together are a usage error. With neither, the protection probe decides,
|
|
1095
|
+
// and it chooses the branch flow only when the default branch is protected
|
|
1096
|
+
// AND that default branch is the one currently checked out. An `unknown`
|
|
1097
|
+
// probe (no gh, no GitHub remote, no network) is the direct flow, which is
|
|
1098
|
+
// what this verb did before protection was detected at all.
|
|
1099
|
+
const amendRoot = repoRoot(dirname(policyPath));
|
|
1100
|
+
const probe = amendRoot === null
|
|
1101
|
+
? {
|
|
1102
|
+
protection: "unknown",
|
|
1103
|
+
defaultBranch: null,
|
|
1104
|
+
currentBranch: null,
|
|
1105
|
+
reason: "the policy file is not inside a git repository",
|
|
1106
|
+
}
|
|
1107
|
+
: probeProtection(amendRoot);
|
|
1108
|
+
const onProtectedDefault = probe.protection === "protected" &&
|
|
1109
|
+
probe.currentBranch !== null &&
|
|
1110
|
+
probe.currentBranch === probe.defaultBranch;
|
|
1111
|
+
const useBranch = branchFlag !== null || (!forceDirect && onProtectedDefault);
|
|
1112
|
+
const branchName = (seq) => branchFlag ?? `policy-amend-${seq}`;
|
|
1113
|
+
// The direct flow's push is about to hit a protected branch. Say so before
|
|
1114
|
+
// the human types it, rather than after GitHub says it.
|
|
1115
|
+
const pushWarning = !useBranch && onProtectedDefault
|
|
1116
|
+
? `${probe.defaultBranch ?? "the default branch"} is protected: this push will be rejected; use --branch`
|
|
1117
|
+
: null;
|
|
1118
|
+
// --commit's preconditions are checked BEFORE anything is written. Refusing
|
|
1119
|
+
// after the attestation would recreate the very interregnum this verb exists
|
|
1120
|
+
// to close: an attested policy with no commit carrying it.
|
|
1121
|
+
let commitPlan = null;
|
|
1122
|
+
/**
|
|
1123
|
+
* The remote tip this ceremony's commit will be parented on (APRV-203).
|
|
1124
|
+
*
|
|
1125
|
+
* Captured HERE, before the attestation, and used unchanged afterwards: a
|
|
1126
|
+
* ceremony that re-read the remote after the tap could build its commit on a
|
|
1127
|
+
* base nobody checked.
|
|
1128
|
+
*/
|
|
1129
|
+
let commitBase = null;
|
|
1130
|
+
/**
|
|
1131
|
+
* The pins file's part in this ceremony (APRV-274), settled before the
|
|
1132
|
+
* attestation like everything else the commit depends on.
|
|
1133
|
+
*/
|
|
1134
|
+
let pinsChange = null;
|
|
1135
|
+
if (wantCommit && !dryRun) {
|
|
1136
|
+
const plan = planCommit(policyPath, logPath, useBranch ? { branch: branchFlag } : null);
|
|
1137
|
+
if (!plan.ok) {
|
|
1138
|
+
return refuse(streams, json, "commit-preconditions", plan.message, EXIT_USAGE);
|
|
1139
|
+
}
|
|
1140
|
+
commitPlan = plan.plan;
|
|
1141
|
+
const prepared = prepareBase({ root: commitPlan.root, policyArg: commitPlan.policyArg, logPath, progress }, probe, attested?.sha256 ?? null);
|
|
1142
|
+
if (!prepared.ok) {
|
|
1143
|
+
return refuse(streams, json, prepared.code, prepared.message, EXIT_IO, prepared.human);
|
|
1144
|
+
}
|
|
1145
|
+
commitBase = prepared.base;
|
|
1146
|
+
// APRV-274: the pins are read against the commit this amendment is BUILT
|
|
1147
|
+
// on, so a pins file that moved rides in the amendment commit and one that
|
|
1148
|
+
// did not is left exactly as the base carries it.
|
|
1149
|
+
pinsChange = pinsChangeIn(commitPlan.root, policyPath, commitBase.sha);
|
|
1150
|
+
// The dogfood pins, run against the AMENDED file before anything is
|
|
1151
|
+
// attested or pushed (APRV-203). A policy edit whose pins nobody updated
|
|
1152
|
+
// used to be found by CI, hours later, on a pull request that was already
|
|
1153
|
+
// open and already carrying an attestation.
|
|
1154
|
+
const expectations = expectationsFor(policyPath);
|
|
1155
|
+
if (expectations !== null) {
|
|
1156
|
+
progress.phase(`running the policy suite against the amended file (${String(expectations.length)} pinned resolutions)`);
|
|
1157
|
+
const checked = checkPolicyExpectations(liveLoad, expectations);
|
|
1158
|
+
progress.done();
|
|
1159
|
+
if (!checked.ok) {
|
|
1160
|
+
// APRV-296: every failure here is a SAFETY class that moved — the pins
|
|
1161
|
+
// name only classes whose loosening is a regression — so the remedy is
|
|
1162
|
+
// a decision about the policy, not a line to paste. The refusal that
|
|
1163
|
+
// printed a pin line went with the `unpinned` failure kind: a declared
|
|
1164
|
+
// class nothing pins is accepted now, and a policy declaring a new
|
|
1165
|
+
// supervised or autonomous class is no longer also a code change.
|
|
1166
|
+
return refuse(streams, json, "policy-suite-failed", `the amended policy does not match its pins: ${checked.failures
|
|
1167
|
+
.map(describeFailure)
|
|
1168
|
+
.join("; ")}. Nothing was attested, committed or pushed`, EXIT_USAGE, runbook(st, "policy-suite-failed", "the amended policy does not match its pins", {
|
|
1169
|
+
state: [
|
|
1170
|
+
"nothing was attested: the policy edit is still only a working-tree change",
|
|
1171
|
+
"nothing was committed and nothing was pushed",
|
|
1172
|
+
...checked.failures.map(describeFailure),
|
|
1173
|
+
],
|
|
1174
|
+
steps: [
|
|
1175
|
+
{
|
|
1176
|
+
command: `$EDITOR APPROVAL.md`,
|
|
1177
|
+
note: "a pinned class moved: the pin says loosening it is a regression, so decide the policy first",
|
|
1178
|
+
},
|
|
1179
|
+
{
|
|
1180
|
+
command: `$EDITOR ${EXPECTATIONS_MODULE}`,
|
|
1181
|
+
note: "or move the pin, when the amendment means what it says and the pin is what is stale",
|
|
1182
|
+
},
|
|
1183
|
+
{ command: "npm run build", note: "the ceremony reads the compiled pins" },
|
|
1184
|
+
{ command: "approval policy amend --commit", note: "re-run; it starts over cleanly" },
|
|
1185
|
+
],
|
|
1186
|
+
footer: [
|
|
1187
|
+
"each pin's note says what loosening that class would cost; the refusal above prints it beside the class",
|
|
1188
|
+
"this is the check CI runs: failing it here costs a minute, failing it there costs a red pull request carrying an attestation",
|
|
1189
|
+
],
|
|
1190
|
+
}));
|
|
1191
|
+
}
|
|
1192
|
+
// APRV-274: and then the whole suite, which is more than the pins. The
|
|
1193
|
+
// seq 23351 ceremony passed the pin check and went red on CI over a
|
|
1194
|
+
// dogfood test about the values block, a test the pin check does not run
|
|
1195
|
+
// and could not have run. Reading `APPROVAL.md` off disk and the pins out
|
|
1196
|
+
// of `dist/`, the built suite asks the ceremony's own question of the
|
|
1197
|
+
// ceremony's own bytes.
|
|
1198
|
+
progress.phase(`running the dogfood suite against the amended file (${DOGFOOD_SUITE_SOURCE})`);
|
|
1199
|
+
const suite = runDogfoodSuite(commitPlan.root);
|
|
1200
|
+
progress.done();
|
|
1201
|
+
if (suite.kind !== "passed" && suite.kind !== "skipped") {
|
|
1202
|
+
const refusal = dogfoodRefusal(suite, st);
|
|
1203
|
+
return refuse(streams, json, "dogfood-suite-failed", `${refusal.message}. Nothing was attested, committed or pushed`, EXIT_USAGE, refusal.human, suite.kind === "failed" ? { dogfood: { suite: DOGFOOD_SUITE_SOURCE, failed: suite.tests } } : undefined);
|
|
1204
|
+
}
|
|
1205
|
+
}
|
|
1206
|
+
}
|
|
1207
|
+
// The report-only and `--dry-run` paths build no commit, so they have no base
|
|
1208
|
+
// to read the pins against; HEAD stands in for one, which is what the printed
|
|
1209
|
+
// `git add` would be run against by hand anyway (APRV-274).
|
|
1210
|
+
if (commitPlan === null && amendRoot !== null) {
|
|
1211
|
+
pinsChange = pinsChangeIn(amendRoot, policyPath, "HEAD");
|
|
1212
|
+
}
|
|
1213
|
+
/**
|
|
1214
|
+
* The same command, with the two long absolute paths written the way the
|
|
1215
|
+
* operator would type them.
|
|
1216
|
+
*
|
|
1217
|
+
* This is a HUMAN transform and nothing else: `--json`'s `git.commands` keeps
|
|
1218
|
+
* the absolute forms, because a machine reading that array has no cwd to
|
|
1219
|
+
* resolve against. `git` itself resolves a relative pathspec against the
|
|
1220
|
+
* process's cwd, so the printed line is still the line that works.
|
|
1221
|
+
*/
|
|
1222
|
+
const humanCommand = (command) => {
|
|
1223
|
+
const shortened = command
|
|
1224
|
+
.split(policyPath)
|
|
1225
|
+
.join(relPath(policyPath, cwd))
|
|
1226
|
+
.split(logPath)
|
|
1227
|
+
.join(relPath(logPath, cwd));
|
|
1228
|
+
return pinsChange === null
|
|
1229
|
+
? shortened
|
|
1230
|
+
: shortened.split(pinsChange.path).join(relPath(pinsChange.path, cwd));
|
|
1231
|
+
};
|
|
1232
|
+
/** A `Label` heading with its body indented under it, then a blank line. */
|
|
1233
|
+
const section = (label, body) => {
|
|
1234
|
+
if (body.length === 0)
|
|
1235
|
+
return;
|
|
1236
|
+
streams.out(`${st.heading(label)}\n`);
|
|
1237
|
+
for (const entry of body) {
|
|
1238
|
+
for (const line of entry.split("\n"))
|
|
1239
|
+
streams.out(line === "" ? "\n" : ` ${line}\n`);
|
|
1240
|
+
}
|
|
1241
|
+
streams.out("\n");
|
|
1242
|
+
};
|
|
1243
|
+
const summary = summarize(policyPath, diff, pinsChange);
|
|
1244
|
+
// APRV-274: the pins file is a member of the `git add` exactly when it moved,
|
|
1245
|
+
// so a copied command lands the same three (or two) files the verb would.
|
|
1246
|
+
const ceremonyFiles = [policyPath, logPath, ...(pinsChange === null ? [] : [pinsChange.path])];
|
|
1247
|
+
const commitCommands = (seq) => [
|
|
1248
|
+
`git add ${ceremonyFiles.join(" ")}`,
|
|
1249
|
+
`git commit -m ${JSON.stringify(`Policy: ${summary} (attested seq ${seq})`)}`,
|
|
1250
|
+
];
|
|
1251
|
+
const gitCommands = (seq) => {
|
|
1252
|
+
// APRV-203: `--commit` runs none of these; it assembles the commit on the
|
|
1253
|
+
// remote's tip without a checkout. These are the HAND procedure, and they
|
|
1254
|
+
// start where `--commit` starts: at the remote, so the branch is not built
|
|
1255
|
+
// on a local trunk that has fallen behind.
|
|
1256
|
+
if (useBranch) {
|
|
1257
|
+
return [
|
|
1258
|
+
"git fetch origin",
|
|
1259
|
+
`git checkout -b ${branchName(seq)} origin/${probe.defaultBranch ?? "main"}`,
|
|
1260
|
+
...commitCommands(seq),
|
|
1261
|
+
`git push -u origin ${branchName(seq)}`,
|
|
1262
|
+
`gh pr create --title ${JSON.stringify(prTitle(summary, seq))} --body ${JSON.stringify(prBody(seq))}`,
|
|
1263
|
+
];
|
|
1264
|
+
}
|
|
1265
|
+
return amendRoot === null
|
|
1266
|
+
? commitCommands(seq)
|
|
1267
|
+
: [...commitCommands(seq), `git push origin ${probe.currentBranch ?? "HEAD"}`];
|
|
1268
|
+
};
|
|
1269
|
+
/**
|
|
1270
|
+
* The paragraph a first-time operator reads: two sentences of why, then the
|
|
1271
|
+
* commands for their situation. It is printed whenever the verb did not run
|
|
1272
|
+
* the commands itself.
|
|
1273
|
+
*/
|
|
1274
|
+
const whyOneCommit = () => {
|
|
1275
|
+
const lines = [
|
|
1276
|
+
st.muted("The policy bytes and the attestation that names their hash have to land in the same commit."),
|
|
1277
|
+
st.muted("If they land separately, then for as long as the gap lasts the branch carries a policy no attestation covers, and every gate operation refuses until the second commit arrives."),
|
|
1278
|
+
"",
|
|
1279
|
+
];
|
|
1280
|
+
if (useBranch) {
|
|
1281
|
+
lines.push(`${probe.protection === "protected" ? `${probe.defaultBranch ?? "the default branch"} is protected, so the commit goes onto a branch and reaches main through a pull request` : "This amendment goes onto a branch and reaches main through a pull request"}. Run these, in order:`);
|
|
1282
|
+
}
|
|
1283
|
+
else {
|
|
1284
|
+
if (pushWarning !== null)
|
|
1285
|
+
lines.push(`${st.fail("WARNING:")} ${pushWarning}`);
|
|
1286
|
+
lines.push("Run these, in order:");
|
|
1287
|
+
}
|
|
1288
|
+
return lines;
|
|
1289
|
+
};
|
|
1290
|
+
// (c) + (d): the report. Human output only; --json emits one object at the end.
|
|
1291
|
+
if (!json) {
|
|
1292
|
+
// `Policy` / `Changes` / `Load`, with the changed resolutions as the visual
|
|
1293
|
+
// centre (APRV-93). The two 64-hex digests that made the old first screen
|
|
1294
|
+
// unreadable are twelve characters each here; the full values are one
|
|
1295
|
+
// `--json` away, and that is the copy a machine should be comparing anyway.
|
|
1296
|
+
const identity = [
|
|
1297
|
+
{ left: "file", right: relPath(policyPath, cwd) },
|
|
1298
|
+
{ left: "live", right: shortHash(liveSha256) },
|
|
1299
|
+
{
|
|
1300
|
+
left: "attested",
|
|
1301
|
+
right: attested === null
|
|
1302
|
+
? "never"
|
|
1303
|
+
: `${shortHash(attested.sha256)} ${st.muted(`(seq ${attested.seq})`)}`,
|
|
1304
|
+
},
|
|
1305
|
+
];
|
|
1306
|
+
section("Policy", [st.table(identity)]);
|
|
1307
|
+
// APRV-274: the pin deltas print BESIDE the class deltas, in the same
|
|
1308
|
+
// section, because they are one amendment. A pin that moved changes what CI
|
|
1309
|
+
// asserts about the policy in this very commit, and a reader deciding
|
|
1310
|
+
// whether to sign has to see both halves at once.
|
|
1311
|
+
const pinSection = pinsChange === null
|
|
1312
|
+
? []
|
|
1313
|
+
: [
|
|
1314
|
+
"",
|
|
1315
|
+
`${st.key("pins")} ${EXPECTATIONS_MODULE} moves with this amendment:`,
|
|
1316
|
+
...(pinsChange.changes.length === 0
|
|
1317
|
+
? [st.muted(" no pinned resolution moved (a note or a comment did)")]
|
|
1318
|
+
: pinsChange.changes.map((change) => ` ${describePinChange(change)}`)),
|
|
1319
|
+
];
|
|
1320
|
+
section("Changes", [
|
|
1321
|
+
...(diff === null
|
|
1322
|
+
? [
|
|
1323
|
+
`${st.warn("HASH-ONLY MODE:")} no semantic diff. ${recovered.baseline.reason ?? ""}`,
|
|
1324
|
+
st.muted("The load advisory below and the attestation still apply; what changed in MEANING is not shown, so read the file diff yourself."),
|
|
1325
|
+
]
|
|
1326
|
+
: renderDiff(diff)),
|
|
1327
|
+
...pinSection,
|
|
1328
|
+
]);
|
|
1329
|
+
section("Load", liveLoad.ok
|
|
1330
|
+
? [`${st.glyph("ok")} loads clean`]
|
|
1331
|
+
: [
|
|
1332
|
+
// APRV-102: glyph, CODE, message — the order every other refusal in
|
|
1333
|
+
// this CLI uses. `DOES NOT LOAD` sat where the machine-readable code
|
|
1334
|
+
// belongs and pushed the code into a parenthesis, so the one token a
|
|
1335
|
+
// reader greps for was the one thing not in the scannable column.
|
|
1336
|
+
renderRefusal(st, liveLoad.code, `the policy does not load: ${liveLoad.message}`),
|
|
1337
|
+
st.muted("Attesting it is allowed (attestation records bytes, not correctness) but it will FAIL CLOSED to all-manual for every class. This is the shape of the seq 2 incident."),
|
|
1338
|
+
]);
|
|
1339
|
+
}
|
|
1340
|
+
// (d) --require-load: refuse before the confirmation and before the append.
|
|
1341
|
+
if (!liveLoad.ok && requireLoad) {
|
|
1342
|
+
return refuse(streams, json, "load-failed", `--require-load: the policy does not load (${liveLoad.code}): ${liveLoad.message}; nothing was attested and the log is unchanged`, EXIT_INTEGRITY);
|
|
1343
|
+
}
|
|
1344
|
+
/**
|
|
1345
|
+
* The `pins` sub-object of the JSON report (APRV-274), or `null` when the
|
|
1346
|
+
* pins are no part of this ceremony.
|
|
1347
|
+
*
|
|
1348
|
+
* `null` is the answer for all three of its reasons: these pins do not govern
|
|
1349
|
+
* this policy, there is no pins module, the file is what the base carries. A
|
|
1350
|
+
* machine caller that wants to know WHICH reason reads `git.commands`, where
|
|
1351
|
+
* the `git add` names the files the commit carries.
|
|
1352
|
+
*/
|
|
1353
|
+
const pinsReport = () => pinsChange === null ? null : { module: EXPECTATIONS_MODULE, changes: pinsChange.changes };
|
|
1354
|
+
/** The `git` sub-object of the JSON report. Every key is always present. */
|
|
1355
|
+
const gitReport = (over) => ({
|
|
1356
|
+
repo: amendRoot !== null,
|
|
1357
|
+
protection: probe.protection,
|
|
1358
|
+
protectionReason: probe.reason,
|
|
1359
|
+
defaultBranch: probe.defaultBranch,
|
|
1360
|
+
currentBranch: probe.currentBranch,
|
|
1361
|
+
flow: useBranch ? "branch" : "direct",
|
|
1362
|
+
warning: pushWarning,
|
|
1363
|
+
...over,
|
|
1364
|
+
});
|
|
1365
|
+
// (e) Confirmation. --dry-run never asks, because it never writes.
|
|
1366
|
+
if (dryRun) {
|
|
1367
|
+
if (json) {
|
|
1368
|
+
emitReport(streams, {
|
|
1369
|
+
policyPath,
|
|
1370
|
+
liveSha256,
|
|
1371
|
+
attested,
|
|
1372
|
+
baseline: recovered.baseline,
|
|
1373
|
+
diff,
|
|
1374
|
+
load: loadSummary(liveLoad),
|
|
1375
|
+
attestation: null,
|
|
1376
|
+
git: gitReport({
|
|
1377
|
+
commands: gitCommands("<seq>"),
|
|
1378
|
+
committed: false,
|
|
1379
|
+
pushed: false,
|
|
1380
|
+
prUrl: null,
|
|
1381
|
+
output: null,
|
|
1382
|
+
branch: useBranch ? branchName("<seq>") : null,
|
|
1383
|
+
}),
|
|
1384
|
+
noop: false,
|
|
1385
|
+
dryRun: true,
|
|
1386
|
+
aborted: false,
|
|
1387
|
+
pins: pinsReport(),
|
|
1388
|
+
});
|
|
1389
|
+
}
|
|
1390
|
+
else {
|
|
1391
|
+
section("Would run", [
|
|
1392
|
+
`${st.warn("--dry-run:")} nothing was attested, nothing was written. The ceremony would run:`,
|
|
1393
|
+
"",
|
|
1394
|
+
...whyOneCommit(),
|
|
1395
|
+
"",
|
|
1396
|
+
...gitCommands("<seq>").map((command) => ` ${st.value(humanCommand(command))}`),
|
|
1397
|
+
...(useBranch
|
|
1398
|
+
? [
|
|
1399
|
+
"",
|
|
1400
|
+
"Merge that pull request with a MERGE COMMIT, so the policy edit and its attestation stay one commit on main.",
|
|
1401
|
+
]
|
|
1402
|
+
: []),
|
|
1403
|
+
]);
|
|
1404
|
+
}
|
|
1405
|
+
return EXIT_OK;
|
|
1406
|
+
}
|
|
1407
|
+
// (e2) The terminal confirmation belongs to the human path only. Under an
|
|
1408
|
+
// agent identity the confirmation IS the tap, and asking this process's stdin
|
|
1409
|
+
// for one would be asking the party under oversight to confirm its own
|
|
1410
|
+
// amendment.
|
|
1411
|
+
if (agentActor === null && !assumeYes) {
|
|
1412
|
+
if (json || process.stdin.isTTY !== true) {
|
|
1413
|
+
return usageError(streams, json, "amend needs a confirmation it cannot ask for: stdin is not a terminal (or --json was given). Re-run with --yes to confirm non-interactively, or --dry-run to see the report without writing anything");
|
|
1414
|
+
}
|
|
1415
|
+
streams.out(`\nattest these bytes and record the amendment? [y/N] `);
|
|
1416
|
+
const answer = (readLineFromStdin() ?? "").trim().toLowerCase();
|
|
1417
|
+
if (answer !== "y" && answer !== "yes") {
|
|
1418
|
+
streams.out("aborted: nothing was attested and nothing was written\n");
|
|
1419
|
+
return EXIT_OK;
|
|
1420
|
+
}
|
|
1421
|
+
}
|
|
1422
|
+
// (f) The attestation itself. One of two doors onto the same act: the human
|
|
1423
|
+
// path performs it here, the agent path asks for it and waits.
|
|
1424
|
+
let seq;
|
|
1425
|
+
let collected = null;
|
|
1426
|
+
if (agentActor === null) {
|
|
1427
|
+
const result = appendAttestation(logPath, policyPath, actor);
|
|
1428
|
+
if (!result.ok) {
|
|
1429
|
+
const exitCode = result.error.code === "corrupt-tail" ? EXIT_TORN_TAIL : EXIT_IO;
|
|
1430
|
+
return refuse(streams, json, "append-failed", result.error.message, exitCode);
|
|
1431
|
+
}
|
|
1432
|
+
seq = result.record.seq;
|
|
1433
|
+
}
|
|
1434
|
+
else {
|
|
1435
|
+
const asked = collectAttestation({
|
|
1436
|
+
streams,
|
|
1437
|
+
json,
|
|
1438
|
+
st,
|
|
1439
|
+
logPath,
|
|
1440
|
+
policyPath,
|
|
1441
|
+
actor,
|
|
1442
|
+
baseline: recovered.bytes,
|
|
1443
|
+
note: proposalNote,
|
|
1444
|
+
liveLoad,
|
|
1445
|
+
waitMs,
|
|
1446
|
+
intervalMs,
|
|
1447
|
+
cwd,
|
|
1448
|
+
});
|
|
1449
|
+
if (!asked.ok) {
|
|
1450
|
+
return refuse(streams, json, asked.code, asked.message, asked.exitCode, asked.human);
|
|
1451
|
+
}
|
|
1452
|
+
collected = asked;
|
|
1453
|
+
seq = asked.seq;
|
|
1454
|
+
}
|
|
1455
|
+
// (f2) SUCCESS FIRST (APRV-130).
|
|
1456
|
+
//
|
|
1457
|
+
// The incident: a re-tighten ceremony attested correctly — the one act only a
|
|
1458
|
+
// human can perform, done — and the terminal opened with the word REJECTED,
|
|
1459
|
+
// because the convenience push that follows had been refused by branch
|
|
1460
|
+
// protection. The reader was told their signature had failed when what had
|
|
1461
|
+
// failed was a `git push`.
|
|
1462
|
+
//
|
|
1463
|
+
// So the achievement is printed HERE, the moment it is true, before a single
|
|
1464
|
+
// git command runs. Everything after it is logistics: it can fail, it is
|
|
1465
|
+
// reported where it fails, and it prints beneath a line that already says the
|
|
1466
|
+
// policy is operative. A failure word may headline a SUB-STEP; it may never
|
|
1467
|
+
// headline a ceremony whose attestation landed.
|
|
1468
|
+
if (!json) {
|
|
1469
|
+
streams.out(`${st.glyph("ok")} attested seq ${String(seq)} — the policy is operative\n`);
|
|
1470
|
+
for (const line of st
|
|
1471
|
+
.table([
|
|
1472
|
+
{ left: "file", right: relPath(policyPath, cwd) },
|
|
1473
|
+
{ left: "sha256", right: shortHash(liveSha256) },
|
|
1474
|
+
// APRV-109: on the agent path the attestation was collected as a tap, so
|
|
1475
|
+
// the prompt it answered is named here. The seq the commit cites is the
|
|
1476
|
+
// ATTESTATION's, as it has always been.
|
|
1477
|
+
...(collected === null
|
|
1478
|
+
? []
|
|
1479
|
+
: [{ left: "attested by tap on", right: `policy.proposed seq ${String(collected.proposedSeq)}` }]),
|
|
1480
|
+
])
|
|
1481
|
+
.split("\n")) {
|
|
1482
|
+
streams.out(` ${line}\n`);
|
|
1483
|
+
}
|
|
1484
|
+
streams.out("\n");
|
|
1485
|
+
}
|
|
1486
|
+
// (g) The git ceremony: the two files, together, or the commands to do it.
|
|
1487
|
+
const commands = gitCommands(String(seq));
|
|
1488
|
+
const branch = useBranch ? branchName(String(seq)) : null;
|
|
1489
|
+
let committed = false;
|
|
1490
|
+
let pushed = false;
|
|
1491
|
+
let prUrl = null;
|
|
1492
|
+
let output = null;
|
|
1493
|
+
/**
|
|
1494
|
+
* What the publishing half did, for the machine and for the terminal
|
|
1495
|
+
* (APRV-130).
|
|
1496
|
+
*
|
|
1497
|
+
* `attested` is deliberately NOT a key of this object and not a rename of the
|
|
1498
|
+
* report's existing top-level `attested` (which is, and stays, the PREVIOUS
|
|
1499
|
+
* attestation this amendment moved from). The new boolean lives on
|
|
1500
|
+
* `ceremony`, so both facts keep their names.
|
|
1501
|
+
*/
|
|
1502
|
+
const publishing = {
|
|
1503
|
+
attempted: false,
|
|
1504
|
+
complete: false,
|
|
1505
|
+
via: "none",
|
|
1506
|
+
branch: null,
|
|
1507
|
+
pushed: false,
|
|
1508
|
+
prUrl: null,
|
|
1509
|
+
autoMerge: "not-attempted",
|
|
1510
|
+
steps: [],
|
|
1511
|
+
stoppedAt: null,
|
|
1512
|
+
reason: null,
|
|
1513
|
+
};
|
|
1514
|
+
/** The `Publishing` section, accumulated as the steps run and flushed once. */
|
|
1515
|
+
const publishLines = [];
|
|
1516
|
+
let publishFlushed = false;
|
|
1517
|
+
const note = (line) => {
|
|
1518
|
+
if (!json)
|
|
1519
|
+
publishLines.push(line);
|
|
1520
|
+
};
|
|
1521
|
+
const quoteUnder = (run) => {
|
|
1522
|
+
for (const line of commandOutputLines(run.stderr, run.stdout))
|
|
1523
|
+
note(` ${st.muted(line)}`);
|
|
1524
|
+
};
|
|
1525
|
+
const flushPublishing = () => {
|
|
1526
|
+
if (json || publishFlushed || publishLines.length === 0)
|
|
1527
|
+
return;
|
|
1528
|
+
publishFlushed = true;
|
|
1529
|
+
section("Publishing", publishLines);
|
|
1530
|
+
};
|
|
1531
|
+
/** The additive `--json` half of the same, for every exit after the append. */
|
|
1532
|
+
const ceremonyJson = () => ({
|
|
1533
|
+
ceremony: { attested: true, seq },
|
|
1534
|
+
publishing,
|
|
1535
|
+
});
|
|
1536
|
+
/**
|
|
1537
|
+
* A `git-failed` refusal, as a runbook (APRV-129).
|
|
1538
|
+
*
|
|
1539
|
+
* The machine message is unchanged. What the terminal gets instead is the
|
|
1540
|
+
* state (the attestation happened; the commit did not) and the commands that
|
|
1541
|
+
* are STILL OWED, one per line — which is what "run the printed commands by
|
|
1542
|
+
* hand" was asking for without ever printing them next to the failure.
|
|
1543
|
+
*/
|
|
1544
|
+
const gitFailed = (what, failure, message, remaining) => refuse(streams, json, "git-failed", message, EXIT_IO, runbook(st, "git-failed", `\`${what}\` failed; the attestation is already appended`, {
|
|
1545
|
+
quote: commandOutputLines(failure),
|
|
1546
|
+
state: [
|
|
1547
|
+
`attestation appended at seq ${seq}: it is in the log, on disk`,
|
|
1548
|
+
"NOT committed: the policy edit and its attestation are working-tree changes",
|
|
1549
|
+
"NOT on origin: origin still carries the previous policy",
|
|
1550
|
+
],
|
|
1551
|
+
steps: remaining.map((command) => ({ command: humanCommand(command) })),
|
|
1552
|
+
footer: [
|
|
1553
|
+
"these two files land in ONE commit: a main carrying the policy without its attestation refuses every gate operation",
|
|
1554
|
+
],
|
|
1555
|
+
}), ceremonyJson());
|
|
1556
|
+
/**
|
|
1557
|
+
* The publishing half stopped at one of its own steps (APRV-130).
|
|
1558
|
+
*
|
|
1559
|
+
* `owed` is the WHOLE recovery, in order, and `index` the step that failed:
|
|
1560
|
+
* the runbook is rendered from there, so the reader is never handed a command
|
|
1561
|
+
* this verb already ran successfully. That slice is the entire relationship
|
|
1562
|
+
* between the automatic path and the APRV-129 runbook — the runbook is what
|
|
1563
|
+
* automation degrades INTO, at the exact point it ran out.
|
|
1564
|
+
*/
|
|
1565
|
+
const stalled = (code, headline, failure, message, state, owed, index, footer) => {
|
|
1566
|
+
note(`${st.glyph("fail")} ${headline}`);
|
|
1567
|
+
quoteUnder(failure);
|
|
1568
|
+
note(st.muted("the automatic path stopped here; what is still owed is printed below"));
|
|
1569
|
+
publishing.stoppedAt = owed[index]?.command ?? null;
|
|
1570
|
+
publishing.reason = commandOutputLines(failure.stderr, failure.stdout)[0] ?? null;
|
|
1571
|
+
flushPublishing();
|
|
1572
|
+
return refuse(streams, json, code, message, EXIT_IO, runbook(st, code, headline, {
|
|
1573
|
+
quote: commandOutputLines(failure.stderr, failure.stdout),
|
|
1574
|
+
state,
|
|
1575
|
+
steps: owed.slice(index),
|
|
1576
|
+
footer,
|
|
1577
|
+
}), ceremonyJson());
|
|
1578
|
+
};
|
|
1579
|
+
/**
|
|
1580
|
+
* `gh pr merge --auto`, which is allowed to say no.
|
|
1581
|
+
*
|
|
1582
|
+
* A merge queue, a repository with auto-merge disabled, a PR that is already
|
|
1583
|
+
* mergeable: `--auto` refuses all three, and none of them is a failure of the
|
|
1584
|
+
* ceremony. The pull request is open either way, so a refusal reports the PR
|
|
1585
|
+
* and stops — still inside the success framing.
|
|
1586
|
+
*/
|
|
1587
|
+
const armAutoMerge = (root, target) => {
|
|
1588
|
+
const merge = gh(["pr", "merge", target, "--merge", "--auto"], root);
|
|
1589
|
+
publishing.steps.push({ command: `gh pr merge ${target} --merge --auto`, ok: merge.ok });
|
|
1590
|
+
if (merge.ok) {
|
|
1591
|
+
publishing.autoMerge = "armed";
|
|
1592
|
+
note(`${st.glyph("ok")} auto-merge armed: ${prLabel(publishing.prUrl)} lands on ${probe.defaultBranch ?? "the default branch"} as a merge commit when CI is green`);
|
|
1593
|
+
return;
|
|
1594
|
+
}
|
|
1595
|
+
publishing.autoMerge = "refused";
|
|
1596
|
+
const why = commandOutputLines(merge.stderr, merge.stdout)[0];
|
|
1597
|
+
note(`${prLabel(publishing.prUrl)} is open — merge it with a MERGE COMMIT when CI is green. auto-merge was not armed${why === undefined ? "" : `: ${why}`}`);
|
|
1598
|
+
};
|
|
1599
|
+
if (commitPlan !== null && commitBase !== null) {
|
|
1600
|
+
// APRV-203. The commit is ASSEMBLED, never checked out: a scratch index is
|
|
1601
|
+
// filled from the remote's tree, the two ceremony files are laid over it
|
|
1602
|
+
// from the working tree, and the result is parented on the remote. HEAD does
|
|
1603
|
+
// not move, the operator's index is not touched, and the working tree ends
|
|
1604
|
+
// the ceremony carrying exactly the policy edit it started with.
|
|
1605
|
+
const message = `Policy: ${summary} (attested seq ${seq})`;
|
|
1606
|
+
/** `origin/main abc123`, or `HEAD abc123` where there is no remote. */
|
|
1607
|
+
const baseLabel = `${commitBase.remote === null ? "HEAD" : `${commitBase.remote}/${commitBase.branch}`} ${commitBase.sha.slice(0, 12)}`;
|
|
1608
|
+
progress.phase(`building the amendment commit on ${baseLabel} (nothing is checked out)`);
|
|
1609
|
+
const built = commitOnBase(commitPlan.root, {
|
|
1610
|
+
base: commitBase.sha,
|
|
1611
|
+
// APRV-274: the pins join the tree exactly when they moved away from the
|
|
1612
|
+
// base. Where they did not, the base's own copy stands, which is what
|
|
1613
|
+
// keeps this ceremony from reverting a pins edit somebody else landed.
|
|
1614
|
+
paths: [
|
|
1615
|
+
commitPlan.policyArg,
|
|
1616
|
+
commitPlan.logArg,
|
|
1617
|
+
...(pinsChange === null ? [] : [pinsChange.arg]),
|
|
1618
|
+
],
|
|
1619
|
+
message,
|
|
1620
|
+
});
|
|
1621
|
+
progress.done();
|
|
1622
|
+
if (!built.ok) {
|
|
1623
|
+
return gitFailed(built.step, built.quote.join(" | "), `the attestation was appended at seq ${seq}, but ${built.message}; the checkout is untouched, so run the printed commands by hand`, commands);
|
|
1624
|
+
}
|
|
1625
|
+
if (built.unchanged) {
|
|
1626
|
+
return gitFailed("git write-tree", `${baseLabel} already carries these exact bytes`, `the attestation was appended at seq ${seq}, but the amendment tree is identical to ${baseLabel}: there is nothing to commit`, commands);
|
|
1627
|
+
}
|
|
1628
|
+
const commitSha = built.sha;
|
|
1629
|
+
/**
|
|
1630
|
+
* The direct flow, when this checkout was already sitting on the base.
|
|
1631
|
+
*
|
|
1632
|
+
* Moving the branch ref here is exactly what `git commit` would have done:
|
|
1633
|
+
* the tree of the new commit is the base tree plus the two files the working
|
|
1634
|
+
* tree already carries, so the status afterwards is clean. It is done ONLY
|
|
1635
|
+
* when HEAD is the base — a checkout that had fallen behind is left where it
|
|
1636
|
+
* is, because moving it would rewrite the working tree, and rewriting the
|
|
1637
|
+
* working tree around a live log is the whole thing this verb never does.
|
|
1638
|
+
*/
|
|
1639
|
+
const headSha = git(["rev-parse", "HEAD"], commitPlan.root).stdout.trim();
|
|
1640
|
+
const inPlace = branch === null &&
|
|
1641
|
+
probe.currentBranch !== null &&
|
|
1642
|
+
headSha === commitBase.sha &&
|
|
1643
|
+
git(["update-ref", `refs/heads/${probe.currentBranch}`, commitSha, headSha], commitPlan.root).ok;
|
|
1644
|
+
if (inPlace) {
|
|
1645
|
+
// The index has to follow the ref, or every one of the two files reads as
|
|
1646
|
+
// a staged modification of a commit that already contains it. `read-tree`
|
|
1647
|
+
// without `-u` writes the index and never the working tree, which is the
|
|
1648
|
+
// half of `git commit` the ref move did not do.
|
|
1649
|
+
git(["read-tree", commitSha], commitPlan.root);
|
|
1650
|
+
}
|
|
1651
|
+
// Otherwise the commit is anchored on a ref of its own before anything is
|
|
1652
|
+
// pushed, so a rejected push leaves an object a human can still find and
|
|
1653
|
+
// push. The branch flow anchors on ITS branch, because that is the name the
|
|
1654
|
+
// recovery instructions use; the direct flow anchors under `refs/approval/`,
|
|
1655
|
+
// where it claims no branch name that the recovery might later need.
|
|
1656
|
+
const fallbackRef = `refs/approval/amend/${String(seq)}`;
|
|
1657
|
+
let anchor = fallbackRef;
|
|
1658
|
+
if (!inPlace) {
|
|
1659
|
+
if (branch !== null && git(["branch", branch, commitSha], commitPlan.root).ok) {
|
|
1660
|
+
anchor = branch;
|
|
1661
|
+
}
|
|
1662
|
+
else {
|
|
1663
|
+
git(["update-ref", fallbackRef, commitSha], commitPlan.root);
|
|
1664
|
+
}
|
|
1665
|
+
}
|
|
1666
|
+
committed = true;
|
|
1667
|
+
output = inPlace
|
|
1668
|
+
? `${commitSha.slice(0, 12)} on ${probe.currentBranch ?? "HEAD"} (built on ${baseLabel})`
|
|
1669
|
+
: `${commitSha.slice(0, 12)} on ${baseLabel} (held at ${anchor}; your checkout was not moved)`;
|
|
1670
|
+
/** `gh pr create …` as the operator would type it, for both flows. */
|
|
1671
|
+
const prCreateCommand = (head) => `gh pr create --title ${JSON.stringify(prTitle(summary, String(seq)))} --body ${JSON.stringify(prBody(String(seq)))} --head ${head}${probe.defaultBranch === null ? "" : ` --base ${probe.defaultBranch}`}`;
|
|
1672
|
+
const prCreateArgs = (head) => [
|
|
1673
|
+
"pr",
|
|
1674
|
+
"create",
|
|
1675
|
+
"--title",
|
|
1676
|
+
prTitle(summary, String(seq)),
|
|
1677
|
+
"--body",
|
|
1678
|
+
prBody(String(seq)),
|
|
1679
|
+
"--head",
|
|
1680
|
+
head,
|
|
1681
|
+
...(probe.defaultBranch === null ? [] : ["--base", probe.defaultBranch]),
|
|
1682
|
+
];
|
|
1683
|
+
if (noPublish) {
|
|
1684
|
+
// The old stop-after-commit ceremony, on request. Nothing is pushed, so
|
|
1685
|
+
// the push and (on the branch flow) the pull request are printed as owed.
|
|
1686
|
+
publishing.via = useBranch ? "branch" : "direct";
|
|
1687
|
+
publishing.branch = branch;
|
|
1688
|
+
publishing.reason = "--no-publish: the ceremony stopped at the commit";
|
|
1689
|
+
}
|
|
1690
|
+
else if (branch !== null) {
|
|
1691
|
+
// The branch flow does not stop at the commit: the commit is only useful
|
|
1692
|
+
// on a protected main once it is on a branch, pushed, and carried by a PR.
|
|
1693
|
+
publishing.attempted = true;
|
|
1694
|
+
publishing.via = "branch";
|
|
1695
|
+
publishing.branch = branch;
|
|
1696
|
+
progress.phase(`pushing ${commitSha.slice(0, 12)} to origin ${branch}`);
|
|
1697
|
+
const push = git(["push", "origin", `${commitSha}:refs/heads/${branch}`], commitPlan.root);
|
|
1698
|
+
progress.done();
|
|
1699
|
+
publishing.steps.push({ command: `git push -u origin ${branch}`, ok: push.ok });
|
|
1700
|
+
if (!push.ok) {
|
|
1701
|
+
const prCreate = `gh pr create --title ${JSON.stringify(prTitle(summary, String(seq)))} --body ${JSON.stringify(prBody(String(seq)))}`;
|
|
1702
|
+
return stalled("push-rejected", `the remote REJECTED \`git push origin ${branch}\``, push, `the attestation was appended at seq ${seq} and committed as ${commitSha.slice(0, 12)} on ${commitBase.remote}/${commitBase.branch}, but \`git push origin ${commitSha.slice(0, 12)}:refs/heads/${branch}\` was REJECTED: ${pushFailureText(push)}. STATE: the amendment commit exists LOCALLY on ${branch} and nowhere else; origin still carries the previous policy. Next: \`git push -u origin ${branch} && ${prCreate}\`, and merge that pull request with a merge commit`, [
|
|
1703
|
+
`attestation appended at seq ${seq}: it is in the log, on disk`,
|
|
1704
|
+
`committed LOCALLY on ${branch} (${commitSha.slice(0, 12)}, parented on ${commitBase.remote}/${commitBase.branch}), and nowhere else`,
|
|
1705
|
+
"your checkout was never moved: same branch, same working tree",
|
|
1706
|
+
"NOT on origin: origin still carries the previous policy",
|
|
1707
|
+
], [
|
|
1708
|
+
{ command: `git push -u origin ${branch}`, note: "once the remote will take it" },
|
|
1709
|
+
{ command: prCreate },
|
|
1710
|
+
{ command: `gh pr merge ${branch} --merge`, note: "or merge it in the web UI" },
|
|
1711
|
+
], 0, [MERGE_COMMIT_LINE]);
|
|
1712
|
+
}
|
|
1713
|
+
pushed = true;
|
|
1714
|
+
publishing.pushed = true;
|
|
1715
|
+
output = `${output}\n${`${push.stdout}${push.stderr}`.trim()}`.trim();
|
|
1716
|
+
if (ghAvailable(commitPlan.root)) {
|
|
1717
|
+
const pr = gh(prCreateArgs(branch), commitPlan.root);
|
|
1718
|
+
publishing.steps.push({ command: prCreateCommand(branch), ok: pr.ok });
|
|
1719
|
+
if (!pr.ok) {
|
|
1720
|
+
const ghFailure = pr.stderr.trim() || pr.stdout.trim() || "gh did not run";
|
|
1721
|
+
return stalled("pr-failed", "`gh pr create` failed; the branch is already on origin", pr, `the attestation was appended at seq ${seq}, committed on ${branch} and pushed, but \`gh pr create\` failed: ${ghFailure}; open the pull request by hand and merge it with a merge commit`, [
|
|
1722
|
+
`attestation appended at seq ${seq}: it is in the log, on disk`,
|
|
1723
|
+
`committed on ${branch} and PUSHED: origin has the branch`,
|
|
1724
|
+
"no pull request: origin's default branch still carries the previous policy",
|
|
1725
|
+
], [
|
|
1726
|
+
{ command: prCreateCommand(branch), note: "retry, or open it in the web UI" },
|
|
1727
|
+
{ command: `gh pr merge ${branch} --merge`, note: "or merge it in the web UI" },
|
|
1728
|
+
], 0, [MERGE_COMMIT_LINE]);
|
|
1729
|
+
}
|
|
1730
|
+
prUrl = lastUrl(pr.stdout);
|
|
1731
|
+
publishing.prUrl = prUrl;
|
|
1732
|
+
publishing.complete = true;
|
|
1733
|
+
// APRV-130: the ceremony offers to finish the last step too.
|
|
1734
|
+
armAutoMerge(commitPlan.root, branch);
|
|
1735
|
+
}
|
|
1736
|
+
}
|
|
1737
|
+
else {
|
|
1738
|
+
// APRV-111. The direct flow used to stop at the commit while PRINTING the
|
|
1739
|
+
// push line among the commands it had just run, so a push that never
|
|
1740
|
+
// happened — and, on the day this was found, a push that GitHub's branch
|
|
1741
|
+
// protection would have rejected — read as a finished ceremony. The commit
|
|
1742
|
+
// sat ahead of origin, unpushed and unnoticed.
|
|
1743
|
+
//
|
|
1744
|
+
// APRV-130. The rejection used to end the verb, handing the operator four
|
|
1745
|
+
// commands to type. Those four commands are non-destructive and entirely
|
|
1746
|
+
// mechanical, so the verb RUNS them: a branch off the commit that already
|
|
1747
|
+
// exists, a push of that branch, a pull request, and an attempt at
|
|
1748
|
+
// auto-merge. What is NOT automated is the APRV-111 constraint, unchanged:
|
|
1749
|
+
// `git branch` copies a ref, so the operator's checked-out branch stays
|
|
1750
|
+
// exactly where they left it, on the commit they just signed for.
|
|
1751
|
+
const target = probe.currentBranch;
|
|
1752
|
+
if (target !== null && hasOrigin(commitPlan.root)) {
|
|
1753
|
+
publishing.attempted = true;
|
|
1754
|
+
publishing.via = "direct";
|
|
1755
|
+
progress.phase(`pushing ${commitSha.slice(0, 12)} to origin ${target}`);
|
|
1756
|
+
const push = git(["push", "origin", `${commitSha}:refs/heads/${target}`], commitPlan.root);
|
|
1757
|
+
progress.done();
|
|
1758
|
+
publishing.steps.push({ command: `git push origin ${target}`, ok: push.ok });
|
|
1759
|
+
if (push.ok) {
|
|
1760
|
+
pushed = true;
|
|
1761
|
+
publishing.pushed = true;
|
|
1762
|
+
publishing.complete = true;
|
|
1763
|
+
output = `${output}\n${`${push.stdout}${push.stderr}`.trim()}`.trim();
|
|
1764
|
+
// APRV-203: the commit was assembled on the remote's tip and pushed
|
|
1765
|
+
// there, so the operator's own branch does not carry it yet. Say so,
|
|
1766
|
+
// rather than letting them find out from a `git status` later.
|
|
1767
|
+
if (!inPlace) {
|
|
1768
|
+
note(`${st.glyph("ok")} pushed to origin ${target}; your ${target} does not carry the commit yet — \`approval log sync\` brings it down safely`);
|
|
1769
|
+
}
|
|
1770
|
+
}
|
|
1771
|
+
else {
|
|
1772
|
+
// ---- the ceremony finishes its own job ----
|
|
1773
|
+
const recovery = branchName(String(seq));
|
|
1774
|
+
publishing.via = "recovery";
|
|
1775
|
+
publishing.branch = recovery;
|
|
1776
|
+
const owed = [
|
|
1777
|
+
{
|
|
1778
|
+
command: `git branch ${recovery} ${commitSha.slice(0, 12)}`,
|
|
1779
|
+
note: "the assembled commit, on a branch",
|
|
1780
|
+
},
|
|
1781
|
+
{ command: `git push -u origin ${recovery}` },
|
|
1782
|
+
{ command: prCreateCommand(recovery) },
|
|
1783
|
+
{ command: `gh pr merge ${recovery} --merge`, note: "or merge it in the web UI" },
|
|
1784
|
+
];
|
|
1785
|
+
/** The state lines every stop on this path shares, plus its own. */
|
|
1786
|
+
const state = (last) => [
|
|
1787
|
+
`attestation appended at seq ${seq}: it is in the log, on disk`,
|
|
1788
|
+
`committed as ${commitSha.slice(0, 12)} on ${commitBase.remote}/${commitBase.branch}, held LOCALLY on ${recovery}`,
|
|
1789
|
+
`your checkout was never moved: still on ${target}, working tree as you left it`,
|
|
1790
|
+
`${target} is protected, whatever the probe reported: the remote just refused`,
|
|
1791
|
+
last,
|
|
1792
|
+
];
|
|
1793
|
+
const preamble = `the attestation was appended at seq ${seq} and committed as ${commitSha.slice(0, 12)} on ${commitBase.remote}/${commitBase.branch}, but \`git push origin ${target}\` was REJECTED by the remote: ${pushFailureText(push)}. ${target} is protected (whatever the protection probe reported: the remote just refused the push), so this ceremony published through branch ${recovery} instead`;
|
|
1794
|
+
note(`${st.warn(`${target} is protected:`)} the direct push was refused, so this amendment publishes through branch ${recovery}`);
|
|
1795
|
+
quoteUnder(push);
|
|
1796
|
+
// 1. The branch. A ref copy at the assembled commit (APRV-203: at the
|
|
1797
|
+
// COMMIT rather than at HEAD, which never carried it). A name
|
|
1798
|
+
// already taken is still a refusal: this verb does not overwrite
|
|
1799
|
+
// somebody else's branch to finish its own ceremony.
|
|
1800
|
+
const branched = git(["branch", recovery, commitSha], commitPlan.root);
|
|
1801
|
+
publishing.steps.push({ command: `git branch ${recovery}`, ok: branched.ok });
|
|
1802
|
+
if (!branched.ok) {
|
|
1803
|
+
return stalled("push-rejected", `\`git branch ${recovery}\` failed, so the recovery branch does not exist`, branched, `${preamble}, and \`git branch ${recovery}\` failed: ${pushFailureText(branched)}. STATE: the amendment is committed LOCALLY on ${target} and is NOT on origin, so origin still carries the previous policy and your ${target} is one commit ahead of it. Next: \`git branch ${recovery} && git push -u origin ${recovery} && ${prCreateCommand(recovery)}\`. Merge it with a merge commit, then run \`approval log sync\` rather than a pull`, state("NOT on origin: origin still carries the previous policy"), owed, 0, [MERGE_COMMIT_LINE, LOG_SAFE_PULL_LINE]);
|
|
1804
|
+
}
|
|
1805
|
+
note(`${st.glyph("ok")} branch ${recovery} created — your checkout stays on ${target}`);
|
|
1806
|
+
// 2. The push of that branch.
|
|
1807
|
+
const branchPush = git(["push", "-u", "origin", recovery], commitPlan.root);
|
|
1808
|
+
publishing.steps.push({ command: `git push -u origin ${recovery}`, ok: branchPush.ok });
|
|
1809
|
+
if (!branchPush.ok) {
|
|
1810
|
+
return stalled("push-rejected", `the remote REJECTED \`git push -u origin ${recovery}\``, branchPush, `${preamble}; \`git push -u origin ${recovery}\` was REJECTED too: ${pushFailureText(branchPush)}. STATE: the amendment is committed LOCALLY on ${target} and is NOT on origin, so origin still carries the previous policy and your ${target} is one commit ahead of it. Next: \`git push -u origin ${recovery} && ${prCreateCommand(recovery)}\`. Merge it with a merge commit, then run \`approval log sync\` rather than a pull`, state("NOT on origin: origin still carries the previous policy"), owed, 1, [MERGE_COMMIT_LINE, LOG_SAFE_PULL_LINE]);
|
|
1811
|
+
}
|
|
1812
|
+
publishing.pushed = true;
|
|
1813
|
+
note(`${st.glyph("ok")} pushed ${recovery} to origin`);
|
|
1814
|
+
// 3. The pull request. `gh` absent is a failure of THIS step and
|
|
1815
|
+
// nothing more: the branch is on origin either way, so the runbook
|
|
1816
|
+
// resumes from here rather than from the beginning.
|
|
1817
|
+
if (!ghAvailable(commitPlan.root)) {
|
|
1818
|
+
return stalled("pr-failed", "gh is not available, so the pull request was not opened", { ok: false, stdout: "", stderr: "gh is not on PATH" }, `${preamble}, and ${recovery} is on origin, but \`gh\` is not available so the pull request was not opened: open it by hand and merge it with a merge commit. STATE: the amendment is committed LOCALLY on ${target} and is on origin as ${recovery}; origin's ${probe.defaultBranch ?? "default branch"} still carries the previous policy. Next: \`${prCreateCommand(recovery)}\`; and \`approval log sync\` rather than a pull afterwards`, state(`on origin as ${recovery}: no pull request carries it yet`), owed, 2, [MERGE_COMMIT_LINE, LOG_SAFE_PULL_LINE]);
|
|
1819
|
+
}
|
|
1820
|
+
const pr = gh(prCreateArgs(recovery), commitPlan.root);
|
|
1821
|
+
publishing.steps.push({ command: prCreateCommand(recovery), ok: pr.ok });
|
|
1822
|
+
if (!pr.ok) {
|
|
1823
|
+
return stalled("pr-failed", "`gh pr create` failed; the branch is already on origin", pr, `${preamble}, and ${recovery} is on origin, but \`gh pr create\` failed: ${pushFailureText(pr)}; open the pull request by hand and merge it with a merge commit. STATE: the amendment is committed LOCALLY on ${target} and is on origin as ${recovery}; origin's ${probe.defaultBranch ?? "default branch"} still carries the previous policy. Next: \`${prCreateCommand(recovery)}\`; and \`approval log sync\` rather than a pull afterwards`, state(`on origin as ${recovery}: no pull request carries it yet`), owed, 2, [MERGE_COMMIT_LINE, LOG_SAFE_PULL_LINE]);
|
|
1824
|
+
}
|
|
1825
|
+
prUrl = lastUrl(pr.stdout);
|
|
1826
|
+
publishing.prUrl = prUrl;
|
|
1827
|
+
publishing.complete = true;
|
|
1828
|
+
note(`${st.glyph("ok")} ${prLabel(prUrl)} opened${prUrl === null ? "" : `: ${prUrl}`}`);
|
|
1829
|
+
// 4. Auto-merge, which is allowed to refuse.
|
|
1830
|
+
armAutoMerge(commitPlan.root, recovery);
|
|
1831
|
+
}
|
|
1832
|
+
}
|
|
1833
|
+
}
|
|
1834
|
+
}
|
|
1835
|
+
if (json) {
|
|
1836
|
+
emitReport(streams, {
|
|
1837
|
+
policyPath,
|
|
1838
|
+
liveSha256,
|
|
1839
|
+
attested,
|
|
1840
|
+
baseline: recovered.baseline,
|
|
1841
|
+
diff,
|
|
1842
|
+
load: loadSummary(liveLoad),
|
|
1843
|
+
attestation: { seq, sha256: liveSha256 },
|
|
1844
|
+
git: gitReport({ commands, committed, pushed, prUrl, output, branch }),
|
|
1845
|
+
noop: false,
|
|
1846
|
+
dryRun: false,
|
|
1847
|
+
aborted: false,
|
|
1848
|
+
pins: pinsReport(),
|
|
1849
|
+
ceremony: { attested: true, seq },
|
|
1850
|
+
publishing,
|
|
1851
|
+
...(collected === null
|
|
1852
|
+
? {}
|
|
1853
|
+
: {
|
|
1854
|
+
proposal: {
|
|
1855
|
+
seq: collected.proposedSeq,
|
|
1856
|
+
sha256: collected.sha256,
|
|
1857
|
+
diff: collected.diff,
|
|
1858
|
+
load: collected.load,
|
|
1859
|
+
},
|
|
1860
|
+
}),
|
|
1861
|
+
});
|
|
1862
|
+
}
|
|
1863
|
+
else {
|
|
1864
|
+
if (committed) {
|
|
1865
|
+
// APRV-274: the headline names what the commit actually carries. A reader
|
|
1866
|
+
// who is told "the policy and the log" and finds a third file in the diff
|
|
1867
|
+
// has been told something false about the one commit that must not lie.
|
|
1868
|
+
const carried = pinsChange === null ? "the policy and the log" : "the policy, the log and the pins";
|
|
1869
|
+
const done = [
|
|
1870
|
+
branch === null
|
|
1871
|
+
? `${st.glyph("ok")} committed ${carried} together:`
|
|
1872
|
+
: `${st.glyph("ok")} committed ${carried} together on ${branch}:`,
|
|
1873
|
+
"",
|
|
1874
|
+
];
|
|
1875
|
+
// APRV-111: only the commands that actually RAN are listed under
|
|
1876
|
+
// "committed". A push the verb skipped (no origin, or a detached HEAD)
|
|
1877
|
+
// used to be printed here as though it had run, which is how an unpushed
|
|
1878
|
+
// amendment came to look like a finished one. A push that ran and FAILED
|
|
1879
|
+
// never reaches this branch at all: it is a refusal above.
|
|
1880
|
+
const remaining = [];
|
|
1881
|
+
for (const command of commands) {
|
|
1882
|
+
// The PR command is printed as a to-do when gh could not run it, or
|
|
1883
|
+
// (APRV-130) when --no-publish stopped the ceremony before it.
|
|
1884
|
+
if (command.startsWith("gh pr create") && branch !== null) {
|
|
1885
|
+
if (noPublish)
|
|
1886
|
+
remaining.push(command);
|
|
1887
|
+
if (prUrl === null)
|
|
1888
|
+
continue;
|
|
1889
|
+
}
|
|
1890
|
+
if (command.startsWith("git push") && !pushed) {
|
|
1891
|
+
// APRV-130: the direct push that the RECOVERY answered is not owed to
|
|
1892
|
+
// anybody. It ran, the remote refused it, and the Publishing section
|
|
1893
|
+
// below says so and says what was done instead. Printing it here as
|
|
1894
|
+
// "still to run" would send the operator to re-run a rejected push.
|
|
1895
|
+
if (publishing.via !== "recovery")
|
|
1896
|
+
remaining.push(command);
|
|
1897
|
+
continue;
|
|
1898
|
+
}
|
|
1899
|
+
done.push(` ${st.value(humanCommand(command))}`);
|
|
1900
|
+
}
|
|
1901
|
+
if (output !== null && output.length > 0)
|
|
1902
|
+
done.push("", ...output.split("\n"));
|
|
1903
|
+
if (remaining.length > 0) {
|
|
1904
|
+
done.push("", noPublish
|
|
1905
|
+
? `${st.warn("--no-publish:")} the commit is local only, and origin still carries the previous policy. Still to run:`
|
|
1906
|
+
: `${st.warn("NOT pushed:")} the commit is local only, and origin still carries the previous policy. Still to run:`, "", ...remaining.map((command) => ` ${st.value(humanCommand(command))}`));
|
|
1907
|
+
}
|
|
1908
|
+
if (branch !== null && !noPublish) {
|
|
1909
|
+
done.push("");
|
|
1910
|
+
if (prUrl !== null) {
|
|
1911
|
+
done.push(`${st.key("pull request:")} ${st.value(prUrl)}`);
|
|
1912
|
+
done.push("Merge it with a MERGE COMMIT, so the policy edit and its attestation stay one commit on main.");
|
|
1913
|
+
}
|
|
1914
|
+
else {
|
|
1915
|
+
done.push("gh is not available, so the pull request was not opened. Open it yourself, and merge it with a MERGE COMMIT so the policy edit and its attestation stay one commit on main:", "");
|
|
1916
|
+
for (const command of commands) {
|
|
1917
|
+
if (command.startsWith("gh pr create"))
|
|
1918
|
+
done.push(` ${st.value(humanCommand(command))}`);
|
|
1919
|
+
}
|
|
1920
|
+
}
|
|
1921
|
+
}
|
|
1922
|
+
section("Committed", done);
|
|
1923
|
+
}
|
|
1924
|
+
else {
|
|
1925
|
+
section("Now run", [
|
|
1926
|
+
...whyOneCommit(),
|
|
1927
|
+
"",
|
|
1928
|
+
...commands.map((command) => ` ${st.value(humanCommand(command))}`),
|
|
1929
|
+
...(useBranch
|
|
1930
|
+
? [
|
|
1931
|
+
"",
|
|
1932
|
+
"Then merge that pull request with a MERGE COMMIT, so the policy edit and its attestation stay one commit on main.",
|
|
1933
|
+
]
|
|
1934
|
+
: []),
|
|
1935
|
+
]);
|
|
1936
|
+
}
|
|
1937
|
+
// Logistics, beneath the achievement and beneath the commit: what the
|
|
1938
|
+
// publishing half did, step by step, when it had to do anything unusual.
|
|
1939
|
+
flushPublishing();
|
|
1940
|
+
}
|
|
1941
|
+
return EXIT_OK;
|
|
1942
|
+
}
|
|
1943
|
+
/**
|
|
1944
|
+
* `--commit`'s preconditions.
|
|
1945
|
+
*
|
|
1946
|
+
* The amendment commit carries **exactly these files**: the policy, the log,
|
|
1947
|
+
* and (APRV-274) the pins in `src/core/policy-expectations.ts` when they moved.
|
|
1948
|
+
* A staged change to anything else is refused rather than swept in, because a
|
|
1949
|
+
* commit that quietly carried an unrelated staged edit would make "this commit
|
|
1950
|
+
* is the amendment" false, and that sentence is the whole reason the commit
|
|
1951
|
+
* exists. Unstaged and untracked changes elsewhere are left alone: they are not
|
|
1952
|
+
* going into this commit.
|
|
1953
|
+
*
|
|
1954
|
+
* The pins joined that set rather than widening it. They are part of an
|
|
1955
|
+
* amendment's contract (CI's dogfood suite resolves the amended policy against
|
|
1956
|
+
* them, and it reads both from the same commit), so a rule that admitted the
|
|
1957
|
+
* policy and the log and refused the pins was a rule that split one amendment
|
|
1958
|
+
* across two commits and a hand-run cherry-pick.
|
|
1959
|
+
*/
|
|
1960
|
+
function planCommit(policyPath, logPath,
|
|
1961
|
+
/**
|
|
1962
|
+
* The branch flow's preconditions, checked here for the same reason: an
|
|
1963
|
+
* `origin` that does not exist, or a branch name already taken, would fail
|
|
1964
|
+
* AFTER the attestation and leave the operator holding a half-run ceremony.
|
|
1965
|
+
* `null` is the direct flow. `branch: null` inside it is the branch flow with
|
|
1966
|
+
* a generated name, which contains the seq and so cannot be checked before
|
|
1967
|
+
* the append happens.
|
|
1968
|
+
*/
|
|
1969
|
+
branchFlow) {
|
|
1970
|
+
const root = repoRoot(dirname(policyPath));
|
|
1971
|
+
if (root === null) {
|
|
1972
|
+
return {
|
|
1973
|
+
ok: false,
|
|
1974
|
+
message: `--commit needs a git repository and ${policyPath} is not inside one; nothing was attested`,
|
|
1975
|
+
};
|
|
1976
|
+
}
|
|
1977
|
+
const policyArg = repoPath(root, policyPath);
|
|
1978
|
+
const logArg = repoPath(root, logPath);
|
|
1979
|
+
// APRV-274. `null` where these pins do not govern this policy, and where the
|
|
1980
|
+
// repository simply has no pins module: in both cases the ceremony's file set
|
|
1981
|
+
// is the two it always was.
|
|
1982
|
+
const pinsArg = expectationsFor(policyPath) !== null && existsSync(join(root, EXPECTATIONS_MODULE))
|
|
1983
|
+
? EXPECTATIONS_MODULE
|
|
1984
|
+
: null;
|
|
1985
|
+
if (policyArg.startsWith("../") || logArg.startsWith("../")) {
|
|
1986
|
+
return {
|
|
1987
|
+
ok: false,
|
|
1988
|
+
message: `--commit needs the policy (${policyPath}) and the log (${logPath}) inside the same repository (${root}); nothing was attested`,
|
|
1989
|
+
};
|
|
1990
|
+
}
|
|
1991
|
+
const status = git(["status", "--porcelain"], root);
|
|
1992
|
+
if (!status.ok) {
|
|
1993
|
+
return { ok: false, message: `--commit could not read git status: ${status.stderr.trim()}` };
|
|
1994
|
+
}
|
|
1995
|
+
const strays = [];
|
|
1996
|
+
for (const line of status.stdout.split("\n")) {
|
|
1997
|
+
if (line.trim().length === 0)
|
|
1998
|
+
continue;
|
|
1999
|
+
const index = line[0] ?? " ";
|
|
2000
|
+
// Only the INDEX column matters: an unstaged or untracked file elsewhere is
|
|
2001
|
+
// not going into this commit, and refusing over it would make the verb
|
|
2002
|
+
// unusable in any working repository.
|
|
2003
|
+
if (index === " " || index === "?")
|
|
2004
|
+
continue;
|
|
2005
|
+
const path = line.slice(3).trim();
|
|
2006
|
+
if (path === policyArg || path === logArg || path === pinsArg)
|
|
2007
|
+
continue;
|
|
2008
|
+
strays.push(path);
|
|
2009
|
+
}
|
|
2010
|
+
if (strays.length > 0) {
|
|
2011
|
+
const carried = pinsArg === null ? "the policy and the log" : `the policy, the log and ${pinsArg}`;
|
|
2012
|
+
return {
|
|
2013
|
+
ok: false,
|
|
2014
|
+
message: `--commit refuses: the index carries ${strays.length} staged change(s) beyond ${carried} (${strays.join(", ")}). The amendment commit carries EXACTLY those files, so that "this commit is the amendment" stays true. Unstage them, or drop --commit and run the printed commands yourself. Nothing was attested`,
|
|
2015
|
+
};
|
|
2016
|
+
}
|
|
2017
|
+
if (branchFlow !== null) {
|
|
2018
|
+
const remote = git(["remote", "get-url", "origin"], root);
|
|
2019
|
+
if (!remote.ok) {
|
|
2020
|
+
return {
|
|
2021
|
+
ok: false,
|
|
2022
|
+
message: `--commit on a branch needs an "origin" remote to push to, and ${root} has none (${remote.stderr.trim()}); pass --direct to commit in place, or add the remote. Nothing was attested`,
|
|
2023
|
+
};
|
|
2024
|
+
}
|
|
2025
|
+
if (branchFlow.branch !== null) {
|
|
2026
|
+
const exists = git(["rev-parse", "--verify", "--quiet", `refs/heads/${branchFlow.branch}`], root);
|
|
2027
|
+
if (exists.ok) {
|
|
2028
|
+
return {
|
|
2029
|
+
ok: false,
|
|
2030
|
+
message: `--branch ${branchFlow.branch} already exists in ${root}; the amendment branch is created fresh so it carries exactly one commit. Pick another name. Nothing was attested`,
|
|
2031
|
+
};
|
|
2032
|
+
}
|
|
2033
|
+
}
|
|
2034
|
+
}
|
|
2035
|
+
return { ok: true, plan: { root, policyArg, logArg, pinsArg } };
|
|
2036
|
+
}
|
|
2037
|
+
/**
|
|
2038
|
+
* Fetch the remote and establish that this ceremony may be based on it (APRV-203).
|
|
2039
|
+
*
|
|
2040
|
+
* The ceremony used to commit on whatever branch the operator was standing on,
|
|
2041
|
+
* which made the operator responsible for that branch being current. On
|
|
2042
|
+
* 2026-09-01 it was not: origin's main had moved, the amendment commit went onto
|
|
2043
|
+
* the stale local tip, and the pull request carried a parent missing everything
|
|
2044
|
+
* main had merged since — including the test pins the previous ceremony landed.
|
|
2045
|
+
* CI went red and the branch had to be rebased by hand. So the verb fetches, and
|
|
2046
|
+
* it bases its commit on the remote.
|
|
2047
|
+
*
|
|
2048
|
+
* Two things are checked before that base is accepted, and both are refusals
|
|
2049
|
+
* with their own codes rather than reconciliations:
|
|
2050
|
+
*
|
|
2051
|
+
* - the remote's POLICY must be the attested bytes this edit was made against.
|
|
2052
|
+
* If the remote carries a policy this working tree never saw, the edit in the
|
|
2053
|
+
* working tree is an edit to a different document.
|
|
2054
|
+
* - the remote's LOG must be a prefix of the working log. The amendment commit
|
|
2055
|
+
* carries the log, so a remote log this working log does not contain would be
|
|
2056
|
+
* reverted by it, and two chains do not merge.
|
|
2057
|
+
*
|
|
2058
|
+
* A local branch AHEAD of the remote is deliberately not checked at all: the
|
|
2059
|
+
* commit is parented on the remote either way, so those commits are simply not
|
|
2060
|
+
* part of the ceremony.
|
|
2061
|
+
*/
|
|
2062
|
+
function prepareBase(ctx, probe, attestedSha256) {
|
|
2063
|
+
const remote = "origin";
|
|
2064
|
+
// A repository with no remote has nothing to fetch and nothing to diverge
|
|
2065
|
+
// from: its own HEAD is the base, which is what this ceremony always used.
|
|
2066
|
+
if (!hasOrigin(ctx.root)) {
|
|
2067
|
+
const head = git(["rev-parse", "HEAD"], ctx.root);
|
|
2068
|
+
const sha = head.stdout.trim();
|
|
2069
|
+
if (!head.ok || sha.length === 0) {
|
|
2070
|
+
return {
|
|
2071
|
+
ok: false,
|
|
2072
|
+
code: "fetch-failed",
|
|
2073
|
+
message: `${ctx.root} has no "origin" remote and no HEAD commit to base this amendment on; nothing was attested. Make the first commit, or add the remote`,
|
|
2074
|
+
};
|
|
2075
|
+
}
|
|
2076
|
+
return { ok: true, base: { remote: null, branch: probe.currentBranch ?? "HEAD", sha } };
|
|
2077
|
+
}
|
|
2078
|
+
const branch = probe.defaultBranch ?? probe.currentBranch;
|
|
2079
|
+
if (branch === null) {
|
|
2080
|
+
return {
|
|
2081
|
+
ok: false,
|
|
2082
|
+
code: "fetch-failed",
|
|
2083
|
+
message: `no branch could be resolved to base this amendment on (${probe.reason}); nothing was attested. Check out the trunk in this checkout, or add an "origin" remote`,
|
|
2084
|
+
};
|
|
2085
|
+
}
|
|
2086
|
+
ctx.progress.phase(`fetching ${remote}/${branch}: the amendment is based on the remote, not on this checkout`);
|
|
2087
|
+
const fetched = fetchBase(ctx.root, remote, branch);
|
|
2088
|
+
ctx.progress.done();
|
|
2089
|
+
if (!fetched.ok) {
|
|
2090
|
+
return {
|
|
2091
|
+
ok: false,
|
|
2092
|
+
code: "fetch-failed",
|
|
2093
|
+
message: `${fetched.message}. The amendment commit is based on ${remote}/${branch}, so the ceremony cannot proceed without knowing where that is; nothing was attested. Fix the remote (network, credentials, or no origin at all) and run this again`,
|
|
2094
|
+
};
|
|
2095
|
+
}
|
|
2096
|
+
const sha = fetched.sha;
|
|
2097
|
+
ctx.progress.phase(`verifying that ${remote}/${branch} ${sha.slice(0, 12)} is this edit's base`);
|
|
2098
|
+
const remotePolicy = showBlob(ctx.root, sha, ctx.policyArg);
|
|
2099
|
+
const remoteSha256 = remotePolicy === null ? null : createHash("sha256").update(remotePolicy).digest("hex");
|
|
2100
|
+
if (attestedSha256 !== null && remoteSha256 !== attestedSha256) {
|
|
2101
|
+
ctx.progress.done();
|
|
2102
|
+
return {
|
|
2103
|
+
ok: false,
|
|
2104
|
+
code: "base-policy-diverged",
|
|
2105
|
+
message: `${remote}/${branch} carries a policy this amendment was not written against: ${remoteSha256 === null
|
|
2106
|
+
? `it has no ${ctx.policyArg} at all`
|
|
2107
|
+
: `${remote}/${branch}:${ctx.policyArg} hashes ${remoteSha256}`}, and the attested baseline this edit is a diff from is ${attestedSha256}. Somebody amended the policy since this edit began, so committing it would revert their amendment. Nothing was attested. Bring this checkout up to ${remote}/${branch} and re-apply the edit on top of it`,
|
|
2108
|
+
};
|
|
2109
|
+
}
|
|
2110
|
+
const remoteLog = showBlob(ctx.root, sha, repoPath(ctx.root, ctx.logPath));
|
|
2111
|
+
const compared = compareChains({ label: `the working log ${ctx.logPath}`, text: readLogText(ctx.logPath) }, {
|
|
2112
|
+
label: `${remote}/${branch}:${repoPath(ctx.root, ctx.logPath)}`,
|
|
2113
|
+
text: remoteLog === null ? "" : remoteLog.toString("utf8"),
|
|
2114
|
+
});
|
|
2115
|
+
ctx.progress.done();
|
|
2116
|
+
if (!compared.ok) {
|
|
2117
|
+
return { ok: false, code: "base-log-diverged", message: `${compared.message}; nothing was attested` };
|
|
2118
|
+
}
|
|
2119
|
+
if (compared.drift.relation === "diverged" || compared.drift.relation === "behind") {
|
|
2120
|
+
const drift = compared.drift;
|
|
2121
|
+
return {
|
|
2122
|
+
ok: false,
|
|
2123
|
+
code: "base-log-diverged",
|
|
2124
|
+
message: drift.relation === "diverged"
|
|
2125
|
+
? `the working log and ${remote}/${branch}'s log part at seq ${String(drift.firstDivergentSeq)}: two chains, not one. The amendment commit carries the log, and hash chains do not merge, so nothing was attested. Run \`approval doctor\` for the log-drift report; which of the two is the log is a human decision`
|
|
2126
|
+
: `${remote}/${branch} carries log records this checkout does not (its head is seq ${String(drift.committedHead?.seq ?? 0)}, the working head is seq ${String(drift.workingHead?.seq ?? 0)}). An amendment commit built on the remote would carry this shorter log over the longer one, dropping records. Nothing was attested. Run \`approval log sync\` first, then run this again`,
|
|
2127
|
+
};
|
|
2128
|
+
}
|
|
2129
|
+
return { ok: true, base: { remote, branch, sha } };
|
|
2130
|
+
}
|
|
2131
|
+
/** The working log's text, or the empty string when there is no file yet. */
|
|
2132
|
+
function readLogText(path) {
|
|
2133
|
+
try {
|
|
2134
|
+
return readFileSync(path, "utf8");
|
|
2135
|
+
}
|
|
2136
|
+
catch {
|
|
2137
|
+
return "";
|
|
2138
|
+
}
|
|
2139
|
+
}
|
|
2140
|
+
function loadSummary(load) {
|
|
2141
|
+
return load.ok
|
|
2142
|
+
? { ok: true, code: null, message: null }
|
|
2143
|
+
: { ok: false, code: load.code, message: load.message };
|
|
2144
|
+
}
|
|
2145
|
+
function emitReport(streams, report) {
|
|
2146
|
+
streams.out(`${JSON.stringify({
|
|
2147
|
+
ok: true,
|
|
2148
|
+
noop: report.noop,
|
|
2149
|
+
dryRun: report.dryRun,
|
|
2150
|
+
aborted: report.aborted,
|
|
2151
|
+
policy: report.policyPath,
|
|
2152
|
+
liveSha256: report.liveSha256,
|
|
2153
|
+
attested: report.attested,
|
|
2154
|
+
baseline: report.baseline,
|
|
2155
|
+
diff: report.diff,
|
|
2156
|
+
load: report.load,
|
|
2157
|
+
attestation: report.attestation,
|
|
2158
|
+
git: report.git,
|
|
2159
|
+
// APRV-274, additive and always present: which pins moved with this
|
|
2160
|
+
// amendment, `null` when the pins are no part of it.
|
|
2161
|
+
pins: report.pins ?? null,
|
|
2162
|
+
// Additive (APRV-130), and always present: a machine caller reads the
|
|
2163
|
+
// ceremony's own outcome without inferring it from `attestation`.
|
|
2164
|
+
ceremony: report.ceremony ?? { attested: report.attestation !== null, seq: null },
|
|
2165
|
+
publishing: report.publishing ?? null,
|
|
2166
|
+
// APRV-109, additive and always present: `null` says the attestation was
|
|
2167
|
+
// performed at this terminal, an object says it was collected as a tap.
|
|
2168
|
+
proposal: report.proposal ?? null,
|
|
2169
|
+
})}\n`);
|
|
2170
|
+
}
|
|
2171
|
+
//# sourceMappingURL=amend.js.map
|