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,2762 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `approval doctor` — environment sanity in one verb (APRV-31).
|
|
3
|
+
*
|
|
4
|
+
* ## Why this exists
|
|
5
|
+
*
|
|
6
|
+
* During the live policy-amendment ceremony the operator lost time twice to
|
|
7
|
+
* questions this command answers in a second. First they drove a **stale
|
|
8
|
+
* checkout**: `dist/` was older than the source tree, so verbs that existed in
|
|
9
|
+
* `src/` were simply absent from the built CLI and every invocation looked like
|
|
10
|
+
* a version confusion rather than a missing `npm run build`. Then they reached
|
|
11
|
+
* for what turned out to be an **unbuilt placeholder binary** — a `cli.js`
|
|
12
|
+
* loader with no `dist/` behind it, which fails with one line about a missing
|
|
13
|
+
* file and says nothing about which of the several checkouts on the machine is
|
|
14
|
+
* the real one. Only on the third try did they find the working install.
|
|
15
|
+
*
|
|
16
|
+
* Neither failure was a bug in the runtime. Both were facts about the
|
|
17
|
+
* environment that nothing was in a position to state out loud. `doctor` is
|
|
18
|
+
* that statement: eleven checks, in the order in which their failures cascade,
|
|
19
|
+
* each with a concrete repair.
|
|
20
|
+
*
|
|
21
|
+
* ## Every fix begins with a command (APRV-75)
|
|
22
|
+
*
|
|
23
|
+
* A `fix` string starts with something the operator can paste, and the prose
|
|
24
|
+
* comes after it. The reason is the reading order of a failed run: an operator
|
|
25
|
+
* scanning a wall of `fix:` lines is looking for the next thing to type, and a
|
|
26
|
+
* line that opens with "check that…" makes them read a sentence to discover
|
|
27
|
+
* there is nothing to type at all. {@link FIX_COMMAND_PREFIXES} is the pinned
|
|
28
|
+
* allowlist, and `tests/cli-doctor.test.ts` drives every failing verdict this
|
|
29
|
+
* command can produce and asserts the shape (never the wording).
|
|
30
|
+
*
|
|
31
|
+
* ## What it will not do
|
|
32
|
+
*
|
|
33
|
+
* **It appends nothing.** Not an event, not a marker, not a "doctor ran"
|
|
34
|
+
* breadcrumb. An operator reaching for a diagnostic while the log is in a state
|
|
35
|
+
* they do not understand must not have that state changed by looking at it; the
|
|
36
|
+
* test suite byte-compares the log across a run.
|
|
37
|
+
*
|
|
38
|
+
* **It sends no message.** The Telegram check calls `getMe` and only `getMe` —
|
|
39
|
+
* a pure identity read. It never calls `sendMessage` (a diagnostic that pings a
|
|
40
|
+
* human's phone is a diagnostic nobody runs twice) and it never calls
|
|
41
|
+
* `getUpdates`, because a running `channel telegram listen` owns that offset
|
|
42
|
+
* and a stray poll would consume an update the listener would then never see.
|
|
43
|
+
*
|
|
44
|
+
* **It repairs nothing.** Every failure yields a `fix` string the human runs
|
|
45
|
+
* themselves. A doctor that rebuilt, re-attested, or truncated on its own would
|
|
46
|
+
* be making exactly the decisions this project exists to keep human.
|
|
47
|
+
*
|
|
48
|
+
* ## Exit codes
|
|
49
|
+
*
|
|
50
|
+
* 0 when every check passed or skipped, 1 when any failed. {@link EXIT_IO} is
|
|
51
|
+
* reserved for doctor's own inability to look — the installation root cannot be
|
|
52
|
+
* stat'd for a reason other than "not there". An unreadable *log* or an
|
|
53
|
+
* unreadable *policy* is not that: those are environment facts, which is
|
|
54
|
+
* precisely what this command reports, so they are check failures (exit 1).
|
|
55
|
+
*/
|
|
56
|
+
import { createServer } from "node:net";
|
|
57
|
+
import { closeSync, existsSync, openSync, readFileSync, readdirSync, statSync, unlinkSync, } from "node:fs";
|
|
58
|
+
import { basename, dirname, isAbsolute, join, resolve as resolvePathSegments } from "node:path";
|
|
59
|
+
import { WEB_DEFAULT_PORT } from "../channels/web.js";
|
|
60
|
+
import { TELEGRAM_DEFAULT_API_BASE, telegramChatEnvFor, telegramTokenEnvFor, } from "../channels/telegram.js";
|
|
61
|
+
import { HUMAN_ACTOR_ENV, checkAttestation, findOrganAttestation, latestOrganAttestation, policyBytesHash, resolveHumanActor, } from "../core/attest.js";
|
|
62
|
+
import { isGateOrganPath } from "../core/command-class.js";
|
|
63
|
+
import { VALUES_INFO_STRING, loadValues } from "../core/values.js";
|
|
64
|
+
import { KEYSTORE_DEFERRED, NON_RESOLVING_RUNNER, envFilePathFor, resolveEnvironment, } from "../core/env-file.js";
|
|
65
|
+
import { readTaskFile } from "../core/frontmatter.js";
|
|
66
|
+
import { instanceFindings, instanceHomeFor, instanceIdFor } from "../core/instance.js";
|
|
67
|
+
import { checkLogAnchor } from "./log-anchor.js";
|
|
68
|
+
import { checkLogCheckpoints, checkpointPolicyOf } from "../core/checkpoint.js";
|
|
69
|
+
import { payloadStoreCensus } from "../core/payload-census.js";
|
|
70
|
+
import { payloadStoreDirFor } from "../core/payload-store.js";
|
|
71
|
+
import { DEFAULT_TASKS_DIR, latestRegistration } from "../core/registration.js";
|
|
72
|
+
import { POLICY_FILENAMES, loadPolicy } from "../core/policy-load.js";
|
|
73
|
+
import { openObligations } from "../core/audit.js";
|
|
74
|
+
import { classSampling, resolveSampler } from "../core/sampler.js";
|
|
75
|
+
import { checkVault, passphraseEnvFor, passphraseFrom, vaultExists, vaultPathFor, } from "../core/vault.js";
|
|
76
|
+
import { admitSnapshot, logBytes, snapshotPathFor, snapshotSummary, } from "../core/verified-snapshot.js";
|
|
77
|
+
import { askDaemonSampling, dialDrawSocket, drawSocketPathFor, liveClassesOf, } from "../core/live-draw.js";
|
|
78
|
+
import { keyStoreDirFor } from "../core/seal.js";
|
|
79
|
+
import { verifyWithRecords } from "../core/verify.js";
|
|
80
|
+
import { boolFlag, parseFlags, stringFlag } from "./args.js";
|
|
81
|
+
import { policyWebPort } from "./channel-web.js";
|
|
82
|
+
import { EXIT_INTEGRITY, EXIT_IO, EXIT_OK, EXIT_USAGE } from "./exit-codes.js";
|
|
83
|
+
import { RESOLVE_DANGLING_COMMAND, lastAdvance, proveDanglingAdvances, } from "../core/advance-cycle.js";
|
|
84
|
+
import { DEFAULT_DARK_WINDOW_MS, reportDarkSessions, } from "../core/dark-session.js";
|
|
85
|
+
import { HARNESS_BINARY, HARNESS_KINDS, installedHarnessVersion, isHarnessKind, readHarnessProvenance, } from "../core/harness-version.js";
|
|
86
|
+
import { git, repoPath, repoRoot } from "./git-scope.js";
|
|
87
|
+
import { ScanError, checkBuildFreshness, checkMainBehindOrigin, installationRoot, } from "./preflight.js";
|
|
88
|
+
import { publishedState } from "./log-advance.js";
|
|
89
|
+
import { DOCTOR_HELP } from "./help.js";
|
|
90
|
+
import { DEFAULT_LOG_PATH, resolvePath } from "./paths.js";
|
|
91
|
+
import { DEFAULT_QUEUE_PATH } from "./render.js";
|
|
92
|
+
import { style } from "./style.js";
|
|
93
|
+
import { usageErrorText } from "./usage.js";
|
|
94
|
+
const FLAGS = {
|
|
95
|
+
"--log": "string",
|
|
96
|
+
"--policy": "string",
|
|
97
|
+
"--dir": "string",
|
|
98
|
+
"--api-base": "string",
|
|
99
|
+
// Where the task files live, for the envelope-integrity check (APRV-63).
|
|
100
|
+
// Defaults to <--dir>/backlog/tasks, the same default the daemon uses.
|
|
101
|
+
"--tasks": "string",
|
|
102
|
+
// Test-only (documented as such in --help): retarget the build-freshness
|
|
103
|
+
// check at a fixture tree. It moves no other check, and a wrong value can
|
|
104
|
+
// only make check 1 wrong — never the log, the policy, or the network.
|
|
105
|
+
"--root": "string",
|
|
106
|
+
// APRV-102. The brief's `--verbose`: never abbreviate a detail, whatever the
|
|
107
|
+
// terminal is doing. See `renderDoctorHuman` for why the default is not the
|
|
108
|
+
// aggressive truncation the brief first proposed.
|
|
109
|
+
"--verbose": "boolean",
|
|
110
|
+
"--json": "boolean",
|
|
111
|
+
"--help": "boolean",
|
|
112
|
+
"-h": "boolean",
|
|
113
|
+
};
|
|
114
|
+
/** How long the Telegram identity probe waits before calling it a network failure. */
|
|
115
|
+
const PROBE_TIMEOUT_MS = 10_000;
|
|
116
|
+
/**
|
|
117
|
+
* Every `fix` string begins with one of these, and the prose follows (APRV-75).
|
|
118
|
+
*
|
|
119
|
+
* A closed, small list rather than "looks like a command": the point is that a
|
|
120
|
+
* reader can paste the head of the line, and a `fix` that opened with a verb
|
|
121
|
+
* nobody has installed would be no better than a sentence. `approval ` is by far
|
|
122
|
+
* the commonest — most repairs in this runtime are another verb of this CLI —
|
|
123
|
+
* and the shell builtins here are the ones an actual repair needs: a variable to
|
|
124
|
+
* export, a mode to set, an ignore line to append, a directory to move aside.
|
|
125
|
+
*
|
|
126
|
+
* Note what is NOT here: no `rm`, no `sudo`, no `git commit`. Doctor repairs
|
|
127
|
+
* nothing, and a fix line that told an operator to delete or to commit would be
|
|
128
|
+
* making the decision this project exists to keep human.
|
|
129
|
+
*/
|
|
130
|
+
export const FIX_COMMAND_PREFIXES = [
|
|
131
|
+
"approval ",
|
|
132
|
+
"chmod ",
|
|
133
|
+
"echo ",
|
|
134
|
+
"export ",
|
|
135
|
+
"mv ",
|
|
136
|
+
"node ",
|
|
137
|
+
"npm ",
|
|
138
|
+
];
|
|
139
|
+
function detailOf(cause) {
|
|
140
|
+
return cause instanceof Error ? cause.message : String(cause);
|
|
141
|
+
}
|
|
142
|
+
/**
|
|
143
|
+
* Fold a multi-line message onto one line.
|
|
144
|
+
*
|
|
145
|
+
* The human renderer is one line per check plus one indented `fix:`, and a
|
|
146
|
+
* message that arrived with an embedded newline (the `.approval/env` mode
|
|
147
|
+
* refusal carries its `chmod` on a second line) would silently break that shape
|
|
148
|
+
* for every reader and every test that counts lines.
|
|
149
|
+
*/
|
|
150
|
+
function oneLine(text) {
|
|
151
|
+
return text.replace(/\s*\n\s*/gu, " ").trim();
|
|
152
|
+
}
|
|
153
|
+
function absolute(value, cwd) {
|
|
154
|
+
return isAbsolute(value) ? value : resolvePathSegments(cwd, value);
|
|
155
|
+
}
|
|
156
|
+
function usageError(streams, json, message) {
|
|
157
|
+
if (json)
|
|
158
|
+
streams.err(`${JSON.stringify({ error: { code: "usage", message } })}\n`);
|
|
159
|
+
else
|
|
160
|
+
streams.err(usageErrorText(message, DOCTOR_HELP));
|
|
161
|
+
return EXIT_USAGE;
|
|
162
|
+
}
|
|
163
|
+
function ioError(streams, json, message) {
|
|
164
|
+
if (json)
|
|
165
|
+
streams.err(`${JSON.stringify({ error: { code: "io", message } })}\n`);
|
|
166
|
+
else
|
|
167
|
+
streams.err(`approval: ${message}\n`);
|
|
168
|
+
return EXIT_IO;
|
|
169
|
+
}
|
|
170
|
+
// ---------------------------------------------------------------------------
|
|
171
|
+
// 1. build freshness
|
|
172
|
+
// ---------------------------------------------------------------------------
|
|
173
|
+
//
|
|
174
|
+
// `installationRoot`, `ScanError`, `newestMtime` and `checkBuildFreshness`
|
|
175
|
+
// moved to `cli/preflight.ts` (APRV-215), where the startup preflight needs the
|
|
176
|
+
// same answer before it decides whether to rebuild. They are imported back
|
|
177
|
+
// above and their behaviour is unchanged; this note is here so the numbered
|
|
178
|
+
// walk through doctor's checks still has a first step to stand on.
|
|
179
|
+
// ---------------------------------------------------------------------------
|
|
180
|
+
// 2. identity
|
|
181
|
+
// ---------------------------------------------------------------------------
|
|
182
|
+
/**
|
|
183
|
+
* Is a human identity declared in the environment?
|
|
184
|
+
*
|
|
185
|
+
* Environment only — deliberately no `--as`. `doctor` reports what the *next*
|
|
186
|
+
* command will find, and a `--as` typed here would answer for this invocation
|
|
187
|
+
* and nothing else, which is the opposite of useful.
|
|
188
|
+
*/
|
|
189
|
+
function checkIdentity() {
|
|
190
|
+
const actor = resolveHumanActor();
|
|
191
|
+
if (actor !== null) {
|
|
192
|
+
return {
|
|
193
|
+
check: "identity",
|
|
194
|
+
status: "pass",
|
|
195
|
+
detail: `${HUMAN_ACTOR_ENV}=${actor} (config-declared: the trust boundary is this machine, not cryptography)`,
|
|
196
|
+
};
|
|
197
|
+
}
|
|
198
|
+
const raw = process.env[HUMAN_ACTOR_ENV];
|
|
199
|
+
return {
|
|
200
|
+
check: "identity",
|
|
201
|
+
status: "fail",
|
|
202
|
+
detail: raw === undefined || raw.length === 0
|
|
203
|
+
? `${HUMAN_ACTOR_ENV} is unset: the human-only verbs (grant, reject, revoke, policy attest) will refuse`
|
|
204
|
+
: `${HUMAN_ACTOR_ENV}=${JSON.stringify(raw)} does not match human:<id>, so it is ignored rather than guessed at`,
|
|
205
|
+
// `approval setup identity` lands in APRV-74, in parallel with this task;
|
|
206
|
+
// the manual alternative is kept after it deliberately, because a fix that
|
|
207
|
+
// named only a verb would be useless to anyone on an older build.
|
|
208
|
+
fix: `approval setup identity — or set it yourself: export ${HUMAN_ACTOR_ENV}=human:<id>, or pass --as human:<id> to each human-only verb`,
|
|
209
|
+
};
|
|
210
|
+
}
|
|
211
|
+
// ---------------------------------------------------------------------------
|
|
212
|
+
// 3. attestation
|
|
213
|
+
// ---------------------------------------------------------------------------
|
|
214
|
+
/**
|
|
215
|
+
* The policy file this runtime would enforce.
|
|
216
|
+
*
|
|
217
|
+
* `--policy` wins outright; otherwise discovery walks {@link POLICY_FILENAMES}
|
|
218
|
+
* in `dir` exactly as `loadPolicy` and `policy attest` do, so doctor never
|
|
219
|
+
* judges a different file from the one the gate reads. When nothing is found,
|
|
220
|
+
* the first candidate name is returned anyway: `checkAttestation` will report
|
|
221
|
+
* it `unreadable`, which is the honest answer ("there is no policy here"), and
|
|
222
|
+
* the message names the path that is missing.
|
|
223
|
+
*/
|
|
224
|
+
function resolvePolicyPath(policyFlag, dir, cwd) {
|
|
225
|
+
if (policyFlag !== null)
|
|
226
|
+
return absolute(policyFlag, cwd);
|
|
227
|
+
for (const filename of POLICY_FILENAMES) {
|
|
228
|
+
const candidate = join(dir, filename);
|
|
229
|
+
try {
|
|
230
|
+
statSync(candidate);
|
|
231
|
+
return candidate;
|
|
232
|
+
}
|
|
233
|
+
catch {
|
|
234
|
+
continue;
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
return join(dir, POLICY_FILENAMES[0] ?? "APPROVAL.md");
|
|
238
|
+
}
|
|
239
|
+
/**
|
|
240
|
+
* Do the live policy bytes match the latest attestation in the log?
|
|
241
|
+
*
|
|
242
|
+
* This is the check that decides whether the gate will do anything at all: an
|
|
243
|
+
* unattested policy makes every gated operation refuse, and the refusal is
|
|
244
|
+
* easily misread as "the policy says no" rather than "the policy is unverified".
|
|
245
|
+
*/
|
|
246
|
+
function checkAttestationHealth(records, policyPath) {
|
|
247
|
+
const status = checkAttestation(records, policyPath);
|
|
248
|
+
switch (status.status) {
|
|
249
|
+
case "attested":
|
|
250
|
+
return {
|
|
251
|
+
check: "attestation",
|
|
252
|
+
status: "pass",
|
|
253
|
+
detail: `${policyPath} is attested at seq ${status.seq} (sha256 ${status.sha256.slice(0, 12)}…)`,
|
|
254
|
+
};
|
|
255
|
+
case "not-attested":
|
|
256
|
+
return {
|
|
257
|
+
check: "attestation",
|
|
258
|
+
status: "fail",
|
|
259
|
+
detail: `${policyPath} has never been attested; every gated operation will refuse with policy-not-attested`,
|
|
260
|
+
fix: "approval policy attest --as human:<id> — after reading the file",
|
|
261
|
+
};
|
|
262
|
+
case "hash-mismatch":
|
|
263
|
+
return {
|
|
264
|
+
check: "attestation",
|
|
265
|
+
status: "fail",
|
|
266
|
+
detail: `${policyPath} has changed since it was attested at seq ${status.seq} (attested ${status.attestedSha256.slice(0, 12)}…, live ${status.liveSha256.slice(0, 12)}…); an edited policy is inoperative until a human re-attests it`,
|
|
267
|
+
fix: `approval policy attest --as human:<id> — after reviewing the diff (\`git diff -- ${policyPath}\`); re-attesting is what makes the new bytes operative`,
|
|
268
|
+
};
|
|
269
|
+
case "unreadable":
|
|
270
|
+
return {
|
|
271
|
+
check: "attestation",
|
|
272
|
+
status: "fail",
|
|
273
|
+
detail: `${status.message}; an unverifiable policy is treated as unattested`,
|
|
274
|
+
fix: `approval init — to scaffold a policy file here (or point --policy / --dir at the one you have), then \`approval policy attest --as human:<id>\``,
|
|
275
|
+
};
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
// ---------------------------------------------------------------------------
|
|
279
|
+
// 4. log
|
|
280
|
+
// ---------------------------------------------------------------------------
|
|
281
|
+
/** The chain verdict, in doctor's vocabulary. Reads; never writes. */
|
|
282
|
+
function checkLog(logPath, result) {
|
|
283
|
+
switch (result.status) {
|
|
284
|
+
case "clean":
|
|
285
|
+
return {
|
|
286
|
+
check: "log",
|
|
287
|
+
status: "pass",
|
|
288
|
+
detail: result.head === null
|
|
289
|
+
? `${logPath} is empty (an audit trail that has recorded nothing is clean, not missing)`
|
|
290
|
+
: `${logPath} verifies: ${result.records} record(s), head seq ${result.head.seq} ${result.head.hash.slice(0, 12)}…`,
|
|
291
|
+
};
|
|
292
|
+
case "torn-tail":
|
|
293
|
+
return {
|
|
294
|
+
check: "log",
|
|
295
|
+
status: "fail",
|
|
296
|
+
detail: `${logPath} ends with an unterminated final line — the signature of a crashed write, not of tampering; records 1..${result.intactThroughSeq} verify clean`,
|
|
297
|
+
fix: "approval log verify — the full report; nothing here truncates the torn line, because that is a human decision",
|
|
298
|
+
};
|
|
299
|
+
case "corrupt":
|
|
300
|
+
return {
|
|
301
|
+
check: "log",
|
|
302
|
+
status: "fail",
|
|
303
|
+
detail: `${logPath} does not verify (${result.reason}${result.firstBadSeq === null ? "" : ` at seq ${result.firstBadSeq}`}): ${result.message}`,
|
|
304
|
+
fix: "approval log verify — and authorize nothing from this log until a human has accounted for the break",
|
|
305
|
+
};
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
// ---------------------------------------------------------------------------
|
|
309
|
+
// 5. telegram
|
|
310
|
+
// ---------------------------------------------------------------------------
|
|
311
|
+
/** Replace the bot token wherever it appears. Nothing leaves this file with it. */
|
|
312
|
+
function redact(text, token) {
|
|
313
|
+
return token.length === 0 ? text : text.split(token).join("<token redacted>");
|
|
314
|
+
}
|
|
315
|
+
/**
|
|
316
|
+
* Is the configured bot token live?
|
|
317
|
+
*
|
|
318
|
+
* `getMe` and nothing else. Not `sendMessage`: a diagnostic that buzzes a
|
|
319
|
+
* human's phone gets run once and then avoided. Not `getUpdates`: that call
|
|
320
|
+
* advances an offset a running `approval channel telegram listen` owns, and a
|
|
321
|
+
* decision tap consumed here would never reach the listener that was waiting
|
|
322
|
+
* for it. `getMe` mutates nothing and acknowledges nothing.
|
|
323
|
+
*
|
|
324
|
+
* Absent configuration is a `skip`, not a failure. Telegram is optional; a
|
|
325
|
+
* runtime driven entirely by `channel cli` is perfectly healthy without it.
|
|
326
|
+
*
|
|
327
|
+
* WHICH variables carry the configuration is the policy's to say (SPEC.md §5.1
|
|
328
|
+
* `channels.telegram.token_env` / `chat_id_env`, amended §5.2 by APRV-72), so
|
|
329
|
+
* the already-computed policy load comes in and every message here names the
|
|
330
|
+
* variable this operator's policy actually asked for. A policy that failed to
|
|
331
|
+
* load names nothing and the reference defaults apply: doctor telling an
|
|
332
|
+
* operator to set a variable their policy never mentions is the failure mode
|
|
333
|
+
* this parameter removes.
|
|
334
|
+
*/
|
|
335
|
+
async function checkTelegram(apiBase, load) {
|
|
336
|
+
const tokenEnv = telegramTokenEnvFor(load);
|
|
337
|
+
const chatEnv = telegramChatEnvFor(load);
|
|
338
|
+
const token = process.env[tokenEnv] ?? "";
|
|
339
|
+
const chat = process.env[chatEnv] ?? "";
|
|
340
|
+
if (token.length === 0 || chat.length === 0) {
|
|
341
|
+
const missing = [
|
|
342
|
+
token.length === 0 ? tokenEnv : null,
|
|
343
|
+
chat.length === 0 ? chatEnv : null,
|
|
344
|
+
].filter((name) => name !== null);
|
|
345
|
+
return {
|
|
346
|
+
check: "telegram",
|
|
347
|
+
status: "skip",
|
|
348
|
+
// A skip carries no `fix` — there is nothing wrong to repair — but a
|
|
349
|
+
// reader who WANTED Telegram still needs the path out, so the detail ends
|
|
350
|
+
// with it. (`approval setup channel telegram` is the verb; APRV-79 renamed it.)
|
|
351
|
+
detail: `${missing.join(" and ")} ${missing.length === 1 ? "is" : "are"} unset: the Telegram channel is not configured, which is a legitimate configuration and not a fault; run \`approval setup channel telegram\` to configure it`,
|
|
352
|
+
};
|
|
353
|
+
}
|
|
354
|
+
const controller = new AbortController();
|
|
355
|
+
const timer = setTimeout(() => controller.abort(), PROBE_TIMEOUT_MS);
|
|
356
|
+
const base = apiBase.replace(/\/+$/u, "");
|
|
357
|
+
try {
|
|
358
|
+
const response = await fetch(`${base}/bot${token}/getMe`, {
|
|
359
|
+
method: "POST",
|
|
360
|
+
headers: { "content-type": "application/json" },
|
|
361
|
+
body: "{}",
|
|
362
|
+
signal: controller.signal,
|
|
363
|
+
});
|
|
364
|
+
const raw = await response.text();
|
|
365
|
+
let envelope = {};
|
|
366
|
+
try {
|
|
367
|
+
const parsed = JSON.parse(raw);
|
|
368
|
+
if (typeof parsed === "object" && parsed !== null) {
|
|
369
|
+
envelope = parsed;
|
|
370
|
+
}
|
|
371
|
+
}
|
|
372
|
+
catch {
|
|
373
|
+
/* handled below: a non-JSON body is not an ok envelope */
|
|
374
|
+
}
|
|
375
|
+
if (!response.ok || envelope["ok"] !== true) {
|
|
376
|
+
const description = redact(String(envelope["description"] ?? "no description"), token);
|
|
377
|
+
return {
|
|
378
|
+
check: "telegram",
|
|
379
|
+
status: "fail",
|
|
380
|
+
detail: `getMe on ${base} was refused: HTTP ${response.status} (${description})`,
|
|
381
|
+
fix: response.status === 401 || /unauthorized/iu.test(description)
|
|
382
|
+
? `approval setup channel telegram — the bot token is not valid; re-copy it from @BotFather into ${tokenEnv}`
|
|
383
|
+
: `approval channel telegram health — the offline configuration report; then check ${tokenEnv} and that ${base} is the right Bot API base`,
|
|
384
|
+
};
|
|
385
|
+
}
|
|
386
|
+
const result = (envelope["result"] ?? {});
|
|
387
|
+
const username = typeof result["username"] === "string" ? `@${result["username"]}` : "unnamed";
|
|
388
|
+
const id = result["id"] === undefined ? "unknown id" : `id ${String(result["id"])}`;
|
|
389
|
+
return {
|
|
390
|
+
check: "telegram",
|
|
391
|
+
status: "pass",
|
|
392
|
+
detail: `token valid: ${username} (${id}) via ${base}, chat ${chat}; no message was sent and no update was consumed`,
|
|
393
|
+
};
|
|
394
|
+
}
|
|
395
|
+
catch (cause) {
|
|
396
|
+
return {
|
|
397
|
+
check: "telegram",
|
|
398
|
+
status: "fail",
|
|
399
|
+
detail: `getMe on ${base} failed: ${redact(detailOf(cause), token)}`,
|
|
400
|
+
fix: `approval channel telegram health — the offline configuration report; then check network reachability of ${base} (and ${tokenEnv} if it is a TLS or auth failure)`,
|
|
401
|
+
};
|
|
402
|
+
}
|
|
403
|
+
finally {
|
|
404
|
+
clearTimeout(timer);
|
|
405
|
+
}
|
|
406
|
+
}
|
|
407
|
+
// ---------------------------------------------------------------------------
|
|
408
|
+
// 6. web port
|
|
409
|
+
// ---------------------------------------------------------------------------
|
|
410
|
+
/**
|
|
411
|
+
* Can the web channel's port be bound on loopback?
|
|
412
|
+
*
|
|
413
|
+
* A **held** port is a `pass`, not a failure, and the detail says why: the most
|
|
414
|
+
* likely holder is this runtime's own `approval channel web`, and a doctor that
|
|
415
|
+
* cried "broken" at a working channel would train operators to ignore it. Only
|
|
416
|
+
* a bind error that means the configuration itself is wrong — `EACCES`, i.e. a
|
|
417
|
+
* privileged port the runtime may not have — is a failure.
|
|
418
|
+
*
|
|
419
|
+
* The probe binds 127.0.0.1 only, never `0.0.0.0`: the web channel is
|
|
420
|
+
* loopback-only, so testing a wider bind would answer a question nobody asked
|
|
421
|
+
* and would briefly open a port to the network.
|
|
422
|
+
*/
|
|
423
|
+
async function checkWebPort(port) {
|
|
424
|
+
return await new Promise((resolve) => {
|
|
425
|
+
const server = createServer();
|
|
426
|
+
const settle = (check) => {
|
|
427
|
+
server.removeAllListeners();
|
|
428
|
+
server.close(() => resolve(check));
|
|
429
|
+
};
|
|
430
|
+
server.once("error", (cause) => {
|
|
431
|
+
server.removeAllListeners();
|
|
432
|
+
if (cause.code === "EADDRINUSE") {
|
|
433
|
+
resolve({
|
|
434
|
+
check: "web-port",
|
|
435
|
+
status: "pass",
|
|
436
|
+
detail: `127.0.0.1:${port} is already held — most likely this runtime's own \`approval channel web\`; nothing here connected to it`,
|
|
437
|
+
});
|
|
438
|
+
return;
|
|
439
|
+
}
|
|
440
|
+
if (cause.code === "EACCES") {
|
|
441
|
+
resolve({
|
|
442
|
+
check: "web-port",
|
|
443
|
+
status: "fail",
|
|
444
|
+
detail: `127.0.0.1:${port} cannot be bound: EACCES (a privileged port this process may not open)`,
|
|
445
|
+
fix: "approval policy attest --as human:<id> — after setting channels.web.port in APPROVAL.md to a port above 1023 (an edited policy is inoperative until it is re-attested)",
|
|
446
|
+
});
|
|
447
|
+
return;
|
|
448
|
+
}
|
|
449
|
+
resolve({
|
|
450
|
+
check: "web-port",
|
|
451
|
+
status: "fail",
|
|
452
|
+
detail: `127.0.0.1:${port} cannot be bound: ${detailOf(cause)}`,
|
|
453
|
+
fix: "approval policy attest --as human:<id> — after setting a usable channels.web.port in APPROVAL.md (an edited policy is inoperative until it is re-attested)",
|
|
454
|
+
});
|
|
455
|
+
});
|
|
456
|
+
server.once("listening", () => {
|
|
457
|
+
settle({
|
|
458
|
+
check: "web-port",
|
|
459
|
+
status: "pass",
|
|
460
|
+
detail: `127.0.0.1:${port} is free (bound and released; nothing was left listening)`,
|
|
461
|
+
});
|
|
462
|
+
});
|
|
463
|
+
server.listen(port, "127.0.0.1");
|
|
464
|
+
});
|
|
465
|
+
}
|
|
466
|
+
// ---------------------------------------------------------------------------
|
|
467
|
+
// 7. payload store
|
|
468
|
+
// ---------------------------------------------------------------------------
|
|
469
|
+
/** The sentence every verdict of this check carries. */
|
|
470
|
+
const PAYLOAD_STORE_WARNING = "the store holds the bytes approvals bind to, keyed by their hash, and it is the one cache that CANNOT be rebuilt from the log: the log records the binding, never the material, so payloads deleted from here are gone and their manual requests render payload-unavailable";
|
|
471
|
+
/**
|
|
472
|
+
* Can the payload store be written?
|
|
473
|
+
*
|
|
474
|
+
* A store that does not exist yet is a `pass`: the directory is created by the
|
|
475
|
+
* first request that carries `--payload`, and a repo that has not made one is
|
|
476
|
+
* not broken. What is worth failing on is an existing directory this process
|
|
477
|
+
* cannot write, because the failure surfaces at exactly the wrong moment: a
|
|
478
|
+
* request already accepted by the gate refuses `payload-store-failed` mid
|
|
479
|
+
* ceremony, and the operator reads it as the runtime refusing rather than as a
|
|
480
|
+
* permission bit.
|
|
481
|
+
*
|
|
482
|
+
* The probe is a real create-and-remove in the store directory, not a `statSync`
|
|
483
|
+
* mode test: mode bits do not answer the question on a read-only mount, under an
|
|
484
|
+
* ACL, or in a container whose uid mapping differs from the one that made the
|
|
485
|
+
* directory. Nothing is left behind, and no payload file is read, written or
|
|
486
|
+
* verified here.
|
|
487
|
+
*/
|
|
488
|
+
function checkPayloadStore(logPath, records) {
|
|
489
|
+
const storeDir = payloadStoreDirFor(logPath);
|
|
490
|
+
let stats;
|
|
491
|
+
try {
|
|
492
|
+
stats = statSync(storeDir);
|
|
493
|
+
}
|
|
494
|
+
catch (cause) {
|
|
495
|
+
if (cause.code === "ENOENT") {
|
|
496
|
+
return {
|
|
497
|
+
check: "payload-store",
|
|
498
|
+
status: "pass",
|
|
499
|
+
detail: `${storeDir} is not created until the first request --payload; ${PAYLOAD_STORE_WARNING}`,
|
|
500
|
+
};
|
|
501
|
+
}
|
|
502
|
+
return {
|
|
503
|
+
check: "payload-store",
|
|
504
|
+
status: "fail",
|
|
505
|
+
detail: `${storeDir} could not be stat'd: ${detailOf(cause)}; ${PAYLOAD_STORE_WARNING}`,
|
|
506
|
+
fix: `chmod u+rwx ${storeDir} — make it readable and writable by the user running approval, and check its ownership`,
|
|
507
|
+
};
|
|
508
|
+
}
|
|
509
|
+
if (!stats.isDirectory()) {
|
|
510
|
+
return {
|
|
511
|
+
check: "payload-store",
|
|
512
|
+
status: "fail",
|
|
513
|
+
detail: `${storeDir} exists and is not a directory, so no payload can be stored beside the log; ${PAYLOAD_STORE_WARNING}`,
|
|
514
|
+
fix: `mv ${storeDir} ${storeDir}.aside — move whatever occupies the store's path out of the way (do not delete it until you know what it is), then re-run the request`,
|
|
515
|
+
};
|
|
516
|
+
}
|
|
517
|
+
const probe = join(storeDir, `.doctor-write-probe-${String(process.pid)}`);
|
|
518
|
+
try {
|
|
519
|
+
const handle = openSync(probe, "wx");
|
|
520
|
+
closeSync(handle);
|
|
521
|
+
}
|
|
522
|
+
catch (cause) {
|
|
523
|
+
return {
|
|
524
|
+
check: "payload-store",
|
|
525
|
+
status: "fail",
|
|
526
|
+
detail: `${storeDir} exists but is not writable (${detailOf(cause)}): a request carrying --payload will refuse payload-store-failed; ${PAYLOAD_STORE_WARNING}`,
|
|
527
|
+
fix: `chmod u+w ${storeDir} — make it writable by the user running approval, and check its ownership`,
|
|
528
|
+
};
|
|
529
|
+
}
|
|
530
|
+
finally {
|
|
531
|
+
try {
|
|
532
|
+
unlinkSync(probe);
|
|
533
|
+
}
|
|
534
|
+
catch {
|
|
535
|
+
// The probe may never have been created; nothing to clean up.
|
|
536
|
+
}
|
|
537
|
+
}
|
|
538
|
+
let files = 0;
|
|
539
|
+
try {
|
|
540
|
+
for (const entry of readdirSync(storeDir, { withFileTypes: true })) {
|
|
541
|
+
if (entry.isFile() && entry.name.endsWith(".json") && !entry.name.startsWith(".")) {
|
|
542
|
+
files += 1;
|
|
543
|
+
}
|
|
544
|
+
}
|
|
545
|
+
}
|
|
546
|
+
catch {
|
|
547
|
+
files = 0;
|
|
548
|
+
}
|
|
549
|
+
// What the log says about the store, beside what the store holds (APRV-41).
|
|
550
|
+
// `pruned` is retention doing its job and leaving the evidence of the deletion
|
|
551
|
+
// behind; `orphans` are files no record binds; `awaiting removal` are files the
|
|
552
|
+
// log already says are gone, which the next daemon tick unlinks without
|
|
553
|
+
// appending a second event.
|
|
554
|
+
const census = payloadStoreCensus(records, storeDir);
|
|
555
|
+
const residue = census.awaitingRemoval === 0
|
|
556
|
+
? ""
|
|
557
|
+
: `, ${census.awaitingRemoval} already recorded as pruned and awaiting removal by the daemon`;
|
|
558
|
+
return {
|
|
559
|
+
check: "payload-store",
|
|
560
|
+
status: "pass",
|
|
561
|
+
detail: `${storeDir} is writable and holds ${files} payload file(s), ${census.pruned} pruned by the log, ${census.orphans} bound to no record${residue}; ${PAYLOAD_STORE_WARNING}`,
|
|
562
|
+
};
|
|
563
|
+
}
|
|
564
|
+
// ---------------------------------------------------------------------------
|
|
565
|
+
// 7b. the verified-head snapshot (APRV-188)
|
|
566
|
+
// ---------------------------------------------------------------------------
|
|
567
|
+
/**
|
|
568
|
+
* Is the daemon's verified-head snapshot present, and does it still cover the
|
|
569
|
+
* log a hook would read?
|
|
570
|
+
*
|
|
571
|
+
* The snapshot is what lets a hook process re-prove a digest instead of
|
|
572
|
+
* re-walking the chain, so its absence is a latency fact and never a
|
|
573
|
+
* correctness one. That is why nothing here is a `fail` on the snapshot's own
|
|
574
|
+
* account: every reader re-proves it, an unusable one is ignored, and a hook
|
|
575
|
+
* behind an unusable snapshot behaves exactly as it did before APRV-188. The
|
|
576
|
+
* row exists so an operator can see whether the acceleration is actually in
|
|
577
|
+
* force, and so a snapshot that is somehow unreadable (a bad mode, a foreign
|
|
578
|
+
* owner) is visible rather than silent.
|
|
579
|
+
*
|
|
580
|
+
* The one `fail` is a snapshot a reader would REFUSE for a reason the operator
|
|
581
|
+
* should act on: permissions or ownership. A stale one is a `pass` that says so
|
|
582
|
+
* — it endorses a shorter prefix, and the hook walks the tail.
|
|
583
|
+
*/
|
|
584
|
+
function checkVerifiedSnapshot(logPath) {
|
|
585
|
+
const path = snapshotPathFor(logPath);
|
|
586
|
+
const read = snapshotSummary(logPath);
|
|
587
|
+
if (!read.ok) {
|
|
588
|
+
if (read.reason === "absent") {
|
|
589
|
+
return {
|
|
590
|
+
check: "verified-snapshot",
|
|
591
|
+
status: "skip",
|
|
592
|
+
detail: `no snapshot at ${path}; every hook invocation verifies the log from genesis. It is published by \`approval daemon run\`, so this is expected when the daemon has never run here.`,
|
|
593
|
+
};
|
|
594
|
+
}
|
|
595
|
+
if (read.reason === "foreign-owner" || read.reason === "loose-permissions") {
|
|
596
|
+
return {
|
|
597
|
+
check: "verified-snapshot",
|
|
598
|
+
status: "fail",
|
|
599
|
+
detail: `${path} would be refused by every reader: ${read.detail}. Hooks fall back to a full chain walk, which is correct but slower.`,
|
|
600
|
+
fix: `rm ${path} — remove it and let \`approval daemon run\` republish it as this user at mode 0600`,
|
|
601
|
+
};
|
|
602
|
+
}
|
|
603
|
+
return {
|
|
604
|
+
check: "verified-snapshot",
|
|
605
|
+
status: "pass",
|
|
606
|
+
detail: `${path} is present but not usable (${read.reason}: ${read.detail}); hooks verify the log from genesis, which is the behaviour without a snapshot at all.`,
|
|
607
|
+
};
|
|
608
|
+
}
|
|
609
|
+
const raw = logBytes(logPath);
|
|
610
|
+
if (raw === null) {
|
|
611
|
+
return {
|
|
612
|
+
check: "verified-snapshot",
|
|
613
|
+
status: "pass",
|
|
614
|
+
detail: `${path} endorses ${String(read.snapshot.lines)} record(s), and the log could not be read here to check it against.`,
|
|
615
|
+
};
|
|
616
|
+
}
|
|
617
|
+
const admitted = admitSnapshot(logPath, raw, read.snapshot, undefined);
|
|
618
|
+
if (!admitted.ok) {
|
|
619
|
+
return {
|
|
620
|
+
check: "verified-snapshot",
|
|
621
|
+
status: "pass",
|
|
622
|
+
detail: `${path} no longer applies to the log (${admitted.reason}: ${admitted.detail}); hooks verify from genesis until the daemon republishes it.`,
|
|
623
|
+
};
|
|
624
|
+
}
|
|
625
|
+
const behind = raw.length - read.snapshot.byte_length;
|
|
626
|
+
const currency = behind === 0
|
|
627
|
+
? "the whole log"
|
|
628
|
+
: `all but the last ${String(behind)} byte(s), which a hook walks itself`;
|
|
629
|
+
return {
|
|
630
|
+
check: "verified-snapshot",
|
|
631
|
+
status: "pass",
|
|
632
|
+
detail: `${path} endorses ${currency}: ${String(read.snapshot.lines)} record(s) through seq ${String(read.snapshot.head.seq)}, published ${read.snapshot.verified_at}. A hook re-proves the digest rather than re-walking the chain.`,
|
|
633
|
+
};
|
|
634
|
+
}
|
|
635
|
+
// ---------------------------------------------------------------------------
|
|
636
|
+
// 7d. the live draw (APRV-208)
|
|
637
|
+
// ---------------------------------------------------------------------------
|
|
638
|
+
/**
|
|
639
|
+
* Is a daemon answering live draws for this log?
|
|
640
|
+
*
|
|
641
|
+
* The row exists because the difference it reports is invisible everywhere else
|
|
642
|
+
* and expensive: with nothing answering, a class an operator declared
|
|
643
|
+
* `supervised-live` at 0.1 is gated at 100% — safely, silently, and for as long
|
|
644
|
+
* as nobody notices, which is the state APRV-184 found this repository in for a
|
|
645
|
+
* fortnight. "Every policy edit asks for a tap" and "one in ten policy edits
|
|
646
|
+
* asks for a tap" look identical from inside the policy file.
|
|
647
|
+
*
|
|
648
|
+
* `skip` when the policy declares no `supervised-live` class: there is nothing
|
|
649
|
+
* to draw, and a row announcing a missing socket for a feature nobody uses is
|
|
650
|
+
* noise. Otherwise `pass` with the socket, or `fail` — this row's one `fail` —
|
|
651
|
+
* when a live class is declared and no usable socket is there, because that IS
|
|
652
|
+
* the operator's control not being in force.
|
|
653
|
+
*
|
|
654
|
+
* ## Why it connects (APRV-282)
|
|
655
|
+
*
|
|
656
|
+
* It used to `stat` the socket and stop there, and on 2026-09-05 that read a
|
|
657
|
+
* socket file left behind by a daemon that had exited as a healthy gate: the
|
|
658
|
+
* row was green while every tap on the operator's phone sat unconsumed. A
|
|
659
|
+
* socket file is created by a bind and removed by an orderly shutdown, so the
|
|
660
|
+
* one state its presence cannot report is the one that matters — a process that
|
|
661
|
+
* died. PRESENCE PROVES NOTHING. So the row opens a connection and closes it
|
|
662
|
+
* again, which is the first thing an asker does and the first thing that fails.
|
|
663
|
+
*
|
|
664
|
+
* It still asks the daemon NOTHING: it sends no question, waits for no answer,
|
|
665
|
+
* and hangs up the moment the connection is accepted. What it reports is what
|
|
666
|
+
* an asker would conclude before it had said a word.
|
|
667
|
+
*/
|
|
668
|
+
async function checkLiveDraw(logPath, load) {
|
|
669
|
+
const path = drawSocketPathFor(logPath);
|
|
670
|
+
// The same helper the daemon's server asks, so this row and the process that
|
|
671
|
+
// serves draws can never disagree about whether the file declares one.
|
|
672
|
+
const liveClasses = load.ok ? liveClassesOf(load.policy) : [];
|
|
673
|
+
if (liveClasses.length === 0) {
|
|
674
|
+
return {
|
|
675
|
+
check: "live-draw",
|
|
676
|
+
status: "skip",
|
|
677
|
+
detail: `this policy declares no supervised-live class, so no draw is ever made and ${path} is not needed.`,
|
|
678
|
+
};
|
|
679
|
+
}
|
|
680
|
+
const declared = liveClasses.join(", ");
|
|
681
|
+
let stats;
|
|
682
|
+
try {
|
|
683
|
+
stats = statSync(path);
|
|
684
|
+
}
|
|
685
|
+
catch {
|
|
686
|
+
return {
|
|
687
|
+
check: "live-draw",
|
|
688
|
+
status: "fail",
|
|
689
|
+
detail: `no draw socket at ${path}, so every action of ${declared} gates to a human instead of being sampled: a gate process holds no sampling secret, and there is no daemon to ask.`,
|
|
690
|
+
fix: 'eval "$(approval env)" && approval up — start the ambient runtime in a shell where the sampling secret resolves, so it can answer draws',
|
|
691
|
+
};
|
|
692
|
+
}
|
|
693
|
+
const euid = typeof process.geteuid === "function" ? process.geteuid() : null;
|
|
694
|
+
if (!stats.isSocket() || euid === null || stats.uid !== euid || (stats.mode & 0o077) !== 0) {
|
|
695
|
+
return {
|
|
696
|
+
check: "live-draw",
|
|
697
|
+
status: "fail",
|
|
698
|
+
detail: `${path} exists but every asker would refuse it (owner uid ${String(stats.uid)}, mode ${(stats.mode & 0o777).toString(8)}), so ${declared} gates to a human on every action.`,
|
|
699
|
+
fix: "stop the daemon, remove the socket, and start it again as the user who owns this approval home",
|
|
700
|
+
};
|
|
701
|
+
}
|
|
702
|
+
// The question a `stat` cannot answer: is anything on the other end?
|
|
703
|
+
const dialled = await dialDrawSocket(path, null);
|
|
704
|
+
if (!dialled.ok) {
|
|
705
|
+
return {
|
|
706
|
+
check: "live-draw",
|
|
707
|
+
status: "fail",
|
|
708
|
+
detail: oneLine(`${path} is on disk and refuses connections (${dialled.detail}). That is what a daemon killed rather than stopped leaves behind: the file was last written ${stats.mtime.toISOString()} and nothing has served it since. Every action of ${declared} gates to a human at 100% while this stands, and the file's presence says otherwise.`),
|
|
709
|
+
fix: "approval up — start the ambient runtime again, in a shell where the sampling secret resolves; it clears the stale socket and binds a new one",
|
|
710
|
+
};
|
|
711
|
+
}
|
|
712
|
+
return {
|
|
713
|
+
check: "live-draw",
|
|
714
|
+
status: "pass",
|
|
715
|
+
detail: `${path} is owner-only and answered a connection, so a gate process with no sampling secret can have its draw answered and ${declared} is sampled at its declared rate rather than gated at 100%. The connection was opened and closed with no question asked.`,
|
|
716
|
+
};
|
|
717
|
+
}
|
|
718
|
+
// ---------------------------------------------------------------------------
|
|
719
|
+
// 7d. the values block (APRV-238)
|
|
720
|
+
// ---------------------------------------------------------------------------
|
|
721
|
+
/**
|
|
722
|
+
* Whether the optional values block of `APPROVAL.md` can be read.
|
|
723
|
+
*
|
|
724
|
+
* The row exists because nothing else would ever report a broken one. A values
|
|
725
|
+
* block is guidance and not policy (SPEC.md §5.3, §11.1 invariant 10), so a
|
|
726
|
+
* malformed one changes nothing about what the policy says and deliberately
|
|
727
|
+
* does not appear in `approval policy check`, whose answer is the enforcement
|
|
728
|
+
* trace. Left there, a typo in the block would silently mean the operator's
|
|
729
|
+
* stated values reach no agent while every gate keeps working perfectly.
|
|
730
|
+
*
|
|
731
|
+
* Absence is a `pass` and says so in the words SPEC.md §5.3 fixes: a file with
|
|
732
|
+
* no block is an operator who declared no values, which is a state and not a
|
|
733
|
+
* fault. The only `fail` is a block that is present and unreadable, and its fix
|
|
734
|
+
* names the code rather than proposing a repair, because what the block should
|
|
735
|
+
* say is the human's to write.
|
|
736
|
+
*/
|
|
737
|
+
function checkValuesBlock(policyPath, policyFlagged, dir) {
|
|
738
|
+
const result = loadValues(policyFlagged ? { file: policyPath } : { dir });
|
|
739
|
+
if (!result.ok) {
|
|
740
|
+
if (result.code === "file-missing") {
|
|
741
|
+
return {
|
|
742
|
+
check: "values-block",
|
|
743
|
+
status: "skip",
|
|
744
|
+
detail: oneLine(`${result.message}, so there is no values block to read. The policy file's own absence is reported by the attestation row above.`),
|
|
745
|
+
};
|
|
746
|
+
}
|
|
747
|
+
return {
|
|
748
|
+
check: "values-block",
|
|
749
|
+
status: "fail",
|
|
750
|
+
detail: oneLine(`a \`\`\`${VALUES_INFO_STRING} block is present and could not be read (${result.code}): ${result.message}. Nothing about the policy changed — guidance is not enforcement — but the operator's stated values reach no agent until this parses.`),
|
|
751
|
+
fix: `approval values --json — prints the same failure with its code (${result.code}); fix the block in ${result.source?.filename ?? "the policy file"} and re-attest, since the attestation digests the whole file`,
|
|
752
|
+
};
|
|
753
|
+
}
|
|
754
|
+
if (!result.present) {
|
|
755
|
+
return {
|
|
756
|
+
check: "values-block",
|
|
757
|
+
status: "pass",
|
|
758
|
+
detail: `${result.source.filename}: no approval-values block; the operator has declared no values here. That is a declaration rather than a gap, and \`approval values\` says so in those words.`,
|
|
759
|
+
};
|
|
760
|
+
}
|
|
761
|
+
const declared = ["love", "like", "dislike", "wants"].filter((key) => result.values[key] !== undefined);
|
|
762
|
+
const responds = result.values.responds === undefined ? "" : ", responds";
|
|
763
|
+
return {
|
|
764
|
+
check: "values-block",
|
|
765
|
+
status: "pass",
|
|
766
|
+
detail: `${result.source.filename}: the values block parses and validates (version ${String(result.values.version)}; ${declared.length === 0 ? "no list" : declared.join(", ")}${responds}). It is guidance, so nothing here is enforced; read it with \`approval values\`.`,
|
|
767
|
+
};
|
|
768
|
+
}
|
|
769
|
+
// ---------------------------------------------------------------------------
|
|
770
|
+
// 7c. the prefix proof long-lived readers run (APRV-217)
|
|
771
|
+
// ---------------------------------------------------------------------------
|
|
772
|
+
/**
|
|
773
|
+
* Which prefix proof this policy configures for its long-lived readers.
|
|
774
|
+
*
|
|
775
|
+
* A configuration row, and only that. It reads the POLICY and never a running
|
|
776
|
+
* daemon: the mode a process is actually using is on that process's own
|
|
777
|
+
* `started` line, a daemon may have been launched with a flag that beat the
|
|
778
|
+
* policy, and a doctor that reported a live process's memory would be reporting
|
|
779
|
+
* something it cannot verify. Nothing here is ever a `fail` — both modes are
|
|
780
|
+
* correct, they differ in what a repeat read re-proves and how often — and a
|
|
781
|
+
* policy that declares no `daemon` block skips the row rather than announcing a
|
|
782
|
+
* default nobody wrote.
|
|
783
|
+
*/
|
|
784
|
+
function checkReadProof(policyLoad) {
|
|
785
|
+
if (!policyLoad.ok) {
|
|
786
|
+
return {
|
|
787
|
+
check: "read-proof",
|
|
788
|
+
status: "skip",
|
|
789
|
+
detail: `the policy did not load (${policyLoad.code}), so no daemon read proof is configured; every reader proves the whole prefix on every read, which is the strict default. The policy failure itself is reported by \`approval policy check\`.`,
|
|
790
|
+
};
|
|
791
|
+
}
|
|
792
|
+
const configured = policyLoad.daemon;
|
|
793
|
+
if (!configured.declared) {
|
|
794
|
+
return {
|
|
795
|
+
check: "read-proof",
|
|
796
|
+
status: "skip",
|
|
797
|
+
detail: `${policyLoad.source.filename} declares no \`daemon\` block, so long-lived readers re-hash the whole verified prefix on every read (read_proof: full). That is the default and the strictest setting; \`daemon.read_proof: incremental\` trades it for a cadence-bounded proof of the appended bytes.`,
|
|
798
|
+
};
|
|
799
|
+
}
|
|
800
|
+
if (configured.readProof === "full") {
|
|
801
|
+
return {
|
|
802
|
+
check: "read-proof",
|
|
803
|
+
status: "pass",
|
|
804
|
+
detail: `${policyLoad.source.filename} sets daemon.read_proof: full — every cached read re-hashes the whole verified prefix and compares the digest. One-shot processes and \`approval log verify\` do that regardless.`,
|
|
805
|
+
};
|
|
806
|
+
}
|
|
807
|
+
return {
|
|
808
|
+
check: "read-proof",
|
|
809
|
+
status: "pass",
|
|
810
|
+
detail: `${policyLoad.source.filename} sets daemon.read_proof: incremental — a long-lived reader hashes only the appended bytes, re-proving the whole prefix at least every ${String(configured.fullReproofEvery)} read(s) or ${String(configured.fullReproofAfterMs)} ms, whichever comes first, and after every append it makes. The Claude Code hook, \`approval log verify\` and \`approval doctor\` prove in full whatever this says.`,
|
|
811
|
+
};
|
|
812
|
+
}
|
|
813
|
+
// ---------------------------------------------------------------------------
|
|
814
|
+
// 8. audit sampling
|
|
815
|
+
// ---------------------------------------------------------------------------
|
|
816
|
+
/**
|
|
817
|
+
* Surface the sampler's state, because sampling fails open by design.
|
|
818
|
+
*
|
|
819
|
+
* SPEC.md §5.2: an unconfigured sampler is disabled with a machine-readable
|
|
820
|
+
* reason rather than escalating everything (the only remaining seed would be
|
|
821
|
+
* agent-authored event content, which §5.2 forbids). The human ruling that
|
|
822
|
+
* accepted the fail-open pairs it with this check: doctor states the disabled
|
|
823
|
+
* state and its reason prominently, so unconfigured-in-production cannot
|
|
824
|
+
* persist unnoticed.
|
|
825
|
+
*
|
|
826
|
+
* Verdict mapping: a sampler the operator plainly chose not to have (no rate,
|
|
827
|
+
* or rate 0) is `skip`, stated in full. A sampler that is half-configured or
|
|
828
|
+
* unreadable (rate set but the secret unnamed or unset, an invalid rate, an
|
|
829
|
+
* unloadable policy) is `fail` with a fix: someone intended sampling and is not
|
|
830
|
+
* getting it.
|
|
831
|
+
*/
|
|
832
|
+
/**
|
|
833
|
+
* The per-class half of the sampling report (amended SPEC.md §5.2, APRV-183).
|
|
834
|
+
*
|
|
835
|
+
* A rate that can differ per class makes "sampling is on" an incomplete answer:
|
|
836
|
+
* an operator needs to know which classes sample, at which rate, and which
|
|
837
|
+
* sample at nothing and why. Appended to the existing `audit-sampling` detail
|
|
838
|
+
* rather than added as a check of its own, because it is the same fact about the
|
|
839
|
+
* same control and a second check would let one of them go stale.
|
|
840
|
+
*/
|
|
841
|
+
function classDetail(load, sampler) {
|
|
842
|
+
const entries = classSampling(load, sampler);
|
|
843
|
+
if (entries.length === 0)
|
|
844
|
+
return "";
|
|
845
|
+
const rendered = entries.map((entry) => entry.enabled
|
|
846
|
+
? `${entry.pattern} ${String(entry.rate)} (${entry.source})`
|
|
847
|
+
: `${entry.pattern} none (${entry.reason ?? "rate-absent"})`);
|
|
848
|
+
return `; supervised classes: ${rendered.join(", ")}`;
|
|
849
|
+
}
|
|
850
|
+
/**
|
|
851
|
+
* The half of the sampling report that this shell cannot answer (APRV-271).
|
|
852
|
+
*
|
|
853
|
+
* `secret-unset` means "the variable the policy names is not in MY
|
|
854
|
+
* environment", and doctor's environment is almost never the one that matters.
|
|
855
|
+
* The secret lives in the single terminal the operator ran `eval "$(approval
|
|
856
|
+
* env)"` in and started the daemon from, and `core/child-env.ts` strips
|
|
857
|
+
* `APPROVAL_*` from every child, so a doctor run from an agent session, a
|
|
858
|
+
* different tab, or a hook could not see it even on a machine where sampling
|
|
859
|
+
* has been running for a fortnight. That is what this row reported as a fault
|
|
860
|
+
* on 2026-09-05, in red, beside a daemon banner confirming the secret in use.
|
|
861
|
+
*
|
|
862
|
+
* So the row asks the daemon, over the APRV-208 socket, and reports the answer
|
|
863
|
+
* WITH ITS SOURCE. Three things bound what that is allowed to do:
|
|
864
|
+
*
|
|
865
|
+
* - **Only this branch.** `secret-unset` is the one disabled reason that is a
|
|
866
|
+
* fact about a process environment. `rate-absent`, `rate-invalid`,
|
|
867
|
+
* `rate-zero`, `secret-env-unnamed` and `policy-unreadable` are facts about
|
|
868
|
+
* the policy FILE, which doctor is reading for itself, and no daemon's answer
|
|
869
|
+
* may soften one of those.
|
|
870
|
+
* - **Only an owner-only socket.** `askDaemonSampling` refuses a socket that is
|
|
871
|
+
* not owned by this euid or is reachable by group or other, and refuses an
|
|
872
|
+
* answer naming a pid that is gone.
|
|
873
|
+
* - **Nothing is authorized either way.** The answer moves a diagnostic row and
|
|
874
|
+
* the exit code of a verb that appends nothing, sends nothing and repairs
|
|
875
|
+
* nothing. It reaches no gate, no budget and no log, so SPEC.md §11.1's rule
|
|
876
|
+
* that self-reported fields never reduce scrutiny is not in play: there is no
|
|
877
|
+
* scrutiny here to reduce, only a report to get right.
|
|
878
|
+
*/
|
|
879
|
+
async function samplingFromDaemon(logPath, sampler) {
|
|
880
|
+
if (sampler.enabled || sampler.reason !== "secret-unset")
|
|
881
|
+
return null;
|
|
882
|
+
const probe = await askDaemonSampling(logPath);
|
|
883
|
+
const variable = sampler.secretEnv ?? "the sampling secret's variable";
|
|
884
|
+
if (!probe.ok) {
|
|
885
|
+
return {
|
|
886
|
+
check: "audit-sampling",
|
|
887
|
+
status: "fail",
|
|
888
|
+
detail: oneLine(`disabled (${sampler.reason}): ${sampler.message} No daemon answered on ${probe.socket} (${probe.reason}), so this is what THIS shell can see and the daemon's shell is what decides: a daemon started where ${variable} resolves is sampling whatever this row says.`),
|
|
889
|
+
fix: `approval setup sampling — or set it yourself: export ${variable} with the operator-held sampling secret in the environment that runs the daemon`,
|
|
890
|
+
};
|
|
891
|
+
}
|
|
892
|
+
const report = probe.answer.sampling;
|
|
893
|
+
const where = `the running daemon (pid ${String(probe.answer.daemon_pid)}, ${probe.socket})`;
|
|
894
|
+
if (!report.enabled) {
|
|
895
|
+
return {
|
|
896
|
+
check: "audit-sampling",
|
|
897
|
+
status: "fail",
|
|
898
|
+
detail: oneLine(`disabled (${report.reason ?? "unstated"}) per ${where}, which is the process that decides: ${variable} is unset in this shell too, and the daemon reports its own sampler off. Nothing is sampled.`),
|
|
899
|
+
fix: `approval setup sampling — or set it yourself: export ${variable} with the operator-held sampling secret in the environment that runs the daemon, then restart it`,
|
|
900
|
+
};
|
|
901
|
+
}
|
|
902
|
+
const rate = report.rate === null
|
|
903
|
+
? "no global fallback rate: only classes declaring their own retro_rate are sampled"
|
|
904
|
+
: `fallback rate ${String(report.rate)} from audit.supervised_sample_rate`;
|
|
905
|
+
return {
|
|
906
|
+
check: "audit-sampling",
|
|
907
|
+
status: "pass",
|
|
908
|
+
detail: oneLine(`enabled per ${where}; ${variable} is not exported in THIS shell, and the daemon's is the environment that decides. The value itself is never printed and never logged; ${rate}.`),
|
|
909
|
+
};
|
|
910
|
+
}
|
|
911
|
+
/**
|
|
912
|
+
* The per-class half of the report, said in DECLARED terms (APRV-271).
|
|
913
|
+
*
|
|
914
|
+
* {@link classDetail} renders what the LOCAL sampler puts in force, which on
|
|
915
|
+
* this branch is nothing: the local reading is `secret-unset`, so every class
|
|
916
|
+
* would come out as "none (secret-unset)" beside a sentence saying sampling is
|
|
917
|
+
* enabled. The entries still carry each rule's own declared `retro_rate`, which
|
|
918
|
+
* is a fact about the policy file and true whoever is holding the secret, so
|
|
919
|
+
* they are rendered as declarations and a rule with no rate of its own is named
|
|
920
|
+
* as taking the daemon's fallback.
|
|
921
|
+
*/
|
|
922
|
+
function declaredClassDetail(load, sampler) {
|
|
923
|
+
const entries = classSampling(load, sampler);
|
|
924
|
+
if (entries.length === 0)
|
|
925
|
+
return "";
|
|
926
|
+
const rendered = entries.map((entry) => entry.rate === null
|
|
927
|
+
? `${entry.pattern} at the daemon's fallback rate`
|
|
928
|
+
: `${entry.pattern} ${String(entry.rate)} (class)`);
|
|
929
|
+
return `; supervised classes, as the policy declares them: ${rendered.join(", ")}`;
|
|
930
|
+
}
|
|
931
|
+
function checkSamplingLocally(load) {
|
|
932
|
+
const sampler = resolveSampler(load);
|
|
933
|
+
if (sampler.enabled) {
|
|
934
|
+
const fallback = sampler.rate === null
|
|
935
|
+
? `no global fallback rate (${sampler.fallbackReason ?? "rate-absent"}): only classes declaring their own retro_rate are sampled`
|
|
936
|
+
: `fallback rate ${String(sampler.rate)} from audit.supervised_sample_rate`;
|
|
937
|
+
return {
|
|
938
|
+
check: "audit-sampling",
|
|
939
|
+
status: "pass",
|
|
940
|
+
detail: `enabled at rate ${String(sampler.rate)}; secret read from $${sampler.secretEnv} (the value itself is never printed and never logged); ${fallback}${classDetail(load, sampler)}`,
|
|
941
|
+
};
|
|
942
|
+
}
|
|
943
|
+
const deliberate = sampler.reason === "rate-absent" || sampler.reason === "rate-zero";
|
|
944
|
+
if (deliberate) {
|
|
945
|
+
return {
|
|
946
|
+
check: "audit-sampling",
|
|
947
|
+
status: "skip",
|
|
948
|
+
detail: `disabled (${sampler.reason}): ${sampler.message}${classDetail(load, sampler)}`,
|
|
949
|
+
};
|
|
950
|
+
}
|
|
951
|
+
return {
|
|
952
|
+
check: "audit-sampling",
|
|
953
|
+
status: "fail",
|
|
954
|
+
detail: `disabled (${sampler.reason}): ${sampler.message}${classDetail(load, sampler)}`,
|
|
955
|
+
fix: sampler.reason === "secret-unset" && sampler.secretEnv !== null
|
|
956
|
+
? `approval setup sampling — or set it yourself: export ${sampler.secretEnv} with the operator-held sampling secret in the environment that runs the daemon`
|
|
957
|
+
: "approval policy attest --as human:<id> — after setting audit.supervised_sample_rate and audit.sampling_secret_env in the policy; then export the named variable where the daemon runs",
|
|
958
|
+
};
|
|
959
|
+
}
|
|
960
|
+
/**
|
|
961
|
+
* The row, from this shell first and from the daemon only where this shell
|
|
962
|
+
* cannot be right (APRV-271).
|
|
963
|
+
*
|
|
964
|
+
* The local reading is computed regardless, so a daemon that answers nothing
|
|
965
|
+
* leaves the row saying exactly what it said before, plus who could have
|
|
966
|
+
* answered. There is no path on which the probe makes the report weaker.
|
|
967
|
+
*/
|
|
968
|
+
async function checkSampling(load, logPath) {
|
|
969
|
+
const sampler = resolveSampler(load);
|
|
970
|
+
const delegated = await samplingFromDaemon(logPath, sampler);
|
|
971
|
+
if (delegated === null)
|
|
972
|
+
return checkSamplingLocally(load);
|
|
973
|
+
// The per-class breakdown is a fact about the policy file, so it belongs on
|
|
974
|
+
// the row whichever process answered the environment half. It is said in
|
|
975
|
+
// declared terms only where the daemon says sampling is on: everywhere else
|
|
976
|
+
// the local reading IS what is in force, and the existing wording holds.
|
|
977
|
+
const classes = delegated.status === "pass"
|
|
978
|
+
? declaredClassDetail(load, sampler)
|
|
979
|
+
: classDetail(load, sampler);
|
|
980
|
+
return { ...delegated, detail: `${delegated.detail}${classes}` };
|
|
981
|
+
}
|
|
982
|
+
// ---------------------------------------------------------------------------
|
|
983
|
+
// 12. reconciliation obligations (amended SPEC.md §5.2 — APRV-127)
|
|
984
|
+
// ---------------------------------------------------------------------------
|
|
985
|
+
/**
|
|
986
|
+
* Is any retrospective denial still unreconciled?
|
|
987
|
+
*
|
|
988
|
+
* A denial cannot undo the action it denies. What it does is open an obligation,
|
|
989
|
+
* and the obligation is worth nothing unless somebody is told about it — so
|
|
990
|
+
* doctor FAILS while one is open, in the same voice it uses for a half-configured
|
|
991
|
+
* sampler. This is the "loud" half of the design: a human said an action should
|
|
992
|
+
* not have happened, and until a person records what was done about it, the
|
|
993
|
+
* system has not responded to that at all.
|
|
994
|
+
*
|
|
995
|
+
* **It repairs nothing.** Satisfaction is human-only, in code and in the event
|
|
996
|
+
* schema; a doctor that could close an obligation would be the runtime closing
|
|
997
|
+
* its own homework. The `fix` is the command a person runs after they have
|
|
998
|
+
* actually done the thing.
|
|
999
|
+
*/
|
|
1000
|
+
function checkReconciliation(records) {
|
|
1001
|
+
const open = openObligations(records);
|
|
1002
|
+
if (open.length === 0) {
|
|
1003
|
+
return {
|
|
1004
|
+
check: "reconciliation",
|
|
1005
|
+
status: "pass",
|
|
1006
|
+
detail: "no retrospective denial is waiting to be reconciled",
|
|
1007
|
+
};
|
|
1008
|
+
}
|
|
1009
|
+
const first = open[0];
|
|
1010
|
+
return {
|
|
1011
|
+
check: "reconciliation",
|
|
1012
|
+
status: "fail",
|
|
1013
|
+
detail: `${String(open.length)} unreconciled retrospective denial(s): ${open
|
|
1014
|
+
.map((item) => `seq ${String(item.seq)} ${item.actionKey} (${item.obligation})`)
|
|
1015
|
+
.join(", ")}. A denial cannot undo what already ran, so what it leaves is this obligation, and it stays open until a PERSON records what was done.`,
|
|
1016
|
+
fix: `approval audit obligations — then, once you have done it: approval audit reconcile ${String(first.seq)} --note "<what you did>"${first.obligation === "gated-revert" ? " --revert <action-key>" : ""}`,
|
|
1017
|
+
};
|
|
1018
|
+
}
|
|
1019
|
+
// ---------------------------------------------------------------------------
|
|
1020
|
+
// 9. envelope integrity
|
|
1021
|
+
// ---------------------------------------------------------------------------
|
|
1022
|
+
/**
|
|
1023
|
+
* Which task files have lost the envelope the log says they had? (APRV-63)
|
|
1024
|
+
*
|
|
1025
|
+
* The failure this reports was observed live in APRV-60: a task-file rewrite by
|
|
1026
|
+
* a tool that did not know the `approval:` key dropped it. Nothing was corrupt,
|
|
1027
|
+
* nothing refused, and the loss was invisible until someone looked — which is
|
|
1028
|
+
* precisely the shape of question doctor exists to answer out loud.
|
|
1029
|
+
*
|
|
1030
|
+
* Log-derived in both directions. A file is only interesting when the *log*
|
|
1031
|
+
* holds a `task.registered` for its id; the file's own claims are read for one
|
|
1032
|
+
* thing, whether an `approval:` key is present, and trusted for nothing else. A
|
|
1033
|
+
* file with no frontmatter at all leaves no id, so its Backlog.md file name is
|
|
1034
|
+
* matched case-insensitively against registered ids — a way of asking the log a
|
|
1035
|
+
* question, never a way of deciding the answer.
|
|
1036
|
+
*
|
|
1037
|
+
* **It repairs nothing**, in the strong sense doctor means it: the registration
|
|
1038
|
+
* in the log holds every action the envelope declared, so a writer could re-emit
|
|
1039
|
+
* one, and doing so would make a projection into a source. The fix is a human
|
|
1040
|
+
* restoring the block by hand.
|
|
1041
|
+
*/
|
|
1042
|
+
function checkEnvelopeIntegrity(tasksDir, records) {
|
|
1043
|
+
let entries;
|
|
1044
|
+
try {
|
|
1045
|
+
entries = readdirSync(tasksDir, { withFileTypes: true });
|
|
1046
|
+
}
|
|
1047
|
+
catch (cause) {
|
|
1048
|
+
if (cause.code === "ENOENT") {
|
|
1049
|
+
return {
|
|
1050
|
+
check: "envelope-integrity",
|
|
1051
|
+
status: "skip",
|
|
1052
|
+
detail: `no task folder at ${tasksDir}, so no task file can be compared against the log (pass --tasks <dir> if your task files live elsewhere)`,
|
|
1053
|
+
};
|
|
1054
|
+
}
|
|
1055
|
+
return {
|
|
1056
|
+
check: "envelope-integrity",
|
|
1057
|
+
status: "fail",
|
|
1058
|
+
detail: `${tasksDir} could not be listed: ${detailOf(cause)}; whether any task lost its envelope is unknown`,
|
|
1059
|
+
fix: `chmod u+rx ${tasksDir} — make it readable by the user running approval, or point --tasks at the task folder`,
|
|
1060
|
+
};
|
|
1061
|
+
}
|
|
1062
|
+
const files = entries
|
|
1063
|
+
.filter((entry) => entry.isFile() && entry.name.endsWith(".md"))
|
|
1064
|
+
.map((entry) => entry.name)
|
|
1065
|
+
.sort();
|
|
1066
|
+
const lost = [];
|
|
1067
|
+
for (const name of files) {
|
|
1068
|
+
const read = readTaskFile(join(tasksDir, name));
|
|
1069
|
+
// Unreadable or unparseable frontmatter is the daemon's warning to raise,
|
|
1070
|
+
// not this check's verdict: the question here is only "is the envelope
|
|
1071
|
+
// gone", and a file nobody can parse has not answered it.
|
|
1072
|
+
if (read.ok && read.data["approval"] !== undefined)
|
|
1073
|
+
continue;
|
|
1074
|
+
if (!read.ok && read.code !== "no-frontmatter")
|
|
1075
|
+
continue;
|
|
1076
|
+
const declared = read.ok ? read.data["id"] : undefined;
|
|
1077
|
+
const hasId = typeof declared === "string" && declared.length > 0;
|
|
1078
|
+
const id = hasId ? declared : taskIdFromFileName(name);
|
|
1079
|
+
if (id === null)
|
|
1080
|
+
continue;
|
|
1081
|
+
const registration = latestRegistration(records, id, !hasId);
|
|
1082
|
+
if (registration === null)
|
|
1083
|
+
continue;
|
|
1084
|
+
lost.push(`${String(registration.task)} (${name}, registered at seq ${String(registration.seq)})`);
|
|
1085
|
+
}
|
|
1086
|
+
if (lost.length === 0) {
|
|
1087
|
+
return {
|
|
1088
|
+
check: "envelope-integrity",
|
|
1089
|
+
status: "pass",
|
|
1090
|
+
detail: `${String(files.length)} task file(s) in ${tasksDir}; every task the log has registered still carries its approval: envelope`,
|
|
1091
|
+
};
|
|
1092
|
+
}
|
|
1093
|
+
return {
|
|
1094
|
+
check: "envelope-integrity",
|
|
1095
|
+
status: "fail",
|
|
1096
|
+
detail: `${String(lost.length)} task(s) have log history and no envelope in their file: ${lost.join("; ")}. The log still holds every action they declared; the file does not.`,
|
|
1097
|
+
fix: "approval log tail — it shows the actions each registration declared. The envelope was removed by an external rewrite; restore it from the log by hand — see docs/dogfood-cutover.md (\"If an envelope goes missing\") and the APRV-60 record. Nothing here rewrites a task file: re-emitting the envelope from the log would turn a projection into a source.",
|
|
1098
|
+
};
|
|
1099
|
+
}
|
|
1100
|
+
// ---------------------------------------------------------------------------
|
|
1101
|
+
// 10. vault
|
|
1102
|
+
// ---------------------------------------------------------------------------
|
|
1103
|
+
/** The exact line doctor tells an operator to add for the vault. */
|
|
1104
|
+
const VAULT_IGNORE_LINE = ".approval/vault.enc";
|
|
1105
|
+
/** The same, for the environment source map (APRV-75). */
|
|
1106
|
+
const ENV_IGNORE_LINE = ".approval/env";
|
|
1107
|
+
/**
|
|
1108
|
+
* Patterns in a `.gitignore` that cover one path under `.approval/`.
|
|
1109
|
+
*
|
|
1110
|
+
* A deliberately small, literal set rather than a gitignore engine. The two
|
|
1111
|
+
* error directions are not symmetric: a false PASS says a file holding
|
|
1112
|
+
* credentials is safe from a commit when it is not, and a false FAIL costs an
|
|
1113
|
+
* operator one glance at a fix line they can ignore. So only forms whose
|
|
1114
|
+
* meaning is unambiguous are accepted, and anything cleverer (a negation, a
|
|
1115
|
+
* nested `.gitignore`, a `core.excludesFile`) reads as "not covered here".
|
|
1116
|
+
*
|
|
1117
|
+
* Generalised from the vault's fixed list (APRV-68) when the env file arrived
|
|
1118
|
+
* (APRV-75), because the two questions are the same question: the bare basename
|
|
1119
|
+
* is included in both cases because a `.gitignore` pattern with no slash matches
|
|
1120
|
+
* at every level, so a line `vault.enc` — or `env` — does cover the file.
|
|
1121
|
+
*
|
|
1122
|
+
* `kind` was added for the sealed-token key store (APRV-285), which is a
|
|
1123
|
+
* DIRECTORY rather than a file. The trailing-slash forms are accepted only for
|
|
1124
|
+
* that kind, and deliberately: `keys/` covers everything under `.approval/keys`,
|
|
1125
|
+
* while a line `env/` would match a directory and not the file `.approval/env`,
|
|
1126
|
+
* so accepting it there would be exactly the false PASS this list is shaped to
|
|
1127
|
+
* avoid.
|
|
1128
|
+
*/
|
|
1129
|
+
function ignorePatternsFor(relative, kind = "file") {
|
|
1130
|
+
const base = relative.slice(relative.lastIndexOf("/") + 1);
|
|
1131
|
+
return [
|
|
1132
|
+
relative,
|
|
1133
|
+
`/${relative}`,
|
|
1134
|
+
".approval/",
|
|
1135
|
+
"/.approval/",
|
|
1136
|
+
".approval",
|
|
1137
|
+
"/.approval",
|
|
1138
|
+
".approval/*",
|
|
1139
|
+
"/.approval/*",
|
|
1140
|
+
base,
|
|
1141
|
+
...(relative.endsWith(".enc") ? ["*.enc"] : []),
|
|
1142
|
+
...(kind === "dir" ? [`${relative}/`, `/${relative}/`, `${base}/`] : []),
|
|
1143
|
+
];
|
|
1144
|
+
}
|
|
1145
|
+
/**
|
|
1146
|
+
* Is `relative` covered by the project's `.gitignore`?
|
|
1147
|
+
*
|
|
1148
|
+
* `not-a-repo` when `dir` holds no `.git` entry (a directory in a normal clone,
|
|
1149
|
+
* a file in a worktree or submodule): outside a repository there is nothing to
|
|
1150
|
+
* accidentally commit the file to, and failing a check about a risk that does
|
|
1151
|
+
* not exist trains people to ignore the check.
|
|
1152
|
+
*/
|
|
1153
|
+
function ignoreVerdict(dir, relative, kind = "file") {
|
|
1154
|
+
try {
|
|
1155
|
+
statSync(join(dir, ".git"));
|
|
1156
|
+
}
|
|
1157
|
+
catch {
|
|
1158
|
+
return "not-a-repo";
|
|
1159
|
+
}
|
|
1160
|
+
let text;
|
|
1161
|
+
try {
|
|
1162
|
+
text = readFileSync(join(dir, ".gitignore"), "utf8");
|
|
1163
|
+
}
|
|
1164
|
+
catch {
|
|
1165
|
+
return "not-ignored";
|
|
1166
|
+
}
|
|
1167
|
+
const patterns = ignorePatternsFor(relative, kind);
|
|
1168
|
+
for (const raw of text.split(/\r\n|\n|\r/u)) {
|
|
1169
|
+
const line = raw.trim();
|
|
1170
|
+
if (line.length === 0 || line.startsWith("#"))
|
|
1171
|
+
continue;
|
|
1172
|
+
if (patterns.includes(line))
|
|
1173
|
+
return "ignored";
|
|
1174
|
+
}
|
|
1175
|
+
return "not-ignored";
|
|
1176
|
+
}
|
|
1177
|
+
/**
|
|
1178
|
+
* Can the credential vault be opened, and is it kept out of the repository?
|
|
1179
|
+
*
|
|
1180
|
+
* Three verdicts, in an order chosen for what stays wrong the longest:
|
|
1181
|
+
*
|
|
1182
|
+
* 1. **Not gitignored** is reported FIRST, ahead of any passphrase problem. A
|
|
1183
|
+
* vault that is one `git add -A` from being published is the worse fault, it
|
|
1184
|
+
* is silent, and it remains true after every other problem here is fixed. An
|
|
1185
|
+
* encrypted file in a public repository is not a catastrophe, but it is a
|
|
1186
|
+
* permanent offline-attack target against one human-chosen passphrase, and
|
|
1187
|
+
* history is not something a later commit removes.
|
|
1188
|
+
* 2. **No passphrase, or it does not decrypt.** Both fail: the credentials are
|
|
1189
|
+
* unreachable, so every adapter that needs one refuses at execution time,
|
|
1190
|
+
* and that refusal reads as "the adapter is broken" rather than "this
|
|
1191
|
+
* machine cannot open the vault". The fix names the variable.
|
|
1192
|
+
* 3. **Absent vault** is a SKIP with the consequence stated. Nobody has created
|
|
1193
|
+
* one, which is a legitimate configuration — the same reading the Telegram
|
|
1194
|
+
* check gives an unconfigured channel.
|
|
1195
|
+
*
|
|
1196
|
+
* The detail names the credential COUNT and never a name, never a value, and
|
|
1197
|
+
* the passphrase is read but never printed (SPEC.md §11.1 invariant 3).
|
|
1198
|
+
*/
|
|
1199
|
+
function checkVaultHealth(logPath, dir, load) {
|
|
1200
|
+
const vaultPath = vaultPathFor(logPath);
|
|
1201
|
+
const passphraseEnv = passphraseEnvFor(load);
|
|
1202
|
+
if (!vaultExists(vaultPath)) {
|
|
1203
|
+
return {
|
|
1204
|
+
check: "vault",
|
|
1205
|
+
status: "skip",
|
|
1206
|
+
detail: `no credential vault at ${vaultPath}; adapters that need a credential will refuse credential-unavailable until one exists, which is a legitimate configuration for a runtime driven by \`approval run\` and the CLI channel. The passphrase would be read from $${passphraseEnv} (\`approval vault set <name>\` creates the file)`,
|
|
1207
|
+
};
|
|
1208
|
+
}
|
|
1209
|
+
const ignored = ignoreVerdict(dir, VAULT_IGNORE_LINE);
|
|
1210
|
+
if (ignored === "not-ignored") {
|
|
1211
|
+
return {
|
|
1212
|
+
check: "vault",
|
|
1213
|
+
status: "fail",
|
|
1214
|
+
detail: `${vaultPath} exists and is NOT gitignored in ${dir}: one \`git add -A\` publishes an encrypted credential file, and a commit is not something a later commit removes. The contents stay encrypted, but a published vault is a permanent offline-attack target against one human-chosen passphrase`,
|
|
1215
|
+
fix: `echo '${VAULT_IGNORE_LINE}' >> ${join(dir, ".gitignore")} — and if the file has already been committed, treat every credential in it as disclosed and rotate`,
|
|
1216
|
+
};
|
|
1217
|
+
}
|
|
1218
|
+
const passphrase = passphraseFrom(passphraseEnv);
|
|
1219
|
+
if (passphrase === null) {
|
|
1220
|
+
return {
|
|
1221
|
+
check: "vault",
|
|
1222
|
+
status: "fail",
|
|
1223
|
+
detail: `${vaultPath} exists and $${passphraseEnv} is unset or empty in this process, so no credential can be read: every adapter that needs one will refuse credential-unavailable, which reads like a broken adapter rather than an unopened vault`,
|
|
1224
|
+
fix: `approval setup vault — or set it yourself: export ${passphraseEnv} with the vault passphrase in the environment that runs the adapters (the policy names the variable, never the value; there is no --passphrase flag)`,
|
|
1225
|
+
};
|
|
1226
|
+
}
|
|
1227
|
+
const opened = checkVault(vaultPath, passphrase);
|
|
1228
|
+
if (!opened.ok) {
|
|
1229
|
+
return {
|
|
1230
|
+
check: "vault",
|
|
1231
|
+
status: "fail",
|
|
1232
|
+
detail: `${vaultPath} did not open (${opened.code}): ${opened.message}`,
|
|
1233
|
+
fix: opened.code === "vault-unreadable"
|
|
1234
|
+
? `approval env --check — confirm value-free where $${passphraseEnv} is coming from, and that it is the passphrase this vault was created with; then check the file's provenance. A wrong passphrase and an altered file are ONE verdict on purpose, because distinguishing them would confirm a guessed passphrase against a file someone had modified`
|
|
1235
|
+
: `mv ${vaultPath} ${vaultPath}.unreadable — set it aside and inspect it by hand (do NOT delete it: it may be the only copy of a credential). It is not a vault this build can read, and nothing here rewrites it`,
|
|
1236
|
+
};
|
|
1237
|
+
}
|
|
1238
|
+
return {
|
|
1239
|
+
check: "vault",
|
|
1240
|
+
status: "pass",
|
|
1241
|
+
detail: `${vaultPath} opens with the passphrase in $${passphraseEnv} and holds ${String(opened.count)} credential(s)${ignored === "not-a-repo" ? "; no git repository at " + dir + ", so there is nothing here to commit it to" : ", and it is gitignored"}. No credential name or value is printed by this check`,
|
|
1242
|
+
};
|
|
1243
|
+
}
|
|
1244
|
+
// ---------------------------------------------------------------------------
|
|
1245
|
+
// 11. environment (APRV-75)
|
|
1246
|
+
// ---------------------------------------------------------------------------
|
|
1247
|
+
/**
|
|
1248
|
+
* WHY DOCTOR DOES NOT RESOLVE KEYSTORE SOURCES.
|
|
1249
|
+
*
|
|
1250
|
+
* `resolveEnvironment` is called here exactly as `approval env --check` calls it
|
|
1251
|
+
* — same function, same policy load, same file path — so doctor and env cannot
|
|
1252
|
+
* disagree about what the environment IS. The one difference is this runner,
|
|
1253
|
+
* and it is a difference about what doctor is allowed to DO.
|
|
1254
|
+
*
|
|
1255
|
+
* `security find-generic-password -w` is not a read. On macOS it can raise a GUI
|
|
1256
|
+
* prompt: an item whose ACL does not already trust the calling binary produces
|
|
1257
|
+
* the "wants to access key … in your keychain" dialog, and a locked keychain
|
|
1258
|
+
* produces an unlock dialog. Both BLOCK the process until a human answers, and
|
|
1259
|
+
* both ask a human for a password. `secret-tool lookup` has the same shape
|
|
1260
|
+
* against a locked keyring. Neither is acceptable from a diagnostic: doctor is
|
|
1261
|
+
* the command an operator runs when something is already wrong, frequently over
|
|
1262
|
+
* ssh or from a CI job where no one will ever see the dialog, and a `doctor`
|
|
1263
|
+
* that hangs forever is worse than no doctor. It is also the wrong thing to
|
|
1264
|
+
* TEACH: a command that pops a keychain prompt trains people to click through
|
|
1265
|
+
* keychain prompts.
|
|
1266
|
+
*
|
|
1267
|
+
* So a keystore-backed variable is reported as DECLARED, never resolved, and the
|
|
1268
|
+
* report says which scheme and which service or label — facts `.approval/env`
|
|
1269
|
+
* already carries in the open. `approval env --check`, which the human runs
|
|
1270
|
+
* deliberately and watches, is the command that resolves them.
|
|
1271
|
+
*
|
|
1272
|
+
* This is not a general exception to doctor probing: the Telegram check does
|
|
1273
|
+
* make a network call. The line is that a probe may cost time and packets, and
|
|
1274
|
+
* may not block on a human or ask anyone for a password.
|
|
1275
|
+
*
|
|
1276
|
+
* The runner itself is `core/env-file.ts`'s {@link NON_RESOLVING_RUNNER} since
|
|
1277
|
+
* APRV-178, because `approval up`'s cross-instance report needs the same
|
|
1278
|
+
* refusal and two copies would be two sets of words for one rule.
|
|
1279
|
+
*/
|
|
1280
|
+
/** Was this variable left unresolved by {@link NON_RESOLVING_RUNNER}? */
|
|
1281
|
+
function isDeferred(variable) {
|
|
1282
|
+
return (variable.refusal !== undefined &&
|
|
1283
|
+
variable.refusal.code === "helper-failed" &&
|
|
1284
|
+
variable.refusal.message.startsWith(KEYSTORE_DEFERRED));
|
|
1285
|
+
}
|
|
1286
|
+
/**
|
|
1287
|
+
* The `approval setup <thing>` that knows a given variable, or `null`.
|
|
1288
|
+
*
|
|
1289
|
+
* Derived here from the names doctor already resolves rather than read off the
|
|
1290
|
+
* resolution, so that a fix line in this file is a fix line this file can be
|
|
1291
|
+
* read to verify. A name the policy invented under the `_env` convention maps to
|
|
1292
|
+
* nothing, and its repair is the generic one.
|
|
1293
|
+
*/
|
|
1294
|
+
function setupThingFor(name, load) {
|
|
1295
|
+
if (name === HUMAN_ACTOR_ENV)
|
|
1296
|
+
return "identity";
|
|
1297
|
+
// Two words, because the verb is two words: SPEC.md §4 gives channels and
|
|
1298
|
+
// adapters separate setup nouns, and a fix line that printed the old
|
|
1299
|
+
// one-word spelling would be a command that exits 2 (APRV-79).
|
|
1300
|
+
if (name === telegramTokenEnvFor(load) || name === telegramChatEnvFor(load)) {
|
|
1301
|
+
return "channel telegram";
|
|
1302
|
+
}
|
|
1303
|
+
if (name === passphraseEnvFor(load))
|
|
1304
|
+
return "vault";
|
|
1305
|
+
if (name === resolveSampler(load).secretEnv)
|
|
1306
|
+
return "sampling";
|
|
1307
|
+
return null;
|
|
1308
|
+
}
|
|
1309
|
+
/**
|
|
1310
|
+
* One variable, in words, with NO VALUE ON ANY PATH.
|
|
1311
|
+
*
|
|
1312
|
+
* `ResolvedVariable.value` is never read by this check — not to length-check it,
|
|
1313
|
+
* not to redact it. The fields consulted are `status`, `source`, `plaintext` and
|
|
1314
|
+
* `refusal`, and `source` is a scheme and a service label, which is what
|
|
1315
|
+
* `.approval/env` carries in the open (SPEC.md §11.1 invariant 3).
|
|
1316
|
+
*/
|
|
1317
|
+
function describeVariable(variable) {
|
|
1318
|
+
switch (variable.status) {
|
|
1319
|
+
case "set-in-environment":
|
|
1320
|
+
return "set in the environment";
|
|
1321
|
+
case "resolved-from-keychain":
|
|
1322
|
+
case "resolved-from-secret-service":
|
|
1323
|
+
return `resolved from ${variable.source}`;
|
|
1324
|
+
case "resolved-literal":
|
|
1325
|
+
return variable.plaintext
|
|
1326
|
+
? `declared in ${ENV_IGNORE_LINE} as a PLAINTEXT literal`
|
|
1327
|
+
: `declared in ${ENV_IGNORE_LINE} as a literal`;
|
|
1328
|
+
case "unset":
|
|
1329
|
+
if (isDeferred(variable)) {
|
|
1330
|
+
return `declared in ${ENV_IGNORE_LINE} as ${variable.source} (${KEYSTORE_DEFERRED}; \`approval env --check\` resolves it)`;
|
|
1331
|
+
}
|
|
1332
|
+
if (variable.refusal !== undefined) {
|
|
1333
|
+
return `unresolved — ${variable.refusal.code}: ${variable.refusal.message}`;
|
|
1334
|
+
}
|
|
1335
|
+
if (variable.source.startsWith("env:")) {
|
|
1336
|
+
return `declared in ${ENV_IGNORE_LINE} as env: (inherited), and not set in this shell`;
|
|
1337
|
+
}
|
|
1338
|
+
return "unset";
|
|
1339
|
+
}
|
|
1340
|
+
}
|
|
1341
|
+
/**
|
|
1342
|
+
* Are the variables the policy NAMES actually going to be there? (APRV-75)
|
|
1343
|
+
*
|
|
1344
|
+
* The gap this closes: every check above reports on ONE variable at the moment
|
|
1345
|
+
* it needs it (identity, the sampling secret, the vault passphrase, the Telegram
|
|
1346
|
+
* pair), each in its own message, and nothing states the environment as a whole
|
|
1347
|
+
* or mentions `.approval/env` — the file SPEC.md §5.2 made the written-down
|
|
1348
|
+
* place for where those values come from. An operator whose file has the wrong
|
|
1349
|
+
* mode learns it one refusal at a time.
|
|
1350
|
+
*
|
|
1351
|
+
* ## The verdict rule, and why unset is a SKIP
|
|
1352
|
+
*
|
|
1353
|
+
* - **PASS** when every policy-named variable is set in this environment,
|
|
1354
|
+
* resolved, or declared against a keystore (see {@link NON_RESOLVING_RUNNER}:
|
|
1355
|
+
* declared-and-deferred counts as configured, because the operator wrote the
|
|
1356
|
+
* line and only `approval env --check` may run the lookup).
|
|
1357
|
+
* - **FAIL** for something that is WRONG: a mode other than 0600, an unreadable
|
|
1358
|
+
* or unparseable file, a secret-bearing variable sitting in the working tree
|
|
1359
|
+
* as a plaintext literal, an env file a `git add -A` would commit, or a
|
|
1360
|
+
* variable whose declared source refused for a real reason (a missing helper
|
|
1361
|
+
* binary, an item that is not there, a policy `_env` key that is not a usable
|
|
1362
|
+
* variable name).
|
|
1363
|
+
* - **SKIP**, naming the variables, when the only thing true is that some are
|
|
1364
|
+
* unset. Unset is a STATE, exactly as an absent vault and an unconfigured
|
|
1365
|
+
* Telegram are states, and each of those is a skip already. It is a real
|
|
1366
|
+
* consideration that doctor runs in THIS shell and an unset variable here
|
|
1367
|
+
* means the verbs run from here will refuse — but doctor is also run from a
|
|
1368
|
+
* shell that never intends to grant anything, and a machine with no Telegram
|
|
1369
|
+
* and no vault would then be permanently "unhealthy" for declining features it
|
|
1370
|
+
* was never asked to have. The state is stated loudly instead, with the verb
|
|
1371
|
+
* that gives the full table. The checks that DO fail on a specific unset
|
|
1372
|
+
* variable are the ones that know it is needed: `identity` fails because
|
|
1373
|
+
* human-only verbs refuse without it, `vault` fails only once a vault exists,
|
|
1374
|
+
* and `audit-sampling` fails only once a rate has been configured.
|
|
1375
|
+
*/
|
|
1376
|
+
function checkEnvironment(logPath, dir, load) {
|
|
1377
|
+
const envPath = envFilePathFor(logPath);
|
|
1378
|
+
const resolved = resolveEnvironment(load, envPath, NON_RESOLVING_RUNNER, process.env);
|
|
1379
|
+
if (!resolved.ok) {
|
|
1380
|
+
return {
|
|
1381
|
+
check: "environment",
|
|
1382
|
+
status: "fail",
|
|
1383
|
+
detail: `${resolved.path}: ${resolved.code}: ${oneLine(resolved.message)}`,
|
|
1384
|
+
fix: resolved.code === "env-file-mode"
|
|
1385
|
+
? `chmod 600 ${resolved.path} — the file may carry a plaintext secret, so it is read only at mode 0600`
|
|
1386
|
+
: `approval env --check — the value-free report on this file; fix the line it names (nothing here rewrites it)`,
|
|
1387
|
+
};
|
|
1388
|
+
}
|
|
1389
|
+
const variables = resolved.variables;
|
|
1390
|
+
const table = variables
|
|
1391
|
+
.map((variable) => `${variable.name} ${describeVariable(variable)}`)
|
|
1392
|
+
.join("; ");
|
|
1393
|
+
const head = resolved.present
|
|
1394
|
+
? `${resolved.path} (mode 0600, and no verb loads it implicitly: \`eval "$(approval env)"\` is how a human puts these in a shell)`
|
|
1395
|
+
: `${resolved.path} is absent, so every variable below is inherited from this shell or unset`;
|
|
1396
|
+
const preamble = `${head}. ${table}`;
|
|
1397
|
+
// Ordered by what stays wrong the longest, the same reading the vault check
|
|
1398
|
+
// uses: a file one `git add -A` from publication is the fault that survives
|
|
1399
|
+
// fixing everything else here.
|
|
1400
|
+
if (resolved.present && ignoreVerdict(dir, ENV_IGNORE_LINE) === "not-ignored") {
|
|
1401
|
+
return {
|
|
1402
|
+
check: "environment",
|
|
1403
|
+
status: "fail",
|
|
1404
|
+
detail: `${preamble}. The file is NOT gitignored in ${dir}: one \`git add -A\` commits it, and it is the file whose whole purpose is to say where credentials come from — a plaintext literal in it would be published outright, and even a keychain: line publishes the service names`,
|
|
1405
|
+
fix: `echo '${ENV_IGNORE_LINE}' >> ${join(dir, ".gitignore")} — and if the file has already been committed, treat anything literal in it as disclosed and rotate`,
|
|
1406
|
+
};
|
|
1407
|
+
}
|
|
1408
|
+
// A plaintext secret is REPORTED, never failed (APRV-76 review). SPEC §5.2
|
|
1409
|
+
// permits the literal form and `approval setup` itself writes one, behind a
|
|
1410
|
+
// typed "yes", on a machine with no keystore; a verdict that called setup's
|
|
1411
|
+
// own documented fallback wrong would have two verbs disagreeing about the
|
|
1412
|
+
// same line. So the state is a skip: prominent, named, with the upgrade in
|
|
1413
|
+
// the detail, and never a pass with a fix (passing checks carry none).
|
|
1414
|
+
const plaintext = variables.filter((variable) => variable.plaintext);
|
|
1415
|
+
if (plaintext.length > 0) {
|
|
1416
|
+
const thing = setupThingFor(plaintext[0]?.name ?? "", load);
|
|
1417
|
+
const upgrade = thing === null
|
|
1418
|
+
? `move each one to \`<NAME>=keychain:<service>\` (macOS) or \`<NAME>=secret-service:<label>\` (Linux) in ${resolved.path}`
|
|
1419
|
+
: `run \`approval setup ${thing}\` on a machine with a keystore, or edit ${resolved.path} to \`<NAME>=keychain:<service>\` (macOS) / \`<NAME>=secret-service:<label>\` (Linux)`;
|
|
1420
|
+
return {
|
|
1421
|
+
check: "environment",
|
|
1422
|
+
status: "skip",
|
|
1423
|
+
detail: `${preamble}. ${plaintext.map((variable) => variable.name).join(", ")} ${plaintext.length === 1 ? "is a secret written" : "are secrets written"} literally into ${resolved.path}: permitted, and reported every time because the value sits in the working tree where a backup, an editor swap file or a stray \`git add -f\` reaches it; to stop seeing this, ${upgrade}`,
|
|
1424
|
+
};
|
|
1425
|
+
}
|
|
1426
|
+
const broken = variables.filter((variable) => variable.refusal !== undefined && !isDeferred(variable));
|
|
1427
|
+
const first = broken[0];
|
|
1428
|
+
if (first !== undefined) {
|
|
1429
|
+
const thing = setupThingFor(first.name, load);
|
|
1430
|
+
return {
|
|
1431
|
+
check: "environment",
|
|
1432
|
+
status: "fail",
|
|
1433
|
+
detail: `${preamble}. ${broken.map((variable) => variable.name).join(", ")} ${broken.length === 1 ? "declares a source that did not resolve" : "declare sources that did not resolve"}: a line was written for ${broken.length === 1 ? "it" : "them"}, so this is a configuration that is not working rather than one nobody made`,
|
|
1434
|
+
fix: first.refusal?.code === "invalid-variable-name"
|
|
1435
|
+
? `approval policy attest --as human:<id> — after fixing the _env key in APPROVAL.md: ${JSON.stringify(first.name)} is not a usable shell variable name, so no export line is ever emitted for it`
|
|
1436
|
+
: thing === null
|
|
1437
|
+
? `approval env --check — the full value-free report, with the helper's own reason for each variable`
|
|
1438
|
+
: `approval setup ${thing} — re-store the item the file names; \`approval env --check\` shows the helper's own reason`,
|
|
1439
|
+
};
|
|
1440
|
+
}
|
|
1441
|
+
const unset = variables.filter((variable) => variable.status === "unset" && !isDeferred(variable));
|
|
1442
|
+
if (unset.length > 0) {
|
|
1443
|
+
return {
|
|
1444
|
+
check: "environment",
|
|
1445
|
+
status: "skip",
|
|
1446
|
+
detail: `${preamble}. ${unset.map((variable) => variable.name).join(", ")} ${unset.length === 1 ? "is" : "are"} unset in this shell, which is a state and not a fault — but the verbs run from THIS shell will refuse anything that needs ${unset.length === 1 ? "it" : "them"}; run \`approval env --check\` for the full table, and \`eval "$(approval env)"\` once you have written the file`,
|
|
1447
|
+
};
|
|
1448
|
+
}
|
|
1449
|
+
return {
|
|
1450
|
+
check: "environment",
|
|
1451
|
+
status: "pass",
|
|
1452
|
+
detail: `${preamble}. Every variable your policy names is available to the verbs run from this shell. No value is printed by this check on any path`,
|
|
1453
|
+
};
|
|
1454
|
+
}
|
|
1455
|
+
/**
|
|
1456
|
+
* Whose credentials is this instance actually using? (APRV-178)
|
|
1457
|
+
*
|
|
1458
|
+
* The row this check exists to print did not exist on the morning a demo gate
|
|
1459
|
+
* in another directory stored its bot token under the same fixed keystore name
|
|
1460
|
+
* the production gate used, read the production token back, and put two long
|
|
1461
|
+
* pollers on one bot until a human's approval tap was delivered to the listener
|
|
1462
|
+
* that had not asked the question. Nothing on the machine could be asked "are
|
|
1463
|
+
* two instances sharing a credential"; the answer was assembled by hand,
|
|
1464
|
+
* afterwards.
|
|
1465
|
+
*
|
|
1466
|
+
* It resolves nothing (`core/instance.ts` calls the {@link NON_RESOLVING_RUNNER}
|
|
1467
|
+
* for exactly the reason the environment check does), reads no value, and
|
|
1468
|
+
* prints none: a scope suffix, an item name and a variable name are all the
|
|
1469
|
+
* evidence it needs, and all three are already in `.approval/env` in the open.
|
|
1470
|
+
*
|
|
1471
|
+
* ## The verdict rule
|
|
1472
|
+
*
|
|
1473
|
+
* - **FAIL** for an item whose scope suffix belongs to ANOTHER instance. That
|
|
1474
|
+
* is two gates on one credential, and it is wrong rather than a state.
|
|
1475
|
+
* - **SKIP**, named and loud, for the unscoped legacy item and for a value that
|
|
1476
|
+
* came from the ambient environment while the file names something else. Both
|
|
1477
|
+
* are what a correct pre-APRV-178 machine looks like, and the primary gate on
|
|
1478
|
+
* this project's own machine is fed exactly that way on purpose. A red row for
|
|
1479
|
+
* every existing installation is a red row people learn to skip past.
|
|
1480
|
+
* - **PASS** when every line names this instance's own item.
|
|
1481
|
+
*/
|
|
1482
|
+
function checkKeychainScope(logPath, load) {
|
|
1483
|
+
const findings = instanceFindings(logPath, load);
|
|
1484
|
+
const id = instanceIdFor(logPath);
|
|
1485
|
+
const head = `${instanceHomeFor(logPath)} is instance ${id}; its keystore items are named \`<secret>-${id}\``;
|
|
1486
|
+
const foreign = findings.filter((finding) => finding.kind === "foreign-instance");
|
|
1487
|
+
if (foreign.length > 0) {
|
|
1488
|
+
return {
|
|
1489
|
+
check: "keychain-scope",
|
|
1490
|
+
status: "fail",
|
|
1491
|
+
detail: `${head}. ${foreign.map((finding) => finding.detail).join("; ")}. Two instances resolving one item share a bot: both long-poll it, their getUpdates offsets acknowledge each other's messages, and an approval tap is answered by whichever listener asked first`,
|
|
1492
|
+
fix: `approval setup channel telegram — store this instance's own token under its own item; \`approval env --check\` shows which name each variable resolves through`,
|
|
1493
|
+
};
|
|
1494
|
+
}
|
|
1495
|
+
const shared = findings.filter((finding) => finding.kind === "legacy-shared");
|
|
1496
|
+
const bleed = findings.filter((finding) => finding.kind === "ambient-bleed");
|
|
1497
|
+
if (shared.length > 0 || bleed.length > 0) {
|
|
1498
|
+
return {
|
|
1499
|
+
check: "keychain-scope",
|
|
1500
|
+
status: "skip",
|
|
1501
|
+
detail: `${head}. ${[...shared, ...bleed].map((finding) => finding.detail).join("; ")}. Neither is broken here and both are how one instance becomes two instances' problem: re-run \`approval setup channel telegram\` to move onto this instance's own item, and \`unset\` an inherited variable before \`eval "$(approval env)"\` if the exported value is another gate's`,
|
|
1502
|
+
};
|
|
1503
|
+
}
|
|
1504
|
+
return {
|
|
1505
|
+
check: "keychain-scope",
|
|
1506
|
+
status: "pass",
|
|
1507
|
+
detail: `${head}, and every source ${envFilePathFor(logPath)} names is this instance's own. No value is read or printed by this check on any path`,
|
|
1508
|
+
};
|
|
1509
|
+
}
|
|
1510
|
+
// ---------------------------------------------------------------------------
|
|
1511
|
+
// 12. log-drift (APRV-125)
|
|
1512
|
+
// ---------------------------------------------------------------------------
|
|
1513
|
+
/**
|
|
1514
|
+
* How the working log stands against the committed one.
|
|
1515
|
+
*
|
|
1516
|
+
* This is the doctor mitigation named in APRV-104's fork-2 notes: the fork that
|
|
1517
|
+
* incident produced was invisible until something tried to append onto it, and
|
|
1518
|
+
* the instrument a person reaches for first is `approval doctor`.
|
|
1519
|
+
*
|
|
1520
|
+
* Since APRV-219 the row IS `approval log verify --anchor`'s check
|
|
1521
|
+
* (`cli/log-anchor.ts`), rather than a second comparison written beside it. Two
|
|
1522
|
+
* implementations were two chances to disagree about whether a repository has
|
|
1523
|
+
* forked, and that is the one question where disagreement is intolerable — and
|
|
1524
|
+
* the disagreement duly arrived: APRV-210 recorded this row printing "this log
|
|
1525
|
+
* has never been committed" in a checkout where `git show HEAD:<log>` printed
|
|
1526
|
+
* the log, because it built its blob spec from an unresolved path. The anchor
|
|
1527
|
+
* check resolves that path through `git-scope.repoPath`, realpath on both
|
|
1528
|
+
* sides, and looks at every rev a committed copy may live at rather than only
|
|
1529
|
+
* `HEAD`.
|
|
1530
|
+
*
|
|
1531
|
+
* Reads only. It never fetches, never pulls and never writes: the committed
|
|
1532
|
+
* side comes out of git's object store with `git show`.
|
|
1533
|
+
*/
|
|
1534
|
+
function checkLogDrift(logPath, records) {
|
|
1535
|
+
const outcome = checkLogAnchor({ logPath, records });
|
|
1536
|
+
switch (outcome.status) {
|
|
1537
|
+
// A SKIP carries no `fix` — the rule every non-git fixture in
|
|
1538
|
+
// `tests/cli-doctor.test.ts` pins. A check that could not look has nothing
|
|
1539
|
+
// to prescribe, so what a reader might still want to run is said in the
|
|
1540
|
+
// detail. A pass that owes records keeps its `fix`, as this row always has.
|
|
1541
|
+
// The reason is carried through whole, `oneLine`d rather than trimmed. When
|
|
1542
|
+
// no rev resolved it now names the git command each candidate ran and what
|
|
1543
|
+
// git answered, and that is the half of the sentence a person acts on: the
|
|
1544
|
+
// row that misread a twelve-megabyte committed log said only which revs it
|
|
1545
|
+
// had tried, which is equally true of a repository that has genuinely never
|
|
1546
|
+
// committed one.
|
|
1547
|
+
case "skip":
|
|
1548
|
+
return {
|
|
1549
|
+
check: "log-drift",
|
|
1550
|
+
status: "skip",
|
|
1551
|
+
detail: oneLine(`${outcome.reason}. \`approval log advance --dry-run\` shows what a first advance would carry`),
|
|
1552
|
+
};
|
|
1553
|
+
case "pass":
|
|
1554
|
+
return {
|
|
1555
|
+
check: "log-drift",
|
|
1556
|
+
status: "pass",
|
|
1557
|
+
detail: outcome.ahead === 0
|
|
1558
|
+
? outcome.detail
|
|
1559
|
+
: `${outcome.detail} — the ordinary state of a checkout that has been recording decisions`,
|
|
1560
|
+
...(outcome.ahead === 0
|
|
1561
|
+
? {}
|
|
1562
|
+
: { fix: "approval log advance — commit those records onto a records branch" }),
|
|
1563
|
+
};
|
|
1564
|
+
case "behind":
|
|
1565
|
+
return {
|
|
1566
|
+
check: "log-drift",
|
|
1567
|
+
status: "pass",
|
|
1568
|
+
detail: `${outcome.detail} — the committed copy carries records this working file does not`,
|
|
1569
|
+
fix: "approval log sync — fast-forward, then reconcile the chain",
|
|
1570
|
+
};
|
|
1571
|
+
case "diverged":
|
|
1572
|
+
return {
|
|
1573
|
+
check: "log-drift",
|
|
1574
|
+
status: "fail",
|
|
1575
|
+
detail: `${oneLine(outcome.message)} Hash chains do not merge and nothing in this runtime will re-chain them: which of these is the log is a human decision`,
|
|
1576
|
+
fix: "approval log verify --anchor — then `git log -- .approval/log/events.jsonl` for who committed the other chain",
|
|
1577
|
+
};
|
|
1578
|
+
}
|
|
1579
|
+
}
|
|
1580
|
+
// ---------------------------------------------------------------------------
|
|
1581
|
+
// 25. checkpoint (APRV-257)
|
|
1582
|
+
// ---------------------------------------------------------------------------
|
|
1583
|
+
/**
|
|
1584
|
+
* The second witness, as a row: how many checkpoints verify, how old the newest
|
|
1585
|
+
* one is against the cadence, and how many keys the policy declares.
|
|
1586
|
+
*
|
|
1587
|
+
* The row IS `core/checkpoint.ts`'s check, exactly as `log-drift` IS the anchor
|
|
1588
|
+
* check — the argument APRV-219 made and APRV-210 proved the hard way. Two
|
|
1589
|
+
* implementations of "does this log's own signature contradict it" would be two
|
|
1590
|
+
* chances to disagree about the one question where disagreement is intolerable.
|
|
1591
|
+
*
|
|
1592
|
+
* Three verdicts and no fourth:
|
|
1593
|
+
*
|
|
1594
|
+
* - **skip** when the policy declares no readable key. Nothing was verified,
|
|
1595
|
+
* and a check that could not look must never report a pass. A skip carries no
|
|
1596
|
+
* `fix` — the rule every non-git fixture in `tests/cli-doctor.test.ts` pins —
|
|
1597
|
+
* so what to run is said in the detail.
|
|
1598
|
+
* - **fail** on any refusal. A checkpoint whose signature does not verify, or
|
|
1599
|
+
* whose named hash is not the hash at that seq, is a human's key vouching for
|
|
1600
|
+
* a chain this file does not carry. That is the finding this whole mechanism
|
|
1601
|
+
* exists to produce, and doctor exits 1 on it.
|
|
1602
|
+
* - **pass** otherwise, INCLUDING when a checkpoint is due. The cadence carries
|
|
1603
|
+
* a `fix` rather than a status: a person who has not signed recently is not
|
|
1604
|
+
* evidence of tampering, and a doctor that went red because somebody was on
|
|
1605
|
+
* holiday is a doctor whose red people stop reading.
|
|
1606
|
+
*/
|
|
1607
|
+
function checkCheckpoints(records, policy) {
|
|
1608
|
+
const configured = checkpointPolicyOf(policy);
|
|
1609
|
+
const outcome = checkLogCheckpoints({
|
|
1610
|
+
records,
|
|
1611
|
+
publicKeys: configured.publicKeys,
|
|
1612
|
+
checkpointEveryMs: configured.checkpointEveryMs,
|
|
1613
|
+
keysUnavailable: configured.unloadable,
|
|
1614
|
+
});
|
|
1615
|
+
if (outcome.status === "skip") {
|
|
1616
|
+
return {
|
|
1617
|
+
check: "checkpoint",
|
|
1618
|
+
status: "skip",
|
|
1619
|
+
detail: `${outcome.reason}. \`approval setup checkpoint\` mints a key and prints the audit.checkpoint_keys block to add`,
|
|
1620
|
+
};
|
|
1621
|
+
}
|
|
1622
|
+
if (outcome.status === "refused") {
|
|
1623
|
+
return {
|
|
1624
|
+
check: "checkpoint",
|
|
1625
|
+
status: "fail",
|
|
1626
|
+
detail: `${oneLine(outcome.message)} A key no agent process holds signed a head this chain does not carry: the chain was rewritten after the checkpoint was taken`,
|
|
1627
|
+
fix: "approval log verify --checkpoints — then `git log -- .approval/log/events.jsonl` for who wrote the other chain",
|
|
1628
|
+
};
|
|
1629
|
+
}
|
|
1630
|
+
const newest = outcome.checkpoints[outcome.checkpoints.length - 1];
|
|
1631
|
+
const detail = `${outcome.detail}, ${String(configured.publicKeys.length)} key(s) declared` +
|
|
1632
|
+
(newest === undefined ? "" : ` (newest at seq ${String(newest.at)}, ${newest.ts})`) +
|
|
1633
|
+
(outcome.unchecked === 0
|
|
1634
|
+
? ""
|
|
1635
|
+
: `; ${String(outcome.unchecked)} signed a seq below this range`);
|
|
1636
|
+
return {
|
|
1637
|
+
check: "checkpoint",
|
|
1638
|
+
status: "pass",
|
|
1639
|
+
detail: outcome.warning === null ? detail : `${detail} — ${oneLine(outcome.warning)}`,
|
|
1640
|
+
...(outcome.warning === null
|
|
1641
|
+
? {}
|
|
1642
|
+
: {
|
|
1643
|
+
fix: "approval log checkpoint --as human:<id> — or answer the CHECKPOINT DUE prompt on your channel",
|
|
1644
|
+
}),
|
|
1645
|
+
};
|
|
1646
|
+
}
|
|
1647
|
+
// ---------------------------------------------------------------------------
|
|
1648
|
+
// 13. log-advance-cadence (APRV-204)
|
|
1649
|
+
// ---------------------------------------------------------------------------
|
|
1650
|
+
/**
|
|
1651
|
+
* How far the log has run ahead of any records branch, and how the daemon's
|
|
1652
|
+
* last advance ended.
|
|
1653
|
+
*
|
|
1654
|
+
* This is the status surface the cadence needed and `approval daemon` did not
|
|
1655
|
+
* have. There is no `approval daemon status` subcommand and no status file: the
|
|
1656
|
+
* daemon reports live on its own event stream, which is gone the moment nobody
|
|
1657
|
+
* is tailing it, and a status file would be a second copy of facts the log
|
|
1658
|
+
* already carries. So the answer is read from the log itself (the advance
|
|
1659
|
+
* cycles the daemon registers under `daemon-advance-*`) plus git's local refs,
|
|
1660
|
+
* which is why it can be answered by a DIFFERENT process from the one that made
|
|
1661
|
+
* the attempt, and why an operator gets the same answer whether or not a daemon
|
|
1662
|
+
* is running at all.
|
|
1663
|
+
*
|
|
1664
|
+
* Reads only, and never fetches: the same rule `log-drift` holds itself to.
|
|
1665
|
+
* Advisory rather than failing — records waiting to be published is the normal
|
|
1666
|
+
* state of a checkout that has been recording decisions, and only the reader
|
|
1667
|
+
* knows how long is too long.
|
|
1668
|
+
*/
|
|
1669
|
+
function checkAdvanceCadence(logPath, records) {
|
|
1670
|
+
const check = "log-advance-cadence";
|
|
1671
|
+
const root = repoRoot(dirname(logPath));
|
|
1672
|
+
if (root === null) {
|
|
1673
|
+
return {
|
|
1674
|
+
check,
|
|
1675
|
+
status: "skip",
|
|
1676
|
+
detail: `${logPath} is not inside a git repository, so there is no records branch for anything to be waiting for`,
|
|
1677
|
+
};
|
|
1678
|
+
}
|
|
1679
|
+
const today = new Date().toISOString();
|
|
1680
|
+
const state = publishedState(root, logPath, records, { remote: "origin", base: null }, today);
|
|
1681
|
+
const last = lastAdvance(records);
|
|
1682
|
+
// The reason, when the log carries one (APRV-211). A failed advance used to
|
|
1683
|
+
// reach this row as the bare word `failed`, which told an operator that
|
|
1684
|
+
// something had gone wrong and nothing about what: the daemon knew, said it
|
|
1685
|
+
// once on an event stream nobody was tailing, and recorded `exit_code: 1`.
|
|
1686
|
+
// The verb's own code and message now travel onto `execution.failed`, so this
|
|
1687
|
+
// row says them. A cycle recorded before the field existed still reads `null`
|
|
1688
|
+
// and still prints the bare outcome; the shape is not assumed away.
|
|
1689
|
+
const why = last === null || last.code === null
|
|
1690
|
+
? ""
|
|
1691
|
+
: ` (${last.code}${last.message === null ? "" : `: ${last.message}`})`;
|
|
1692
|
+
const attempt = last === null
|
|
1693
|
+
? "no daemon advance cycle is in this log yet (the cadence is opt-in: `approval daemon run --advance`)"
|
|
1694
|
+
: `the last daemon advance (through seq ${String(last.toSeq)}, ${last.ts}) ended ${last.outcome}${why}`;
|
|
1695
|
+
// Which ref the count came from (APRV-210). A row that says "9,875 records
|
|
1696
|
+
// are not yet on a records branch" is unreadable without it: a rev that
|
|
1697
|
+
// resolved to nothing and a rev that carried nothing produce the same number
|
|
1698
|
+
// and are completely different facts, and this row reported the first as the
|
|
1699
|
+
// second on a log whose first 8,379 records had been merged to the trunk an
|
|
1700
|
+
// hour earlier.
|
|
1701
|
+
const from = state.publishedRev === null
|
|
1702
|
+
? `no rev this checkout can see carries a copy of this chain (tried ${state.revs.join(", ")})`
|
|
1703
|
+
: `read from ${state.publishedRev}`;
|
|
1704
|
+
// APRV-264. The advance cycles nobody closed, and what this checkout can
|
|
1705
|
+
// prove about each. They belong on THIS row rather than only in `status`'s
|
|
1706
|
+
// dangling list, because their effect is on the cadence: while one stands the
|
|
1707
|
+
// daemon authorizes no further advance, so a row reporting how far behind the
|
|
1708
|
+
// records branch is without saying that the thing that publishes it is
|
|
1709
|
+
// blocked reports the symptom and hides the cause. Provable ones are named as
|
|
1710
|
+
// the daemon's to close on its next tick; the rest are a person's, with the
|
|
1711
|
+
// one command that takes them all.
|
|
1712
|
+
const open = proveDanglingAdvances(records, state);
|
|
1713
|
+
const outstanding = open.filter((entry) => entry.provenBy === null);
|
|
1714
|
+
const provable = open.filter((entry) => entry.provenBy !== null);
|
|
1715
|
+
const blocked = open.length === 0
|
|
1716
|
+
? ""
|
|
1717
|
+
: ` ${String(open.length)} advance execution(s) are open and no further advance is authorized while they stand: ${open
|
|
1718
|
+
.map((entry) => `${entry.actionKey} (${entry.provenBy === null
|
|
1719
|
+
? "nothing in this checkout carries the seq it named"
|
|
1720
|
+
: `proved by ${entry.provenBy}`})`)
|
|
1721
|
+
.join(", ")}.${provable.length === 0
|
|
1722
|
+
? ""
|
|
1723
|
+
: ` A running daemon closes ${String(provable.length)} of them on its next tick.`}`;
|
|
1724
|
+
const sweepFix = outstanding.length === 0
|
|
1725
|
+
? null
|
|
1726
|
+
: `${RESOLVE_DANGLING_COMMAND} — close the advance executions this checkout can prove and list the ${String(outstanding.length)} it cannot`;
|
|
1727
|
+
if (state.pending === 0) {
|
|
1728
|
+
return {
|
|
1729
|
+
check,
|
|
1730
|
+
status: "pass",
|
|
1731
|
+
detail: `every record through seq ${String(state.publishedSeq)} is on a records branch or the trunk (${from}); ${attempt}${blocked}`,
|
|
1732
|
+
...(sweepFix === null ? {} : { fix: sweepFix }),
|
|
1733
|
+
};
|
|
1734
|
+
}
|
|
1735
|
+
return {
|
|
1736
|
+
check,
|
|
1737
|
+
status: "pass",
|
|
1738
|
+
detail: `${String(state.pending)} record(s) are not yet on a records branch (${String(state.substantive)} of them are not the daemon's own advance bookkeeping); published through seq ${String(state.publishedSeq)} (${from}), working head seq ${String(state.workingSeq)}. ${attempt}${blocked}`,
|
|
1739
|
+
fix: sweepFix ??
|
|
1740
|
+
"approval log advance --pr — publish them now, or run the daemon with --advance",
|
|
1741
|
+
};
|
|
1742
|
+
}
|
|
1743
|
+
// ---------------------------------------------------------------------------
|
|
1744
|
+
// harness hook outcome reporting (APRV-145)
|
|
1745
|
+
// ---------------------------------------------------------------------------
|
|
1746
|
+
/** Where Claude Code keeps the hook registration a human commits. */
|
|
1747
|
+
const CLAUDE_SETTINGS = join(".claude", "settings.json");
|
|
1748
|
+
/** Does any `hooks.<event>` entry run this CLI's harness hook? */
|
|
1749
|
+
function registersApprovalHook(hooks, event) {
|
|
1750
|
+
if (typeof hooks !== "object" || hooks === null || Array.isArray(hooks))
|
|
1751
|
+
return false;
|
|
1752
|
+
const matchers = hooks[event];
|
|
1753
|
+
if (!Array.isArray(matchers))
|
|
1754
|
+
return false;
|
|
1755
|
+
for (const matcher of matchers) {
|
|
1756
|
+
if (typeof matcher !== "object" || matcher === null)
|
|
1757
|
+
continue;
|
|
1758
|
+
const entries = matcher["hooks"];
|
|
1759
|
+
if (!Array.isArray(entries))
|
|
1760
|
+
continue;
|
|
1761
|
+
for (const entry of entries) {
|
|
1762
|
+
if (typeof entry !== "object" || entry === null)
|
|
1763
|
+
continue;
|
|
1764
|
+
const command = entry["command"];
|
|
1765
|
+
if (typeof command === "string" && /\bapproval hook\b/u.test(command))
|
|
1766
|
+
return true;
|
|
1767
|
+
}
|
|
1768
|
+
}
|
|
1769
|
+
return false;
|
|
1770
|
+
}
|
|
1771
|
+
/**
|
|
1772
|
+
* Is the harness registered for the event that reports outcomes (APRV-145)?
|
|
1773
|
+
*
|
|
1774
|
+
* The configuration this exists to name is the one in which loop escalation
|
|
1775
|
+
* cannot accrue AT ALL: the pre-execution hook registered and the post-execution
|
|
1776
|
+
* one not, so every tool call opens a delegated `execution.started` that nothing
|
|
1777
|
+
* ever closes, the harness streaks of amended SPEC.md §10.2 hold at zero, and
|
|
1778
|
+
* the guard reads as passing because there is nothing for it to see. That is a
|
|
1779
|
+
* silent control, which is worse than an absent one.
|
|
1780
|
+
*
|
|
1781
|
+
* Doctor READS this file and never writes it. `.claude/settings.json` is
|
|
1782
|
+
* `policy.core` in this taxonomy — a file that configures the gate is part of
|
|
1783
|
+
* the gate — so the repair is a line for a human to commit, printed by
|
|
1784
|
+
* `approval instructions hook`.
|
|
1785
|
+
*/
|
|
1786
|
+
function checkHarnessOutcomes(dir) {
|
|
1787
|
+
const check = "harness-hook-outcomes";
|
|
1788
|
+
const path = join(dir, CLAUDE_SETTINGS);
|
|
1789
|
+
if (!existsSync(path)) {
|
|
1790
|
+
return {
|
|
1791
|
+
check,
|
|
1792
|
+
status: "skip",
|
|
1793
|
+
detail: `no ${CLAUDE_SETTINGS} in ${dir}: this checkout does not run a Claude Code harness hook`,
|
|
1794
|
+
};
|
|
1795
|
+
}
|
|
1796
|
+
let parsed;
|
|
1797
|
+
try {
|
|
1798
|
+
parsed = JSON.parse(readFileSync(path, "utf8"));
|
|
1799
|
+
}
|
|
1800
|
+
catch (cause) {
|
|
1801
|
+
return {
|
|
1802
|
+
check,
|
|
1803
|
+
status: "skip",
|
|
1804
|
+
detail: `${path} is not readable as JSON (${detailOf(cause)}), so which hooks it registers cannot be established here`,
|
|
1805
|
+
};
|
|
1806
|
+
}
|
|
1807
|
+
const hooks = typeof parsed === "object" && parsed !== null && !Array.isArray(parsed)
|
|
1808
|
+
? parsed["hooks"]
|
|
1809
|
+
: null;
|
|
1810
|
+
const pre = registersApprovalHook(hooks, "PreToolUse");
|
|
1811
|
+
const post = registersApprovalHook(hooks, "PostToolUse") ||
|
|
1812
|
+
registersApprovalHook(hooks, "PostToolUseFailure");
|
|
1813
|
+
if (!pre && !post) {
|
|
1814
|
+
return {
|
|
1815
|
+
check,
|
|
1816
|
+
status: "skip",
|
|
1817
|
+
detail: `${path} registers no \`approval hook\` entry, so this checkout is not gated by the harness hook at all`,
|
|
1818
|
+
};
|
|
1819
|
+
}
|
|
1820
|
+
if (!post) {
|
|
1821
|
+
return {
|
|
1822
|
+
check,
|
|
1823
|
+
status: "fail",
|
|
1824
|
+
detail: `${path} registers \`approval hook\` for PreToolUse and not for PostToolUse, so no tool call ever reports an outcome: every harness execution.started stays delegated, and the loop escalation of SPEC.md §10.2 cannot accrue on this path`,
|
|
1825
|
+
fix: "approval hook claude-code --help — prints the PostToolUse entry to add, which a human commits (.claude/settings.json is policy.core)",
|
|
1826
|
+
};
|
|
1827
|
+
}
|
|
1828
|
+
return {
|
|
1829
|
+
check,
|
|
1830
|
+
status: "pass",
|
|
1831
|
+
detail: `${path} registers \`approval hook\` for the ${pre ? "pre-execution and " : ""}post-execution event, so tool call outcomes reach the log and loop escalation can accrue`,
|
|
1832
|
+
};
|
|
1833
|
+
}
|
|
1834
|
+
// ---------------------------------------------------------------------------
|
|
1835
|
+
// harness hook wiring in THIS worktree (APRV-151)
|
|
1836
|
+
// ---------------------------------------------------------------------------
|
|
1837
|
+
/** The tool names a protected-path write can arrive as. */
|
|
1838
|
+
const GATED_TOOLS = ["Edit", "Write", "Bash"];
|
|
1839
|
+
/** The `matcher` strings of every `approval hook` entry registered for `event`. */
|
|
1840
|
+
function approvalHookMatchers(hooks, event) {
|
|
1841
|
+
if (typeof hooks !== "object" || hooks === null || Array.isArray(hooks))
|
|
1842
|
+
return [];
|
|
1843
|
+
const matchers = hooks[event];
|
|
1844
|
+
if (!Array.isArray(matchers))
|
|
1845
|
+
return [];
|
|
1846
|
+
const found = [];
|
|
1847
|
+
for (const matcher of matchers) {
|
|
1848
|
+
if (typeof matcher !== "object" || matcher === null)
|
|
1849
|
+
continue;
|
|
1850
|
+
const entries = matcher["hooks"];
|
|
1851
|
+
if (!Array.isArray(entries))
|
|
1852
|
+
continue;
|
|
1853
|
+
for (const entry of entries) {
|
|
1854
|
+
if (typeof entry !== "object" || entry === null)
|
|
1855
|
+
continue;
|
|
1856
|
+
const command = entry["command"];
|
|
1857
|
+
if (typeof command !== "string" || !/\bapproval hook\b/u.test(command))
|
|
1858
|
+
continue;
|
|
1859
|
+
const pattern = matcher["matcher"];
|
|
1860
|
+
found.push(typeof pattern === "string" ? pattern : "");
|
|
1861
|
+
}
|
|
1862
|
+
}
|
|
1863
|
+
return found;
|
|
1864
|
+
}
|
|
1865
|
+
/**
|
|
1866
|
+
* Does the settings file THIS worktree carries register the pre-execution hook
|
|
1867
|
+
* for the tools a protected-path write arrives through? (APRV-151.)
|
|
1868
|
+
*
|
|
1869
|
+
* The incidents this row exists for are two file-tool Edits to protected paths
|
|
1870
|
+
* that applied in spawned-agent worktrees with no prompt, no denial, and no
|
|
1871
|
+
* refused-request record — the hook never ran, and nothing anywhere said so.
|
|
1872
|
+
* A session cannot be asked whether it is hooked (a party under oversight does
|
|
1873
|
+
* not report its own oversight, SPEC.md §11), so this row reports only the one
|
|
1874
|
+
* thing a process CAN establish about itself from disk: whether the settings
|
|
1875
|
+
* file in this checkout carries the entry at all.
|
|
1876
|
+
*
|
|
1877
|
+
* Read the `pass` wording carefully, because the limit is the point. The entry
|
|
1878
|
+
* being on disk is NOT proof the session loaded it: `.claude/settings.json` is
|
|
1879
|
+
* git-tracked here, so every worktree has an identical copy, and both bypasses
|
|
1880
|
+
* happened in worktrees whose copy was present and correct. What actually
|
|
1881
|
+
* differs between a gated and an ungated session is whether the harness
|
|
1882
|
+
* resolved and trusted this file when the session started, which is state this
|
|
1883
|
+
* runtime cannot see. That is exactly why the deterministic backstop is
|
|
1884
|
+
* CI-side, over the committed log, in `core/protected-path-guard.ts`: it does
|
|
1885
|
+
* not trust session wiring, and this row does not claim to establish it.
|
|
1886
|
+
*
|
|
1887
|
+
* Advisory, so it never fails the run. Doctor reads and never writes; the file
|
|
1888
|
+
* is `policy.edit` and its repair is a line for a human to commit.
|
|
1889
|
+
*/
|
|
1890
|
+
function checkHarnessWiring(dir) {
|
|
1891
|
+
const check = "harness-hook-wiring";
|
|
1892
|
+
const root = repoRoot(dir);
|
|
1893
|
+
const where = root === null ? dir : root;
|
|
1894
|
+
const path = join(where, CLAUDE_SETTINGS);
|
|
1895
|
+
const scope = root === null
|
|
1896
|
+
? `${dir} (git could not say what checkout this is)`
|
|
1897
|
+
: root === dir
|
|
1898
|
+
? root
|
|
1899
|
+
: `${root}, the checkout root above ${dir}`;
|
|
1900
|
+
if (!existsSync(path)) {
|
|
1901
|
+
return {
|
|
1902
|
+
check,
|
|
1903
|
+
status: "skip",
|
|
1904
|
+
// No `fix`, deliberately: a checkout that is not a Claude Code checkout
|
|
1905
|
+
// at all owes no repair, exactly as `harness-hook-outcomes` treats the
|
|
1906
|
+
// same absence. The two branches below DO carry one, because there the
|
|
1907
|
+
// harness is present and the entry is what is missing.
|
|
1908
|
+
detail: `NOT WIRED: ${scope} carries no ${CLAUDE_SETTINGS}, so nothing in this worktree registers the pre-execution hook and a protected-path Edit here would apply unclassified. A session started elsewhere may still be hooked; this row can only see this checkout.`,
|
|
1909
|
+
};
|
|
1910
|
+
}
|
|
1911
|
+
let parsed;
|
|
1912
|
+
try {
|
|
1913
|
+
parsed = JSON.parse(readFileSync(path, "utf8"));
|
|
1914
|
+
}
|
|
1915
|
+
catch (cause) {
|
|
1916
|
+
return {
|
|
1917
|
+
check,
|
|
1918
|
+
status: "skip",
|
|
1919
|
+
detail: `UNDETERMINABLE: ${path} exists and is not readable as JSON (${detailOf(cause)}), so whether this checkout registers the pre-execution hook cannot be established here.`,
|
|
1920
|
+
};
|
|
1921
|
+
}
|
|
1922
|
+
const hooks = typeof parsed === "object" && parsed !== null && !Array.isArray(parsed)
|
|
1923
|
+
? parsed["hooks"]
|
|
1924
|
+
: null;
|
|
1925
|
+
const matchers = approvalHookMatchers(hooks, "PreToolUse");
|
|
1926
|
+
if (matchers.length === 0) {
|
|
1927
|
+
return {
|
|
1928
|
+
check,
|
|
1929
|
+
status: "skip",
|
|
1930
|
+
detail: `NOT WIRED: ${path} registers no \`approval hook\` entry for PreToolUse, so a protected-path Edit, Write or Bash call in this checkout reaches the file system unclassified.`,
|
|
1931
|
+
fix: "approval instructions hook — prints the PreToolUse entry a human commits",
|
|
1932
|
+
};
|
|
1933
|
+
}
|
|
1934
|
+
const covered = GATED_TOOLS.filter((tool) => matchers.some((pattern) => pattern.length === 0 || pattern.split("|").includes(tool)));
|
|
1935
|
+
const missing = GATED_TOOLS.filter((tool) => !covered.includes(tool));
|
|
1936
|
+
if (missing.length > 0) {
|
|
1937
|
+
return {
|
|
1938
|
+
check,
|
|
1939
|
+
status: "skip",
|
|
1940
|
+
detail: `NOT WIRED for every tool: ${path} registers \`approval hook\` for PreToolUse with matcher ${JSON.stringify(matchers.join(", "))}, which does not cover ${missing.join(", ")}. A protected-path write arriving through ${missing[0]} is never classified.`,
|
|
1941
|
+
fix: "approval instructions hook — prints the PreToolUse entry a human commits",
|
|
1942
|
+
};
|
|
1943
|
+
}
|
|
1944
|
+
return {
|
|
1945
|
+
check,
|
|
1946
|
+
status: "pass",
|
|
1947
|
+
detail: `WIRED on disk: ${path} registers \`approval hook\` for PreToolUse over ${GATED_TOOLS.join(", ")}. This is the file being present, NOT proof this session loaded it — the APRV-151 bypasses happened in worktrees carrying exactly this entry. The check that does not trust session wiring is the CI-side grant cross-check over the committed log, which asks whether the CHANGE was granted rather than whether the path ever was (APRV-202).`,
|
|
1948
|
+
};
|
|
1949
|
+
}
|
|
1950
|
+
/** The project-local Codex hook files doctor can observe without asking Codex. */
|
|
1951
|
+
const CODEX_HOOKS = join(".codex", "hooks.json");
|
|
1952
|
+
const CODEX_CONFIG = join(".codex", "config.toml");
|
|
1953
|
+
const CODEX_MATCHER = "Bash|apply_patch";
|
|
1954
|
+
const CODEX_HOOK_TIMEOUT_SECONDS = 600;
|
|
1955
|
+
function isDirectCodexHookCommand(command) {
|
|
1956
|
+
const match = command.match(/^(?:"([^"$`\\\r\n;&|<>]+)"|'([^'\\\r\n;&|<>]+)'|([^\s"'$`\\\r\n;&|<>]+)) hook codex --dir (?:"([^"$`\\\r\n;&|<>]+)"|'([^'\\\r\n;&|<>]+)'|([^\s"'$`\\\r\n;&|<>]+)) --as agent:codex --timeout 9m$/u);
|
|
1957
|
+
if (match === null)
|
|
1958
|
+
return false;
|
|
1959
|
+
const executable = match[1] ?? match[2] ?? match[3] ?? "";
|
|
1960
|
+
const primaryDir = match[4] ?? match[5] ?? match[6] ?? "";
|
|
1961
|
+
return isAbsolute(executable) && basename(executable) === "approval" && isAbsolute(primaryDir);
|
|
1962
|
+
}
|
|
1963
|
+
function codexEventConfigured(hooks, event) {
|
|
1964
|
+
if (typeof hooks !== "object" || hooks === null || Array.isArray(hooks))
|
|
1965
|
+
return false;
|
|
1966
|
+
const groups = hooks[event];
|
|
1967
|
+
if (!Array.isArray(groups))
|
|
1968
|
+
return false;
|
|
1969
|
+
return groups.some((group) => {
|
|
1970
|
+
if (typeof group !== "object" || group === null || Array.isArray(group))
|
|
1971
|
+
return false;
|
|
1972
|
+
const fields = group;
|
|
1973
|
+
if (fields["matcher"] !== CODEX_MATCHER || !Array.isArray(fields["hooks"]))
|
|
1974
|
+
return false;
|
|
1975
|
+
return fields["hooks"].some((handler) => {
|
|
1976
|
+
if (typeof handler !== "object" || handler === null || Array.isArray(handler))
|
|
1977
|
+
return false;
|
|
1978
|
+
const entry = handler;
|
|
1979
|
+
return (entry["type"] === "command" &&
|
|
1980
|
+
typeof entry["command"] === "string" &&
|
|
1981
|
+
isDirectCodexHookCommand(entry["command"]) &&
|
|
1982
|
+
entry["timeout"] === CODEX_HOOK_TIMEOUT_SECONDS &&
|
|
1983
|
+
(entry["async"] === undefined || entry["async"] === false));
|
|
1984
|
+
});
|
|
1985
|
+
});
|
|
1986
|
+
}
|
|
1987
|
+
/**
|
|
1988
|
+
* Report only project configuration visible on disk.
|
|
1989
|
+
*
|
|
1990
|
+
* Codex owns hook trust and runtime loading. Neither is inferable from a file,
|
|
1991
|
+
* and no historical log record proves what the current desktop session loaded.
|
|
1992
|
+
*/
|
|
1993
|
+
export function checkCodexHookWiring(dir) {
|
|
1994
|
+
const check = "codex-hook-wiring";
|
|
1995
|
+
const root = repoRoot(dir);
|
|
1996
|
+
const where = root ?? dir;
|
|
1997
|
+
const hooksPath = join(where, CODEX_HOOKS);
|
|
1998
|
+
const configPath = join(where, CODEX_CONFIG);
|
|
1999
|
+
const hasHooks = existsSync(hooksPath);
|
|
2000
|
+
const hasConfig = existsSync(configPath);
|
|
2001
|
+
if (!hasHooks && !hasConfig) {
|
|
2002
|
+
return {
|
|
2003
|
+
check,
|
|
2004
|
+
status: "skip",
|
|
2005
|
+
detail: `NOT CONFIGURED on disk: ${where} carries neither ${CODEX_HOOKS} nor ${CODEX_CONFIG}. Codex hook trust and observed execution are separate and remain unknown.`,
|
|
2006
|
+
};
|
|
2007
|
+
}
|
|
2008
|
+
if (!hasHooks) {
|
|
2009
|
+
return {
|
|
2010
|
+
check,
|
|
2011
|
+
status: "skip",
|
|
2012
|
+
detail: `${configPath} exists. Doctor does not interpret inline TOML hook tables, so Codex hook configuration, trust and observed execution are undetermined.`,
|
|
2013
|
+
fix: "approval hook codex --help — compare the documented PreToolUse and PostToolUse entries with .codex/config.toml",
|
|
2014
|
+
};
|
|
2015
|
+
}
|
|
2016
|
+
let parsed;
|
|
2017
|
+
try {
|
|
2018
|
+
parsed = JSON.parse(readFileSync(hooksPath, "utf8"));
|
|
2019
|
+
}
|
|
2020
|
+
catch (cause) {
|
|
2021
|
+
return {
|
|
2022
|
+
check,
|
|
2023
|
+
status: "fail",
|
|
2024
|
+
detail: `${hooksPath} cannot be read as JSON (${oneLine(detailOf(cause))}); configured wiring cannot be established, and trust or execution cannot be inferred.`,
|
|
2025
|
+
fix: "approval hook codex --help — compare and repair the project-local hook JSON after human review",
|
|
2026
|
+
};
|
|
2027
|
+
}
|
|
2028
|
+
const hooks = typeof parsed === "object" && parsed !== null && !Array.isArray(parsed)
|
|
2029
|
+
? parsed["hooks"]
|
|
2030
|
+
: null;
|
|
2031
|
+
const pre = codexEventConfigured(hooks, "PreToolUse");
|
|
2032
|
+
const post = codexEventConfigured(hooks, "PostToolUse");
|
|
2033
|
+
if (!pre || !post) {
|
|
2034
|
+
const missing = [!pre ? "PreToolUse" : null, !post ? "PostToolUse" : null]
|
|
2035
|
+
.filter((value) => value !== null)
|
|
2036
|
+
.join(" and ");
|
|
2037
|
+
return {
|
|
2038
|
+
check,
|
|
2039
|
+
status: "skip",
|
|
2040
|
+
detail: `${hooksPath} is present but does not match the expected APRV-313 profile for ${missing}: synchronous command hooks with matcher ${JSON.stringify(CODEX_MATCHER)}, command \`approval hook codex\`, and timeout ${String(CODEX_HOOK_TIMEOUT_SECONDS)} seconds. Other Codex hook configurations may be valid; this integration's coverage is undetermined. File presence proves neither trust nor observed execution.`,
|
|
2041
|
+
fix: "approval hook codex --help — install the documented pair only after human review",
|
|
2042
|
+
};
|
|
2043
|
+
}
|
|
2044
|
+
if (hasConfig) {
|
|
2045
|
+
return {
|
|
2046
|
+
check,
|
|
2047
|
+
status: "skip",
|
|
2048
|
+
detail: `CONFIGURED in ${hooksPath}: the required PreToolUse and PostToolUse entries are present. ${configPath} also exists and Codex merges hook sources; doctor does not interpret its TOML tables, so the effective configuration is not fully established. Trust and observed execution remain unknown.`,
|
|
2049
|
+
fix: "approval hook codex --help — compare .codex/config.toml with the reviewed hooks.json and keep one representation per layer",
|
|
2050
|
+
};
|
|
2051
|
+
}
|
|
2052
|
+
return {
|
|
2053
|
+
check,
|
|
2054
|
+
status: "pass",
|
|
2055
|
+
detail: `CONFIGURED on disk: ${hooksPath} carries synchronous PreToolUse and PostToolUse \`approval hook codex\` entries for ${CODEX_MATCHER}, each with a ${String(CODEX_HOOK_TIMEOUT_SECONDS)} second outer timeout. This does not establish Codex trust, loading, or observed execution; inspect and trust the exact hook with \`/hooks\`, then run the bounded smoke test.`,
|
|
2056
|
+
};
|
|
2057
|
+
}
|
|
2058
|
+
// ---------------------------------------------------------------------------
|
|
2059
|
+
// harness version provenance (APRV-227)
|
|
2060
|
+
// ---------------------------------------------------------------------------
|
|
2061
|
+
/** The Cursor counterpart of {@link CLAUDE_SETTINGS}. */
|
|
2062
|
+
const CURSOR_HOOKS = join(".cursor", "hooks.json");
|
|
2063
|
+
/** Where a harness hook registration can be written, one file per harness. */
|
|
2064
|
+
const HARNESS_SETTINGS = [CLAUDE_SETTINGS, CURSOR_HOOKS, CODEX_HOOKS];
|
|
2065
|
+
/** `approval hook <kind>` inside a command string, whichever file shape holds it. */
|
|
2066
|
+
const HOOK_COMMAND = /\bapproval["']?\s+hook\s+(claude-code|cursor|codex)\b/u;
|
|
2067
|
+
/**
|
|
2068
|
+
* Every harness this checkout registers an `approval hook` command for.
|
|
2069
|
+
*
|
|
2070
|
+
* Shape-agnostic on purpose: `.claude/settings.json` nests the command under
|
|
2071
|
+
* `hooks.PreToolUse[].hooks[].command` and `.cursor/hooks.json` under
|
|
2072
|
+
* `hooks.preToolUse[].command`, and a third harness would nest it somewhere
|
|
2073
|
+
* else again. What all of them have in common is a STRING somewhere in the
|
|
2074
|
+
* document that invokes this CLI, so the document is parsed as JSON (a file
|
|
2075
|
+
* that is not JSON registers nothing this can read) and its string leaves are
|
|
2076
|
+
* searched. The row this feeds can only SKIP when the answer is empty, so a
|
|
2077
|
+
* miss costs a skip and never a false red.
|
|
2078
|
+
*/
|
|
2079
|
+
export function registeredHarnesses(dir) {
|
|
2080
|
+
const found = new Set();
|
|
2081
|
+
for (const relative of HARNESS_SETTINGS) {
|
|
2082
|
+
const path = join(dir, relative);
|
|
2083
|
+
if (!existsSync(path))
|
|
2084
|
+
continue;
|
|
2085
|
+
let parsed;
|
|
2086
|
+
try {
|
|
2087
|
+
parsed = JSON.parse(readFileSync(path, "utf8"));
|
|
2088
|
+
}
|
|
2089
|
+
catch {
|
|
2090
|
+
continue;
|
|
2091
|
+
}
|
|
2092
|
+
const stack = [parsed];
|
|
2093
|
+
while (stack.length > 0) {
|
|
2094
|
+
const node = stack.pop();
|
|
2095
|
+
if (typeof node === "string") {
|
|
2096
|
+
const match = HOOK_COMMAND.exec(node);
|
|
2097
|
+
if (match !== null &&
|
|
2098
|
+
isHarnessKind(match[1]) &&
|
|
2099
|
+
(match[1] !== "codex" || isDirectCodexHookCommand(node))) {
|
|
2100
|
+
found.add(match[1]);
|
|
2101
|
+
}
|
|
2102
|
+
continue;
|
|
2103
|
+
}
|
|
2104
|
+
if (Array.isArray(node)) {
|
|
2105
|
+
stack.push(...node);
|
|
2106
|
+
continue;
|
|
2107
|
+
}
|
|
2108
|
+
if (typeof node === "object" && node !== null) {
|
|
2109
|
+
stack.push(...Object.values(node));
|
|
2110
|
+
}
|
|
2111
|
+
}
|
|
2112
|
+
}
|
|
2113
|
+
return HARNESS_KINDS.filter((kind) => found.has(kind));
|
|
2114
|
+
}
|
|
2115
|
+
/**
|
|
2116
|
+
* The last version each harness recorded, from the records the hook writes.
|
|
2117
|
+
*
|
|
2118
|
+
* Latest wins: the log is append-only and ordered, so the newest record naming
|
|
2119
|
+
* a harness is the newest statement about that binary. Only `task.registered`
|
|
2120
|
+
* and `gate.bypassed` are consulted, because those are the two the hook stamps
|
|
2121
|
+
* (APRV-227); the pair appearing on any other event type was not written by the
|
|
2122
|
+
* surface this row reports on and is ignored rather than trusted.
|
|
2123
|
+
*/
|
|
2124
|
+
function recordedHarnessVersions(records) {
|
|
2125
|
+
const latest = new Map();
|
|
2126
|
+
for (const record of records) {
|
|
2127
|
+
if (record.event !== "task.registered" && record.event !== "gate.bypassed")
|
|
2128
|
+
continue;
|
|
2129
|
+
const provenance = readHarnessProvenance(record.payload);
|
|
2130
|
+
if (provenance === null)
|
|
2131
|
+
continue;
|
|
2132
|
+
latest.set(provenance.harness, {
|
|
2133
|
+
version: provenance.harness_version,
|
|
2134
|
+
seq: record.seq,
|
|
2135
|
+
});
|
|
2136
|
+
}
|
|
2137
|
+
return latest;
|
|
2138
|
+
}
|
|
2139
|
+
/**
|
|
2140
|
+
* Has the harness binary changed under the hook since the log last saw it?
|
|
2141
|
+
*
|
|
2142
|
+
* ## What this row is for
|
|
2143
|
+
*
|
|
2144
|
+
* A harness upgrade swaps the binary that hosts the PreToolUse hook, and it
|
|
2145
|
+
* happens on a human's own machine, unattended, at whatever hour an updater
|
|
2146
|
+
* runs. A release can change the hook envelope semantics; the gate then answers
|
|
2147
|
+
* a protocol nobody is speaking any more and the tool calls go through
|
|
2148
|
+
* unclassified. Nothing in the log would say so, because the thing that changed
|
|
2149
|
+
* is outside the log entirely.
|
|
2150
|
+
*
|
|
2151
|
+
* So this row compares the two facts it can actually establish: what
|
|
2152
|
+
* `<binary> --version` says now, and what the last hook-written record says the
|
|
2153
|
+
* binary was. A difference is not evidence of a fault, since most upgrades are
|
|
2154
|
+
* fine. It is evidence that the gate has not been exercised since the binary
|
|
2155
|
+
* changed, and the remedy is to exercise it. The self-test in
|
|
2156
|
+
* `docs/claude-code-hook.md` does that and costs nobody a prompt.
|
|
2157
|
+
*
|
|
2158
|
+
* ## Why it fails rather than warns
|
|
2159
|
+
*
|
|
2160
|
+
* The reason `dark-sessions` fails. A row in the pass column would be saying
|
|
2161
|
+
* "the gate may or may not still fire and I am content", and the whole content
|
|
2162
|
+
* of an unverified change is that nobody has checked. It clears the moment one
|
|
2163
|
+
* record is written under the new binary, which is a cheap and bounded remedy,
|
|
2164
|
+
* and that is what makes a red row here honest rather than nagging.
|
|
2165
|
+
*
|
|
2166
|
+
* ## What it will not claim
|
|
2167
|
+
*
|
|
2168
|
+
* A recorded version is SELF-REPORTED (SPEC.md §11.1 invariant 4), so this row
|
|
2169
|
+
* is careful about the direction it can move. A match ADDS nothing: not proof
|
|
2170
|
+
* the hook fired, not proof the harness is honest, and no substitute for
|
|
2171
|
+
* `harness-hook-wiring` or the CI-side guard. A mismatch is the only thing it
|
|
2172
|
+
* asserts, and all it asserts about one is that a human should run the
|
|
2173
|
+
* self-test. Nothing anywhere reads the field as an input to a verdict, a
|
|
2174
|
+
* floor, a budget, a streak or a sampling draw.
|
|
2175
|
+
*
|
|
2176
|
+
* Three skips, each with its reason in the detail: no harness hook registered
|
|
2177
|
+
* in this checkout; no hook-written record naming that harness yet (a fresh log
|
|
2178
|
+
* has nothing to compare against, and inventing a baseline would be inventing
|
|
2179
|
+
* the fact); and no such binary on PATH, since doctor may be running somewhere
|
|
2180
|
+
* the harness is not installed, which is a state and not a fault.
|
|
2181
|
+
*/
|
|
2182
|
+
function checkHarnessVersion(dir, records) {
|
|
2183
|
+
const check = "harness-version-unverified";
|
|
2184
|
+
const root = repoRoot(dir);
|
|
2185
|
+
const where = root === null ? dir : root;
|
|
2186
|
+
const kinds = registeredHarnesses(where);
|
|
2187
|
+
if (kinds.length === 0) {
|
|
2188
|
+
return {
|
|
2189
|
+
check,
|
|
2190
|
+
status: "skip",
|
|
2191
|
+
detail: `${where} registers no \`approval hook\` command in ${HARNESS_SETTINGS.join(" or ")}, so no harness hosts the hook here and there is no installed version for the log to be behind`,
|
|
2192
|
+
};
|
|
2193
|
+
}
|
|
2194
|
+
const recorded = recordedHarnessVersions(records);
|
|
2195
|
+
const mismatched = [];
|
|
2196
|
+
const matched = [];
|
|
2197
|
+
const unknown = [];
|
|
2198
|
+
for (const kind of kinds) {
|
|
2199
|
+
const last = recorded.get(kind);
|
|
2200
|
+
if (last === undefined) {
|
|
2201
|
+
unknown.push(`${kind}: no hook-written task.registered or gate.bypassed names a version yet, so there is no baseline to compare against`);
|
|
2202
|
+
continue;
|
|
2203
|
+
}
|
|
2204
|
+
const installed = installedHarnessVersion(kind);
|
|
2205
|
+
if (installed === null) {
|
|
2206
|
+
unknown.push(`${kind}: \`${HARNESS_BINARY[kind]} --version\` gave no usable answer here (not on PATH, a non-zero exit, or output this runtime will not record), so what is installed cannot be established; the log last saw ${JSON.stringify(last.version)} at seq ${String(last.seq)}`);
|
|
2207
|
+
continue;
|
|
2208
|
+
}
|
|
2209
|
+
if (installed === last.version) {
|
|
2210
|
+
matched.push(`${kind} ${JSON.stringify(installed)} matches the version on the hook record at seq ${String(last.seq)}`);
|
|
2211
|
+
continue;
|
|
2212
|
+
}
|
|
2213
|
+
mismatched.push(`${kind} is installed at ${JSON.stringify(installed)} and the last hook-written record (seq ${String(last.seq)}) was issued by ${JSON.stringify(last.version)}`);
|
|
2214
|
+
}
|
|
2215
|
+
if (mismatched.length > 0) {
|
|
2216
|
+
const first = kinds[0];
|
|
2217
|
+
return {
|
|
2218
|
+
check,
|
|
2219
|
+
status: "fail",
|
|
2220
|
+
detail: `the harness binary changed and the gate has not been exercised since: ${mismatched.join("; ")}. A release can change the hook envelope semantics, so until one record is written under the new binary nothing here shows the hook still fires. The recorded version is self-reported and reduces nothing: a match would not have proved the hook fired either, and what a mismatch says is that nobody has looked.`,
|
|
2221
|
+
fix: `approval hook ${first} --dir ${where} < one PreToolUse event for a supervised-class command — the self-test in docs/${first === "cursor" ? "cursor" : "claude-code"}-hook.md. It prompts nobody and writes one task.registered carrying the installed version.`,
|
|
2222
|
+
};
|
|
2223
|
+
}
|
|
2224
|
+
if (matched.length > 0) {
|
|
2225
|
+
return {
|
|
2226
|
+
check,
|
|
2227
|
+
status: "pass",
|
|
2228
|
+
detail: `${matched.join("; ")}${unknown.length === 0 ? "" : `; ${unknown.join("; ")}`}. A match is not proof the hook fired; it is the absence of the one thing this row can see, an unverified change of the binary hosting it.`,
|
|
2229
|
+
};
|
|
2230
|
+
}
|
|
2231
|
+
return {
|
|
2232
|
+
check,
|
|
2233
|
+
status: "skip",
|
|
2234
|
+
detail: `${where} registers ${kinds.join(", ")} and no comparison could be made: ${unknown.join("; ")}`,
|
|
2235
|
+
};
|
|
2236
|
+
}
|
|
2237
|
+
// ---------------------------------------------------------------------------
|
|
2238
|
+
// dark sessions (APRV-192)
|
|
2239
|
+
// ---------------------------------------------------------------------------
|
|
2240
|
+
/**
|
|
2241
|
+
* Does the git activity in this checkout have log records beside it?
|
|
2242
|
+
*
|
|
2243
|
+
* The detective complement to `harness-hook-wiring` above. That row reports
|
|
2244
|
+
* whether the settings file is on disk and says plainly that this is not proof
|
|
2245
|
+
* a session loaded it; this row asks the question that does not depend on
|
|
2246
|
+
* session wiring at all — git shows commits and worktrees, and the log either
|
|
2247
|
+
* carries records beside them or it does not.
|
|
2248
|
+
*
|
|
2249
|
+
* Reads only. Doctor never appends, so a dark subject found HERE is reported
|
|
2250
|
+
* and not recorded: the record is the daemon's, written by the sweep it runs on
|
|
2251
|
+
* its own cadence (`approval daemon run --dark-sessions`). Two processes
|
|
2252
|
+
* appending the same observation would be two writers to one fact, and doctor
|
|
2253
|
+
* is a reader.
|
|
2254
|
+
*
|
|
2255
|
+
* This row DOES fail the run, which is where it parts company with
|
|
2256
|
+
* `harness-hook-wiring` above. That row reports a configuration, and a
|
|
2257
|
+
* configuration this runtime cannot verify from disk is not a health verdict.
|
|
2258
|
+
* This one reports an EVENT: work was done in this repository and the log was
|
|
2259
|
+
* not told. "Never silently tolerate it" is the whole of APRV-192, and a row
|
|
2260
|
+
* that reported a dark session in the pass column would be tolerating it
|
|
2261
|
+
* quietly in the one place an operator goes to ask whether anything is wrong.
|
|
2262
|
+
*
|
|
2263
|
+
* An `undetermined` subject is a skip, not a fail, for the reason the daemon
|
|
2264
|
+
* appends nothing for one: what the detector could not see is a gap in the
|
|
2265
|
+
* instrument, and a red row for it would train an operator to ignore red rows.
|
|
2266
|
+
* The gap is named in the detail, never folded into a pass.
|
|
2267
|
+
*/
|
|
2268
|
+
function checkDarkSessions(logPath, dir, policyPath, records, verified) {
|
|
2269
|
+
const check = "dark-sessions";
|
|
2270
|
+
const root = repoRoot(dir);
|
|
2271
|
+
if (root === null) {
|
|
2272
|
+
return {
|
|
2273
|
+
check,
|
|
2274
|
+
status: "skip",
|
|
2275
|
+
detail: `${dir} is not inside a git repository, so there is no git activity for the log to owe records against`,
|
|
2276
|
+
};
|
|
2277
|
+
}
|
|
2278
|
+
// `reportDarkSessions`, never `sweepDarkSessions`: the read-only half of the
|
|
2279
|
+
// same code, so doctor and the daemon reach identical verdicts and only the
|
|
2280
|
+
// daemon writes them down.
|
|
2281
|
+
const { report } = reportDarkSessions({
|
|
2282
|
+
logPath,
|
|
2283
|
+
root,
|
|
2284
|
+
policy: { file: policyPath },
|
|
2285
|
+
windowMs: DEFAULT_DARK_WINDOW_MS,
|
|
2286
|
+
records: verified ? records : null,
|
|
2287
|
+
...(verified ? {} : { logDetail: "the chain did not verify; see the log check above" }),
|
|
2288
|
+
});
|
|
2289
|
+
const dark = report.findings.filter((finding) => finding.verdict === "dark");
|
|
2290
|
+
const undetermined = report.findings.filter((finding) => finding.verdict === "undetermined");
|
|
2291
|
+
const watched = report.findings.length;
|
|
2292
|
+
if (dark.length > 0) {
|
|
2293
|
+
return {
|
|
2294
|
+
check,
|
|
2295
|
+
status: "fail",
|
|
2296
|
+
detail: `${String(dark.length)} of ${String(watched)} checkout(s) show git activity the log carries no record of: ${dark
|
|
2297
|
+
.map((finding) => `${finding.subject} [${finding.code ?? "?"}]`)
|
|
2298
|
+
.join(", ")}. ${dark[0].detail}`,
|
|
2299
|
+
fix: "approval doctor --dir <that checkout> — its harness-hook-wiring row, then `approval instructions hook`",
|
|
2300
|
+
};
|
|
2301
|
+
}
|
|
2302
|
+
if (undetermined.length > 0) {
|
|
2303
|
+
return {
|
|
2304
|
+
check,
|
|
2305
|
+
status: "skip",
|
|
2306
|
+
detail: `UNDETERMINED for ${String(undetermined.length)} of ${String(watched)} checkout(s): ${undetermined
|
|
2307
|
+
.map((finding) => `${finding.subject} [${finding.code ?? "?"}]`)
|
|
2308
|
+
.join(", ")}. ${undetermined[0].detail}`,
|
|
2309
|
+
};
|
|
2310
|
+
}
|
|
2311
|
+
return {
|
|
2312
|
+
check,
|
|
2313
|
+
status: "pass",
|
|
2314
|
+
detail: `${String(watched)} checkout(s) swept over the last ${String(Math.round(DEFAULT_DARK_WINDOW_MS / 3_600_000))}h and every one of them either produced no git activity or has records beside it. ${report.coverage}`,
|
|
2315
|
+
};
|
|
2316
|
+
}
|
|
2317
|
+
// ---------------------------------------------------------------------------
|
|
2318
|
+
// 27. gate-organs (APRV-272)
|
|
2319
|
+
// ---------------------------------------------------------------------------
|
|
2320
|
+
/**
|
|
2321
|
+
* Where the gate's organs live, as repository-relative prefixes.
|
|
2322
|
+
*
|
|
2323
|
+
* The enumeration is deliberately narrow and one level deep. `core/command-class.ts`
|
|
2324
|
+
* decides what IS an organ (and every path listed here is put to it before it
|
|
2325
|
+
* is reported); this list only says where to look, so a directory nobody uses
|
|
2326
|
+
* costs nothing and a file nobody named is not invented.
|
|
2327
|
+
*/
|
|
2328
|
+
const ORGAN_SEARCH = [
|
|
2329
|
+
{ dir: ".claude", prefix: "settings" },
|
|
2330
|
+
{ dir: ".cursor", prefix: "hooks.json" },
|
|
2331
|
+
{ dir: join(".cursor", "hooks") },
|
|
2332
|
+
{ dir: join(".cursor", "agents") },
|
|
2333
|
+
];
|
|
2334
|
+
/** The organ files this checkout actually carries, repository-relative, sorted. */
|
|
2335
|
+
function listGateOrgans(root) {
|
|
2336
|
+
const found = [];
|
|
2337
|
+
for (const entry of ORGAN_SEARCH) {
|
|
2338
|
+
let names;
|
|
2339
|
+
try {
|
|
2340
|
+
names = readdirSync(join(root, entry.dir));
|
|
2341
|
+
}
|
|
2342
|
+
catch {
|
|
2343
|
+
continue;
|
|
2344
|
+
}
|
|
2345
|
+
for (const name of names.sort()) {
|
|
2346
|
+
if (entry.prefix !== undefined && !name.startsWith(entry.prefix))
|
|
2347
|
+
continue;
|
|
2348
|
+
const relative = `${entry.dir.split(/[/\\]+/u).join("/")}/${name}`;
|
|
2349
|
+
let isFile;
|
|
2350
|
+
try {
|
|
2351
|
+
isFile = statSync(join(root, relative)).isFile();
|
|
2352
|
+
}
|
|
2353
|
+
catch {
|
|
2354
|
+
continue;
|
|
2355
|
+
}
|
|
2356
|
+
// The classifier has the last word on what an organ is, so a file that
|
|
2357
|
+
// merely sits in one of these directories is not reported as one.
|
|
2358
|
+
if (isFile && isGateOrganPath(relative))
|
|
2359
|
+
found.push(relative);
|
|
2360
|
+
}
|
|
2361
|
+
}
|
|
2362
|
+
return found;
|
|
2363
|
+
}
|
|
2364
|
+
/**
|
|
2365
|
+
* Which gate organs in this checkout carry no attestation of their CURRENT
|
|
2366
|
+
* bytes (APRV-272)?
|
|
2367
|
+
*
|
|
2368
|
+
* **This row never moves the exit code, by design.** It reports a fact about
|
|
2369
|
+
* files a human edits by hand, and the enforcement for that fact lives in the
|
|
2370
|
+
* CI-side protected-path guard, which fails the pull request. Doctor's job here
|
|
2371
|
+
* is to make the state visible BEFORE a pull request fails on it: a human who
|
|
2372
|
+
* has just hand-edited the settings file should be told they owe an
|
|
2373
|
+
* attestation while they are still at the terminal, not by a red check twenty
|
|
2374
|
+
* minutes later. A failing row would also be wrong on its own terms — an
|
|
2375
|
+
* unattested organ breaks nothing on this machine, unlike an unattested policy,
|
|
2376
|
+
* which makes every gated operation refuse.
|
|
2377
|
+
*
|
|
2378
|
+
* A checkout with no organ files at all is a skip: there is no harness
|
|
2379
|
+
* configuration here, which is a state and not a fault, exactly as
|
|
2380
|
+
* `harness-hook-wiring` treats the same absence.
|
|
2381
|
+
*/
|
|
2382
|
+
function checkGateOrgans(dir, records) {
|
|
2383
|
+
const check = "gate-organs";
|
|
2384
|
+
const root = repoRoot(dir) ?? dir;
|
|
2385
|
+
const organs = listGateOrgans(root);
|
|
2386
|
+
if (organs.length === 0) {
|
|
2387
|
+
return {
|
|
2388
|
+
check,
|
|
2389
|
+
status: "skip",
|
|
2390
|
+
detail: `${root} carries no gate organ files (${ORGAN_SEARCH.map((entry) => entry.dir).join(", ")}), so there is nothing here for a human to have attested`,
|
|
2391
|
+
};
|
|
2392
|
+
}
|
|
2393
|
+
const unattested = [];
|
|
2394
|
+
const unreadable = [];
|
|
2395
|
+
let attested = 0;
|
|
2396
|
+
for (const organ of organs) {
|
|
2397
|
+
let sha256;
|
|
2398
|
+
try {
|
|
2399
|
+
sha256 = policyBytesHash(readFileSync(join(root, organ)));
|
|
2400
|
+
}
|
|
2401
|
+
catch (cause) {
|
|
2402
|
+
unreadable.push(`${organ} (${detailOf(cause)})`);
|
|
2403
|
+
continue;
|
|
2404
|
+
}
|
|
2405
|
+
if (findOrganAttestation(records, organ, sha256) !== null) {
|
|
2406
|
+
attested += 1;
|
|
2407
|
+
continue;
|
|
2408
|
+
}
|
|
2409
|
+
const previous = latestOrganAttestation(records, organ);
|
|
2410
|
+
unattested.push(previous === null
|
|
2411
|
+
? `${organ} (never attested, live ${sha256.slice(0, 12)}…)`
|
|
2412
|
+
: `${organ} (edited since seq ${previous.record.seq}: attested ${previous.sha256.slice(0, 12)}…, live ${sha256.slice(0, 12)}…)`);
|
|
2413
|
+
}
|
|
2414
|
+
if (unattested.length === 0 && unreadable.length === 0) {
|
|
2415
|
+
return {
|
|
2416
|
+
check,
|
|
2417
|
+
status: "pass",
|
|
2418
|
+
detail: `${String(attested)} gate organ file(s) carry an attestation of their current bytes: ${organs.join(", ")}`,
|
|
2419
|
+
};
|
|
2420
|
+
}
|
|
2421
|
+
const parts = [];
|
|
2422
|
+
if (unattested.length > 0)
|
|
2423
|
+
parts.push(`NOT ATTESTED: ${unattested.join("; ")}`);
|
|
2424
|
+
if (unreadable.length > 0)
|
|
2425
|
+
parts.push(`unreadable: ${unreadable.join("; ")}`);
|
|
2426
|
+
return {
|
|
2427
|
+
check,
|
|
2428
|
+
// Never a fail: see the note above. The exit code belongs to the guard.
|
|
2429
|
+
status: "skip",
|
|
2430
|
+
detail: `${parts.join(". ")}. A gate organ is policy.core, so no grant for a hand edit to one can exist and the protected-path guard accepts only an attestation of these exact bytes; a pull request carrying this change will fail until one is in the committed log`,
|
|
2431
|
+
fix: `approval policy attest --organ ${(unattested[0] ?? "<path>").split(" ")[0] ?? "<path>"} --as human:<id> — after reading the file`,
|
|
2432
|
+
};
|
|
2433
|
+
}
|
|
2434
|
+
// ---------------------------------------------------------------------------
|
|
2435
|
+
// 28. sealed-keys (APRV-285)
|
|
2436
|
+
// ---------------------------------------------------------------------------
|
|
2437
|
+
/** The exact line doctor tells an operator to add for the sealed-token key store. */
|
|
2438
|
+
const KEYS_IGNORE_LINE = ".approval/keys/";
|
|
2439
|
+
/** The same path without the trailing slash, which is how git spells a path. */
|
|
2440
|
+
const KEYS_IGNORE_PATH = ".approval/keys";
|
|
2441
|
+
/** The private key files this key store actually holds, sorted, names only. */
|
|
2442
|
+
function listPrivateKeys(keyDir) {
|
|
2443
|
+
try {
|
|
2444
|
+
return readdirSync(keyDir)
|
|
2445
|
+
.filter((name) => name.endsWith(".key"))
|
|
2446
|
+
.sort();
|
|
2447
|
+
}
|
|
2448
|
+
catch {
|
|
2449
|
+
return [];
|
|
2450
|
+
}
|
|
2451
|
+
}
|
|
2452
|
+
/**
|
|
2453
|
+
* The paths git TRACKS under `keyDir`, repo-relative, or `[]` when git cannot say.
|
|
2454
|
+
*
|
|
2455
|
+
* Tracking is asked of git rather than inferred from the working tree, because
|
|
2456
|
+
* the two can disagree in the direction that matters: a key consumed and
|
|
2457
|
+
* unlinked is gone from disk and still in the index, and it is the index that
|
|
2458
|
+
* becomes a commit.
|
|
2459
|
+
*/
|
|
2460
|
+
function trackedPrivateKeys(root, keyDir) {
|
|
2461
|
+
const relative = repoPath(root, keyDir);
|
|
2462
|
+
// A key store outside this repository cannot be committed to it.
|
|
2463
|
+
if (relative.startsWith("../") || isAbsolute(relative))
|
|
2464
|
+
return [];
|
|
2465
|
+
const listed = git(["ls-files", "-z", "--", relative], root);
|
|
2466
|
+
if (!listed.ok)
|
|
2467
|
+
return [];
|
|
2468
|
+
return listed.stdout.split("\0").filter((entry) => entry.length > 0);
|
|
2469
|
+
}
|
|
2470
|
+
/**
|
|
2471
|
+
* Is a sealed-delivery private key one `git add` away from publication?
|
|
2472
|
+
*
|
|
2473
|
+
* The key store holds the X25519 private halves of sealed token delivery
|
|
2474
|
+
* (`core/seal.ts`): one per request, written 0600 in a 0700 directory, unlinked
|
|
2475
|
+
* at consume, expiry or revocation. The log — which IS shared, and which this
|
|
2476
|
+
* project commits on purpose — carries only the ciphertext. A private key in
|
|
2477
|
+
* that same history hands every reader of it the ability to open that action's
|
|
2478
|
+
* `token_sealed` for as long as the token is unspent and inside its TTL, so the
|
|
2479
|
+
* whole design of sealed delivery rests on the key never being committed.
|
|
2480
|
+
*
|
|
2481
|
+
* Nothing enforced that. `.approval/payloads/` is deliberately TRACKED (evidence
|
|
2482
|
+
* belongs in the history), so `.approval/` is a directory an operator adds from
|
|
2483
|
+
* during a records or ceremony commit, and a key store with no ignore line is
|
|
2484
|
+
* swept in by the same `git add` that carries the payloads.
|
|
2485
|
+
*
|
|
2486
|
+
* Two questions, in the order of what stays wrong the longest, the same reading
|
|
2487
|
+
* the vault and environment rows use:
|
|
2488
|
+
*
|
|
2489
|
+
* 1. **A tracked key** is the fault that has already happened, and a commit is
|
|
2490
|
+
* not something a later commit removes. Asked of git, so a key that was
|
|
2491
|
+
* consumed and unlinked but is still in the index is still reported.
|
|
2492
|
+
* 2. **An unignored key store** is the fault about to happen. A key present with
|
|
2493
|
+
* no ignore line covering it FAILS; an empty or absent store is a SKIP that
|
|
2494
|
+
* still names the line in its detail and carries no `fix`, because nothing on
|
|
2495
|
+
* this machine is wrong yet and a non-failing row that hands an operator
|
|
2496
|
+
* something to type is a row they learn to scroll past.
|
|
2497
|
+
*
|
|
2498
|
+
* Outside a git repository the row skips: there is nothing here to commit a key
|
|
2499
|
+
* to, and failing a check about a risk that does not exist trains people to
|
|
2500
|
+
* ignore the check.
|
|
2501
|
+
*
|
|
2502
|
+
* Neither fix line deletes anything and neither commits anything. The repair for
|
|
2503
|
+
* a key already in the index is named in prose and left to the human, exactly as
|
|
2504
|
+
* {@link FIX_COMMAND_PREFIXES} requires.
|
|
2505
|
+
*/
|
|
2506
|
+
function checkSealedKeys(logPath, dir) {
|
|
2507
|
+
const check = "sealed-keys";
|
|
2508
|
+
const keyDir = keyStoreDirFor(logPath);
|
|
2509
|
+
const ignored = ignoreVerdict(dir, KEYS_IGNORE_PATH, "dir");
|
|
2510
|
+
if (ignored === "not-a-repo") {
|
|
2511
|
+
return {
|
|
2512
|
+
check,
|
|
2513
|
+
status: "skip",
|
|
2514
|
+
detail: `no git repository at ${dir}, so there is nothing to commit a sealed-delivery private key to. ${keyDir} is where they would live, 0600 in a 0700 directory, and the log carries only the ciphertext they open`,
|
|
2515
|
+
};
|
|
2516
|
+
}
|
|
2517
|
+
const root = repoRoot(dir);
|
|
2518
|
+
const tracked = root === null ? [] : trackedPrivateKeys(root, keyDir);
|
|
2519
|
+
if (tracked.length > 0) {
|
|
2520
|
+
return {
|
|
2521
|
+
check,
|
|
2522
|
+
status: "fail",
|
|
2523
|
+
detail: `${String(tracked.length)} sealed-delivery private key(s) are TRACKED by git: ${tracked.join(", ")}. The log is committed and carries the ciphertext, so a committed key opens that action's token_sealed for anyone holding the history, for as long as the token is unspent and inside its TTL — and a commit is not something a later commit removes`,
|
|
2524
|
+
fix: `approval init — it writes '${KEYS_IGNORE_LINE}' into .gitignore so the next key is not swept in; a key already in the index has to be untracked by hand (\`git rm --cached\` on the paths above), and every action whose token is still unspent inside its TTL treated as disclosed and revoked`,
|
|
2525
|
+
};
|
|
2526
|
+
}
|
|
2527
|
+
const present = listPrivateKeys(keyDir);
|
|
2528
|
+
if (ignored === "not-ignored") {
|
|
2529
|
+
if (present.length > 0) {
|
|
2530
|
+
return {
|
|
2531
|
+
check,
|
|
2532
|
+
status: "fail",
|
|
2533
|
+
detail: `${String(present.length)} sealed-delivery private key(s) in ${keyDir} are NOT gitignored in ${dir}: one \`git add .approval/\` — the command a records or ceremony commit uses, because \`.approval/payloads/\` is deliberately tracked — publishes a live key beside the ciphertext it opens`,
|
|
2534
|
+
fix: `echo '${KEYS_IGNORE_LINE}' >> ${join(dir, ".gitignore")} — the line \`approval init\` writes; and treat every action whose token is still unspent inside its TTL as disclosed`,
|
|
2535
|
+
};
|
|
2536
|
+
}
|
|
2537
|
+
// A SKIP WITH NO FIX, because nothing here is wrong yet. Doctor's older
|
|
2538
|
+
// rule is that a non-failing row carries no `fix` line, and an operator
|
|
2539
|
+
// scanning a wall of them for the next thing to type must not be handed one
|
|
2540
|
+
// for a risk that does not exist on this machine today. The line is named in
|
|
2541
|
+
// the detail instead, where a reader who wants it can still find it.
|
|
2542
|
+
return {
|
|
2543
|
+
check,
|
|
2544
|
+
status: "skip",
|
|
2545
|
+
detail: `no key is in ${keyDir}, so nothing is exposed here today, and no \`${KEYS_IGNORE_LINE}\` line covers it in ${dir}. \`approval init\` writes that line whether or not a policy has opted into \`token_delivery: sealed\`, because the entry an operator needs is the one already there on the day they turn the knob`,
|
|
2546
|
+
};
|
|
2547
|
+
}
|
|
2548
|
+
return {
|
|
2549
|
+
check,
|
|
2550
|
+
status: "pass",
|
|
2551
|
+
detail: `${keyDir} is covered by ${KEYS_IGNORE_LINE} in ${dir} and git tracks nothing under it${present.length === 0 ? " (no key is stored there right now)" : `; ${String(present.length)} key(s) are stored there`}. The private halves stay on this machine and the log carries only the ciphertext they open`,
|
|
2552
|
+
};
|
|
2553
|
+
}
|
|
2554
|
+
/** The Backlog.md board key a task file's name begins with (`task-3 - Slug.md`). */
|
|
2555
|
+
function taskIdFromFileName(name) {
|
|
2556
|
+
const match = /^([A-Za-z][A-Za-z0-9_]*-\d+)/u.exec(name);
|
|
2557
|
+
return match?.[1] ?? null;
|
|
2558
|
+
}
|
|
2559
|
+
// ---------------------------------------------------------------------------
|
|
2560
|
+
// Rendering
|
|
2561
|
+
// ---------------------------------------------------------------------------
|
|
2562
|
+
/** The status column, as a glyph role the shared style already knows how to paint. */
|
|
2563
|
+
const GLYPH_OF = {
|
|
2564
|
+
pass: "ok",
|
|
2565
|
+
fail: "fail",
|
|
2566
|
+
skip: "skip",
|
|
2567
|
+
};
|
|
2568
|
+
/**
|
|
2569
|
+
* The human report: one aligned row per check, fixes indented under their row.
|
|
2570
|
+
*
|
|
2571
|
+
* The line contract is load-bearing and older than the table (APRV-91 #9): a
|
|
2572
|
+
* check occupies exactly one line, and a `fix` exactly one indented line under
|
|
2573
|
+
* it, so an operator scanning a failed run counts rows rather than paragraphs.
|
|
2574
|
+
* What the table changed is alignment and colour, never that arithmetic.
|
|
2575
|
+
*
|
|
2576
|
+
* A detail is abbreviated only when a TERMINAL WIDTH IS KNOWN and the row would
|
|
2577
|
+
* not fit it, and `--verbose` (APRV-102) turns even that off. The brief asked
|
|
2578
|
+
* for truncation outright; this is the narrowed version of it, for two reasons.
|
|
2579
|
+
* A pipe has no width, so piped output — which every other suite pins, and
|
|
2580
|
+
* which is what a bug report contains — is never abbreviated at all. And a
|
|
2581
|
+
* `fix:` line is never touched on any path: repair instructions cut off
|
|
2582
|
+
* mid-command are worse than a wide line, which is what the truncation was
|
|
2583
|
+
* supposed to prevent.
|
|
2584
|
+
*/
|
|
2585
|
+
export function renderDoctorHuman(checks, st = style(), options = {}) {
|
|
2586
|
+
const labelWidth = Math.max(0, ...checks.map((entry) => entry.check.length));
|
|
2587
|
+
// glyph (1) + space + label + gap (2), the columns the detail starts after.
|
|
2588
|
+
const room = options.verbose === true || options.width === null || options.width === undefined
|
|
2589
|
+
? null
|
|
2590
|
+
: Math.max(20, options.width - labelWidth - 4);
|
|
2591
|
+
const fit = (detail) => room === null || detail.length <= room ? detail : `${detail.slice(0, room - 1)}…`;
|
|
2592
|
+
const rows = checks.map((entry) => ({
|
|
2593
|
+
left: entry.check,
|
|
2594
|
+
right: fit(entry.detail),
|
|
2595
|
+
glyph: GLYPH_OF[entry.status],
|
|
2596
|
+
...(entry.fix === undefined ? {} : { under: [`fix: ${entry.fix}`] }),
|
|
2597
|
+
}));
|
|
2598
|
+
const count = (status) => checks.filter((entry) => entry.status === status).length;
|
|
2599
|
+
const failed = count("fail");
|
|
2600
|
+
// Each count wears its own role, so the summary is scannable at the same
|
|
2601
|
+
// glance as the glyph column above it and says the same thing.
|
|
2602
|
+
const summary = [
|
|
2603
|
+
st.ok(`${count("pass")} ok`),
|
|
2604
|
+
st.warn(`${count("skip")} not applicable`),
|
|
2605
|
+
failed === 0 ? st.muted("0 failed") : st.fail(`${failed} failed`),
|
|
2606
|
+
].join(" · ");
|
|
2607
|
+
return `${st.table(rows)}\n${summary}\n`;
|
|
2608
|
+
}
|
|
2609
|
+
// ---------------------------------------------------------------------------
|
|
2610
|
+
// The verb
|
|
2611
|
+
// ---------------------------------------------------------------------------
|
|
2612
|
+
/**
|
|
2613
|
+
* `approval doctor …` — run every check in order and report.
|
|
2614
|
+
*
|
|
2615
|
+
* Returns a number for the paths that are decided before any I/O (help, usage),
|
|
2616
|
+
* and a promise otherwise, because two checks are asynchronous. `main`
|
|
2617
|
+
* dispatches both shapes, as it already does for `channel`.
|
|
2618
|
+
*/
|
|
2619
|
+
export function commandDoctor(argv, streams, cwd) {
|
|
2620
|
+
const json = argv.includes("--json");
|
|
2621
|
+
const parsed = parseFlags(argv, FLAGS);
|
|
2622
|
+
if (!parsed.ok)
|
|
2623
|
+
return usageError(streams, json, parsed.message);
|
|
2624
|
+
if (boolFlag(parsed.flags, "--help") || boolFlag(parsed.flags, "-h")) {
|
|
2625
|
+
streams.out(`${DOCTOR_HELP}\n`);
|
|
2626
|
+
return EXIT_OK;
|
|
2627
|
+
}
|
|
2628
|
+
const extra = parsed.positionals[0];
|
|
2629
|
+
if (extra !== undefined) {
|
|
2630
|
+
return usageError(streams, json, `unexpected argument ${JSON.stringify(extra)}`);
|
|
2631
|
+
}
|
|
2632
|
+
const rootFlag = stringFlag(parsed.flags, "--root");
|
|
2633
|
+
const root = rootFlag === null ? installationRoot() : absolute(rootFlag, cwd);
|
|
2634
|
+
let build;
|
|
2635
|
+
try {
|
|
2636
|
+
build = checkBuildFreshness(root);
|
|
2637
|
+
}
|
|
2638
|
+
catch (cause) {
|
|
2639
|
+
// Doctor could not look — the one thing that is not a report about the
|
|
2640
|
+
// environment but a failure of the instrument. Exit 4.
|
|
2641
|
+
if (cause instanceof ScanError) {
|
|
2642
|
+
return ioError(streams, json, `doctor could not inspect ${root}: ${cause.message}`);
|
|
2643
|
+
}
|
|
2644
|
+
throw cause;
|
|
2645
|
+
}
|
|
2646
|
+
const dirFlag = stringFlag(parsed.flags, "--dir");
|
|
2647
|
+
const dir = dirFlag === null ? cwd : absolute(dirFlag, cwd);
|
|
2648
|
+
const policyFlag = stringFlag(parsed.flags, "--policy");
|
|
2649
|
+
const policyPath = resolvePolicyPath(policyFlag, dir, cwd);
|
|
2650
|
+
const logPath = resolvePath(stringFlag(parsed.flags, "--log"), DEFAULT_LOG_PATH, cwd);
|
|
2651
|
+
// The second path the startup preflight must not let a fast-forward clobber.
|
|
2652
|
+
// Spelled from `--dir` rather than from a flag of its own: doctor has no
|
|
2653
|
+
// `--out`, and inventing one for a single row would be a new surface.
|
|
2654
|
+
const queuePath = join(dir, DEFAULT_QUEUE_PATH);
|
|
2655
|
+
// ONE walk of the log for both the attestation check and the log check: two
|
|
2656
|
+
// walks could disagree, and doctor is the last place a reader wants to be
|
|
2657
|
+
// told two different things about one file.
|
|
2658
|
+
const verified = verifyWithRecords(logPath);
|
|
2659
|
+
const policyLoad = loadPolicy(policyFlag === null ? { dir } : { file: policyPath });
|
|
2660
|
+
const port = policyWebPort(policyLoad);
|
|
2661
|
+
const apiBase = stringFlag(parsed.flags, "--api-base") ?? TELEGRAM_DEFAULT_API_BASE;
|
|
2662
|
+
const tasksFlag = stringFlag(parsed.flags, "--tasks");
|
|
2663
|
+
const tasksDir = tasksFlag === null ? join(dir, DEFAULT_TASKS_DIR) : absolute(tasksFlag, cwd);
|
|
2664
|
+
return (async () => {
|
|
2665
|
+
const checks = [
|
|
2666
|
+
build,
|
|
2667
|
+
checkIdentity(),
|
|
2668
|
+
checkAttestationHealth(verified.records, policyPath),
|
|
2669
|
+
checkLog(logPath, verified.result),
|
|
2670
|
+
await checkTelegram(apiBase, policyLoad),
|
|
2671
|
+
await checkWebPort(port ?? WEB_DEFAULT_PORT),
|
|
2672
|
+
checkPayloadStore(logPath, verified.records),
|
|
2673
|
+
// APRV-271: asks the running daemon for the one half of this answer that
|
|
2674
|
+
// doctor's own environment cannot hold.
|
|
2675
|
+
await checkSampling(policyLoad, logPath),
|
|
2676
|
+
checkEnvelopeIntegrity(tasksDir, verified.records),
|
|
2677
|
+
// APRV-68: appended rather than inserted, for the same reason the
|
|
2678
|
+
// envelope check was — a reader's position-based expectations still hold.
|
|
2679
|
+
checkVaultHealth(logPath, dir, policyLoad),
|
|
2680
|
+
// APRV-75: appended, for the third time and the same reason — the check
|
|
2681
|
+
// list is a frozen shape that grows only at the end.
|
|
2682
|
+
checkEnvironment(logPath, dir, policyLoad),
|
|
2683
|
+
// APRV-125: appended, fourth time, same reason. The fork this reports is
|
|
2684
|
+
// the one APRV-104 could only find by hand.
|
|
2685
|
+
checkLogDrift(logPath, verified.records),
|
|
2686
|
+
// APRV-127: appended, fifth time, same reason.
|
|
2687
|
+
checkReconciliation(verified.records),
|
|
2688
|
+
// APRV-145: appended, sixth time, same reason.
|
|
2689
|
+
checkHarnessOutcomes(dir),
|
|
2690
|
+
// APRV-151: appended, seventh time, same reason.
|
|
2691
|
+
checkHarnessWiring(dir),
|
|
2692
|
+
// APRV-178: appended, eighth time, same reason. The sharing this reports
|
|
2693
|
+
// is what put a demo gate on the production bot.
|
|
2694
|
+
checkKeychainScope(logPath, policyLoad),
|
|
2695
|
+
// APRV-204: appended, ninth time, same reason. The cadence advance needed
|
|
2696
|
+
// a status surface that outlives the daemon's own event stream.
|
|
2697
|
+
checkAdvanceCadence(logPath, verified.records),
|
|
2698
|
+
// APRV-192: appended, tenth time, same reason. The detective complement
|
|
2699
|
+
// to harness-hook-wiring above — that row asks this checkout's settings
|
|
2700
|
+
// file, this one asks git and the log and never asks a session anything.
|
|
2701
|
+
checkDarkSessions(logPath, dir, policyPath, verified.records, verified.result.status === "clean"),
|
|
2702
|
+
// APRV-188: appended, eleventh time, same reason.
|
|
2703
|
+
checkVerifiedSnapshot(logPath),
|
|
2704
|
+
// APRV-217: appended, twelfth time, same reason. A configuration row: it
|
|
2705
|
+
// reads the policy, never a running daemon's memory.
|
|
2706
|
+
checkReadProof(policyLoad),
|
|
2707
|
+
// APRV-215: appended, thirteenth time, same reason. The report half of
|
|
2708
|
+
// `approval up`'s startup preflight, and the only row that reads the
|
|
2709
|
+
// remote-tracking refs. It fetches NOTHING: a report that reached the
|
|
2710
|
+
// network to be more accurate would be acting on its own account, so the
|
|
2711
|
+
// answer is as fresh as the operator's last fetch and says so.
|
|
2712
|
+
checkMainBehindOrigin(logPath, queuePath, root),
|
|
2713
|
+
// APRV-227: appended, fourteenth time, same reason. The only row that
|
|
2714
|
+
// asks a question about a binary OUTSIDE this repository, and it asks it
|
|
2715
|
+
// the one way a log can: what the last record said the harness was,
|
|
2716
|
+
// against what `<binary> --version` says it is now.
|
|
2717
|
+
checkHarnessVersion(dir, verified.records),
|
|
2718
|
+
// APRV-208: appended, fourteenth time, same reason. The one row that says
|
|
2719
|
+
// whether supervised-live is actually live on this machine.
|
|
2720
|
+
// APRV-282: it connects now, because a socket file outlives the process
|
|
2721
|
+
// that bound it and a `stat` reads the leftovers as a healthy gate.
|
|
2722
|
+
await checkLiveDraw(logPath, policyLoad),
|
|
2723
|
+
// APRV-238: appended, fifteenth time, same reason. The one surface
|
|
2724
|
+
// besides `approval values` that would notice a broken values block:
|
|
2725
|
+
// `policy check` deliberately says nothing about it, because guidance has
|
|
2726
|
+
// no place in an enforcement trace.
|
|
2727
|
+
checkValuesBlock(policyPath, policyFlag !== null, dir),
|
|
2728
|
+
// APRV-257: appended, sixteenth time, same reason. The status surface the
|
|
2729
|
+
// second witness needed. It runs the SAME check `approval log verify
|
|
2730
|
+
// --checkpoints` and the daemon's full re-proof run, over the same single
|
|
2731
|
+
// walk of the log every other row here reads, so three instruments cannot
|
|
2732
|
+
// disagree about one file.
|
|
2733
|
+
checkCheckpoints(verified.records, policyFlag === null ? { dir } : { file: policyPath }),
|
|
2734
|
+
// APRV-272: appended, seventeenth time, same reason. Informational and
|
|
2735
|
+
// never a fail: the enforcement for an unattested organ is the CI-side
|
|
2736
|
+
// protected-path guard, and this row exists so a hand edit is visible at
|
|
2737
|
+
// the terminal before a pull request fails on it.
|
|
2738
|
+
checkGateOrgans(dir, verified.records),
|
|
2739
|
+
// APRV-285: appended, eighteenth time, same reason. The sibling of the
|
|
2740
|
+
// vault and environment rows for the one file under `.approval/` that is
|
|
2741
|
+
// a raw private key: `.approval/payloads/` is tracked on purpose, so
|
|
2742
|
+
// `.approval/` is a directory people `git add` from, and the key store had
|
|
2743
|
+
// nothing telling them it must not come along.
|
|
2744
|
+
checkSealedKeys(logPath, dir),
|
|
2745
|
+
// APRV-313: appended, nineteenth time, same reason. Configuration on
|
|
2746
|
+
// disk is distinct from Codex trust, loading and observed execution.
|
|
2747
|
+
checkCodexHookWiring(dir),
|
|
2748
|
+
];
|
|
2749
|
+
const ok = checks.every((entry) => entry.status !== "fail");
|
|
2750
|
+
if (json)
|
|
2751
|
+
streams.out(`${JSON.stringify({ ok, checks })}\n`);
|
|
2752
|
+
else {
|
|
2753
|
+
streams.out(renderDoctorHuman(checks, style({ json }), {
|
|
2754
|
+
verbose: boolFlag(parsed.flags, "--verbose"),
|
|
2755
|
+
// `undefined` in a pipe, which is exactly when nothing is abbreviated.
|
|
2756
|
+
width: process.stdout.columns ?? null,
|
|
2757
|
+
}));
|
|
2758
|
+
}
|
|
2759
|
+
return ok ? EXIT_OK : EXIT_INTEGRITY;
|
|
2760
|
+
})();
|
|
2761
|
+
}
|
|
2762
|
+
//# sourceMappingURL=doctor.js.map
|