approval-md 0.0.1 → 0.1.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 +909 -4
- package/SPEC.md +445 -32
- package/cli.js +29 -3
- package/dist/src/adapters/agentmail.js +1200 -0
- package/dist/src/adapters/agentmail.js.map +1 -0
- package/dist/src/adapters/conformance.js +461 -0
- package/dist/src/adapters/conformance.js.map +1 -0
- package/dist/src/adapters/contract.js +941 -0
- package/dist/src/adapters/contract.js.map +1 -0
- package/dist/src/adapters/email.js +749 -0
- package/dist/src/adapters/email.js.map +1 -0
- package/dist/src/adapters/env-passphrase.js +132 -0
- package/dist/src/adapters/env-passphrase.js.map +1 -0
- package/dist/src/adapters/registry.js +76 -0
- package/dist/src/adapters/registry.js.map +1 -0
- package/dist/src/adapters/smtp.js +499 -0
- package/dist/src/adapters/smtp.js.map +1 -0
- package/dist/src/adapters/vault-provider.js +161 -0
- package/dist/src/adapters/vault-provider.js.map +1 -0
- package/dist/src/channels/batch.js +121 -0
- package/dist/src/channels/batch.js.map +1 -0
- package/dist/src/channels/cli.js +468 -0
- package/dist/src/channels/cli.js.map +1 -0
- package/dist/src/channels/conformance.js +445 -0
- package/dist/src/channels/conformance.js.map +1 -0
- package/dist/src/channels/contract.js +494 -0
- package/dist/src/channels/contract.js.map +1 -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.js +564 -0
- package/dist/src/channels/render-queue.js.map +1 -0
- package/dist/src/channels/tagging.js +723 -0
- package/dist/src/channels/tagging.js.map +1 -0
- package/dist/src/channels/telegram.js +3190 -0
- package/dist/src/channels/telegram.js.map +1 -0
- package/dist/src/channels/web.js +903 -0
- package/dist/src/channels/web.js.map +1 -0
- package/dist/src/cli/adapter.js +278 -0
- package/dist/src/cli/adapter.js.map +1 -0
- package/dist/src/cli/amend.js +2171 -0
- package/dist/src/cli/amend.js.map +1 -0
- package/dist/src/cli/args.js +86 -0
- package/dist/src/cli/args.js.map +1 -0
- package/dist/src/cli/attest.js +307 -0
- package/dist/src/cli/attest.js.map +1 -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.js +460 -0
- package/dist/src/cli/audit.js.map +1 -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.js +357 -0
- package/dist/src/cli/channel-web.js.map +1 -0
- package/dist/src/cli/channel.js +438 -0
- package/dist/src/cli/channel.js.map +1 -0
- package/dist/src/cli/checkpoint-tap.js +238 -0
- package/dist/src/cli/checkpoint-tap.js.map +1 -0
- package/dist/src/cli/coverage.js +343 -0
- package/dist/src/cli/coverage.js.map +1 -0
- package/dist/src/cli/daemon.js +631 -0
- package/dist/src/cli/daemon.js.map +1 -0
- package/dist/src/cli/doctor.js +2648 -0
- package/dist/src/cli/doctor.js.map +1 -0
- package/dist/src/cli/env.js +302 -0
- package/dist/src/cli/env.js.map +1 -0
- package/dist/src/cli/execute.js +1682 -0
- package/dist/src/cli/execute.js.map +1 -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.js +205 -0
- package/dist/src/cli/feedback.js.map +1 -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.js +557 -0
- package/dist/src/cli/gate.js.map +1 -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.js +107 -0
- package/dist/src/cli/gloss-attach.js.map +1 -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.js +255 -0
- package/dist/src/cli/gloss-codex.js.map +1 -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.js +362 -0
- package/dist/src/cli/gloss.js.map +1 -0
- package/dist/src/cli/help.js +2217 -0
- package/dist/src/cli/help.js.map +1 -0
- package/dist/src/cli/hook.js +2743 -0
- package/dist/src/cli/hook.js.map +1 -0
- package/dist/src/cli/import.js +175 -0
- package/dist/src/cli/import.js.map +1 -0
- package/dist/src/cli/init.js +336 -0
- package/dist/src/cli/init.js.map +1 -0
- package/dist/src/cli/instructions.js +262 -0
- package/dist/src/cli/instructions.js.map +1 -0
- package/dist/src/cli/journal.js +238 -0
- package/dist/src/cli/journal.js.map +1 -0
- package/dist/src/cli/log-advance.js +749 -0
- package/dist/src/cli/log-advance.js.map +1 -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.js +128 -0
- package/dist/src/cli/log-checkpoint.js.map +1 -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.js +354 -0
- package/dist/src/cli/log-verbs.js.map +1 -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.js +1056 -0
- package/dist/src/cli/main.js.map +1 -0
- package/dist/src/cli/mcp.js +306 -0
- package/dist/src/cli/mcp.js.map +1 -0
- package/dist/src/cli/paths.js +79 -0
- package/dist/src/cli/paths.js.map +1 -0
- package/dist/src/cli/payload.js +253 -0
- package/dist/src/cli/payload.js.map +1 -0
- package/dist/src/cli/policy.js +229 -0
- package/dist/src/cli/policy.js.map +1 -0
- package/dist/src/cli/preflight.js +888 -0
- package/dist/src/cli/preflight.js.map +1 -0
- package/dist/src/cli/progress.js +112 -0
- package/dist/src/cli/progress.js.map +1 -0
- package/dist/src/cli/prompt.js +312 -0
- package/dist/src/cli/prompt.js.map +1 -0
- package/dist/src/cli/records.js +66 -0
- package/dist/src/cli/records.js.map +1 -0
- package/dist/src/cli/render.js +132 -0
- package/dist/src/cli/render.js.map +1 -0
- package/dist/src/cli/sandbox.js +150 -0
- package/dist/src/cli/sandbox.js.map +1 -0
- package/dist/src/cli/scaffold.js +137 -0
- package/dist/src/cli/scaffold.js.map +1 -0
- package/dist/src/cli/setup-adapter.js +475 -0
- package/dist/src/cli/setup-adapter.js.map +1 -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.js +196 -0
- package/dist/src/cli/setup-checkpoint.js.map +1 -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.js +476 -0
- package/dist/src/cli/setup-flow.js.map +1 -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.js +473 -0
- package/dist/src/cli/setup.js.map +1 -0
- package/dist/src/cli/style.js +469 -0
- package/dist/src/cli/style.js.map +1 -0
- package/dist/src/cli/token.js +274 -0
- package/dist/src/cli/token.js.map +1 -0
- package/dist/src/cli/up.js +847 -0
- package/dist/src/cli/up.js.map +1 -0
- package/dist/src/cli/usage.js +91 -0
- package/dist/src/cli/usage.js.map +1 -0
- package/dist/src/cli/values.js +189 -0
- package/dist/src/cli/values.js.map +1 -0
- package/dist/src/cli/vault.js +362 -0
- package/dist/src/cli/vault.js.map +1 -0
- package/dist/src/cli/verb-registry.js +2173 -0
- package/dist/src/cli/verb-registry.js.map +1 -0
- package/dist/src/cli/wordmark.js +52 -0
- package/dist/src/cli/wordmark.js.map +1 -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.js +747 -0
- package/dist/src/core/agents-md.js.map +1 -0
- package/dist/src/core/attest.js +577 -0
- package/dist/src/core/attest.js.map +1 -0
- package/dist/src/core/audit.js +882 -0
- package/dist/src/core/audit.js.map +1 -0
- package/dist/src/core/budgets.js +449 -0
- package/dist/src/core/budgets.js.map +1 -0
- package/dist/src/core/checkpoint.js +738 -0
- package/dist/src/core/checkpoint.js.map +1 -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.js +43 -0
- package/dist/src/core/clock.js.map +1 -0
- package/dist/src/core/command-class.js +2321 -0
- package/dist/src/core/command-class.js.map +1 -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.js +136 -0
- package/dist/src/core/coverage-sources/gh.js.map +1 -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.js +337 -0
- package/dist/src/core/coverage.js.map +1 -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.js +714 -0
- package/dist/src/core/dark-session.js.map +1 -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.js +837 -0
- package/dist/src/core/env-file.js.map +1 -0
- package/dist/src/core/execute.js +1233 -0
- package/dist/src/core/execute.js.map +1 -0
- package/dist/src/core/frontmatter.js +100 -0
- package/dist/src/core/frontmatter.js.map +1 -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.js +2947 -0
- package/dist/src/core/gate.js.map +1 -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.js +210 -0
- package/dist/src/core/harness-version.js.map +1 -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.js +121 -0
- package/dist/src/core/head-retry.js.map +1 -0
- package/dist/src/core/instance.js +319 -0
- package/dist/src/core/instance.js.map +1 -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.js +132 -0
- package/dist/src/core/jcs.js.map +1 -0
- package/dist/src/core/journal.js +200 -0
- package/dist/src/core/journal.js.map +1 -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.js +136 -0
- package/dist/src/core/log-reconcile.js.map +1 -0
- package/dist/src/core/log.js +546 -0
- package/dist/src/core/log.js.map +1 -0
- package/dist/src/core/loop.js +476 -0
- package/dist/src/core/loop.js.map +1 -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.js +195 -0
- package/dist/src/core/money.js.map +1 -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.js +340 -0
- package/dist/src/core/payload-store.js.map +1 -0
- package/dist/src/core/payload.js +80 -0
- package/dist/src/core/payload.js.map +1 -0
- package/dist/src/core/policy-diff.js +565 -0
- package/dist/src/core/policy-diff.js.map +1 -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.js +230 -0
- package/dist/src/core/policy-explain.js.map +1 -0
- package/dist/src/core/policy-load.js +524 -0
- package/dist/src/core/policy-load.js.map +1 -0
- package/dist/src/core/policy-match.js +467 -0
- package/dist/src/core/policy-match.js.map +1 -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.js +422 -0
- package/dist/src/core/prompt-layout.js.map +1 -0
- package/dist/src/core/protected-path-guard.js +1087 -0
- package/dist/src/core/protected-path-guard.js.map +1 -0
- package/dist/src/core/registration.js +39 -0
- package/dist/src/core/registration.js.map +1 -0
- package/dist/src/core/reindex.js +336 -0
- package/dist/src/core/reindex.js.map +1 -0
- package/dist/src/core/sampler.js +388 -0
- package/dist/src/core/sampler.js.map +1 -0
- package/dist/src/core/sandbox.js +424 -0
- package/dist/src/core/sandbox.js.map +1 -0
- package/dist/src/core/seal.js +290 -0
- package/dist/src/core/seal.js.map +1 -0
- package/dist/src/core/state.js +1009 -0
- package/dist/src/core/state.js.map +1 -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.js +114 -0
- package/dist/src/core/telegram-config.js.map +1 -0
- package/dist/src/core/token.js +578 -0
- package/dist/src/core/token.js.map +1 -0
- package/dist/src/core/validate.js +0 -0
- package/dist/src/core/validate.js.map +1 -0
- package/dist/src/core/values.js +153 -0
- package/dist/src/core/values.js.map +1 -0
- package/dist/src/core/vault.js +612 -0
- package/dist/src/core/vault.js.map +1 -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.js +549 -0
- package/dist/src/core/verify.js.map +1 -0
- package/dist/src/core/version.js +9 -0
- package/dist/src/core/version.js.map +1 -0
- package/dist/src/core/wysiwys.js +728 -0
- package/dist/src/core/wysiwys.js.map +1 -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.js +849 -0
- package/dist/src/daemon/advance.js.map +1 -0
- package/dist/src/daemon/audit.js +90 -0
- package/dist/src/daemon/audit.js.map +1 -0
- package/dist/src/daemon/daemon.js +1988 -0
- package/dist/src/daemon/daemon.js.map +1 -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.js +132 -0
- package/dist/src/daemon/draw-child.js.map +1 -0
- package/dist/src/daemon/draw.js +458 -0
- package/dist/src/daemon/draw.js.map +1 -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.js +233 -0
- package/dist/src/daemon/projection.js.map +1 -0
- package/dist/src/daemon/prune.js +376 -0
- package/dist/src/daemon/prune.js.map +1 -0
- package/dist/src/mcp/http.js +343 -0
- package/dist/src/mcp/http.js.map +1 -0
- package/dist/src/mcp/server.js +594 -0
- package/dist/src/mcp/server.js.map +1 -0
- package/docs/cli-reference.md +5363 -0
- package/package.json +43 -4
- package/schema/.gitkeep +0 -0
- package/schema/LICENSE +117 -0
- package/schema/envelope.schema.json +137 -0
- package/schema/event.schema.json +1810 -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 +481 -0
- package/schema/sample-record.schema.json +26 -0
- package/schema/values.schema.json +55 -0
|
@@ -0,0 +1,728 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* WYSIWYS: the canonical rendering of a payload (APRV-119), and the structural
|
|
3
|
+
* views it is built from (APRV-100, APRV-124, APRV-126, APRV-144).
|
|
4
|
+
*
|
|
5
|
+
* ## What you see is what you sign
|
|
6
|
+
*
|
|
7
|
+
* The prompt a human approves is a deterministic function of the payload bytes
|
|
8
|
+
* and the action class, and of nothing else. {@link canonicalRender} is that
|
|
9
|
+
* function. Two channels, two runtimes, or two versions of this one cannot show
|
|
10
|
+
* two humans two different readings of the same payload without the difference
|
|
11
|
+
* being detectable: the rendering carries its own `display_hash`, the gate
|
|
12
|
+
* records that hash on `approval.requested`, and a rendering that disagrees is a
|
|
13
|
+
* rendering whose hash disagrees.
|
|
14
|
+
*
|
|
15
|
+
* The threat this closes is signoff social engineering. An approval surface that
|
|
16
|
+
* renders benign text while the hashed payload is malicious leaves the human
|
|
17
|
+
* signing blind. `payload_hash` binds the bytes; `display_hash` binds the
|
|
18
|
+
* reading of them.
|
|
19
|
+
*
|
|
20
|
+
* Four properties, and they are the whole module:
|
|
21
|
+
*
|
|
22
|
+
* 1. **Pure.** No clock, no locale, no environment, no randomness, no IO. The
|
|
23
|
+
* only inputs are the payload value and the class string, and
|
|
24
|
+
* `tests/wysiwys.test.ts` reads this file's own source and fails on a
|
|
25
|
+
* reference to any of them.
|
|
26
|
+
* 2. **Closed field set per kind.** A payload is recognised as one of four kinds
|
|
27
|
+
* (`command`, `file-change`, `email`, `opaque`) by its STRUCTURE, and each
|
|
28
|
+
* kind renders a fixed list of fields. A shape carrying one key its kind does
|
|
29
|
+
* not render is not that kind: it falls through to `opaque`, where the
|
|
30
|
+
* canonical JSON is shown whole. Nothing is hidden by being unrecognised.
|
|
31
|
+
* 3. **Absent renders explicitly.** A field the payload does not carry is
|
|
32
|
+
* printed as {@link ABSENT}, never omitted. An omitted line and a line whose
|
|
33
|
+
* value happens to be empty are different facts, and a reader who cannot tell
|
|
34
|
+
* them apart is reading a rendering that lost information.
|
|
35
|
+
* 4. **Claimed material stays outside.** Everything inside the canonical block
|
|
36
|
+
* is derived from the bound bytes. Summaries, cost estimates, rationale,
|
|
37
|
+
* confidence, and model-written glosses are rendered OUTSIDE it, under the
|
|
38
|
+
* channel's own claimed heading (SPEC.md §9).
|
|
39
|
+
*
|
|
40
|
+
* ## Why this lives in `src/core/`, not `src/channels/`
|
|
41
|
+
*
|
|
42
|
+
* It is deterministic core in the sense CLAUDE.md means: pure, exhaustively
|
|
43
|
+
* tested, and consulted by the gate. `core/gate.ts` computes `display_hash` at
|
|
44
|
+
* the write boundary from the same function every channel renders with, so the
|
|
45
|
+
* log states what rendering the approver was shown. A renderer under
|
|
46
|
+
* `src/channels/` would have to be imported BY core to do that, inverting the
|
|
47
|
+
* direction the codebase is built on. `channels/payload-view.ts` is the
|
|
48
|
+
* channel-side facade over this module and holds the one function that needs a
|
|
49
|
+
* channel type.
|
|
50
|
+
*
|
|
51
|
+
* ## The reading aids this absorbed (APRV-100, APRV-124, APRV-126)
|
|
52
|
+
*
|
|
53
|
+
* SPEC.md §10.4 requires a channel to present, for a `manual` action, "the full
|
|
54
|
+
* payload or a faithful rendering of it". Until now every channel used the one
|
|
55
|
+
* rendering `channels/tagging.ts` builds: pretty-printed JSON. That is faithful
|
|
56
|
+
* and it is exact, and for an email it is close to unreadable — the observed
|
|
57
|
+
* failure (2026-08-18, examples/email-demo.md) is a body arriving on a phone as
|
|
58
|
+
* a single line carrying literal `\n` sequences, which is precisely the text a
|
|
59
|
+
* human is being asked to take responsibility for.
|
|
60
|
+
*
|
|
61
|
+
* So this module adds a second rendering *on top of* the first, never instead
|
|
62
|
+
* of it:
|
|
63
|
+
*
|
|
64
|
+
* - A payload whose **structure** matches the email adapter's payload shape
|
|
65
|
+
* ({@link ../adapters/email.js}) is rendered field by field, with the body as
|
|
66
|
+
* the human will read it: real line breaks, no JSON escapes.
|
|
67
|
+
* - A payload whose structure is a **shell command** (APRV-126: the shape
|
|
68
|
+
* `cli/hook.ts` builds for a `Bash` tool call) is rendered as the command a
|
|
69
|
+
* shell would receive, over as many lines as it really has, with `cwd` on its
|
|
70
|
+
* own line beneath it. Same reason again: `"command": "gh pr create --body
|
|
71
|
+
* 'a\nb'"` on a phone is one paragraph of escaped JSON, and the observed
|
|
72
|
+
* failure (2026-08-20) is a human being asked to take responsibility for it.
|
|
73
|
+
* - A payload whose structure is a **file change** (APRV-124: the shape
|
|
74
|
+
* `cli/hook.ts` builds for an `Edit` or a `Write` tool call) is rendered as a
|
|
75
|
+
* diff, so the approver reads the change rather than the fact that a file was
|
|
76
|
+
* touched. Same reason as the email case: `"before": "a\nb"` on a phone is
|
|
77
|
+
* bytes nobody can check.
|
|
78
|
+
* - Every other shape falls to `opaque`, whose view IS the canonical JSON: the
|
|
79
|
+
* bytes whole, pretty-printed, exactly the rendering every payload had before
|
|
80
|
+
* the structural views existed.
|
|
81
|
+
*
|
|
82
|
+
* Three properties this file is careful about:
|
|
83
|
+
*
|
|
84
|
+
* 1. **Detection is structural.** Nothing here reads a self-declared "kind" or
|
|
85
|
+
* "type" field, because that field would be authored by the party under
|
|
86
|
+
* oversight, and a channel that lets an agent choose its own presentation
|
|
87
|
+
* has handed the agent the display. A payload is email-shaped when its keys
|
|
88
|
+
* and its value types say so, and for no other reason.
|
|
89
|
+
* 2. **Nothing is hidden.** The shape is accepted only when *every* key is one
|
|
90
|
+
* this module renders, so the field-by-field view never omits a byte of the
|
|
91
|
+
* payload — a `bcc` or a `content_type` that the reader could not see would
|
|
92
|
+
* be the same failure as a truncated payload, wearing a friendlier face.
|
|
93
|
+
* 3. **This is claimed content.** The payload is authored by the requesting
|
|
94
|
+
* agent. The block says so in its first line, and the computed binding (the
|
|
95
|
+
* `sha256` label each channel already prints around this region) stays where
|
|
96
|
+
* it is. Making the payload *legible* must not make it look *verified*.
|
|
97
|
+
* A `tool` or a `rule` value inside a file-change payload is rendered for
|
|
98
|
+
* the reader and is never what selects the rendering: the shape is, exactly
|
|
99
|
+
* as for an email.
|
|
100
|
+
* 4. **Two different byte strings never look the same.** A reading aid that
|
|
101
|
+
* interprets escape sequences has to answer the question it creates: if a
|
|
102
|
+
* real line break becomes a line break, what does the two-byte sequence
|
|
103
|
+
* backslash-`n` become? Rendering both as a line break would let an agent
|
|
104
|
+
* write one payload and have the approver read another. So the rendering is
|
|
105
|
+
* INJECTIVE by construction ({@link markEscapes}), and the property is
|
|
106
|
+
* tested by generating pairs of distinct byte strings.
|
|
107
|
+
* 5. **The view is the whole reading (APRV-162, `approval.md/wysiwys/2`).** A
|
|
108
|
+
* structured kind's view is the canonical rendering entire; no canonical-JSON
|
|
109
|
+
* appendix follows it. The completeness argument is property 2 above: kind
|
|
110
|
+
* detection is a closed field set, one unrecognised key sends the payload to
|
|
111
|
+
* `opaque` whose view is the whole JSON, so a structural view that renders at
|
|
112
|
+
* all renders every byte. The views therefore do not fold. A fold was
|
|
113
|
+
* survivable only while the appendix restated the hidden lines underneath it;
|
|
114
|
+
* with the appendix gone it would hide bytes from the only reading a human
|
|
115
|
+
* gets, which is the failure this module exists to remove.
|
|
116
|
+
*
|
|
117
|
+
* The output is plain text with real newlines. Escaping belongs to the channel:
|
|
118
|
+
* `telegram.ts` and `web.ts` each pass this through their own `escapeHtml` and
|
|
119
|
+
* their own `<pre>`, so the injection surface is exactly what it was before.
|
|
120
|
+
*/
|
|
121
|
+
import { createHash } from "node:crypto";
|
|
122
|
+
import { classifyCommand, commandSegmentWords, isProtectedPath, } from "./command-class.js";
|
|
123
|
+
import { payloadHash } from "./payload.js";
|
|
124
|
+
/**
|
|
125
|
+
* How a field the payload does not carry is rendered.
|
|
126
|
+
*
|
|
127
|
+
* Never an omission. A closed field set that silently drops its absent members
|
|
128
|
+
* is not a closed field set: the reader cannot tell "no `cc`" from "a `cc` this
|
|
129
|
+
* renderer does not know how to show", and those are the two cases the whole
|
|
130
|
+
* design exists to keep apart.
|
|
131
|
+
*/
|
|
132
|
+
export const ABSENT = "(absent)";
|
|
133
|
+
/** Keys the email adapter's payload may carry (`adapters/email.ts`). */
|
|
134
|
+
const STRING_KEYS = ["from", "subject", "body", "content_type"];
|
|
135
|
+
const ADDRESS_LIST_KEYS = ["to", "cc", "bcc"];
|
|
136
|
+
/**
|
|
137
|
+
* The keys that must be present for a value to read as an email at all.
|
|
138
|
+
*
|
|
139
|
+
* A recipient, a subject and a body: the triple that makes the JSON rendering
|
|
140
|
+
* unreadable in the first place. `from` is optional here even though the
|
|
141
|
+
* adapter requires it, because this module answers "will a human read this
|
|
142
|
+
* better field by field?", not "will the adapter accept it?" — the adapter
|
|
143
|
+
* enforces its own shape at execution, and duplicating that judgement here
|
|
144
|
+
* would only mean a payload the adapter rejects gets rendered badly first.
|
|
145
|
+
*/
|
|
146
|
+
const REQUIRED_KEYS = ["to", "subject", "body"];
|
|
147
|
+
/** Display order. `body` last: it is the only multi-line field. */
|
|
148
|
+
const FIELD_ORDER = ["from", "to", "cc", "bcc", "subject", "content_type", "body"];
|
|
149
|
+
/** The heading and delimiters. Exported because the tests pin them. */
|
|
150
|
+
export const EMAIL_VIEW_HEADING = "email — rendered field by field; every value below is CLAIMED, authored by the requesting party";
|
|
151
|
+
export const BODY_BEGIN = "--- body begins ---";
|
|
152
|
+
export const BODY_END = "--- body ends ---";
|
|
153
|
+
export const CANONICAL_JSON_HEADING = "--- the same bytes, canonical JSON ---";
|
|
154
|
+
function isStringArray(value) {
|
|
155
|
+
return Array.isArray(value) && value.every((entry) => typeof entry === "string");
|
|
156
|
+
}
|
|
157
|
+
/**
|
|
158
|
+
* Recognise an email-shaped payload, structurally.
|
|
159
|
+
*
|
|
160
|
+
* Returns the fields in display order, or `null` when the value is any other
|
|
161
|
+
* shape — including an email-ish object carrying one key this module does not
|
|
162
|
+
* know how to show, which falls back to JSON rather than hiding it.
|
|
163
|
+
*/
|
|
164
|
+
export function emailPayloadFields(value) {
|
|
165
|
+
if (typeof value !== "object" || value === null || Array.isArray(value))
|
|
166
|
+
return null;
|
|
167
|
+
const record = value;
|
|
168
|
+
const known = new Set([...STRING_KEYS, ...ADDRESS_LIST_KEYS]);
|
|
169
|
+
for (const key of Object.keys(record)) {
|
|
170
|
+
if (!known.has(key))
|
|
171
|
+
return null;
|
|
172
|
+
}
|
|
173
|
+
for (const key of REQUIRED_KEYS) {
|
|
174
|
+
if (!(key in record))
|
|
175
|
+
return null;
|
|
176
|
+
}
|
|
177
|
+
for (const key of STRING_KEYS) {
|
|
178
|
+
if (key in record && typeof record[key] !== "string")
|
|
179
|
+
return null;
|
|
180
|
+
}
|
|
181
|
+
for (const key of ADDRESS_LIST_KEYS) {
|
|
182
|
+
if (!(key in record))
|
|
183
|
+
continue;
|
|
184
|
+
const entry = record[key];
|
|
185
|
+
if (typeof entry !== "string" && !isStringArray(entry))
|
|
186
|
+
return null;
|
|
187
|
+
}
|
|
188
|
+
const fields = [];
|
|
189
|
+
for (const key of FIELD_ORDER) {
|
|
190
|
+
if (!(key in record))
|
|
191
|
+
continue;
|
|
192
|
+
const entry = record[key];
|
|
193
|
+
fields.push({
|
|
194
|
+
label: key,
|
|
195
|
+
text: typeof entry === "string" ? entry : entry.join(", "),
|
|
196
|
+
});
|
|
197
|
+
}
|
|
198
|
+
return fields;
|
|
199
|
+
}
|
|
200
|
+
// ---------------------------------------------------------------------------
|
|
201
|
+
// File changes (APRV-124)
|
|
202
|
+
// ---------------------------------------------------------------------------
|
|
203
|
+
/**
|
|
204
|
+
* Keys a file-change payload may carry (`cli/hook.ts`).
|
|
205
|
+
*
|
|
206
|
+
* `tool` and `rule` are rendered, never consulted: `tool` is the harness's name
|
|
207
|
+
* for the call, and `rule` is the hook's own qualifier (`protected-path` or
|
|
208
|
+
* `protected-path-proposal`, APRV-124). Which rendering this module uses is
|
|
209
|
+
* decided by the presence of `file` and of the change itself, below.
|
|
210
|
+
*/
|
|
211
|
+
const CHANGE_LABEL_KEYS = ["tool", "rule", "file"];
|
|
212
|
+
const CHANGE_TEXT_KEYS = ["before", "after", "content"];
|
|
213
|
+
const CHANGE_BOOL_KEYS = ["replace_all"];
|
|
214
|
+
/** The heading and delimiters of the diff view. Exported because tests pin them. */
|
|
215
|
+
export const EDIT_VIEW_HEADING = "file change — the change itself, not the touch; every value below is CLAIMED, authored by the requesting party";
|
|
216
|
+
export const DIFF_BEGIN = "--- change begins ---";
|
|
217
|
+
export const DIFF_END = "--- change ends ---";
|
|
218
|
+
/** The qualifier a proposal-tier touch renders (APRV-124). */
|
|
219
|
+
export const PROPOSAL_QUALIFIER = "this edit targets a file inside an AGENT WORKTREE: it is a branch PROPOSAL, not the live file. Merging it to the live checkout is a separate gated action.";
|
|
220
|
+
export const LIVE_QUALIFIER = "this edit targets the LIVE checkout, not a branch proposal.";
|
|
221
|
+
/** The qualifier a protected-name touch outside the gated checkout renders (APRV-161). */
|
|
222
|
+
export const ELSEWHERE_QUALIFIER = "this edit targets a file NAMED like a policy file, OUTSIDE the gated checkout: it is not the live policy. It gates because the name is protected wherever it sits.";
|
|
223
|
+
/**
|
|
224
|
+
* Recognise a file-change payload, structurally.
|
|
225
|
+
*
|
|
226
|
+
* Accepted when the payload names a `file` and carries either both sides of an
|
|
227
|
+
* edit (`before` and `after`) or a whole-file `content`, and every other key is
|
|
228
|
+
* one this module renders. Anything else — including a payload that carries
|
|
229
|
+
* only `before`, where the reader would be shown half a change — is `null` and
|
|
230
|
+
* falls back to JSON.
|
|
231
|
+
*/
|
|
232
|
+
export function changePayloadView(value) {
|
|
233
|
+
if (typeof value !== "object" || value === null || Array.isArray(value))
|
|
234
|
+
return null;
|
|
235
|
+
const record = value;
|
|
236
|
+
const known = new Set([...CHANGE_LABEL_KEYS, ...CHANGE_TEXT_KEYS, ...CHANGE_BOOL_KEYS]);
|
|
237
|
+
for (const key of Object.keys(record)) {
|
|
238
|
+
if (!known.has(key))
|
|
239
|
+
return null;
|
|
240
|
+
}
|
|
241
|
+
if (typeof record["file"] !== "string")
|
|
242
|
+
return null;
|
|
243
|
+
for (const key of [...CHANGE_LABEL_KEYS, ...CHANGE_TEXT_KEYS]) {
|
|
244
|
+
if (key in record && typeof record[key] !== "string")
|
|
245
|
+
return null;
|
|
246
|
+
}
|
|
247
|
+
for (const key of CHANGE_BOOL_KEYS) {
|
|
248
|
+
if (key in record && typeof record[key] !== "boolean")
|
|
249
|
+
return null;
|
|
250
|
+
}
|
|
251
|
+
const before = record["before"];
|
|
252
|
+
const after = record["after"];
|
|
253
|
+
const content = record["content"];
|
|
254
|
+
const isEdit = typeof before === "string" && typeof after === "string";
|
|
255
|
+
const isWrite = typeof content === "string" && before === undefined && after === undefined;
|
|
256
|
+
if (!isEdit && !isWrite)
|
|
257
|
+
return null;
|
|
258
|
+
if (isEdit && content !== undefined)
|
|
259
|
+
return null;
|
|
260
|
+
const labels = [];
|
|
261
|
+
for (const key of CHANGE_LABEL_KEYS) {
|
|
262
|
+
const entry = record[key];
|
|
263
|
+
if (typeof entry === "string")
|
|
264
|
+
labels.push({ label: key, text: entry });
|
|
265
|
+
}
|
|
266
|
+
for (const key of CHANGE_BOOL_KEYS) {
|
|
267
|
+
const entry = record[key];
|
|
268
|
+
if (typeof entry === "boolean")
|
|
269
|
+
labels.push({ label: key, text: entry ? "true" : "false" });
|
|
270
|
+
}
|
|
271
|
+
return {
|
|
272
|
+
labels,
|
|
273
|
+
before: isEdit ? before : null,
|
|
274
|
+
after: isEdit ? after : content,
|
|
275
|
+
};
|
|
276
|
+
}
|
|
277
|
+
/**
|
|
278
|
+
* `text` as prefixed diff lines, whole (APRV-162).
|
|
279
|
+
*
|
|
280
|
+
* No budget and no fold: this view is the entire canonical rendering of the
|
|
281
|
+
* payload, so a line it stops showing is a line nobody sees. Length is the
|
|
282
|
+
* channel's problem (`telegram.ts` chunks, never truncates).
|
|
283
|
+
*/
|
|
284
|
+
function diffLines(text, marker) {
|
|
285
|
+
return text.split("\n").map((line) => `${marker}${line}`);
|
|
286
|
+
}
|
|
287
|
+
function lineCount(text) {
|
|
288
|
+
return text === "" ? 0 : text.split("\n").length;
|
|
289
|
+
}
|
|
290
|
+
/**
|
|
291
|
+
* The closed field set of a file change, in display order (APRV-119).
|
|
292
|
+
*
|
|
293
|
+
* Every one of these is printed for every file change, {@link ABSENT} where the
|
|
294
|
+
* payload carries none. `replace_all` is the field this matters most for: an
|
|
295
|
+
* edit that replaces one occurrence and an edit that replaces all of them are
|
|
296
|
+
* different actions, and a rendering that showed the flag only when it was set
|
|
297
|
+
* would leave the reader unable to tell "replace_all: false" from "a renderer
|
|
298
|
+
* that does not show this".
|
|
299
|
+
*/
|
|
300
|
+
const CHANGE_FIELD_ORDER = [...CHANGE_LABEL_KEYS, ...CHANGE_BOOL_KEYS];
|
|
301
|
+
/** The diff view: labels, then one block per side, `-` removed, `+` added. */
|
|
302
|
+
function changeRegionText(view, hash) {
|
|
303
|
+
const lines = [EDIT_VIEW_HEADING];
|
|
304
|
+
for (const key of CHANGE_FIELD_ORDER) {
|
|
305
|
+
const field = view.labels.find((entry) => entry.label === key);
|
|
306
|
+
lines.push(`${key}: ${field === undefined ? ABSENT : field.text}`);
|
|
307
|
+
}
|
|
308
|
+
// The tier qualifier, rendered for EVERY tier. Printing it only for the
|
|
309
|
+
// qualified tiers would make "live" the silent default, and the silent
|
|
310
|
+
// default is the one that reaches the real file. Each tier has its own
|
|
311
|
+
// wording: LIVE_QUALIFIER is a claim about the file the edit lands in, so a
|
|
312
|
+
// protected-name touch elsewhere (APRV-161) must not borrow it.
|
|
313
|
+
const rule = view.labels.find((field) => field.label === "rule");
|
|
314
|
+
if (rule !== undefined && rule.text === "protected-name-elsewhere") {
|
|
315
|
+
lines.push(`note: ${ELSEWHERE_QUALIFIER}`);
|
|
316
|
+
}
|
|
317
|
+
else if (rule !== undefined && rule.text.startsWith("protected-path")) {
|
|
318
|
+
lines.push(`note: ${rule.text.endsWith("-proposal") ? PROPOSAL_QUALIFIER : LIVE_QUALIFIER}`);
|
|
319
|
+
}
|
|
320
|
+
lines.push(DIFF_BEGIN);
|
|
321
|
+
if (view.before === null) {
|
|
322
|
+
lines.push(`the whole file as it will be written (${String(lineCount(view.after))} lines); the bytes it replaces are not part of what is being approved`);
|
|
323
|
+
lines.push(...diffLines(view.after, "+"));
|
|
324
|
+
}
|
|
325
|
+
else {
|
|
326
|
+
lines.push(`replacing ${String(lineCount(view.before))} line(s) with ${String(lineCount(view.after))}`);
|
|
327
|
+
lines.push(...diffLines(view.before, "-"));
|
|
328
|
+
lines.push(...diffLines(view.after, "+"));
|
|
329
|
+
}
|
|
330
|
+
lines.push(DIFF_END);
|
|
331
|
+
lines.push(rawBytesLine(hash));
|
|
332
|
+
return lines;
|
|
333
|
+
}
|
|
334
|
+
// ---------------------------------------------------------------------------
|
|
335
|
+
// Shell commands (APRV-126)
|
|
336
|
+
// ---------------------------------------------------------------------------
|
|
337
|
+
/** Keys a command payload may carry (`cli/hook.ts`: `{command, cwd}`). */
|
|
338
|
+
const COMMAND_KEYS = ["command", "cwd"];
|
|
339
|
+
/** The heading and delimiters of the command view. Exported because tests pin them. */
|
|
340
|
+
export const COMMAND_VIEW_HEADING = "command — rendered; the hash binds the RAW BYTES, not this view. Every value below is CLAIMED, authored by the requesting party";
|
|
341
|
+
export const COMMAND_BEGIN = "--- command begins ---";
|
|
342
|
+
export const COMMAND_END = "--- command ends ---";
|
|
343
|
+
/**
|
|
344
|
+
* The delimiters around a marked escape sequence.
|
|
345
|
+
*
|
|
346
|
+
* Guillemets rather than brackets: `[` and `]` are ordinary shell and regex
|
|
347
|
+
* characters, so a marker built from them would be indistinguishable from the
|
|
348
|
+
* command's own text at a glance, which is the failure this marker exists to
|
|
349
|
+
* prevent.
|
|
350
|
+
*/
|
|
351
|
+
export const ESCAPE_OPEN = "«";
|
|
352
|
+
export const ESCAPE_CLOSE = "»";
|
|
353
|
+
/** The legend printed above every command block, so the marker needs no lore. */
|
|
354
|
+
export const ESCAPE_LEGEND = `escapes: ${ESCAPE_OPEN}\\n${ESCAPE_CLOSE} is the two LITERAL bytes backslash-n; a real line break is a line break`;
|
|
355
|
+
/**
|
|
356
|
+
* The escape letters that name a character the reader could otherwise mistake
|
|
357
|
+
* for the real thing.
|
|
358
|
+
*
|
|
359
|
+
* `\` is in the set for the same reason: `\\n` and `\n` are different bytes and
|
|
360
|
+
* a reader must not have to count backslashes to tell them apart.
|
|
361
|
+
*/
|
|
362
|
+
const MARKED_ESCAPES = new Set(["n", "r", "t", "\\"]);
|
|
363
|
+
/**
|
|
364
|
+
* One line of a command, with literal escape sequences marked.
|
|
365
|
+
*
|
|
366
|
+
* INJECTIVE, and the proof is short enough to keep here. The rendering is a
|
|
367
|
+
* left-to-right tokenizer over two tokens: a backslash followed by a letter in
|
|
368
|
+
* {@link MARKED_ESCAPES} becomes `«\c»`, and every other character is itself.
|
|
369
|
+
* A `«\c»` in the OUTPUT can therefore only have come from that first token,
|
|
370
|
+
* because a backslash followed by such a letter in the input is never emitted
|
|
371
|
+
* bare — so reading the output back left to right recovers the input exactly,
|
|
372
|
+
* and a left inverse is all injectivity needs.
|
|
373
|
+
*
|
|
374
|
+
* Real newlines are handled by the caller, which splits on them before calling
|
|
375
|
+
* this: a line break in the output comes from a line break in the input, and
|
|
376
|
+
* from nothing else.
|
|
377
|
+
*/
|
|
378
|
+
export function markEscapes(line) {
|
|
379
|
+
let out = "";
|
|
380
|
+
for (let index = 0; index < line.length; index += 1) {
|
|
381
|
+
const character = line[index];
|
|
382
|
+
const next = index + 1 < line.length ? line[index + 1] : "";
|
|
383
|
+
if (character === "\\" && MARKED_ESCAPES.has(next)) {
|
|
384
|
+
out += `${ESCAPE_OPEN}\\${next}${ESCAPE_CLOSE}`;
|
|
385
|
+
index += 1;
|
|
386
|
+
continue;
|
|
387
|
+
}
|
|
388
|
+
out += character;
|
|
389
|
+
}
|
|
390
|
+
return out;
|
|
391
|
+
}
|
|
392
|
+
/**
|
|
393
|
+
* Recognise a command payload, structurally.
|
|
394
|
+
*
|
|
395
|
+
* Accepted when the payload carries a string `command` and nothing this module
|
|
396
|
+
* cannot show. `cwd` is optional here even though `cli/hook.ts` always sets it:
|
|
397
|
+
* the question this answers is "will a human read this better as a command?",
|
|
398
|
+
* and a payload missing its directory reads better either way.
|
|
399
|
+
*/
|
|
400
|
+
export function commandPayloadView(value) {
|
|
401
|
+
if (typeof value !== "object" || value === null || Array.isArray(value))
|
|
402
|
+
return null;
|
|
403
|
+
const record = value;
|
|
404
|
+
const known = new Set(COMMAND_KEYS);
|
|
405
|
+
for (const key of Object.keys(record)) {
|
|
406
|
+
if (!known.has(key))
|
|
407
|
+
return null;
|
|
408
|
+
}
|
|
409
|
+
if (typeof record["command"] !== "string")
|
|
410
|
+
return null;
|
|
411
|
+
const cwd = record["cwd"];
|
|
412
|
+
if (cwd !== undefined && typeof cwd !== "string")
|
|
413
|
+
return null;
|
|
414
|
+
return { command: record["command"], cwd: typeof cwd === "string" ? cwd : null };
|
|
415
|
+
}
|
|
416
|
+
/**
|
|
417
|
+
* Where the exact bytes live, and how to get them back (APRV-126, APRV-162).
|
|
418
|
+
*
|
|
419
|
+
* Carried by every structural view, not the command view alone: with no
|
|
420
|
+
* canonical-JSON appendix underneath, this line is the reader's only route from
|
|
421
|
+
* a rendering back to the bytes it was derived from.
|
|
422
|
+
*
|
|
423
|
+
* The store is content-addressed by this very hash and re-verified on every
|
|
424
|
+
* read (`core/payload-store.ts`), so the line is an instruction, never a claim:
|
|
425
|
+
* following it produces the bytes or produces a refusal, and never something
|
|
426
|
+
* else wearing the same name.
|
|
427
|
+
*/
|
|
428
|
+
export function rawBytesLine(hash) {
|
|
429
|
+
return `raw bytes: .approval/payloads/${hash}.json — re-verified against this sha256 on every read`;
|
|
430
|
+
}
|
|
431
|
+
/** The command view: the command over its real lines, then `cwd`. */
|
|
432
|
+
function commandRegionText(view, hash) {
|
|
433
|
+
const lines = [COMMAND_VIEW_HEADING, ESCAPE_LEGEND];
|
|
434
|
+
const commandLines = view.command.split("\n");
|
|
435
|
+
const count = commandLines.length;
|
|
436
|
+
lines.push(`command (${String(count)} line${count === 1 ? "" : "s"}):`);
|
|
437
|
+
lines.push(COMMAND_BEGIN);
|
|
438
|
+
lines.push(...commandLines.map(markEscapes));
|
|
439
|
+
lines.push(COMMAND_END);
|
|
440
|
+
// On its own line, beneath the command, because it is the other half of what
|
|
441
|
+
// the command does and a `cd` the reader never saw is the whole difference
|
|
442
|
+
// between a repository and someone else's.
|
|
443
|
+
lines.push(`cwd: ${view.cwd === null ? "(none declared)" : markEscapes(view.cwd)}`);
|
|
444
|
+
lines.push(rawBytesLine(hash));
|
|
445
|
+
return lines;
|
|
446
|
+
}
|
|
447
|
+
// ---------------------------------------------------------------------------
|
|
448
|
+
// The command breakdown (APRV-144)
|
|
449
|
+
// ---------------------------------------------------------------------------
|
|
450
|
+
/** What separates two segments of the breakdown. Exported: the tests pin it. */
|
|
451
|
+
export const BREAKDOWN_SEPARATOR = " · ";
|
|
452
|
+
/** Characters one segment of the breakdown may take before it folds. */
|
|
453
|
+
export const BREAKDOWN_SEGMENT_BUDGET = 40;
|
|
454
|
+
/** Segments the breakdown shows before it says how many it did not. */
|
|
455
|
+
export const BREAKDOWN_MAX_SEGMENTS = 8;
|
|
456
|
+
/**
|
|
457
|
+
* The words of one segment worth showing: its arguments, minus the flags and
|
|
458
|
+
* minus the words that are probably flag VALUES.
|
|
459
|
+
*
|
|
460
|
+
* The classifier does not know which flags take a value, and neither does this
|
|
461
|
+
* (`RuleContext.positionals` has the same blind spot, on purpose). What it does
|
|
462
|
+
* is drop the word after a bare SHORT flag, which is what turns
|
|
463
|
+
* `git commit -m "APRV-…"` into `git commit` rather than into a segment whose
|
|
464
|
+
* only visible argument is a commit message.
|
|
465
|
+
*
|
|
466
|
+
* Short and not long, deliberately. A short flag that takes a value almost
|
|
467
|
+
* always takes it as the next word (`-m msg`, `-H header`, `-o file`); a long
|
|
468
|
+
* one almost always carries it inline (`--message=…`) or takes none at all, so
|
|
469
|
+
* the word after `--force-with-lease` is `origin` and dropping it would hide
|
|
470
|
+
* the destination of a push. Both mistakes cost a word on a reading aid whose
|
|
471
|
+
* raw bytes sit underneath it in full, and nothing reads this back — but the
|
|
472
|
+
* long-flag case is the one that would have hidden something worth seeing.
|
|
473
|
+
*/
|
|
474
|
+
function salientArgs(args) {
|
|
475
|
+
const kept = [];
|
|
476
|
+
let afterValueFlag = false;
|
|
477
|
+
for (const arg of args) {
|
|
478
|
+
const flag = arg.startsWith("-") && arg !== "-";
|
|
479
|
+
if (!flag && !afterValueFlag)
|
|
480
|
+
kept.push(arg);
|
|
481
|
+
afterValueFlag = flag && !arg.startsWith("--") && !arg.includes("=");
|
|
482
|
+
}
|
|
483
|
+
return kept;
|
|
484
|
+
}
|
|
485
|
+
/** One segment as `bin sub arg…`, collapsed to one line and folded at the budget. */
|
|
486
|
+
function breakdownSegment(bin, args) {
|
|
487
|
+
const text = [bin, ...salientArgs(args)].join(" ").replace(/\s+/gu, " ").trim();
|
|
488
|
+
return text.length <= BREAKDOWN_SEGMENT_BUDGET
|
|
489
|
+
? text
|
|
490
|
+
: `${text.slice(0, BREAKDOWN_SEGMENT_BUDGET - 1)}…`;
|
|
491
|
+
}
|
|
492
|
+
/**
|
|
493
|
+
* What a compound command does, segment by segment (APRV-144).
|
|
494
|
+
*
|
|
495
|
+
* `git add … · git commit · git push origin main:records-… · gh pr create`.
|
|
496
|
+
*
|
|
497
|
+
* The observed complaint (Carter, 2026-08-25) is that the claimed summary of a
|
|
498
|
+
* shell action is `truncate(command, 160)`, which for a chained command is the
|
|
499
|
+
* first clause and a path prefix: the approver reads where the command starts
|
|
500
|
+
* and never what it ends by doing. This is the deterministic half of the
|
|
501
|
+
* answer. It is derived from {@link commandSegmentWords} — the classifier's own
|
|
502
|
+
* tokenizer, never a second one — so a channel showing it cannot describe a
|
|
503
|
+
* command differently from the module that chose its class.
|
|
504
|
+
*
|
|
505
|
+
* `null` for a string the tokenizer refuses (the same input the classifier
|
|
506
|
+
* answers `unparseable` for) and for one with no segment carrying a binary: an
|
|
507
|
+
* aid that cannot be derived is absent, never guessed.
|
|
508
|
+
*/
|
|
509
|
+
export function commandBreakdown(command) {
|
|
510
|
+
const segments = commandSegmentWords(command);
|
|
511
|
+
if (segments === null || segments.length === 0)
|
|
512
|
+
return null;
|
|
513
|
+
const shown = segments.slice(0, BREAKDOWN_MAX_SEGMENTS);
|
|
514
|
+
const parts = shown.map((segment) => breakdownSegment(segment.bin, segment.args));
|
|
515
|
+
const hidden = segments.length - shown.length;
|
|
516
|
+
if (hidden > 0)
|
|
517
|
+
parts.push(`… ${String(hidden)} more`);
|
|
518
|
+
return parts.join(BREAKDOWN_SEPARATOR);
|
|
519
|
+
}
|
|
520
|
+
// ---------------------------------------------------------------------------
|
|
521
|
+
// The protected path (APRV-143)
|
|
522
|
+
// ---------------------------------------------------------------------------
|
|
523
|
+
/** The rule names a file-tool touch of a protected path reports (`cli/hook.ts`). */
|
|
524
|
+
const PROTECTED_RULE_NAMES = [
|
|
525
|
+
"protected-path",
|
|
526
|
+
"protected-path-proposal",
|
|
527
|
+
"protected-name-elsewhere",
|
|
528
|
+
];
|
|
529
|
+
/**
|
|
530
|
+
* Which protected path selected this payload's class, when one did (APRV-143).
|
|
531
|
+
*
|
|
532
|
+
* A prompt that says `class: policy.edit` and stops there tells the approver
|
|
533
|
+
* that *some* rule fired and leaves them to find the file. Both gated shapes
|
|
534
|
+
* can say which:
|
|
535
|
+
*
|
|
536
|
+
* - a shell payload is re-classified here, by the same
|
|
537
|
+
* {@link classifyCommand} the hook decided with, and the segment that took
|
|
538
|
+
* `policy.edit` carries the word it matched (`ClassifiedSegment.path`);
|
|
539
|
+
* - a file-tool payload names its target in `file`, and
|
|
540
|
+
* {@link isProtectedPath} is re-run over it rather than trusted: the answer
|
|
541
|
+
* is recomputed from the bound bytes, so this stays a computed field. The
|
|
542
|
+
* payload's own `rule` is used as the label only when it is one of the three
|
|
543
|
+
* the hook writes, which is what keeps the worktree-proposal and
|
|
544
|
+
* protected-name-elsewhere tiers legible (APRV-124, APRV-161).
|
|
545
|
+
*
|
|
546
|
+
* `extra` is `policy.protected_paths`, passed exactly as every enforcement path
|
|
547
|
+
* passes it; omitting it narrows the answer and never widens it.
|
|
548
|
+
*/
|
|
549
|
+
export function protectedPathView(value, extra = []) {
|
|
550
|
+
const command = commandPayloadView(value);
|
|
551
|
+
if (command !== null) {
|
|
552
|
+
const classified = classifyCommand(command.command, extra);
|
|
553
|
+
if (!classified.ok)
|
|
554
|
+
return null;
|
|
555
|
+
for (const segment of classified.segments) {
|
|
556
|
+
if (segment.path !== undefined)
|
|
557
|
+
return { path: segment.path, rule: segment.rule };
|
|
558
|
+
}
|
|
559
|
+
return null;
|
|
560
|
+
}
|
|
561
|
+
if (typeof value !== "object" || value === null || Array.isArray(value))
|
|
562
|
+
return null;
|
|
563
|
+
const record = value;
|
|
564
|
+
const file = record["file"];
|
|
565
|
+
if (typeof file !== "string" || !isProtectedPath(file, extra))
|
|
566
|
+
return null;
|
|
567
|
+
const rule = record["rule"];
|
|
568
|
+
return {
|
|
569
|
+
path: file,
|
|
570
|
+
rule: typeof rule === "string" && PROTECTED_RULE_NAMES.includes(rule) ? rule : "protected-path",
|
|
571
|
+
};
|
|
572
|
+
}
|
|
573
|
+
/** The email view: every field of the closed set, then the body in delimiters. */
|
|
574
|
+
function emailRegionText(fields, hash) {
|
|
575
|
+
const lines = [EMAIL_VIEW_HEADING];
|
|
576
|
+
for (const key of FIELD_ORDER) {
|
|
577
|
+
if (key === "body")
|
|
578
|
+
continue;
|
|
579
|
+
const field = fields.find((entry) => entry.label === key);
|
|
580
|
+
lines.push(`${key}: ${field === undefined ? ABSENT : field.text}`);
|
|
581
|
+
}
|
|
582
|
+
// `body` is required for the shape to be recognised at all, so its absence
|
|
583
|
+
// here is unreachable; it is rendered as an absence anyway rather than as an
|
|
584
|
+
// empty block, because a delimiter pair around nothing reads as "the body is
|
|
585
|
+
// empty" and that is a different claim.
|
|
586
|
+
const body = fields.find((field) => field.label === "body");
|
|
587
|
+
if (body === undefined) {
|
|
588
|
+
lines.push(`body: ${ABSENT}`, rawBytesLine(hash));
|
|
589
|
+
return lines;
|
|
590
|
+
}
|
|
591
|
+
const count = body.text === "" ? 0 : body.text.split("\n").length;
|
|
592
|
+
lines.push(`body (${count} line${count === 1 ? "" : "s"}):`, BODY_BEGIN, body.text, BODY_END);
|
|
593
|
+
lines.push(rawBytesLine(hash));
|
|
594
|
+
return lines;
|
|
595
|
+
}
|
|
596
|
+
// ---------------------------------------------------------------------------
|
|
597
|
+
// The canonical renderer (APRV-119)
|
|
598
|
+
// ---------------------------------------------------------------------------
|
|
599
|
+
/**
|
|
600
|
+
* The renderer's identity, printed inside every canonical block.
|
|
601
|
+
*
|
|
602
|
+
* Inside the text, and therefore inside {@link CanonicalRendering.display_hash}:
|
|
603
|
+
* a version that rode alongside the hash rather than inside it would let two
|
|
604
|
+
* renderer versions produce the same digest for two different readings, which is
|
|
605
|
+
* the one thing the digest exists to make impossible. Any change to the bytes
|
|
606
|
+
* this module emits — a new field, a reworded heading, a line that used to be
|
|
607
|
+
* folded away — is a new version, and a reader comparing a stored
|
|
608
|
+
* `display_hash` against a re-render can see which renderer wrote it. A record
|
|
609
|
+
* written under an earlier version re-derives under the renderer its own hashed
|
|
610
|
+
* text names, never under this one.
|
|
611
|
+
*
|
|
612
|
+
* `/2` (APRV-162): the structural views render whole and carry no canonical-JSON
|
|
613
|
+
* appendix; `opaque` is unchanged, its view being that JSON.
|
|
614
|
+
*/
|
|
615
|
+
export const CANONICAL_RENDERER_VERSION = "approval.md/wysiwys/2";
|
|
616
|
+
/**
|
|
617
|
+
* The `approval.requested` payload field carrying {@link
|
|
618
|
+
* CanonicalRendering.display_hash} (APRV-119).
|
|
619
|
+
*
|
|
620
|
+
* Written by the gate at the write boundary, exactly as `ts` and `policy_sha256`
|
|
621
|
+
* are, and for the same reason: the requesting party must not be able to name
|
|
622
|
+
* the rendering it claims a human was shown. {@link RequestInput} carries no
|
|
623
|
+
* field for it.
|
|
624
|
+
*/
|
|
625
|
+
export const DISPLAY_HASH_FIELD = "display_hash";
|
|
626
|
+
/** The delimiters of the canonical block. Exported because the tests pin them. */
|
|
627
|
+
export const CANONICAL_BEGIN = "--- canonical rendering begins ---";
|
|
628
|
+
export const CANONICAL_END = "--- canonical rendering ends ---";
|
|
629
|
+
/** The heading of the `opaque` kind: no structural view, the bytes whole. */
|
|
630
|
+
export const OPAQUE_VIEW_HEADING = "payload — no structural view applies to this shape; every byte of it is in the canonical JSON below, and every value is CLAIMED, authored by the requesting party";
|
|
631
|
+
/**
|
|
632
|
+
* The kinds a payload can be rendered as.
|
|
633
|
+
*
|
|
634
|
+
* Closed, and decided by structure alone. `opaque` is not a failure: it is the
|
|
635
|
+
* kind whose closed field set is "the whole canonical JSON", which is the
|
|
636
|
+
* rendering every payload had before the structural views existed.
|
|
637
|
+
*/
|
|
638
|
+
export const CANONICAL_KINDS = ["command", "file-change", "email", "opaque"];
|
|
639
|
+
/** The structural view for a payload, and the kind that selected it. */
|
|
640
|
+
function canonicalBody(payload, hash) {
|
|
641
|
+
const command = commandPayloadView(payload);
|
|
642
|
+
if (command !== null)
|
|
643
|
+
return { kind: "command", lines: commandRegionText(command, hash) };
|
|
644
|
+
const change = changePayloadView(payload);
|
|
645
|
+
if (change !== null)
|
|
646
|
+
return { kind: "file-change", lines: changeRegionText(change, hash) };
|
|
647
|
+
const fields = emailPayloadFields(payload);
|
|
648
|
+
if (fields !== null)
|
|
649
|
+
return { kind: "email", lines: emailRegionText(fields, hash) };
|
|
650
|
+
// The `opaque` view IS the canonical JSON, and it is the only view that
|
|
651
|
+
// carries it (APRV-162): under a structured kind the same bytes would be a
|
|
652
|
+
// second reading of a payload the view already showed entire, and two
|
|
653
|
+
// readings is one more than a human checks.
|
|
654
|
+
return {
|
|
655
|
+
kind: "opaque",
|
|
656
|
+
lines: [OPAQUE_VIEW_HEADING, "", CANONICAL_JSON_HEADING, canonicalJson(payload)],
|
|
657
|
+
};
|
|
658
|
+
}
|
|
659
|
+
/**
|
|
660
|
+
* The `opaque` view: the payload, pretty-printed.
|
|
661
|
+
*
|
|
662
|
+
* Indented rather than RFC 8785 canonical, deliberately. The line above it
|
|
663
|
+
* states the RFC 8785 digest, which is the value the approval binds to and the
|
|
664
|
+
* value any second implementation can recompute; what this is for is a human
|
|
665
|
+
* reading a shape no structural view knows, and a single 4KB line is not that.
|
|
666
|
+
* The two never disagree, because both are derived from the same value in the
|
|
667
|
+
* same call.
|
|
668
|
+
*/
|
|
669
|
+
function canonicalJson(payload) {
|
|
670
|
+
return JSON.stringify(payload, null, 2) ?? String(payload);
|
|
671
|
+
}
|
|
672
|
+
/**
|
|
673
|
+
* Render a payload the way every channel MUST present it (APRV-119).
|
|
674
|
+
*
|
|
675
|
+
* A pure function of `(payload, actionClass)`. Same arguments, byte-identical
|
|
676
|
+
* `text` and `display_hash`, in this process or another, today or next year
|
|
677
|
+
* under the same {@link CANONICAL_RENDERER_VERSION}.
|
|
678
|
+
*
|
|
679
|
+
* The block states its own renderer, class, kind and payload digest before it
|
|
680
|
+
* shows anything, so a reader who is handed the text alone can tell what
|
|
681
|
+
* produced it and what it binds to. Then the view for the kind, which is the
|
|
682
|
+
* whole reading: it renders every byte of the payload or the payload is
|
|
683
|
+
* `opaque` and the view is its JSON (APRV-162).
|
|
684
|
+
*
|
|
685
|
+
* Throws `JcsError` for a payload RFC 8785 cannot serialize (a cycle, a NaN).
|
|
686
|
+
* That is {@link payloadHash}'s contract and it is the right one here too: a
|
|
687
|
+
* payload that cannot be bound to must not acquire a plausible-looking rendering
|
|
688
|
+
* of itself. Every caller in this repository renders material that has already
|
|
689
|
+
* been hash-checked against the log's binding, so the throw is unreachable on
|
|
690
|
+
* the paths a human ever sees.
|
|
691
|
+
*/
|
|
692
|
+
export function canonicalRender(payload, actionClass) {
|
|
693
|
+
const hash = payloadHash(payload);
|
|
694
|
+
const body = canonicalBody(payload, hash);
|
|
695
|
+
const text = [
|
|
696
|
+
CANONICAL_BEGIN,
|
|
697
|
+
`renderer: ${CANONICAL_RENDERER_VERSION}`,
|
|
698
|
+
`class: ${actionClass}`,
|
|
699
|
+
`payload kind: ${body.kind}`,
|
|
700
|
+
`payload sha256: ${hash}`,
|
|
701
|
+
"",
|
|
702
|
+
...body.lines,
|
|
703
|
+
CANONICAL_END,
|
|
704
|
+
].join("\n");
|
|
705
|
+
return {
|
|
706
|
+
version: CANONICAL_RENDERER_VERSION,
|
|
707
|
+
kind: body.kind,
|
|
708
|
+
text,
|
|
709
|
+
display_hash: createHash("sha256").update(text, "utf8").digest("hex"),
|
|
710
|
+
};
|
|
711
|
+
}
|
|
712
|
+
/**
|
|
713
|
+
* The `display_hash` of a payload, or `null` when there is none to compute.
|
|
714
|
+
*
|
|
715
|
+
* The gate's entry point (`core/gate.ts`), where a payload that cannot be
|
|
716
|
+
* canonicalized must not abort a request that has already passed every check
|
|
717
|
+
* that matters. A missing `display_hash` costs a reader one cross-check; a
|
|
718
|
+
* throw here would cost them the request.
|
|
719
|
+
*/
|
|
720
|
+
export function displayHashOf(payload, actionClass) {
|
|
721
|
+
try {
|
|
722
|
+
return canonicalRender(payload, actionClass).display_hash;
|
|
723
|
+
}
|
|
724
|
+
catch {
|
|
725
|
+
return null;
|
|
726
|
+
}
|
|
727
|
+
}
|
|
728
|
+
//# sourceMappingURL=wysiwys.js.map
|