approval-md 0.3.0 → 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +83 -914
- package/SPEC.md +98 -13
- package/cli.js +66 -7
- package/dist/src/adapters/smtp.d.ts +18 -0
- package/dist/src/adapters/smtp.js +24 -3
- package/dist/src/adapters/smtp.js.map +1 -1
- package/dist/src/channels/contract.d.ts +19 -0
- package/dist/src/channels/contract.js +105 -1
- package/dist/src/channels/contract.js.map +1 -1
- package/dist/src/channels/relay-server.d.ts +90 -0
- package/dist/src/channels/relay-server.js +295 -0
- package/dist/src/channels/relay-server.js.map +1 -0
- package/dist/src/channels/relay.d.ts +273 -0
- package/dist/src/channels/relay.js +564 -0
- package/dist/src/channels/relay.js.map +1 -0
- package/dist/src/channels/tagging.js +6 -1
- package/dist/src/channels/tagging.js.map +1 -1
- package/dist/src/channels/telegram-webhook.d.ts +273 -0
- package/dist/src/channels/telegram-webhook.js +536 -0
- package/dist/src/channels/telegram-webhook.js.map +1 -0
- package/dist/src/channels/telegram.d.ts +271 -1
- package/dist/src/channels/telegram.js +403 -45
- package/dist/src/channels/telegram.js.map +1 -1
- package/dist/src/cli/amend.js +350 -4
- package/dist/src/cli/amend.js.map +1 -1
- package/dist/src/cli/attest.js +43 -6
- package/dist/src/cli/attest.js.map +1 -1
- package/dist/src/cli/channel-relay.d.ts +108 -0
- package/dist/src/cli/channel-relay.js +334 -0
- package/dist/src/cli/channel-relay.js.map +1 -0
- package/dist/src/cli/channel-telegram-webhook.d.ts +273 -0
- package/dist/src/cli/channel-telegram-webhook.js +709 -0
- package/dist/src/cli/channel-telegram-webhook.js.map +1 -0
- package/dist/src/cli/channel-telegram.d.ts +272 -26
- package/dist/src/cli/channel-telegram.js +478 -41
- package/dist/src/cli/channel-telegram.js.map +1 -1
- package/dist/src/cli/channel.d.ts +1 -1
- package/dist/src/cli/channel.js +5 -1
- package/dist/src/cli/channel.js.map +1 -1
- package/dist/src/cli/daemon.js +37 -5
- package/dist/src/cli/daemon.js.map +1 -1
- package/dist/src/cli/doctor.d.ts +63 -0
- package/dist/src/cli/doctor.js +856 -111
- package/dist/src/cli/doctor.js.map +1 -1
- package/dist/src/cli/execute.d.ts +17 -1
- package/dist/src/cli/execute.js +341 -20
- package/dist/src/cli/execute.js.map +1 -1
- package/dist/src/cli/exit-codes.d.ts +12 -0
- package/dist/src/cli/exit-codes.js +13 -0
- package/dist/src/cli/exit-codes.js.map +1 -1
- package/dist/src/cli/gate.d.ts +2 -0
- package/dist/src/cli/gate.js +233 -2
- package/dist/src/cli/gate.js.map +1 -1
- package/dist/src/cli/help.d.ts +9 -1
- package/dist/src/cli/help.js +246 -38
- package/dist/src/cli/help.js.map +1 -1
- package/dist/src/cli/hook.d.ts +371 -3
- package/dist/src/cli/hook.js +1926 -44
- package/dist/src/cli/hook.js.map +1 -1
- package/dist/src/cli/init.js +16 -7
- package/dist/src/cli/init.js.map +1 -1
- package/dist/src/cli/instructions.js +24 -3
- package/dist/src/cli/instructions.js.map +1 -1
- package/dist/src/cli/log-anchor.d.ts +55 -0
- package/dist/src/cli/log-anchor.js +199 -60
- package/dist/src/cli/log-anchor.js.map +1 -1
- package/dist/src/cli/main.d.ts +0 -12
- package/dist/src/cli/main.js +51 -0
- package/dist/src/cli/main.js.map +1 -1
- package/dist/src/cli/muse.d.ts +2 -0
- package/dist/src/cli/muse.js +62 -0
- package/dist/src/cli/muse.js.map +1 -0
- package/dist/src/cli/payload.js +79 -1
- package/dist/src/cli/payload.js.map +1 -1
- package/dist/src/cli/scaffold.js +6 -0
- package/dist/src/cli/scaffold.js.map +1 -1
- package/dist/src/cli/serve.d.ts +53 -0
- package/dist/src/cli/serve.js +270 -0
- package/dist/src/cli/serve.js.map +1 -0
- package/dist/src/cli/up.js +21 -2
- package/dist/src/cli/up.js.map +1 -1
- package/dist/src/cli/verb-registry.d.ts +2 -0
- package/dist/src/cli/verb-registry.js +337 -39
- package/dist/src/cli/verb-registry.js.map +1 -1
- package/dist/src/cli/wordmark.d.ts +2 -2
- package/dist/src/cli/wordmark.js +2 -2
- package/dist/src/core/attest-bootstrap.d.ts +95 -0
- package/dist/src/core/attest-bootstrap.js +135 -0
- package/dist/src/core/attest-bootstrap.js.map +1 -0
- package/dist/src/core/attest.d.ts +20 -3
- package/dist/src/core/attest.js +25 -0
- package/dist/src/core/attest.js.map +1 -1
- package/dist/src/core/audit.d.ts +55 -3
- package/dist/src/core/audit.js +52 -5
- package/dist/src/core/audit.js.map +1 -1
- package/dist/src/core/channel-lease.d.ts +322 -0
- package/dist/src/core/channel-lease.js +663 -0
- package/dist/src/core/channel-lease.js.map +1 -0
- package/dist/src/core/command-class.d.ts +51 -0
- package/dist/src/core/command-class.js +760 -8
- package/dist/src/core/command-class.js.map +1 -1
- package/dist/src/core/daemon-host.d.ts +178 -0
- package/dist/src/core/daemon-host.js +228 -0
- package/dist/src/core/daemon-host.js.map +1 -0
- package/dist/src/core/daemon-identity.d.ts +238 -0
- package/dist/src/core/daemon-identity.js +252 -0
- package/dist/src/core/daemon-identity.js.map +1 -0
- package/dist/src/core/gate.d.ts +306 -5
- package/dist/src/core/gate.js +772 -24
- package/dist/src/core/gate.js.map +1 -1
- package/dist/src/core/harness-version.d.ts +128 -4
- package/dist/src/core/harness-version.js +179 -12
- package/dist/src/core/harness-version.js.map +1 -1
- package/dist/src/core/harness-wait.d.ts +82 -3
- package/dist/src/core/harness-wait.js +89 -3
- package/dist/src/core/harness-wait.js.map +1 -1
- package/dist/src/core/log-subscribe.d.ts +21 -0
- package/dist/src/core/log-subscribe.js +16 -6
- package/dist/src/core/log-subscribe.js.map +1 -1
- package/dist/src/core/log-write-layer.d.ts +55 -0
- package/dist/src/core/log-write-layer.js +91 -0
- package/dist/src/core/log-write-layer.js.map +1 -0
- package/dist/src/core/log.d.ts +50 -21
- package/dist/src/core/log.js +197 -19
- package/dist/src/core/log.js.map +1 -1
- package/dist/src/core/payload-census.d.ts +51 -0
- package/dist/src/core/payload-census.js +125 -2
- package/dist/src/core/payload-census.js.map +1 -1
- package/dist/src/core/payload-store.d.ts +31 -0
- package/dist/src/core/payload-store.js +113 -8
- package/dist/src/core/payload-store.js.map +1 -1
- package/dist/src/core/payload.d.ts +18 -3
- package/dist/src/core/payload.js +20 -4
- package/dist/src/core/payload.js.map +1 -1
- package/dist/src/core/policy-diff.d.ts +10 -0
- package/dist/src/core/policy-diff.js +10 -2
- package/dist/src/core/policy-diff.js.map +1 -1
- package/dist/src/core/policy-explain.d.ts +7 -1
- package/dist/src/core/policy-explain.js +16 -1
- package/dist/src/core/policy-explain.js.map +1 -1
- package/dist/src/core/policy-load.d.ts +24 -0
- package/dist/src/core/policy-load.js.map +1 -1
- package/dist/src/core/policy-match.d.ts +38 -0
- package/dist/src/core/policy-match.js +35 -0
- package/dist/src/core/policy-match.js.map +1 -1
- package/dist/src/core/policy-proposal.d.ts +23 -0
- package/dist/src/core/policy-proposal.js +8 -2
- package/dist/src/core/policy-proposal.js.map +1 -1
- package/dist/src/core/read-scope.d.ts +10 -0
- package/dist/src/core/read-scope.js +4 -0
- package/dist/src/core/read-scope.js.map +1 -1
- package/dist/src/core/run-payload.d.ts +160 -0
- package/dist/src/core/run-payload.js +304 -0
- package/dist/src/core/run-payload.js.map +1 -0
- package/dist/src/core/sender-identity.d.ts +35 -10
- package/dist/src/core/sender-identity.js +48 -11
- package/dist/src/core/sender-identity.js.map +1 -1
- package/dist/src/core/state.d.ts +52 -1
- package/dist/src/core/state.js +52 -6
- package/dist/src/core/state.js.map +1 -1
- package/dist/src/core/telegram-config.d.ts +79 -0
- package/dist/src/core/telegram-config.js +108 -0
- package/dist/src/core/telegram-config.js.map +1 -1
- package/dist/src/core/verify.d.ts +23 -1
- package/dist/src/core/verify.js +20 -2
- package/dist/src/core/verify.js.map +1 -1
- package/dist/src/daemon/audit.d.ts +80 -5
- package/dist/src/daemon/audit.js +126 -6
- package/dist/src/daemon/audit.js.map +1 -1
- package/dist/src/daemon/daemon.d.ts +174 -2
- package/dist/src/daemon/daemon.js +298 -7
- package/dist/src/daemon/daemon.js.map +1 -1
- package/dist/src/daemon/git-evidence.d.ts +2 -2
- package/dist/src/daemon/git-evidence.js +1 -1
- package/dist/src/daemon/projection.d.ts +13 -2
- package/dist/src/daemon/projection.js +39 -6
- package/dist/src/daemon/projection.js.map +1 -1
- package/dist/src/mcp/server.d.ts +39 -3
- package/dist/src/mcp/server.js +22 -3
- package/dist/src/mcp/server.js.map +1 -1
- package/dist/src/muse/server.d.ts +16 -0
- package/dist/src/muse/server.js +238 -0
- package/dist/src/muse/server.js.map +1 -0
- package/dist/src/serve/archive.d.ts +158 -0
- package/dist/src/serve/archive.js +354 -0
- package/dist/src/serve/archive.js.map +1 -0
- package/dist/src/serve/arguments.d.ts +93 -0
- package/dist/src/serve/arguments.js +228 -0
- package/dist/src/serve/arguments.js.map +1 -0
- package/dist/src/serve/credentials.d.ts +91 -0
- package/dist/src/serve/credentials.js +143 -0
- package/dist/src/serve/credentials.js.map +1 -0
- package/dist/src/serve/follow.d.ts +86 -0
- package/dist/src/serve/follow.js +101 -0
- package/dist/src/serve/follow.js.map +1 -0
- package/dist/src/serve/hook-thread.d.ts +134 -0
- package/dist/src/serve/hook-thread.js +333 -0
- package/dist/src/serve/hook-thread.js.map +1 -0
- package/dist/src/serve/hook-worker.d.ts +134 -0
- package/dist/src/serve/hook-worker.js +173 -0
- package/dist/src/serve/hook-worker.js.map +1 -0
- package/dist/src/serve/server.d.ts +412 -0
- package/dist/src/serve/server.js +1251 -0
- package/dist/src/serve/server.js.map +1 -0
- package/docs/README-extended.md +766 -0
- package/docs/cli-reference.md +1633 -43
- package/package.json +4 -2
- package/schema/event.schema.json +59 -2
- package/schema/fixtures/event/invalid/daemon-id-malformed.json +15 -0
- package/schema/fixtures/event/invalid/execution-started-truncated-policy-sha256.json +18 -0
- package/schema/fixtures/event/invalid/harness-cap-at-margin.json +16 -0
- package/schema/fixtures/event/invalid/harness-cap-fractional.json +16 -0
- package/schema/fixtures/event/invalid/harness-cap-negative.json +16 -0
- package/schema/fixtures/event/invalid/harness-cap-string.json +16 -0
- package/schema/fixtures/event/invalid/harness-cap-without-harness-execution.json +16 -0
- package/schema/fixtures/event/invalid/harness-cap-zero.json +16 -0
- package/schema/fixtures/event/valid/approval-requested-harness-cap.json +18 -0
- package/schema/fixtures/event/valid/daemon-identity-declared.json +17 -0
- package/schema/fixtures/event/valid/daemon-identity.json +15 -0
- package/schema/fixtures/event/valid/execution-started-policy-sha256.json +18 -0
- package/schema/fixtures/event/valid/harness-kind-hermes.json +23 -0
- package/schema/fixtures/policy/invalid/daemons-malformed-id.json +10 -0
- package/schema/fixtures/policy/valid/daemons-allowlist.json +19 -0
- package/schema/fixtures/policy-md/invalid/schema-invalid-agent-may-request.md +21 -0
- package/schema/fixtures/policy-md/valid/agent-may-request.md +24 -0
- package/schema/policy.schema.json +50 -6
package/docs/cli-reference.md
CHANGED
|
@@ -65,11 +65,20 @@ tampering.
|
|
|
65
65
|
```
|
|
66
66
|
clean {"status":"clean","records":3,"head":{"seq":3,"hash":"<64 hex>"}}
|
|
67
67
|
torn-tail {"status":"torn-tail","records":3,"head":null,
|
|
68
|
-
"intactThroughSeq":3,"
|
|
68
|
+
"intactThroughSeq":3,"tear":"nul-filled","tornBytes":456,
|
|
69
|
+
"intactBytes":2817,"message":"..."}
|
|
69
70
|
corrupt {"status":"corrupt","records":null,"head":null,
|
|
70
71
|
"firstBadSeq":2,"reason":"hash-mismatch","message":"..."}
|
|
71
72
|
```
|
|
72
73
|
|
|
74
|
+
On a torn tail, `tear` says which crash left it (APRV-440). `nul-filled` means
|
|
75
|
+
every byte of the unterminated tail is NUL: the crash-before-writeback
|
|
76
|
+
signature, where the file grew but its data never reached the disk, so nothing
|
|
77
|
+
was tampered and no record was half-written. `partial-line` is a writer that
|
|
78
|
+
died mid-line. `intactBytes` is where the verified records end, which is the
|
|
79
|
+
length to truncate the file to; `tornBytes` is what that removes. Nothing is
|
|
80
|
+
truncated by this verb.
|
|
81
|
+
|
|
73
82
|
`head` is null for an empty log. `reason` is one of `malformed-line`,
|
|
74
83
|
`schema-invalid`, `bad-alg`, `hash-mismatch`, `prev-mismatch`, `seq-gap`,
|
|
75
84
|
`seq-duplicate`, `not-genesis`, `head-mismatch`. `anomalies` is ADDITIVE and
|
|
@@ -498,6 +507,11 @@ advance is the one that may carry `log.advance.daemon` instead, and only from
|
|
|
498
507
|
inside the daemon process: see the `--advance` paragraph under `daemon run`
|
|
499
508
|
(APRV-382).
|
|
500
509
|
|
|
510
|
+
An advance publishes the WHOLE log, so while its branch is open on origin it is
|
|
511
|
+
the log's publisher and `policy amend` stands aside: an amendment opened in that
|
|
512
|
+
window carries only the policy bytes and cannot conflict with the records. See
|
|
513
|
+
"Who publishes the log" under `policy amend` (APRV-420).
|
|
514
|
+
|
|
501
515
|
`--co-author "Name <email>"` adds one validated `Co-authored-by` trailer to the
|
|
502
516
|
generated records commit and to the pull request body. When the day's pull
|
|
503
517
|
request already exists, the verb preserves its body and adds the trailer once.
|
|
@@ -669,9 +683,18 @@ channel; policy-authorized execution is never represented as a human grant.
|
|
|
669
683
|
"autonomy":"autonomous","winner":true,
|
|
670
684
|
"tieBreak":"specificity"|"strictest-autonomy"|
|
|
671
685
|
"lexicographic"|"tied-specificity"}],
|
|
686
|
+
"agentRequest":{"allowed":false,"explicit":true,"pattern":null},
|
|
672
687
|
"decisionPath":["...","..."]}
|
|
673
688
|
```
|
|
674
689
|
|
|
690
|
+
`agentRequest` (APRV-445) answers whether `approval propose` would admit this
|
|
691
|
+
class: `explicit` is whether the class is an exact key of `classes`, `pattern`
|
|
692
|
+
is the key whose `agent_may_request` decided (the exact key, or the most
|
|
693
|
+
specific declared `<prefix>.*` family that states one), and `allowed` is both.
|
|
694
|
+
It is read without the resolution above, because a wildcard match never makes a
|
|
695
|
+
class proposable. A `decisionPath` line appears only when some key states the
|
|
696
|
+
flag for this class.
|
|
697
|
+
|
|
675
698
|
`specificity` is [literalSegments, wildcardSegments, totalSegments] (SPEC §5.2).
|
|
676
699
|
`overridden.pattern` is null when the floor overrode `defaults.autonomy` rather
|
|
677
700
|
than a rule.
|
|
@@ -741,6 +764,62 @@ existed still validate and verify; a reader treats the absent field as the
|
|
|
741
764
|
pre-amendment state, where the in-force bytes are unrecoverable and the
|
|
742
765
|
fail-closed fallback applies.
|
|
743
766
|
|
|
767
|
+
**No terminal is needed.** The identity is declared, so the verb runs the same
|
|
768
|
+
from a provisioning script with stdin at `/dev/null` as from a shell. The
|
|
769
|
+
`attest-requires-terminal` refusal belongs to the channel path (a tap cannot
|
|
770
|
+
attest a policy that maps no sender for that channel), never to this verb.
|
|
771
|
+
|
|
772
|
+
### `--bootstrap`: a store's first policy, safe to re-run (APRV-449)
|
|
773
|
+
|
|
774
|
+
The plain verb is an unconditional assertion: it reads no log and appends a
|
|
775
|
+
`policy.updated` every time it runs. That is right for a human at a terminal and
|
|
776
|
+
wrong for a provisioning step that runs again on every update and recreate of a
|
|
777
|
+
hosted tenant, where a re-run would add an attestation per redeploy and, once
|
|
778
|
+
the tenant has attested or amended their own policy, would put the operator back
|
|
779
|
+
on record as its attester. `--bootstrap` reads the VERIFIED log first:
|
|
780
|
+
|
|
781
|
+
```
|
|
782
|
+
approval policy attest --bootstrap --as human:<operator> [--json]
|
|
783
|
+
```
|
|
784
|
+
|
|
785
|
+
| the log carries | answer | exit |
|
|
786
|
+
|---|---|---|
|
|
787
|
+
| no attestation | attests, exactly as the plain verb | 0 |
|
|
788
|
+
| an attestation of these exact bytes | `policy-already-attested`, nothing appended | 1 |
|
|
789
|
+
| an attestation of other bytes | `policy-amendment-required`, nothing appended | 1 |
|
|
790
|
+
|
|
791
|
+
Both refusals add `seq` and `attested_by` (the attestation in force and who made
|
|
792
|
+
it) to the error object:
|
|
793
|
+
|
|
794
|
+
```
|
|
795
|
+
refusal {"ok":false,"error":{"code":"policy-already-attested","message":"...",
|
|
796
|
+
"seq":1,"attested_by":"human:carter"}} on stderr
|
|
797
|
+
```
|
|
798
|
+
|
|
799
|
+
`policy-already-attested` is the expected answer on a re-run, so a provisioning
|
|
800
|
+
step branches on `error.code` and treats that one code as done.
|
|
801
|
+
`policy-amendment-required` means the bytes on disk moved after the store was
|
|
802
|
+
attested. The operator does not attest that change: a change to an attested
|
|
803
|
+
policy is an amendment, and an amendment is the approver's act through a channel
|
|
804
|
+
(SPEC.md §10.3). The append is compare-and-append against the head the decision
|
|
805
|
+
was read from, and binds the digest it checked, so an attestation landing in
|
|
806
|
+
between, or a file rewritten in between, refuses (`head-moved`, or `io` naming
|
|
807
|
+
the changed bytes) rather than stacking an operator attestation on top. A torn
|
|
808
|
+
log is exit 3 and a log that does not verify is `log-corrupt` at exit 1.
|
|
809
|
+
|
|
810
|
+
With `--bootstrap` the log is the store's: it resolves under `--dir`
|
|
811
|
+
(`<dir>/.approval/log/events.jsonl`) unless `--log` names one, so `approval
|
|
812
|
+
policy attest --bootstrap --dir "$STORE"` attests the store's own log from any
|
|
813
|
+
working directory. The plain verb keeps its working-directory default. A
|
|
814
|
+
refused append can leave one unbound copy of the policy text in the payload
|
|
815
|
+
store, because the text is stored before the append (APRV-356). It is not
|
|
816
|
+
removed: the record that moved the head may be another attestation of the same
|
|
817
|
+
bytes, which binds that very file. An unbound file is inert, and `approval
|
|
818
|
+
status` counts it under `payload_store.orphans`.
|
|
819
|
+
|
|
820
|
+
`--bootstrap` beside `--organ` or `--path` is a usage error. The whole
|
|
821
|
+
provisioning sequence is [docs/hosted-provisioning.md](hosted-provisioning.md).
|
|
822
|
+
|
|
744
823
|
### `--organ <path>`: the gate's organs (APRV-272)
|
|
745
824
|
|
|
746
825
|
The ORGANS are the harness files that install the hook: `.claude/settings*`,
|
|
@@ -1035,6 +1114,69 @@ base carries it, and a pins edit somebody else landed is not reverted by this
|
|
|
1035
1114
|
ceremony. The pin deltas print in the semantic diff beside the class deltas, ride
|
|
1036
1115
|
in the commit subject, and appear in `--json` as `pins`.
|
|
1037
1116
|
|
|
1117
|
+
**Who publishes the log (APRV-420).** The log is in that file set only when this
|
|
1118
|
+
amendment is the thing publishing it. On 2026-09-21 it always was, and PR 530
|
|
1119
|
+
paid for it: two records advances merged first carrying the same records (the
|
|
1120
|
+
attestation at seq 65736 and its payload reached main through PR 531), and git
|
|
1121
|
+
does not care that one side's appended lines are a prefix of the other's, so the
|
|
1122
|
+
amendment went DIRTY, lost its arm, and the repair was a hand merge of
|
|
1123
|
+
`events.jsonl` in a throwaway worktree.
|
|
1124
|
+
|
|
1125
|
+
The cause was two publishers carrying the same bytes, so the ceremony now leaves
|
|
1126
|
+
one of them holding it. It asks two questions of the base it is about to commit
|
|
1127
|
+
on, in this order:
|
|
1128
|
+
|
|
1129
|
+
1. does the base's log already carry every record this attestation added? Then
|
|
1130
|
+
the log is published and the amendment has nothing to add to it.
|
|
1131
|
+
2. is a records advance live, meaning origin carries a `records-log-*` branch?
|
|
1132
|
+
That advance publishes the log, so the amendment has nothing to add to it
|
|
1133
|
+
either. Whether the branch already carries THIS attestation is checked
|
|
1134
|
+
rather than assumed: the ceremony fetches the branch and compares chains.
|
|
1135
|
+
An advance pushed before the human signed does not carry it, which is the
|
|
1136
|
+
ordinary order; the ceremony then says so, the next advance publishes the
|
|
1137
|
+
record, and the pull request's protected-path guard holds the policy
|
|
1138
|
+
change until a records branch or main carries it.
|
|
1139
|
+
|
|
1140
|
+
Either way the commit is the policy bytes, the attested text and the pins, and a
|
|
1141
|
+
commit that does not touch `events.jsonl` cannot conflict on `events.jsonl`,
|
|
1142
|
+
whichever side lands first. Otherwise the amendment is the only publisher and
|
|
1143
|
+
carries the log, exactly as it always has. The ceremony prints which of the three
|
|
1144
|
+
happened, so the pull request's file list is never a surprise.
|
|
1145
|
+
|
|
1146
|
+
The question is asked with `git ls-remote`, not `gh`, so it answers the same on a
|
|
1147
|
+
box with no GitHub CLI and no token. And it fails toward CARRYING: anything the
|
|
1148
|
+
check cannot establish leaves the amendment holding the log. A dirty pull request
|
|
1149
|
+
is a nuisance; an attestation that reaches `main` in nobody's commit is a policy
|
|
1150
|
+
in force that the committed log does not record.
|
|
1151
|
+
|
|
1152
|
+
**A re-run repairs the pull request it finds (APRV-420).** A second run used to
|
|
1153
|
+
stop at `nothing to amend`, which is true and useless: the live policy does match
|
|
1154
|
+
its attestation, and the pull request carrying that attestation is open,
|
|
1155
|
+
unmergeable and unarmed. So when `--commit` or `--pr` is asked for and origin
|
|
1156
|
+
already carries the amendment branch, the re-run fetches the default branch and
|
|
1157
|
+
computes what it still LACKS — the policy bytes, the attested text, the pins, and
|
|
1158
|
+
the log under the rule above — and then:
|
|
1159
|
+
|
|
1160
|
+
- nothing lacking: the amendment landed in full. It says so and prints `gh pr
|
|
1161
|
+
close <branch> --delete-branch`.
|
|
1162
|
+
- something lacking: it rebuilds that commit on the CURRENT default branch and
|
|
1163
|
+
force-updates the branch, then re-arms `gh pr merge <branch> --merge --auto`.
|
|
1164
|
+
The trunk's log is a superset of the amendment's by construction, so rebuilding
|
|
1165
|
+
on it is the re-merge without a merge.
|
|
1166
|
+
|
|
1167
|
+
The rebuild is `commitOnBase`'s scratch index like every other commit this verb
|
|
1168
|
+
makes, so nothing is checked out and no live appender has the log moved
|
|
1169
|
+
underneath it. The force-push is the one in this codebase, and it is bounded: the
|
|
1170
|
+
remote branch must be a single commit on top of a commit the base already
|
|
1171
|
+
contains, which is the shape this ceremony creates and nothing else. A branch
|
|
1172
|
+
carrying anything more is reported with `git log --oneline <base>..<tip>` and
|
|
1173
|
+
left alone. `--dry-run` and `--no-publish` compute the repair, print the
|
|
1174
|
+
commands and run none of it. `--json` carries a `repair` object (`branch`,
|
|
1175
|
+
`state` of `landed`/`repaired`/`owed`/`failed`, `base`, `carried`, `commit`,
|
|
1176
|
+
`pushed`, `autoMerge`, `commands`, `message`), additively and always present:
|
|
1177
|
+
`null` on every run that had an amendment to make and on every no-op that found
|
|
1178
|
+
no branch standing. Only `failed` is a nonzero exit.
|
|
1179
|
+
|
|
1038
1180
|
It refuses outside a git repository (`commit-preconditions`), and refuses
|
|
1039
1181
|
`staged-unrelated` when the index holds staged changes to anything beyond those
|
|
1040
1182
|
three: a commit that swept in an unrelated staged edit would make "this commit is
|
|
@@ -1524,6 +1666,24 @@ hash must equal the declared `payload_hash` and it is filed in
|
|
|
1524
1666
|
bytes from. Supply it here once and no channel needs `--payload-dir` or
|
|
1525
1667
|
`--payloads` at all.
|
|
1526
1668
|
|
|
1669
|
+
For an action `approval run` will execute, produce those bytes with `approval
|
|
1670
|
+
payload run -- <cmd…>` rather than by hand. Since APRV-401 the payload carries
|
|
1671
|
+
the size and digest of the script the argv names, and a hand-written
|
|
1672
|
+
`{"argv":…,"cwd":…}` for a command that names a script is a declaration the
|
|
1673
|
+
execution will refuse `payload-mismatch`, correctly, because it describes bytes
|
|
1674
|
+
nobody bound:
|
|
1675
|
+
|
|
1676
|
+
```
|
|
1677
|
+
approval payload run --hash -- bash scripts/install.sh # the declaration
|
|
1678
|
+
approval payload run -- bash scripts/install.sh \
|
|
1679
|
+
| approval request <task> --action <key> --as agent:<id> --payload -
|
|
1680
|
+
```
|
|
1681
|
+
|
|
1682
|
+
Material supplied here is checked against the declared hash and never against
|
|
1683
|
+
the filesystem: this verb does not know where the run will happen, and the
|
|
1684
|
+
guarantee is that `approval run` recomputes the same value from the tree it is
|
|
1685
|
+
about to spawn into and refuses on any difference.
|
|
1686
|
+
|
|
1527
1687
|
When the policy permits an unattended action, supplied material is still checked
|
|
1528
1688
|
against the registered action's task, class and payload hash and retained before
|
|
1529
1689
|
`proceed:true` is returned. This creates no approval event and does not require
|
|
@@ -1545,6 +1705,129 @@ refusal {"ok":false,"error":{"code":"...","message":"...",
|
|
|
1545
1705
|
The refusal goes to stderr, and `seq` is the `budget.exceeded` record that WAS
|
|
1546
1706
|
appended.
|
|
1547
1707
|
|
|
1708
|
+
## propose
|
|
1709
|
+
|
|
1710
|
+
`approval propose --class <c> --key <k> --summary <s> --payload-json <json>`
|
|
1711
|
+
registers one action and requests it in one call (APRV-445, amended SPEC.md
|
|
1712
|
+
§10.1). It exists for a requester that holds its payload in memory and has no
|
|
1713
|
+
file on the gate's machine: an agent in a sandbox reaching `approval serve`
|
|
1714
|
+
with the agent credential, which refuses every path flag. `--payload-json` is
|
|
1715
|
+
the payload as the flag's VALUE, a JSON object of at most 262144 bytes of UTF-8
|
|
1716
|
+
as sent; an array, string, number or `null` is a usage error, and a larger
|
|
1717
|
+
payload is refused `payload-too-large` at exit 2 before it is parsed.
|
|
1718
|
+
|
|
1719
|
+
**Why this verb may name its class.** `request` never does, so that an agent
|
|
1720
|
+
cannot call a `financial.spend` a `read.web`. Here the agent is also the
|
|
1721
|
+
registrant, so the bound is the operator's: the class must be an EXACT key of
|
|
1722
|
+
the policy's `classes` (a wildcard rule that happens to match does not count),
|
|
1723
|
+
and that key or a declared `<prefix>.*` family of it must set
|
|
1724
|
+
`agent_may_request: true` (SPEC.md §5.2). Anything else refuses
|
|
1725
|
+
`class-not-agent-requestable` and registers nothing. A `human-only` member
|
|
1726
|
+
under an open family refuses `class-human-only`, also before anything is
|
|
1727
|
+
registered. The check runs again inside the request's own read-check-append
|
|
1728
|
+
cycle, so a re-attestation in between cannot slip a closed class through. What
|
|
1729
|
+
the agent picks AMONG the opened classes is still its own statement: an
|
|
1730
|
+
operator who opens a manual class and an autonomous one under the same family
|
|
1731
|
+
is trusting the agent to pick the right one, and the record names the class it
|
|
1732
|
+
picked.
|
|
1733
|
+
|
|
1734
|
+
```yaml approval-policy
|
|
1735
|
+
classes:
|
|
1736
|
+
intent.publish.*: { autonomy: manual, agent_may_request: true }
|
|
1737
|
+
intent.publish.inferred.index: { autonomy: manual }
|
|
1738
|
+
intent.publish.stated.index: { autonomy: autonomous }
|
|
1739
|
+
```
|
|
1740
|
+
|
|
1741
|
+
**The key** must begin with `<class>:` and an id of printable characters with
|
|
1742
|
+
no whitespace or control characters (refused
|
|
1743
|
+
`key-class-mismatch`, exit 2): `intent.publish.inferred.index:<intention id>`.
|
|
1744
|
+
It is recorded verbatim as `action_key`, and a consumer reads the class from the
|
|
1745
|
+
record rather than parsing it back out of the key. The key is at most 1024 bytes
|
|
1746
|
+
and `--summary` at most 4096; a lone UTF-16 surrogate in either or anywhere in
|
|
1747
|
+
the payload, and a number JSON parses to Infinity (`1e400`), are usage errors. An
|
|
1748
|
+
integer beyond 2^53 is hashed as the double it parses to, so send large ids as
|
|
1749
|
+
strings.
|
|
1750
|
+
|
|
1751
|
+
**Do not open a `supervised-live` class to agents.** The live draw would decide,
|
|
1752
|
+
per proposal, whether a human is asked at all, which the requester can neither
|
|
1753
|
+
predict nor explain to the person whose words it is proposing.
|
|
1754
|
+
|
|
1755
|
+
**The task id** is `propose:` and the first 32 hex digits of the SHA-256 over
|
|
1756
|
+
the RFC 8785 canonical JSON of `[actor, class, key]`. The actor is the tenant
|
|
1757
|
+
half: one `approval serve` process is one store and one fixed `agent:<id>`. Two
|
|
1758
|
+
actors proposing one key derive two tasks, and the second registration is
|
|
1759
|
+
refused `task-already-registered` because the key is already declared under the
|
|
1760
|
+
first.
|
|
1761
|
+
|
|
1762
|
+
**Records.** One `task.registered` (actor = the caller, envelope `origin.app:
|
|
1763
|
+
approval-propose`, one action: the class, the summary, `idempotency_key` = the
|
|
1764
|
+
key, `payload_hash`) and one `approval.requested` exactly as `request` writes it,
|
|
1765
|
+
with `action_key` = the key verbatim, `summary`, `payload_hash`, `display_hash`,
|
|
1766
|
+
`policy_sha256` and `execution: "harness"` (a grant mints no token; the
|
|
1767
|
+
requester proceeds on the grant and records it with `start`). The summary is in
|
|
1768
|
+
the log in cleartext, twice; the payload is never in the log, only in the
|
|
1769
|
+
payload store (`.approval/payloads/<hash>.json`, mode 0600 in a 0700 directory),
|
|
1770
|
+
which the tenant credential's `GET /export` includes and the agent credential
|
|
1771
|
+
can never read back. Set `payload_retention` so the daemon prunes bytes whose
|
|
1772
|
+
action has finished.
|
|
1773
|
+
|
|
1774
|
+
**Idempotency.** The same class, key and bytes append nothing and answer the
|
|
1775
|
+
request's standing with `idempotent: true` while it still means something:
|
|
1776
|
+
pending, granted and spendable, rejected, revoked, or executed (`state:
|
|
1777
|
+
"executed"`, after `start`). The same call ASKS AGAIN, under the same
|
|
1778
|
+
registration, when the request can no longer be answered usefully: withdrawn,
|
|
1779
|
+
expired, a grant whose request window lapsed (a grant has no window of its own,
|
|
1780
|
+
so a tap at hour 71 of 72 authorizes nothing at hour 73), or a grant or pending
|
|
1781
|
+
request pinned to a policy that has since been re-attested (a pending one is
|
|
1782
|
+
first withdrawn, reason `superseded`). Two identical calls racing to register
|
|
1783
|
+
are one proposal. The same key with different bytes refuses
|
|
1784
|
+
`duplicate-request` while a request is live and `payload-mismatch` otherwise: a
|
|
1785
|
+
key is bound to the bytes it was first proposed with. A plain `approval request`
|
|
1786
|
+
on a `propose:` task is refused `task-is-proposal`.
|
|
1787
|
+
|
|
1788
|
+
**Off the manual path.** A class that resolves `autonomous` or `supervised` is
|
|
1789
|
+
registered and answered at once with `decision: "autonomous"` (or
|
|
1790
|
+
`"supervised"`) and no approval record. Act, then record it with `start`.
|
|
1791
|
+
|
|
1792
|
+
**`--json`** (one object on stdout):
|
|
1793
|
+
|
|
1794
|
+
```
|
|
1795
|
+
manual {"ok":true,"task":"propose:<32 hex>","action_key":"<k>","class":"<c>",
|
|
1796
|
+
"payload_hash":"<64 hex>","decision":"requested","state":"requested",
|
|
1797
|
+
"seq":7,"idempotent":false}
|
|
1798
|
+
retry {...,"state":"granted","seq":9,"idempotent":true}
|
|
1799
|
+
autonomous {...,"decision":"autonomous","state":null,"seq":null,"idempotent":false}
|
|
1800
|
+
```
|
|
1801
|
+
|
|
1802
|
+
Poll a `requested` proposal with `approval wait <task> --timeout 0`.
|
|
1803
|
+
|
|
1804
|
+
## start
|
|
1805
|
+
|
|
1806
|
+
`approval start <task> --action <key> --payload-json <json>` appends the one
|
|
1807
|
+
`execution.started` for an action its requester is about to carry out itself,
|
|
1808
|
+
outside any harness hook (APRV-445). Proposals only: any other task is refused
|
|
1809
|
+
`task-not-proposal`. REQUESTER-ONLY: the caller must be the actor
|
|
1810
|
+
that registered the task (`not-requester`). The bytes presented must hash to the
|
|
1811
|
+
registered `payload_hash` (`payload-mismatch`), so what is done is what was
|
|
1812
|
+
proposed.
|
|
1813
|
+
|
|
1814
|
+
It writes through the two existing harness spenders and nothing else. A key
|
|
1815
|
+
whose request is pending or granted and unspent spends its harness grant: refused `not-granted` while
|
|
1816
|
+
pending or after a rejection, `expired` after the window, `policy-drift` under
|
|
1817
|
+
a re-attested policy. A key with no such request (none, or one withdrawn,
|
|
1818
|
+
expired, rejected or revoked, which binds nothing) is recorded as authorized by
|
|
1819
|
+
the policy itself: refused `not-granted` for a manual class and `class-human-only`
|
|
1820
|
+
for a human-only one, and charged against the budgets. A second start of one key
|
|
1821
|
+
is `already-executed`. The record carries `execution: "harness"`, which is why no
|
|
1822
|
+
`execution.completed` ever follows it.
|
|
1823
|
+
|
|
1824
|
+
**`--json`**:
|
|
1825
|
+
|
|
1826
|
+
```
|
|
1827
|
+
{"ok":true,"task":"propose:<32 hex>","action_key":"<k>","class":"<c>",
|
|
1828
|
+
"authorization":"grant"|"policy","seq":12}
|
|
1829
|
+
```
|
|
1830
|
+
|
|
1548
1831
|
## grant
|
|
1549
1832
|
|
|
1550
1833
|
Legal only on a request that is awaiting a decision. A second decision is
|
|
@@ -1719,8 +2002,8 @@ refusal {"ok":false,"error":{"code":"...","message":"...","state"?:"..."}}
|
|
|
1719
2002
|
|
|
1720
2003
|
## gate refusal codes
|
|
1721
2004
|
|
|
1722
|
-
The vocabulary every gate verb (register, request,
|
|
1723
|
-
withdraw, expire) returns in `error.code` with `--json`. Frozen public API: an agent branches on it
|
|
2005
|
+
The vocabulary every gate verb (register, request, propose, start, grant,
|
|
2006
|
+
reject, revoke, withdraw, expire) returns in `error.code` with `--json`. Frozen public API: an agent branches on it
|
|
1724
2007
|
to decide whether to fix itself, stop retrying, or ask a human.
|
|
1725
2008
|
|
|
1726
2009
|
- `policy-not-attested` — policy unattested or its bytes changed since
|
|
@@ -1784,7 +2067,8 @@ to decide whether to fix itself, stop retrying, or ask a human.
|
|
|
1784
2067
|
it, or is withdrawing one it already withdrew. Distinct from `already-decided`:
|
|
1785
2068
|
nobody answered, and nobody can now. Request the action again.
|
|
1786
2069
|
- `not-requester` — a withdrawal was attempted by an actor other than the one
|
|
1787
|
-
that opened the request
|
|
2070
|
+
that opened the request, or a `start` by an actor other than the one that
|
|
2071
|
+
registered the task. Only the party that asked may take the question back;
|
|
1788
2072
|
anyone else who wants it gone rejects it.
|
|
1789
2073
|
- `expired` — the TTL lapsed, judged from the request's own `ts`.
|
|
1790
2074
|
- `not-expired` — expire was called before the TTL lapsed, or the policy declares
|
|
@@ -1822,6 +2106,20 @@ to decide whether to fix itself, stop retrying, or ask a human.
|
|
|
1822
2106
|
at the verb (exit 2) rather than a member of this union.
|
|
1823
2107
|
- `log-unreadable` (exit 4) / `log-torn-tail` (exit 3) / `log-corrupt` (exit 1):
|
|
1824
2108
|
nothing is authorized from a log that does not verify.
|
|
2109
|
+
- `class-not-agent-requestable` — `propose` named a class the policy has not
|
|
2110
|
+
opened to agents: not an exact key of `classes`, or neither that key nor a
|
|
2111
|
+
declared `<prefix>.*` family sets `agent_may_request: true`. Nothing is
|
|
2112
|
+
registered or appended; the repair is a policy edit and a human attestation.
|
|
2113
|
+
- `payload-too-large` (exit 2) — a `propose` or `start` payload over 262144
|
|
2114
|
+
bytes of UTF-8. Refused before it is parsed; nothing is stored.
|
|
2115
|
+
- `task-is-proposal` — a plain `request` on a task `propose` registered, or a
|
|
2116
|
+
task file claiming an id in the `propose:` namespace. Nothing is appended.
|
|
2117
|
+
- `task-not-proposal` — `start` on a task `propose` did not register.
|
|
2118
|
+
- `key-class-mismatch` (exit 2) — a `propose` key that does not begin with
|
|
2119
|
+
`<class>:` and an identifier.
|
|
2120
|
+
- `contended` — `propose` restarted its bounded number of times on a log that
|
|
2121
|
+
kept moving under it and stopped; nothing more was appended. Read the
|
|
2122
|
+
standing with `wait --timeout 0`, or call again.
|
|
1825
2123
|
- `append-failed` — the append itself failed; the exit code follows the cause.
|
|
1826
2124
|
`head-moved` means the log grew between this command's read and its write, so
|
|
1827
2125
|
nothing was written. Since APRV-236 you see it only after the command has
|
|
@@ -1953,9 +2251,23 @@ measurement.
|
|
|
1953
2251
|
Content binding (amended SPEC.md §6.2, §10.4): run computes the hash of the argv
|
|
1954
2252
|
and cwd it is about to spawn, always, and presents that. The command IS the
|
|
1955
2253
|
action here, and an executor that had to be told what it was running could be
|
|
1956
|
-
told wrong.
|
|
2254
|
+
told wrong.
|
|
2255
|
+
|
|
2256
|
+
Since APRV-401 that computation includes THE SCRIPT THE ARGV NAMES. A known
|
|
2257
|
+
interpreter followed by a path operand, or a path at `argv[0]`, carries that
|
|
2258
|
+
file's size and SHA-256 inside the hashed payload, so a grant over `bash
|
|
2259
|
+
install.sh` binds the script's bytes rather than its path: a script edited
|
|
2260
|
+
between the declaration and the run hashes differently and is refused
|
|
2261
|
+
`payload-mismatch`, with nothing appended and the grant still live for the
|
|
2262
|
+
approved bytes. An argv naming no script hashes exactly as it always did.
|
|
2263
|
+
`approval payload run` prints the same value, which is how a requester obtains
|
|
2264
|
+
the declaration in the first place; `docs/run-payload-binding.md` states the
|
|
2265
|
+
rule and the six things it does not reach.
|
|
2266
|
+
|
|
2267
|
+
`--payload-hash` is consequently a CHECK and never a substitute
|
|
1957
2268
|
(APRV-140): a value differing from the recomputed one is refused
|
|
1958
|
-
`payload-mismatch` before the child exists and before anything is appended
|
|
2269
|
+
`payload-mismatch` before the child exists and before anything is appended, and
|
|
2270
|
+
the refusal names the script it bound so the repair is visible. An
|
|
1959
2271
|
action whose payload is content rather than an argv (an email body, a record
|
|
1960
2272
|
write, a message and its recipients) is executed through the adapter contract of
|
|
1961
2273
|
§10.4, `approval adapter email`, which hashes those bytes itself; a token for
|
|
@@ -2154,6 +2466,33 @@ costs, and the carve-outs.
|
|
|
2154
2466
|
|
|
2155
2467
|
## wait
|
|
2156
2468
|
|
|
2469
|
+
`--timeout 0` (also `0s`, `0ms`) reads the current state once and never sleeps
|
|
2470
|
+
(APRV-445): an undecided request answers `timeout`, exit 6, at once, and a
|
|
2471
|
+
decided one answers as it always would. Use it to poll through `approval serve`,
|
|
2472
|
+
which runs every verb call whole under the store lock that hook calls take for
|
|
2473
|
+
their appends (APRV-427), so a wait that sleeps there holds the tenant's hook
|
|
2474
|
+
traffic for as long as it sleeps (APRV-441). The
|
|
2475
|
+
duration grammar has no zero anywhere else. `--withdraw-on-timeout` is refused
|
|
2476
|
+
beside it (exit 2): a status read that withdrew would retract every question it
|
|
2477
|
+
looked at.
|
|
2478
|
+
|
|
2479
|
+
**What a waiter is told is what the request still authorizes** (APRV-445). A
|
|
2480
|
+
grant whose request window has lapsed reads `expired` (exit 3), not `granted`. A
|
|
2481
|
+
pending request pinned to a policy hash that is no longer the attested one (any
|
|
2482
|
+
task), a HARNESS grant so pinned (`execution: "harness"`, every proposal
|
|
2483
|
+
included: its spend refuses `policy-drift`), or a request the runtime withdrew
|
|
2484
|
+
for `policy-drift`, reads `void`, **exit 7**: the answer can never be used, and
|
|
2485
|
+
the repair is to ask again (for a proposal, the same `propose` call re-files). A
|
|
2486
|
+
token grant is NOT void after a re-attest: `approval run` spends it without
|
|
2487
|
+
comparing policy hashes, so `wait && run` keeps working and `wait` reads
|
|
2488
|
+
`granted`. Precedence: rejected > withdrawn >
|
|
2489
|
+
void > expired > granted > executed > nothing-to-wait-for. An action that ran
|
|
2490
|
+
on the policy's own authority after its request ended (a `start` after a
|
|
2491
|
+
withdrawal and a re-tiering) reads `executed`, status `executed`, exit 0. A
|
|
2492
|
+
registered task with no requests answers `nothing-to-wait-for` at exit 0, and an
|
|
2493
|
+
unknown task id is refused `not-registered` at exit 1 (APRV-428), so poll the
|
|
2494
|
+
task id `propose` returned.
|
|
2495
|
+
|
|
2157
2496
|
Polls the log and writes nothing — not even the `approval.expired` event it may
|
|
2158
2497
|
derive: expiry is judged lazily from the request's own timestamp, and
|
|
2159
2498
|
materialising it is `approval expire`'s job, not a reader's. The one exception is
|
|
@@ -2162,9 +2501,27 @@ materialising it is `approval expire`'s job, not a reader's. The one exception i
|
|
|
2162
2501
|
For `approval wait` the exit code IS the decision (SPEC.md §10.1). The
|
|
2163
2502
|
overloading of 1 (integrity / rejected) and 3 (torn tail / expired) is
|
|
2164
2503
|
deliberate: wait appends nothing and cannot fail a chain verification of its
|
|
2165
|
-
own, and `--json` names the outcome exactly (`granted |
|
|
2166
|
-
expired | timeout`) for callers that need more than a
|
|
2167
|
-
review.
|
|
2504
|
+
own, and `--json` names the outcome exactly (`granted | executed | rejected |
|
|
2505
|
+
withdrawn | void | expired | nothing-to-wait-for | timeout`) for callers that need more than a
|
|
2506
|
+
number. Flagged for human review.
|
|
2507
|
+
|
|
2508
|
+
**Nothing to wait for, and nothing registered** (APRV-428). Exit 0 has always
|
|
2509
|
+
answered at once for a task with no requests, because only the manual path
|
|
2510
|
+
produces requests and the supervised path tells an agent its `proceed: true`
|
|
2511
|
+
needs no wait. Its `status` is `nothing-to-wait-for`, never `granted`: a
|
|
2512
|
+
registered task with no `approval.requested` at all (no action declared, none
|
|
2513
|
+
reached the manual path, or none requested yet), or one whose every granted
|
|
2514
|
+
action has already started executing, so every grant is spent. `granted` means
|
|
2515
|
+
at least one granted action has not executed yet, and only that. The per-action
|
|
2516
|
+
rows are unchanged; an executed grant still reads `granted` there.
|
|
2517
|
+
|
|
2518
|
+
A task with no `task.registered` record is refused before anything is derived:
|
|
2519
|
+
`not-registered`, the gate union's code for exactly that condition (SPEC.md
|
|
2520
|
+
§11.2), at exit 1, the code `approval request` exits for the same refusal.
|
|
2521
|
+
Before APRV-428 such a wait answered `{"ok":true,"status":"granted","actions":[]}`,
|
|
2522
|
+
so a typo read as a grant. Through `approval serve` the verb's refusal passes
|
|
2523
|
+
through unchanged: the body's `stderr` carries the same object and `exit_code`
|
|
2524
|
+
is 1.
|
|
2168
2525
|
|
|
2169
2526
|
`withdrawn` (APRV-106) reuses exit **1** rather than claiming a new number. The
|
|
2170
2527
|
exit table in `src/cli/exit-codes.ts` is frozen public API and agents already
|
|
@@ -2192,10 +2549,11 @@ itself fails is reported on stderr and leaves the request live; the exit code is
|
|
|
2192
2549
|
|
|
2193
2550
|
```
|
|
2194
2551
|
decided {"ok":true,"task":"task-042",
|
|
2195
|
-
"status":"granted"|"rejected"|"withdrawn"|"expired",
|
|
2552
|
+
"status":"granted"|"rejected"|"withdrawn"|"expired"|"nothing-to-wait-for",
|
|
2196
2553
|
"actions":[{"action_key":"...","state":"granted","seq":4}]}
|
|
2197
2554
|
timeout {"ok":false,"task":"task-042","status":"timeout",
|
|
2198
2555
|
"actions":[{"action_key":"...","state":"requested","seq":3}]}
|
|
2556
|
+
refused {"ok":false,"error":{"code":"not-registered","message":"..."}} (stderr, exit 1)
|
|
2199
2557
|
```
|
|
2200
2558
|
|
|
2201
2559
|
**Sealed token delivery** (APRV-105). Under policy `defaults.token_delivery:
|
|
@@ -2227,8 +2585,8 @@ so the paste path is preserved rather than replaced.
|
|
|
2227
2585
|
passed, listing the keys actually retracted; the default shape is unchanged.
|
|
2228
2586
|
|
|
2229
2587
|
`state` is the per-action derived state; `status` is the whole task's outcome,
|
|
2230
|
-
with rejected/revoked outranking withdrawn, withdrawn outranking expired,
|
|
2231
|
-
expired outranking granted. `--timeout` and `--interval` take the SPEC.md §5.2
|
|
2588
|
+
with rejected/revoked outranking withdrawn, withdrawn outranking expired,
|
|
2589
|
+
expired outranking granted, and granted outranking nothing-to-wait-for. `--timeout` and `--interval` take the SPEC.md §5.2
|
|
2232
2590
|
duration grammar, `<positive integer><ms|s|m|h|d|w>`.
|
|
2233
2591
|
|
|
2234
2592
|
## queue
|
|
@@ -2252,11 +2610,16 @@ requested, and how much of the TTL is left.
|
|
|
2252
2610
|
{"ok":true,"pending":[{"action_key":"task-042:chaser","task":"task-042",
|
|
2253
2611
|
"class":"communicate.email.external","est_cost_usd":"0.02",
|
|
2254
2612
|
"requested_ts":"2026-08-06T10:00:00.000Z","seq":3,
|
|
2255
|
-
"ttl_remaining_ms":3599000}]}
|
|
2613
|
+
"ttl_remaining_ms":3599000,"ttl_ms":3600000}]}
|
|
2256
2614
|
```
|
|
2257
2615
|
|
|
2258
|
-
`pending` is `[]` for an empty inbox.
|
|
2259
|
-
|
|
2616
|
+
`pending` is `[]` for an empty inbox. Both TTL fields are measured against the
|
|
2617
|
+
window the gate judges the request by (APRV-423): the policy's
|
|
2618
|
+
`defaults.approval_ttl`, narrowed by the harness ceiling the requesting hook
|
|
2619
|
+
declared, if any. `ttl_ms` is that whole window and `ttl_remaining_ms` is what is
|
|
2620
|
+
left of it; both are null only when nothing bounds the request (no TTL and no
|
|
2621
|
+
cap means no lapse). A hook-opened request under a policy with no TTL therefore
|
|
2622
|
+
shows its real remaining window here rather than `null`.
|
|
2260
2623
|
|
|
2261
2624
|
## gate
|
|
2262
2625
|
|
|
@@ -2397,9 +2760,47 @@ informational: it moves neither the health verdict nor the exit code. An empty
|
|
|
2397
2760
|
store is the normal state of a repo that has never made a request carrying
|
|
2398
2761
|
`--payload`. (`approval doctor` is where an unwritable store is a failure.)
|
|
2399
2762
|
|
|
2763
|
+
**attestation** names the policy in force: its state, the seq of the attestation
|
|
2764
|
+
the log last recorded, and `attested_by`, the actor of that record (APRV-449).
|
|
2765
|
+
The identity is read from the verified record and never from the file, so a
|
|
2766
|
+
hosted tenant can see whether their starting policy is the operator's
|
|
2767
|
+
bootstrap or their own later act (docs/hosted-provisioning.md). It is the
|
|
2768
|
+
identity the attesting process declared, which the runtime does not
|
|
2769
|
+
authenticate. `attested_by` is `null` when nothing was attested; on
|
|
2770
|
+
`hash-mismatch` it names who attested the bytes the live file no longer
|
|
2771
|
+
matches. The text row reads `attested (seq 1, by human:carter)`, and `approval
|
|
2772
|
+
doctor`'s attestation row carries the same name.
|
|
2773
|
+
|
|
2400
2774
|
**anomalies** are informational for the same reason `approval log verify`
|
|
2401
2775
|
declined to refuse on them: status does not get to overrule that.
|
|
2402
2776
|
|
|
2777
|
+
**refusals** is the two refusal families, counted, with the newest five of each
|
|
2778
|
+
(APRV-376). `decision` counts `audit.decision_refused`: a human decided and the
|
|
2779
|
+
gate would not take it, which is APRV-235's record. `gesture` counts
|
|
2780
|
+
`audit.gesture_refused`: a human made a gesture that is not a decision (a
|
|
2781
|
+
checkpoint signature, a review, a review note) and the surface refused it before
|
|
2782
|
+
any verb ran, which is APRV-355's. Until this field existed neither was reported
|
|
2783
|
+
anywhere, and an operator could reach them only through `approval log tail` and
|
|
2784
|
+
`approval log export`. The question the field answers is small enough that the
|
|
2785
|
+
chain was a poor place to answer it: told that taps from an account they did not
|
|
2786
|
+
map are being refused, an operator wants to know how many and from which account.
|
|
2787
|
+
|
|
2788
|
+
Each entry carries the `seq`, the surface's own refusal code verbatim, and the
|
|
2789
|
+
observed `sender` when the record carries one, in the form the record carries it
|
|
2790
|
+
(`hashed: true` marks APRV-370's keyed digest rather than a raw account id). The
|
|
2791
|
+
count is every record of that family in the log; the listing is the newest five,
|
|
2792
|
+
newest first, because a row that grew with the log would push the rest of the
|
|
2793
|
+
report off a terminal for a fact that decides nothing.
|
|
2794
|
+
|
|
2795
|
+
It is informational, exactly as `coverage` and `anomalies` are: it moves neither
|
|
2796
|
+
`healthy` nor the exit code. A refusal is the gate having worked, and a report
|
|
2797
|
+
that went red because the gate turned away a stranger's tap would teach an
|
|
2798
|
+
operator to stop reading it. It reads only verified records (SPEC.md §11.1
|
|
2799
|
+
invariant 1), which is also why a log that does not verify reports no refusals
|
|
2800
|
+
rather than reporting some it cannot stand behind. A family with no records is
|
|
2801
|
+
absent rather than zero, and the whole field is absent when both families are, so
|
|
2802
|
+
a repository where nothing has been refused emits the object it always emitted.
|
|
2803
|
+
|
|
2403
2804
|
**coverage** is one line of `approval coverage` (APRV-245): the commits git
|
|
2404
2805
|
recorded on THIS branch, counted against the verified log. The range is the
|
|
2405
2806
|
merge base with `origin/main` to `HEAD`, so what it measures is what this branch
|
|
@@ -2420,7 +2821,7 @@ does not know the flag exists.
|
|
|
2420
2821
|
**What it reports**, in one object:
|
|
2421
2822
|
|
|
2422
2823
|
- `attestation` — attested | hash-mismatch | not-attested | unreadable, with the
|
|
2423
|
-
seq of the governing `policy.updated` record.
|
|
2824
|
+
seq of the governing `policy.updated` record and `attested_by`, its actor.
|
|
2424
2825
|
- `verification` — the latest chain verdict, and the record count (null when
|
|
2425
2826
|
corrupt).
|
|
2426
2827
|
- `dangling` — executions the runtime meant to watch and never closed. Not
|
|
@@ -2443,12 +2844,25 @@ does not know the flag exists.
|
|
|
2443
2844
|
how many the log records as pruned, and how many are unbound. Informational.
|
|
2444
2845
|
- `anomalies` — additive and present only when non-empty: gate-typed events whose
|
|
2445
2846
|
`ts` steps backwards by more than 2s. Informational.
|
|
2847
|
+
- `refusals` — additive and present only when the log carries a refused decision
|
|
2848
|
+
or a refused gesture: `{count, recent}` per family, each recent entry naming the
|
|
2849
|
+
`seq`, the refusal `code` and the observed `sender` where there was one.
|
|
2850
|
+
Informational.
|
|
2851
|
+
- `daemon` — `{id, source, allowed}`, always present (APRV-383): the daemon
|
|
2852
|
+
instance id a daemon run against THIS log would write onto every record it
|
|
2853
|
+
appends, whether it came from `APPROVAL_DAEMON_ID` (`source: "environment"`) or
|
|
2854
|
+
was derived from the instance (`source: "derived"`), and the `daemons` allowlist
|
|
2855
|
+
the attested policy puts in force. `id: null` means `APPROVAL_DAEMON_ID` holds
|
|
2856
|
+
something that is not a usable id, so a daemon started here would be refused
|
|
2857
|
+
every append. `allowed: null` means no restriction, which is not the same fact as
|
|
2858
|
+
an empty list. Informational: nothing here moves `healthy` or the exit code, and
|
|
2859
|
+
being listed grants a daemon nothing beyond the ability to write.
|
|
2446
2860
|
|
|
2447
2861
|
**`--json`** (one object on stdout):
|
|
2448
2862
|
|
|
2449
2863
|
```
|
|
2450
2864
|
{"ok":true,"healthy":false,
|
|
2451
|
-
"attestation":{"state":"attested","seq":1},
|
|
2865
|
+
"attestation":{"state":"attested","seq":1,"attested_by":"human:carter"},
|
|
2452
2866
|
"verification":{"status":"clean","records":6},
|
|
2453
2867
|
"dangling":[{"action_key":"...","task":"...","ts":"...","seq":5}],
|
|
2454
2868
|
"budgets":[{"limit":"global.daily_usd","scope":"global",
|
|
@@ -2460,12 +2874,16 @@ does not know the flag exists.
|
|
|
2460
2874
|
"reconciliation":[{"seq":18,"ts":"...","action_key":"...","task":"...",
|
|
2461
2875
|
"class":"records.write","obligation":"gated-revert","review_seq":17}],
|
|
2462
2876
|
"payload_store":{"present":true,"files":2,"pruned":0,"orphans":0,
|
|
2463
|
-
"note":"..."}
|
|
2877
|
+
"note":"..."},
|
|
2878
|
+
"refusals":{"decision":{"count":2,"recent":[{"seq":21,"code":"policy-drift"}]},
|
|
2879
|
+
"gesture":{"count":1,"recent":[{"seq":24,"code":"sender-unmapped",
|
|
2880
|
+
"sender":{"channel":"telegram","id":"5551234567"}}]}},
|
|
2881
|
+
"daemon":{"id":"daemon-3f2a9c11","source":"derived","allowed":null}}
|
|
2464
2882
|
```
|
|
2465
2883
|
|
|
2466
|
-
`ok` is true whenever status ran; `healthy` is the verdict. `attestation.seq`
|
|
2467
|
-
null for not-attested and unreadable. `note`
|
|
2468
|
-
verbatim.
|
|
2884
|
+
`ok` is true whenever status ran; `healthy` is the verdict. `attestation.seq` and
|
|
2885
|
+
`attestation.attested_by` are null for not-attested and unreadable. `note`
|
|
2886
|
+
carries the unrebuildable warning verbatim.
|
|
2469
2887
|
|
|
2470
2888
|
## coverage
|
|
2471
2889
|
|
|
@@ -2657,7 +3075,8 @@ The checks, at length:
|
|
|
2657
3075
|
`--as`: this reports what the next command will find.
|
|
2658
3076
|
- **attestation** — anything other than "the live bytes match" makes every gated
|
|
2659
3077
|
operation refuse, and that refusal reads like "the policy says no" when it
|
|
2660
|
-
means "the policy is unverified".
|
|
3078
|
+
means "the policy is unverified". A pass names the seq and the attester read
|
|
3079
|
+
from that record (`attested at seq 1 by human:carter`, APRV-449).
|
|
2661
3080
|
- **log** — a torn tail and a corrupt log are both failures here; neither is
|
|
2662
3081
|
repaired, and doctor never truncates a torn line.
|
|
2663
3082
|
- **telegram** — `getMe` against `--api-base`, when both variables are set;
|
|
@@ -2674,7 +3093,17 @@ The checks, at length:
|
|
|
2674
3093
|
`--payload` request creates it); an existing directory this process cannot
|
|
2675
3094
|
write FAILS, because a request already accepted by the gate would refuse
|
|
2676
3095
|
`payload-store-failed` mid ceremony. The probe creates and removes one empty
|
|
2677
|
-
file
|
|
3096
|
+
file. Since APRV-457 the row also reads back every payload a verified record
|
|
3097
|
+
binds (pruned ones excepted) and FAILS on two distinct readings. An empty or
|
|
3098
|
+
all-NUL file, or a missing file whose record proves the store held it
|
|
3099
|
+
(`policy.proposed`, `policy.updated`, or `approval.requested` carrying
|
|
3100
|
+
`display_hash`), is the crash-before-writeback signature: nothing was
|
|
3101
|
+
tampered and the bytes are gone from this store; the fix moves a torn file
|
|
3102
|
+
aside and restores a copy that `approval payload hash` confirms. A file
|
|
3103
|
+
holding any non-NUL byte that does not verify is NOT the crash signature
|
|
3104
|
+
(a partial writeback leaving a valid prefix and a NUL tail lands here too, the stricter reading) and reads as tampering or
|
|
3105
|
+
corruption; the fix moves it aside as `.suspect`. Doctor moves, restores and
|
|
3106
|
+
deletes nothing.
|
|
2678
3107
|
- **audit-sampling** — sampling fails open by design (SPEC.md §5.2), so an
|
|
2679
3108
|
unconfigured sampler silently audits nothing; this states the disabled reason
|
|
2680
3109
|
out loud. A sampler nobody configured skips; a half-configured one fails,
|
|
@@ -2749,17 +3178,25 @@ The checks, at length:
|
|
|
2749
3178
|
of the gate, so the repair is a line for a human to commit, printed by
|
|
2750
3179
|
`approval instructions hook`.
|
|
2751
3180
|
- **harness-hook-wiring** — whether THIS checkout's `.claude/settings.json`
|
|
2752
|
-
registers `approval hook` for PreToolUse over every gated tool
|
|
2753
|
-
|
|
3181
|
+
registers `approval hook claude-code` for PreToolUse over every gated tool.
|
|
3182
|
+
The roster is read from the adapter that answers the calls (Bash, Edit, Write,
|
|
3183
|
+
MultiEdit, NotebookEdit since APRV-408, where it was a hand list that had
|
|
3184
|
+
drifted behind them). SKIP, named, when the file is absent, unreadable, or
|
|
2754
3185
|
registers the hook for only some tools: a spawned-agent worktree without the
|
|
2755
3186
|
entry is how the APRV-151 bypasses happened, and a session started elsewhere
|
|
2756
3187
|
may still be hooked, so this row can only speak for the checkout it runs in.
|
|
2757
3188
|
PASS means the entry is present on disk, and says so plainly: it is not proof
|
|
2758
|
-
the running session loaded it. The
|
|
2759
|
-
|
|
2760
|
-
|
|
2761
|
-
|
|
2762
|
-
|
|
3189
|
+
the running session loaded it. The one FAIL is a handler whose `--dir` names a
|
|
3190
|
+
checkout other than this one, which answers from another policy, another log
|
|
3191
|
+
and another open window. Two informational lines never decide anything: the
|
|
3192
|
+
adapter read tools the matcher leaves out (Read, Glob, Grep) are named as the
|
|
3193
|
+
documented default they are, and a matcher tool the adapter handles as none of
|
|
3194
|
+
its own is named as the unclassified `allow` it will actually receive. The
|
|
3195
|
+
check that trusts no session is the CI-side grant cross-check
|
|
3196
|
+
(`scripts/protected-path-guard.mjs`) over the committed log, which since
|
|
3197
|
+
APRV-202 requires every added and removed line of a protected path to trace to
|
|
3198
|
+
the bound material of a grant, rather than only that the path was granted at
|
|
3199
|
+
some point in the week.
|
|
2763
3200
|
- **keychain-scope** — whose keystore items this instance's `.approval/env`
|
|
2764
3201
|
names, answered from the NAMES alone so that it too can never block on an
|
|
2765
3202
|
unlock dialog. FAIL for an item whose eight-hex scope suffix belongs to
|
|
@@ -2863,7 +3300,14 @@ The checks, at length:
|
|
|
2863
3300
|
the `fix` is the promptless self-test in `docs/claude-code-hook.md`, one
|
|
2864
3301
|
supervised-class tool call, after which the row is green. PASS says only that
|
|
2865
3302
|
the versions match — the field is self-reported (SPEC.md §11.1 invariant 4)
|
|
2866
|
-
and reduces nothing anywhere, so a match is not proof the hook fired.
|
|
3303
|
+
and reduces nothing anywhere, so a match is not proof the hook fired. Since
|
|
3304
|
+
APRV-415 the row carries a second finding for one harness: a Hermes below the
|
|
3305
|
+
**fail-closed version floor** (a build at or after `main` `118984d7` of
|
|
3306
|
+
2026-09-20) FAILS, because `v0.21.3` ignores `fail_closed` silently and every
|
|
3307
|
+
broken hook on it proceeds. That half is read from `hermes --version` rather than
|
|
3308
|
+
from a record — Hermes prints its build stamp behind a non-ASCII separator the
|
|
3309
|
+
write boundary refuses, so no record can carry it — and it compares the build
|
|
3310
|
+
date and the upstream commit, because the two builds report the same semver.
|
|
2867
3311
|
- **live-draw** — whether a daemon is answering `supervised-live` draws for this
|
|
2868
3312
|
log (APRV-208). SKIP when the policy declares no live class: no draw is ever
|
|
2869
3313
|
made, and a missing socket is nothing. It FAILS in three shapes, all of them
|
|
@@ -2997,6 +3441,22 @@ The checks, at length:
|
|
|
2997
3441
|
would find nothing and report green, which is the worst direction a health
|
|
2998
3442
|
check can fail in. The `fix` points at the harness's own configuration,
|
|
2999
3443
|
because nothing here can turn another system's reviewer off.
|
|
3444
|
+
- **daemon-identity** — which daemon id a daemon run against this log would
|
|
3445
|
+
write onto every record it appends, and whether the attested policy admits it
|
|
3446
|
+
(APRV-383). The id is `APPROVAL_DAEMON_ID` from this shell where it is set and
|
|
3447
|
+
otherwise `daemon-` plus the instance id the `keychain-scope` row names, so the
|
|
3448
|
+
two rows describe one gate. A FAIL where `APPROVAL_DAEMON_ID` is set to
|
|
3449
|
+
something that is not an id: a daemon started from this shell would be refused
|
|
3450
|
+
`daemon-id-invalid` on every append, so it would read, render and report while
|
|
3451
|
+
writing nothing, and the `fix` is that one variable. A loud SKIP where the
|
|
3452
|
+
attested policy's `daemons` list does not admit the id, naming what the list
|
|
3453
|
+
does admit — expected where the daemon for this log runs on another machine,
|
|
3454
|
+
which is the hosted case the key exists for, and wrong if that daemon is meant
|
|
3455
|
+
to be this one. A PASS where the list admits it, and where there is no list at
|
|
3456
|
+
all, which is every installation that never adopts the key. Being listed grants
|
|
3457
|
+
nothing beyond the ability to write: no verdict, budget, floor, draw or token
|
|
3458
|
+
reads the id. The row reads the policy and one variable's SHAPE, prints no
|
|
3459
|
+
value on any path, and asks no running daemon anything.
|
|
3000
3460
|
|
|
3001
3461
|
|
|
3002
3462
|
|
|
@@ -3669,6 +4129,16 @@ This starts the standalone Telegram component. For normal operation, use
|
|
|
3669
4129
|
`up` or another listener polling the same bot, even for a different policy
|
|
3670
4130
|
project: competing `getUpdates` calls produce Telegram HTTP 409.
|
|
3671
4131
|
|
|
4132
|
+
A bot whose updates go to a webhook cannot be long-polled at all, so this verb
|
|
4133
|
+
and `approval up` refuse `webhook-registered` in the same preflight that asks
|
|
4134
|
+
`getMe`, naming the origin that holds it. That preflight also takes this gate's
|
|
4135
|
+
transport lease (`.approval/daemon/telegram-transport.lock`), so a second
|
|
4136
|
+
listener or a webhook runner started in the same project refuses
|
|
4137
|
+
`telegram-poller-running` naming the pid that holds it, rather than both
|
|
4138
|
+
processes putting every request on the phone under their own nonce. See
|
|
4139
|
+
[`channel telegram webhook`](#channel-telegram-webhook), which is the other
|
|
4140
|
+
transport and removes both its registration and its lease when it exits.
|
|
4141
|
+
|
|
3672
4142
|
**Delivery is per cycle, not only at startup.** Before every `getUpdates` the
|
|
3673
4143
|
listener re-derives the pending queue from the verified log and sends whatever
|
|
3674
4144
|
it has not already sent, so a request appended while this listener is running
|
|
@@ -3978,6 +4448,253 @@ rather than a query:
|
|
|
3978
4448
|
|
|
3979
4449
|
The raw execution token is never in the JSON stream.
|
|
3980
4450
|
|
|
4451
|
+
## channel telegram webhook
|
|
4452
|
+
|
|
4453
|
+
The same channel, the other arrival (APRV-424). `listen` holds a socket open
|
|
4454
|
+
against `getUpdates` for as long as the gate exists, which needs one
|
|
4455
|
+
always-running process per bot and forbids a second poller on the same token.
|
|
4456
|
+
This registers a URL with `setWebhook` and serves the callback, so Telegram
|
|
4457
|
+
posts each update as it happens and a host that sleeps between requests can run
|
|
4458
|
+
a gate.
|
|
4459
|
+
|
|
4460
|
+
**Only the arrival changes, and that is the whole claim.** The pending queue is
|
|
4461
|
+
re-derived from the verified log every cycle by the same `dispatchPending`. The
|
|
4462
|
+
handlers are the same four function objects `listen` registers. A tap becomes a
|
|
4463
|
+
decision through the same `handleUpdate`, the same `callback_query.from.id`
|
|
4464
|
+
reading, the same `approvers.<id>.senders` resolution, the same
|
|
4465
|
+
`recordChannelDecision` and the same annotate-after-decision edit. There is no
|
|
4466
|
+
second decision path and no second copy of the sender mapping:
|
|
4467
|
+
`tests/telegram-webhook.test.ts` drives one callback through both transports and
|
|
4468
|
+
compares the records the log ends up holding, field for field.
|
|
4469
|
+
|
|
4470
|
+
### The secret, which is the whole of the authentication
|
|
4471
|
+
|
|
4472
|
+
`APPROVAL_TG_WEBHOOK_SECRET` is REQUIRED and is read from the launch
|
|
4473
|
+
environment, never from a file in the tree (SPEC.md §11.1 invariant 7, the rule
|
|
4474
|
+
`approval serve`'s two credentials follow). It is Telegram's own `secret_token`:
|
|
4475
|
+
set on `setWebhook`, echoed on every delivery in the
|
|
4476
|
+
`X-Telegram-Bot-Api-Secret-Token` header, compared here in constant time over
|
|
4477
|
+
SHA-256 digests.
|
|
4478
|
+
|
|
4479
|
+
A webhook URL is a public endpoint, and nothing in a request body could tell a
|
|
4480
|
+
delivery from a forgery — a self-reported field never reduces scrutiny (§11.1
|
|
4481
|
+
invariant 4). So the header is checked FIRST, before the path, the method and
|
|
4482
|
+
the body, and a post without the matching value is refused with its own code,
|
|
4483
|
+
counted, and reported on stderr. **A refusal is never a decision, and it appends
|
|
4484
|
+
nothing to the log.** That is the rule `TELEGRAM_ANOMALY_KINDS` states for
|
|
4485
|
+
ignored callbacks, and it binds harder here: an endpoint the internet can reach
|
|
4486
|
+
that could append would be an endpoint anyone could use to pad the record a
|
|
4487
|
+
human is asked to trust.
|
|
4488
|
+
|
|
4489
|
+
The value is refused at startup when it is unset, shorter than 24 characters, or
|
|
4490
|
+
holds a character outside Telegram's `A-Z a-z 0-9 _ -`. It appears in no policy,
|
|
4491
|
+
no log, no record, no message and no error line: the channel redacts it from
|
|
4492
|
+
everything that leaves it, exactly as it redacts the bot token.
|
|
4493
|
+
|
|
4494
|
+
| refusal | what happened |
|
|
4495
|
+
|---|---|
|
|
4496
|
+
| `webhook-secret-mismatch` | no `X-Telegram-Bot-Api-Secret-Token`, or not the registered value |
|
|
4497
|
+
| `webhook-duplicate-secret-header` | more than one of that header; malformed, and not resolvable by choosing |
|
|
4498
|
+
| `webhook-malformed-request` | the request line and `Host` header do not form a URL (400) |
|
|
4499
|
+
| `webhook-unknown-path` | a path this process does not serve (404) |
|
|
4500
|
+
| `webhook-method-not-allowed` | the right path, the wrong method; Telegram POSTs |
|
|
4501
|
+
| `webhook-body-too-large` | over 256 KiB; drained before the refusal is written |
|
|
4502
|
+
| `webhook-body-unreadable` | the body could not be read off the socket, or is not JSON |
|
|
4503
|
+
| `webhook-not-an-update` | JSON that is not an Update object |
|
|
4504
|
+
| `webhook-transport-conflict` | this channel is already receiving updates by long poll |
|
|
4505
|
+
| `webhook-handler-failed` | the channel threw; the log is what says what was recorded |
|
|
4506
|
+
|
|
4507
|
+
Startup refuses in its own vocabulary before anything binds:
|
|
4508
|
+
`webhook-secret-missing`, `webhook-secret-weak`, `webhook-secret-charset`,
|
|
4509
|
+
`webhook-url-missing`, `webhook-url-insecure`, `webhook-url-port`,
|
|
4510
|
+
`webhook-url-userinfo`, `webhook-path-invalid`, `webhook-path-mismatch`,
|
|
4511
|
+
`webhook-cycle`, and `webhook-registration-failed` for a `setWebhook` the Bot
|
|
4512
|
+
API turned down (its status and its own description, scrubbed of the token and
|
|
4513
|
+
the secret), plus every `channel telegram listen` refusal, which this verb
|
|
4514
|
+
inherits because it needs the same bot token, chat, identity, log and policy.
|
|
4515
|
+
|
|
4516
|
+
Four of those are the review's, and each one closes a start that used to
|
|
4517
|
+
succeed and then not work:
|
|
4518
|
+
|
|
4519
|
+
- **`--path` must be the url's own path.** `telegram/webhook` with no leading
|
|
4520
|
+
slash, or `/hook/` against a `--url` ending `/hook`, registered cleanly and
|
|
4521
|
+
then answered every real delivery 404 after verifying its secret. `--path` is
|
|
4522
|
+
an explicit restatement of the url's path and nothing else; rewriting belongs
|
|
4523
|
+
in the proxy in front of this process.
|
|
4524
|
+
- **`--url` may not carry userinfo.** `https://user:pw@host/hook` is a
|
|
4525
|
+
credential on a command line. It is refused, never stripped.
|
|
4526
|
+
- **The bind port may not be 0**, and must be inside 1..65535. Port 0 asks the
|
|
4527
|
+
kernel for an ephemeral port, and this transport is a fixed public url
|
|
4528
|
+
forwarded to a fixed local address: a receiver on a port nobody registered
|
|
4529
|
+
verifies nothing and answers nothing.
|
|
4530
|
+
- **Every line that prints the url prints its origin and a redacted path**, in
|
|
4531
|
+
the start-up banner, in `webhook_started` and in refusals: with two segments
|
|
4532
|
+
or more the first is kept and the rest becomes `<path redacted>`, a path of
|
|
4533
|
+
exactly one segment is replaced whole (that is the shape a tunnel's own token
|
|
4534
|
+
takes), and a query string becomes `<query redacted>`. A tunnel that hands out
|
|
4535
|
+
`https://host/hook/<random>` puts a bearer value in a path, and this runtime
|
|
4536
|
+
does not copy it into three more places. The local bind line is redacted the
|
|
4537
|
+
same way, because it carries the same path. The operator has the whole of it
|
|
4538
|
+
already: they typed it into `--url`.
|
|
4539
|
+
|
|
4540
|
+
### The proxy or tunnel is required, and this process holds no certificate
|
|
4541
|
+
|
|
4542
|
+
Telegram delivers to HTTPS only, on port 443, 80, 88 or 8443. This process
|
|
4543
|
+
terminates no TLS and holds no certificate, exactly as `approval serve` does
|
|
4544
|
+
not: `--url` names the PUBLIC address your proxy or tunnel answers on, and
|
|
4545
|
+
`--port` / `--listen` names this process's own bind, which is `127.0.0.1:4683`
|
|
4546
|
+
unless you say otherwise. A routable bind takes both `--listen <host:port>` and
|
|
4547
|
+
`--allow-non-loopback` and prints a banner, because the secret arrives in a
|
|
4548
|
+
header and a cleartext hop hands it to whoever is on it.
|
|
4549
|
+
|
|
4550
|
+
So a working deployment is a reverse proxy (nginx, Caddy, a cloud load balancer)
|
|
4551
|
+
or a tunnel (Cloudflare Tunnel, ngrok, Tailscale Funnel) terminating TLS on a
|
|
4552
|
+
name you control and forwarding to the loopback bind. Without one there is
|
|
4553
|
+
nothing to register: a plain-HTTP `--url`, or one on another port, is refused
|
|
4554
|
+
before any call is made rather than attempted and rejected by the Bot API.
|
|
4555
|
+
|
|
4556
|
+
### One transport per bot, refused on both sides
|
|
4557
|
+
|
|
4558
|
+
`getUpdates` is refused by the Bot API outright while a webhook is set, so the
|
|
4559
|
+
two are alternatives and never a pair. Four checks say so before anything runs,
|
|
4560
|
+
and they are in that order because the first needs no network:
|
|
4561
|
+
|
|
4562
|
+
- **A lease per gate.** `approval up`, `approval channel telegram listen` and
|
|
4563
|
+
this verb take a lockfile in the gate's own derived state directory,
|
|
4564
|
+
`.approval/daemon/telegram-transport.lock`, holding the holder's pid, which
|
|
4565
|
+
transport it is running and when it started. It is created with `O_EXCL`, so
|
|
4566
|
+
two processes racing cannot both believe they took it, and a holder that is
|
|
4567
|
+
no longer running is reclaimed automatically (a crash must not lock a gate
|
|
4568
|
+
out of its own channel). A second taker refuses `telegram-poller-running`
|
|
4569
|
+
when a poller holds it and `webhook-registered` when a webhook does, naming
|
|
4570
|
+
the pid and the mode; a directory the lease cannot be written in refuses
|
|
4571
|
+
`telegram-lease-unavailable`, because this file is the only thing keeping one
|
|
4572
|
+
gate from running two transports. Both transports release it on a clean stop.
|
|
4573
|
+
|
|
4574
|
+
**A holder is a process, not a number.** Pids are reused, so the probe asks
|
|
4575
|
+
three questions: does a process with that number exist, can this process
|
|
4576
|
+
signal it, and did it start before the lease was written. A number that is
|
|
4577
|
+
gone, one owned by a process this one cannot signal, and one belonging to a
|
|
4578
|
+
process that started *after* the lease are all reclaimed, and the line says
|
|
4579
|
+
which of the three it was. Only a process that is running, signalable and
|
|
4580
|
+
older than its own lease keeps the gate. Where the platform will not report a
|
|
4581
|
+
start time, a running pid keeps the gate, which is the stricter reading.
|
|
4582
|
+
|
|
4583
|
+
**Taking a lease over is a critical section, and the section is owned.**
|
|
4584
|
+
Reclaiming happens inside a sibling `O_EXCL` lock that carries the entering
|
|
4585
|
+
process's pid and a nonce. Inside it the lease record must still be exactly
|
|
4586
|
+
the one that was judged (same pid, same `started_at`), the lock must still
|
|
4587
|
+
name this entry at the moment of the write, and the lock is removed on the
|
|
4588
|
+
way out only while it still does. Without the first, two processes that had
|
|
4589
|
+
both read one dead holder took turns deleting each other's live lease;
|
|
4590
|
+
without the other two, a process that stalled inside the section renamed its
|
|
4591
|
+
lease over the one that replaced it and deleted the new holder's lock. The
|
|
4592
|
+
replacement is a rename, so the lockfile is never absent and never half
|
|
4593
|
+
written. A reclaim lock is evicted only when its own process is gone, judged
|
|
4594
|
+
by the probe above: its age is reported and decides nothing, because "five
|
|
4595
|
+
seconds have passed" says nothing about whether anybody is still inside. A
|
|
4596
|
+
lock this build cannot read names no process, so the take refuses and names
|
|
4597
|
+
the file rather than forcing it.
|
|
4598
|
+
|
|
4599
|
+
This is the check the other three cannot make. Two processes started in the
|
|
4600
|
+
SAME project — one `approval up` long-polling, one `approval channel telegram
|
|
4601
|
+
webhook` — passed all of them: `getWebhookInfo` is empty while the poller is
|
|
4602
|
+
the one running, and the ownership registry compares instance ids, which are
|
|
4603
|
+
equal because it is the same instance. Each then ran its own dispatch cycle
|
|
4604
|
+
over its own state against one log, so every request reached the phone twice
|
|
4605
|
+
under two nonces and only one copy could resolve a tap.
|
|
4606
|
+
- **What the Bot API says.** `listen` and `approval up` ask `getWebhookInfo` in
|
|
4607
|
+
the same preflight that asks `getMe`, and refuse `webhook-registered` naming
|
|
4608
|
+
the origin that holds the bot. For a poller an unreachable Bot API is not a
|
|
4609
|
+
refusal (a captive portal says nothing about which transport owns a bot) and
|
|
4610
|
+
the HTTP 409 path is the backstop. For THIS verb it is: a probe it could not
|
|
4611
|
+
make refuses `webhook-probe-failed`, because the next call would be a
|
|
4612
|
+
`setWebhook` that overwrites a registration this process never saw.
|
|
4613
|
+
- **Any existing registration, including the same url, refuses.** Telegram
|
|
4614
|
+
allows one webhook per bot and keeps the last registration, so a second host
|
|
4615
|
+
registering takes the taps: it overwrites the first host's `secret_token`, and
|
|
4616
|
+
from then on every real tap arrives at the first host with a secret it does
|
|
4617
|
+
not recognise, indistinguishable from a forgery. `--reclaim` is how an
|
|
4618
|
+
operator takes the bot deliberately; with it, a url that matches the
|
|
4619
|
+
registered one (host lowercased, trailing slash dropped) is reported as a
|
|
4620
|
+
re-registration and anything else as a takeover that stops the other receiver
|
|
4621
|
+
being posted to.
|
|
4622
|
+
|
|
4623
|
+
**One case needs no flag: this gate's own runner was killed.** A `SIGKILL`
|
|
4624
|
+
leaves the registration in place, because the dead process never reached
|
|
4625
|
+
`deleteWebhook`, and a restart that refused every time would teach operators
|
|
4626
|
+
to put `--reclaim` in the unit file, which retires the protection above for
|
|
4627
|
+
good in exchange for a crash recovery. So a restart may re-register the SAME
|
|
4628
|
+
normalised url without the flag when the lease it just reclaimed was this
|
|
4629
|
+
gate's own, written by a webhook runner, and that process is **gone**. A dead
|
|
4630
|
+
poller's lease does not count, a different url does not count, and a gate
|
|
4631
|
+
with no such lease does not count. Nor do the other two reclaim reasons: a
|
|
4632
|
+
lease reclaimed because its pid is owned by a process this one cannot signal,
|
|
4633
|
+
or because the number has been reused, is the right call for the lease and no
|
|
4634
|
+
evidence at all that the runner which registered the url has stopped. Those
|
|
4635
|
+
keep the refusal and the `--reclaim` demand.
|
|
4636
|
+
- Within one process, `TelegramChannel.claimTransport` refuses the second claim.
|
|
4637
|
+
|
|
4638
|
+
A clean stop (SIGINT, SIGTERM) removes the webhook, so long polling works again
|
|
4639
|
+
afterwards. Pending updates are kept in both directions: a tap that arrived
|
|
4640
|
+
while the process was down is the approver's answer, and the gate is what
|
|
4641
|
+
decides whether it is still honourable.
|
|
4642
|
+
|
|
4643
|
+
### Redelivery, and the cycle
|
|
4644
|
+
|
|
4645
|
+
Telegram retries a delivery it did not get a 2xx for, so the same tap can arrive
|
|
4646
|
+
twice. Nothing deduplicates it: the second one reaches the gate through the same
|
|
4647
|
+
path as the first and is refused `already-decided`, with the first human answer
|
|
4648
|
+
standing — the property a button pressed twice has had since the channel
|
|
4649
|
+
shipped. Updates are handled one at a time, in arrival order, and the response
|
|
4650
|
+
is written after the update has been handled.
|
|
4651
|
+
|
|
4652
|
+
`--cycle <duration>` (default 30s) is the dispatch period, the webhook's
|
|
4653
|
+
equivalent of the poll loop's per-cycle dispatch: it re-derives the pending
|
|
4654
|
+
queue and sends what has not been sent. A cycle also runs immediately after each
|
|
4655
|
+
handled update, so a paced listener's next question follows a tap without
|
|
4656
|
+
waiting for the timer.
|
|
4657
|
+
|
|
4658
|
+
**A stop waits for the update it is holding.** Ctrl-C stops the receiver
|
|
4659
|
+
accepting, then drains: an update mid-append keeps its socket, finishes its
|
|
4660
|
+
decision and writes its response, and only then is `deleteWebhook` called and
|
|
4661
|
+
the stopped line printed. A second Ctrl-C during the drain is absorbed rather
|
|
4662
|
+
than killing the process, and a request that never finishes is dropped after ten
|
|
4663
|
+
seconds, because a stop has to end. `deleteWebhook` on that path is given five
|
|
4664
|
+
seconds rather than the channel's usual thirty, for the same reason: a stop an
|
|
4665
|
+
operator has asked for twice should not sit behind an unreachable Bot API, and
|
|
4666
|
+
a removal that does not land is reported as a webhook still registered.
|
|
4667
|
+
|
|
4668
|
+
### The daemon id it stamps (APRV-448)
|
|
4669
|
+
|
|
4670
|
+
Every record this process appends (a decision a tap delivers, the refusal of
|
|
4671
|
+
one, anything a dispatch cycle writes) carries the `daemon` field, with the id
|
|
4672
|
+
`approval status` reports for the same store: `APPROVAL_DAEMON_ID` from the
|
|
4673
|
+
launch environment, or `daemon-` plus the instance id derived from the log's
|
|
4674
|
+
`.approval` directory, which is the id the daemon loop derives. The started line
|
|
4675
|
+
names it (`records stamped daemon <id> (<source>)`) and `webhook_started`
|
|
4676
|
+
carries it as `daemon`. A declared id that is not a usable id refuses the verb
|
|
4677
|
+
before the bot is claimed or a port is bound, exit 2.
|
|
4678
|
+
|
|
4679
|
+
The attested policy's `daemons` list governs this process at the write
|
|
4680
|
+
boundary, as it does the daemon and [`approval serve`](#serve). It is re-read at
|
|
4681
|
+
startup and at the top of every dispatch cycle, which is this process's tick: a
|
|
4682
|
+
list a human narrows and re-attests takes effect within one cycle (a cycle runs
|
|
4683
|
+
after every handled update and every `--cycle`), and a resolution that fails
|
|
4684
|
+
leaves the previous list standing. A tap whose decision is refused
|
|
4685
|
+
`daemon-not-allowed` appends nothing, and the refusal is reported on stderr. The
|
|
4686
|
+
process declares the id without marking itself the daemon.
|
|
4687
|
+
|
|
4688
|
+
```json
|
|
4689
|
+
{"event":"webhook_started","url":"https://gate.example/telegram/<path redacted>",
|
|
4690
|
+
"host":"127.0.0.1","port":4683,"path":"/telegram/<path redacted>","cycle_ms":30000,
|
|
4691
|
+
"daemon":"daemon-3f2a9c11"}
|
|
4692
|
+
{"event":"stopped","notified":1,"updates":1,"decisions":1,"pollErrors":0,
|
|
4693
|
+
"anomalies":{"foreign-chat":0,"malformed-callback":0,"unknown-callback":0,
|
|
4694
|
+
"key-mismatch":0},
|
|
4695
|
+
"webhook":{"requests":2,"updates":1,"refusals":{"webhook-secret-mismatch":1}}}
|
|
4696
|
+
```
|
|
4697
|
+
|
|
3981
4698
|
## channel telegram health
|
|
3982
4699
|
|
|
3983
4700
|
Makes no network call. A health check that contacted the Bot API would announce
|
|
@@ -3991,6 +4708,194 @@ belong to a RUNNING listener: they are on its stderr as they happen, in its
|
|
|
3991
4708
|
Which variables are read comes from the policy, so a renamed variable reads back
|
|
3992
4709
|
as the name you set.
|
|
3993
4710
|
|
|
4711
|
+
## channel relay
|
|
4712
|
+
|
|
4713
|
+
A third arrival for a human gesture, beside a Telegram tap and a terminal
|
|
4714
|
+
(APRV-455). An operator's control plane holds an authenticated session for a
|
|
4715
|
+
person (the Agent Village control plane holds an EdgeOS session for each
|
|
4716
|
+
resident), shows them a request or their policy, and posts what they did to
|
|
4717
|
+
this verb. The post carries the gesture, the EdgeOS `/humans/me` id of the
|
|
4718
|
+
session that made it, the policy hash or the action key it answers, a nonce and
|
|
4719
|
+
an `issued_at`. The daemon resolves the id against the attested policy exactly
|
|
4720
|
+
as it resolves a Telegram `from.id`, and records the gesture through the same
|
|
4721
|
+
`recordChannelDecision` a tap goes through.
|
|
4722
|
+
|
|
4723
|
+
```
|
|
4724
|
+
approval channel relay [--listen [host:]port | --port <n>] [--allow-non-loopback]
|
|
4725
|
+
[--proposer agent:<id>] [--policy <p>] [--dir <p>]
|
|
4726
|
+
[--log <p>] [--json]
|
|
4727
|
+
```
|
|
4728
|
+
|
|
4729
|
+
### Trust level
|
|
4730
|
+
|
|
4731
|
+
**The daemon trusts the relay's attribution.** A post that carries the relay
|
|
4732
|
+
secret is the operator's control plane reporting which EdgeOS account acted,
|
|
4733
|
+
and the daemon believes that report the way it believes the Telegram Bot API's
|
|
4734
|
+
report of `callback_query.from.id`. That is operator trust, stated in
|
|
4735
|
+
APRV-422's words: no hosted service has authority over decisions. The relay
|
|
4736
|
+
carries a person's gesture and decides nothing. Which approver the account is,
|
|
4737
|
+
whether that approver may decide this request or attest this policy, and
|
|
4738
|
+
whether the request is still answerable are all decided by the runtime, from
|
|
4739
|
+
the verified log and the attested policy, with the same codes a tap gets.
|
|
4740
|
+
|
|
4741
|
+
What the relay cannot do, by construction:
|
|
4742
|
+
|
|
4743
|
+
- **Act as anyone of its own.** It takes no `--as` and reads no
|
|
4744
|
+
`APPROVAL_HUMAN`. Every gesture is attributed to the account
|
|
4745
|
+
`approvers.<id>.senders.edgeos` maps, or refused `sender-unmapped` with one
|
|
4746
|
+
`audit.decision_refused`. A policy that maps no EdgeOS account refuses every
|
|
4747
|
+
gesture the same way; there is no fallback to a configured identity.
|
|
4748
|
+
- **Read a mapping nobody has attested.** Every gesture, decisions included,
|
|
4749
|
+
resolves its sender against the policy IN FORCE: the verified bytes the
|
|
4750
|
+
latest attestation names, recovered from the payload store. A mapping
|
|
4751
|
+
sitting unattested on disk (the relay's own `propose` puts one there) decides
|
|
4752
|
+
nothing until a human attests it. Where the in-force bytes cannot be
|
|
4753
|
+
recovered, a decision is refused `policy-not-attested` (an attestation,
|
|
4754
|
+
`attest-requires-terminal`) and nothing is appended; a log that cannot be
|
|
4755
|
+
read keeps its own code.
|
|
4756
|
+
- **Choose who may sign for a policy change.** An attestation is resolved
|
|
4757
|
+
against the policy IN FORCE, never the one being attested, so an account a
|
|
4758
|
+
proposal adds or repoints cannot accept the proposal that names it, and an
|
|
4759
|
+
amendment that introduces the EdgeOS mapping is refused
|
|
4760
|
+
`attest-requires-terminal` (the operator bootstrap of APRV-449 is what maps
|
|
4761
|
+
the resident the first time).
|
|
4762
|
+
- **Hold a token.** A grant that mints a token for the deciding surface has it
|
|
4763
|
+
dropped unread; the response says only `token_issued`. Requests in the Agent
|
|
4764
|
+
Village are harness-executed or sealed, so none is lost; a manual-delivery
|
|
4765
|
+
request granted here has a token nobody holds and is asked again.
|
|
4766
|
+
- **Be reached through `approval serve` or `approval mcp serve`.** It is its
|
|
4767
|
+
own process, port and credential. The registry marks it `human_only`, so no
|
|
4768
|
+
wrapper publishes it, and neither serve credential reaches it.
|
|
4769
|
+
- **Be started by an agent.** The attribution a relay records is exactly as
|
|
4770
|
+
strong as control over who launches it, because its launcher chooses the
|
|
4771
|
+
secret. The hook classifier holds `approval channel relay` as `policy.core`
|
|
4772
|
+
(human-only in the reference policy), so an agent session cannot start one; a
|
|
4773
|
+
deployment starts it from its own launcher as the store's user. A keyed
|
|
4774
|
+
`senders.edgeos` mapping adds a second lock: a process without
|
|
4775
|
+
`APPROVAL_SENDER_KEY` cannot resolve any account.
|
|
4776
|
+
|
|
4777
|
+
### The secret
|
|
4778
|
+
|
|
4779
|
+
`APPROVAL_RELAY_SECRET` is REQUIRED and is read from the launch environment,
|
|
4780
|
+
never from a file in the tree (SPEC.md §11.1 invariant 7). At least 24
|
|
4781
|
+
characters of `A-Z a-z 0-9 _ -` (generate it: `openssl rand -hex 32`), with the
|
|
4782
|
+
same value given to the control plane; surrounding whitespace is stripped at
|
|
4783
|
+
start, so the control plane sends the trimmed value. Under the `APPROVAL_`
|
|
4784
|
+
prefix it is withheld from every child `approval run` spawns. The control plane sends it
|
|
4785
|
+
in `x-approval-relay-secret`; it is compared in constant time over SHA-256
|
|
4786
|
+
digests, BEFORE the path, the method or the body, and a duplicate header is
|
|
4787
|
+
refused rather than resolved. **A refusal never reaches the gate and appends
|
|
4788
|
+
nothing to the log**: it is counted, written to stderr, and answered with its
|
|
4789
|
+
code. The secret appears in no response, no log record and no stderr line.
|
|
4790
|
+
|
|
4791
|
+
Loopback by default (port 4684). There is no TLS here; a routable bind takes
|
|
4792
|
+
`--listen <host:port> --allow-non-loopback` and prints a banner every start.
|
|
4793
|
+
|
|
4794
|
+
### The body
|
|
4795
|
+
|
|
4796
|
+
`POST /relay/gesture`, one JSON object, closed: an unknown field is refused, so
|
|
4797
|
+
nothing that looks like authority (an `actor`, a second sender, a `ts`) rides
|
|
4798
|
+
along on a valid post.
|
|
4799
|
+
|
|
4800
|
+
```json
|
|
4801
|
+
{"gesture":"propose","policy_sha256":"<64 hex>","nonce":"<16..128 of A-Za-z0-9_->","issued_at":"2026-10-03T12:00:00Z"}
|
|
4802
|
+
{"gesture":"attest","policy_sha256":"<64 hex>","sender":{"channel":"edgeos","id":"<EdgeOS id>"},"nonce":"...","issued_at":"..."}
|
|
4803
|
+
{"gesture":"decline","policy_sha256":"<64 hex>","sender":{"channel":"edgeos","id":"..."},"nonce":"...","issued_at":"..."}
|
|
4804
|
+
{"gesture":"grant","action_key":"<the request's key>","sender":{"channel":"edgeos","id":"..."},"nonce":"...","issued_at":"..."}
|
|
4805
|
+
{"gesture":"reject","action_key":"<the request's key>","sender":{"channel":"edgeos","id":"..."},"nonce":"...","issued_at":"..."}
|
|
4806
|
+
```
|
|
4807
|
+
|
|
4808
|
+
- `sender.channel` is `edgeos` and nothing else. `sender.id` is the raw
|
|
4809
|
+
`/humans/me` id: an ASCII letter or digit, then up to 127 letters, digits,
|
|
4810
|
+
`.`, `_` or `-`, the grammar `senders.edgeos` pins. Never a digest (a keyed
|
|
4811
|
+
policy is resolved with `APPROVAL_SENDER_KEY` in the relay's environment) and
|
|
4812
|
+
never an email address.
|
|
4813
|
+
- `issued_at` is RFC 3339 in UTC ending in `Z`, within five minutes of the
|
|
4814
|
+
relay's clock either side. It is used for that window and recorded nowhere;
|
|
4815
|
+
every record's `ts` is the runtime's own.
|
|
4816
|
+
- `nonce` is claimed before the gesture is applied, in a ledger of exclusively
|
|
4817
|
+
linked files under the gate's `.approval/daemon/relay-nonces/`, kept for eleven
|
|
4818
|
+
minutes. A replay is refused `relay-nonce-replayed` at this process, after a
|
|
4819
|
+
restart, or at a second relay on the same gate. A retry is a new post with a
|
|
4820
|
+
new nonce, and the gate answers a repeated decision `already-decided`.
|
|
4821
|
+
|
|
4822
|
+
`propose` is the control plane's half of the onboarding review. It writes the
|
|
4823
|
+
rendered policy into the store, then posts its hash: the relay re-hashes the
|
|
4824
|
+
file and refuses `relay-policy-mismatch` when the bytes are not the ones named,
|
|
4825
|
+
then appends a `policy.proposed` under `--proposer` (an `agent:` id, default
|
|
4826
|
+
`agent:edgeos-relay`: the relay is a machine, and a `human:` proposer would say
|
|
4827
|
+
a person proposed what nobody typed) with the semantic diff against the
|
|
4828
|
+
verified in-force text and a deadline one hour out, after which the prompt
|
|
4829
|
+
retires from every queue. A retry of an open proposal of the same bytes returns it with
|
|
4830
|
+
`"existing":true`. It also admits bytes ALREADY in force (`"reaffirm":true`):
|
|
4831
|
+
the review most residents make is "accept, unchanged", and that acceptance is
|
|
4832
|
+
still their attestation. A reaffirmation changes the attester of record and
|
|
4833
|
+
nothing else. The prompt also reaches Telegram, where the resident's tap
|
|
4834
|
+
answers it equally.
|
|
4835
|
+
|
|
4836
|
+
**Until a proposal is attested, the bytes on disk are not the policy in
|
|
4837
|
+
force.** Every gated call refuses `policy-not-attested` in that window (a
|
|
4838
|
+
grant through the relay included), exactly as during any amendment. So the
|
|
4839
|
+
control plane proposes and collects the acceptance in the same screen, and
|
|
4840
|
+
restores the in-force bytes when the resident declines or the proposal lapses.
|
|
4841
|
+
A `decline` appends `policy.declined` and leaves the attestation in force
|
|
4842
|
+
where it was, the operator's included: it is a refusal to adopt the proposed
|
|
4843
|
+
bytes, never a withdrawal of the policy already attested.
|
|
4844
|
+
|
|
4845
|
+
`attest` / `decline` answer the open proposal of that hash. `attest` appends
|
|
4846
|
+
`policy.updated` with `actor: human:<approver>` and `payload.sender
|
|
4847
|
+
{channel: edgeos, id}`. A hash that names no open proposal (superseded,
|
|
4848
|
+
answered, never proposed) is refused `proposal-not-found`, and a file changed
|
|
4849
|
+
under the proposal is refused `proposal-stale`; neither attests anything.
|
|
4850
|
+
|
|
4851
|
+
`grant` / `reject` answer a pending request exactly as a Telegram tap does,
|
|
4852
|
+
including `expired`, `policy-drift` (with its `audit.decision_refused` and
|
|
4853
|
+
withdrawal), `already-decided` and `actor-not-approver`.
|
|
4854
|
+
|
|
4855
|
+
### Responses
|
|
4856
|
+
|
|
4857
|
+
| status | body | meaning |
|
|
4858
|
+
| --- | --- | --- |
|
|
4859
|
+
| 200 | `{"ok":true,"gesture":…,"event":…,"seq":…,"actor":…}` | recorded (`propose` returns `sha256`, `seq`, `existing`, `reaffirm`, `changes`, `loads`) |
|
|
4860
|
+
| 409 | `{"ok":false,"gesture":…,"refusal":{"code","message"}}` | the gate or the decision surface refused; the code is from `gate_refusal_codes` or `channel_decision_refusal_codes` |
|
|
4861
|
+
| 4xx/5xx | `{"error":{"code","message"}}` | the relay refused the post; the code is from `relay_refusal_codes`, and nothing was appended |
|
|
4862
|
+
|
|
4863
|
+
`relay_refusal_codes` (frozen, pinned in `conformance/vectors/refusal-unions.v1.json`):
|
|
4864
|
+
`relay-secret-mismatch`, `relay-duplicate-secret-header` (401),
|
|
4865
|
+
`relay-malformed-request` (400), `relay-unknown-path` (404),
|
|
4866
|
+
`relay-method-not-allowed` (405), `relay-body-too-large` (413),
|
|
4867
|
+
`relay-body-unreadable`, `relay-body-invalid`, `relay-sender-invalid`,
|
|
4868
|
+
`relay-gesture-stale` (400), `relay-nonce-replayed` (409),
|
|
4869
|
+
`relay-nonce-unavailable` (503, the ledger could not record the nonce, so
|
|
4870
|
+
nothing was decided), `relay-policy-mismatch` (409), `relay-handler-failed`
|
|
4871
|
+
(500).
|
|
4872
|
+
|
|
4873
|
+
Start-up refusals (exit 2, `{"error":{"code"}}` under `--json`):
|
|
4874
|
+
`relay-secret-missing`, `relay-secret-weak`, `relay-secret-charset`,
|
|
4875
|
+
`relay-proposer-invalid`, `relay-bind-invalid`. An unreadable log exits 4, a
|
|
4876
|
+
torn tail 3, a corrupt chain 1.
|
|
4877
|
+
|
|
4878
|
+
With `--json`, stdout carries one object per line: `relay_started` (`host`,
|
|
4879
|
+
`port`, `path`, `proposer`), one `relay_gesture` per gesture that reached the
|
|
4880
|
+
gate (`gesture`, `ok`, and `seq`/`record`/`actor` or `code`), and `stopped`
|
|
4881
|
+
with the counters. Never a body, a sender id or the secret.
|
|
4882
|
+
|
|
4883
|
+
There is no transport lease. A Telegram bot admits one receiver; a relay has no
|
|
4884
|
+
such constraint, because compare-and-append, the gate's idempotency and the
|
|
4885
|
+
nonce ledger (each claim written whole and linked into place, so exactly one
|
|
4886
|
+
claimant wins) make two relays on one gate safe. A second relay on the same
|
|
4887
|
+
port fails to bind.
|
|
4888
|
+
|
|
4889
|
+
**Who is listening on the port is the caller's check.** The secret travels in a
|
|
4890
|
+
header, so whoever holds the loopback port when the control plane posts
|
|
4891
|
+
receives it. In a sandbox where another uid can bind loopback (the Agent
|
|
4892
|
+
Village's `hermes` user), the control plane checks that the listener on 4684
|
|
4893
|
+
belongs to the relay's own user before it posts, the same foreign-listener
|
|
4894
|
+
rule the hook applies to `serve`, and posts to the literal `127.0.0.1`, never
|
|
4895
|
+
`localhost`, which another uid could answer on `[::1]`. A unix socket in a directory only the relay's
|
|
4896
|
+
user can write, or a signature over the body in place of a bearer secret, would
|
|
4897
|
+
remove the check; neither is in this version.
|
|
4898
|
+
|
|
3994
4899
|
## quickstart
|
|
3995
4900
|
|
|
3996
4901
|
`approval quickstart [--dir <path>] [--api-base <url>]` is the human-only solo setup ceremony. It
|
|
@@ -4041,17 +4946,20 @@ code. A re-run in a scaffolded directory writes nothing and exits 0. A directory
|
|
|
4041
4946
|
carrying `APPROVALS.md` (the SPEC.md §5 fallback filename) already has a policy:
|
|
4042
4947
|
init reports `policy-exists` and writes no `APPROVAL.md` beside it.
|
|
4043
4948
|
|
|
4044
|
-
Payloads are tracked. `.approval/payloads/`
|
|
4045
|
-
|
|
4046
|
-
|
|
4047
|
-
|
|
4949
|
+
Payloads are ignored (APRV-445; they were tracked before it). `.approval/payloads/`
|
|
4950
|
+
holds the exact bytes each approval bound to, and for a proposal those are a
|
|
4951
|
+
person's own words, so the merged block lists it. To keep the bytes in history
|
|
4952
|
+
as evidence instead, remove that line yourself; the log keeps every
|
|
4953
|
+
`payload_hash` either way. Files in the store are written 0600 in a 0700
|
|
4954
|
+
directory whatever the umask.
|
|
4048
4955
|
|
|
4049
4956
|
Keys are not. The merged block ignores `.approval/*.sqlite` (a projection),
|
|
4050
4957
|
`.approval/vault.enc`, `.approval/env`, `.approval/keys/`, `.approval/**/*.tmp-*`
|
|
4051
4958
|
and `.approval-journal/`. `.approval/keys/` is the one that matters most and is
|
|
4052
4959
|
easiest to miss: it holds the X25519 private halves of sealed token delivery, and
|
|
4053
|
-
because the payload store beside it
|
|
4054
|
-
|
|
4960
|
+
because the payload store beside it was tracked by default before APRV-445 (and
|
|
4961
|
+
still is wherever an operator chose that), `.approval/` is a directory people
|
|
4962
|
+
`git add` from. A key committed there opens that action's
|
|
4055
4963
|
`token_sealed` for everyone holding the log. The line is written whether or not
|
|
4056
4964
|
your policy has opted into `token_delivery: sealed`, and `approval doctor`'s
|
|
4057
4965
|
`sealed-keys` row reports a key that is tracked or in a store no line covers.
|
|
@@ -4097,6 +5005,81 @@ setting to change it, which the fail-closed invariant does not survive;
|
|
|
4097
5005
|
`docs/grok-hook.md` states which cases the adapter cannot cover and is worth
|
|
4098
5006
|
reading before the file is committed.
|
|
4099
5007
|
|
|
5008
|
+
**`hook muse` emits exactly ONE dialect and nothing else (APRV-350).** Muse Code
|
|
5009
|
+
treats output carrying any key it does not support as a failed hook, and a failed
|
|
5010
|
+
hook fails OPEN, so the belt-and-braces payload that satisfies several harnesses
|
|
5011
|
+
at once satisfies this one not at all. Its envelope is snake_case with its own
|
|
5012
|
+
tool names (`bash` carrying a per-call `workdir`, `write_file`, `read_file`, and
|
|
5013
|
+
`search` naming an ARRAY under `paths`), and a Contributor-tier model is refused
|
|
5014
|
+
for every tool call regardless of policy (`hook-muse-contributor-model`).
|
|
5015
|
+
`approval hook muse --help` prints the `.muse/hooks.json` the human commits;
|
|
5016
|
+
`docs/muse-hook.md` opens with the fail-open finding and the model warning.
|
|
5017
|
+
|
|
5018
|
+
**`hook hermes` is the second exception to the exit codes above, and the one
|
|
5019
|
+
harness whose configuration is not in the repository (APRV-398).** Its event
|
|
5020
|
+
names are its own — `pre_tool_call` and `post_tool_call`, not `PreToolUse` — and
|
|
5021
|
+
its envelope is snake_case (`tool_name`, `tool_input`, `session_id`, `cwd`,
|
|
5022
|
+
`profile`, `extra`). A deny is `{"action":"block","message":…}` **at exit 2**,
|
|
5023
|
+
which Hermes treats as an unconditional block whose message it takes from the
|
|
5024
|
+
stdout directive first; an **allow is `{}`** at exit 0, because Hermes has no
|
|
5025
|
+
allow directive and no directive is the allow, so an allow's reason goes to
|
|
5026
|
+
stderr. `execute_code` is refused outright
|
|
5027
|
+
(`hook-hermes-execute-code-unbound`): it carries a program and no path, no argv
|
|
5028
|
+
and no working directory, so no verdict could bind it. **A call whose effective
|
|
5029
|
+
directory the event does not carry is refused too** (`hook-unsupported-execution-context`,
|
|
5030
|
+
APRV-415): a live probe found that the envelope `cwd` is the Hermes PROCESS
|
|
5031
|
+
directory while `terminal` keeps a per-session recorded directory that a `cd` moves
|
|
5032
|
+
and no field reports, and that the file tools resolve relative paths against that
|
|
5033
|
+
one — so a `terminal` call with no absolute `workdir`, and a `write_file`, `patch`,
|
|
5034
|
+
`read_file` or `search_files` call whose path is relative or missing, are refused
|
|
5035
|
+
with a reason telling the session to retry with an absolute path. The file a human
|
|
5036
|
+
commits is `$HERMES_HOME/config.yaml` — name the home explicitly and spell its last
|
|
5037
|
+
segment `.hermes`, or the classifier does not recognise the organ — where the event
|
|
5038
|
+
is a mapping KEY rather than an `event:` field, `fail_closed: true` belongs on every
|
|
5039
|
+
entry because its default is false, and TWO timeouts bound the wait: the per-entry
|
|
5040
|
+
`timeout` caps at 300s and `plugins.hook_callback_timeout` defaults to 30s and
|
|
5041
|
+
fails closed by itself, so `--timeout` must come down under 300s. `--dir` is
|
|
5042
|
+
mandatory here, because a gateway session's `cwd` is the user's home. Without
|
|
5043
|
+
`hooks_auto_accept: true` (or `HERMES_ACCEPT_HOOKS=1`) and no TTY, Hermes
|
|
5044
|
+
**silently never registers the hook**. `fail_closed: true` was OBSERVED to block a
|
|
5045
|
+
hook crash, a hook timeout and unparseable output on `main` `118984d7`, which makes
|
|
5046
|
+
this the first adapter since Claude Code that is a gate rather than a backstop;
|
|
5047
|
+
`v0.21.3` (build 2026.9.14) does not know the key and fails open silently, so
|
|
5048
|
+
`approval doctor`'s `harness-version-unverified` row pins that floor.
|
|
5049
|
+
`approval hook hermes --help` prints the YAML; `docs/hermes-hook.md` opens with the
|
|
5050
|
+
fail-closed result, the floor, and what a post event does not prove.
|
|
5051
|
+
|
|
5052
|
+
**`--harness-cap <duration>` makes the question end before its asker does
|
|
5053
|
+
(APRV-423).** Every harness in the table kills the hook at some ceiling (Hermes
|
|
5054
|
+
caps a `pre_tool_call` entry at 300s, Claude Code kills it at the `timeout` in
|
|
5055
|
+
its settings file), and every one of them reads the killed process as a
|
|
5056
|
+
non-blocking error and runs the tool call. Before this flag the request that
|
|
5057
|
+
hook had opened stayed pending afterwards, so a tap arriving later was recorded
|
|
5058
|
+
as a plain `approval.granted` on a call nobody was holding, and the deny and the
|
|
5059
|
+
grant described one request and disagreed about it (APRV-410).
|
|
5060
|
+
|
|
5061
|
+
The flag states the ceiling. The hook records it on `approval.requested` as
|
|
5062
|
+
`payload.harness_cap_ms`, and the runtime judges the request against the SHORTER
|
|
5063
|
+
of the policy's `defaults.approval_ttl` and that ceiling minus a 60s margin
|
|
5064
|
+
(`HARNESS_CAP_MARGIN_MS` in `src/core/harness-wait.ts`, two of the daemon's 30s
|
|
5065
|
+
sweep intervals, so the `approval.expired` record lands while the harness is
|
|
5066
|
+
still listening in the common case; a skipped sweep or a slow intake can put it
|
|
5067
|
+
after the kill, and what keeps a late tap safe then is the gate's lazy `expired`
|
|
5068
|
+
refusal, which judges the lapse by arithmetic whether or not the record exists).
|
|
5069
|
+
A cap can only SHORTEN: the runtime takes a minimum, so a stated ceiling longer
|
|
5070
|
+
than the TTL changes nothing, which is SPEC.md §11.1 invariant 4 applied to a
|
|
5071
|
+
duration. Without the flag, `hook hermes` assumes **30s**, Hermes's default
|
|
5072
|
+
`plugins.hook_callback_timeout`, which does not clear the margin: every
|
|
5073
|
+
manual-class call is refused `hook-harness-cap-too-short` until the operator
|
|
5074
|
+
raises `plugins.hook_callback_timeout` above the per-entry `timeout` and passes
|
|
5075
|
+
`--harness-cap` with the smaller of the two (`--harness-cap 300s` for the
|
|
5076
|
+
documented `600` over `300`). A stated cap is still clamped to Hermes's observed
|
|
5077
|
+
300s per-entry maximum. No other adapter assumes a ceiling, because no other
|
|
5078
|
+
harness documents one. A ceiling at or below the margin leaves no window for a
|
|
5079
|
+
human and is refused `hook-harness-cap-too-short` before anything is registered
|
|
5080
|
+
or requested. `approval serve` pins the same value on every hook call it makes
|
|
5081
|
+
with `--hook-harness-cap`, as it pins `--timeout` with `--hook-timeout`.
|
|
5082
|
+
|
|
4100
5083
|
**Register the same command for the post-execution event too (APRV-145).** One
|
|
4101
5084
|
binary answers two events, dispatched on `hook_event_name`. A `PostToolUse` or
|
|
4102
5085
|
`PostToolUseFailure` run closes the delegated `execution.started` the
|
|
@@ -4133,7 +5116,38 @@ semantic-version-shaped tag, `tag <name>`, `--tags`, or `--follow-tags` is
|
|
|
4133
5116
|
that DELETES a remote ref — `--delete`, `-d`, or a colon refspec such as
|
|
4134
5117
|
`:refs/heads/x` — is `vcs.ref.delete` with the ref names bound (APRV-352), and a
|
|
4135
5118
|
tag deletion keeps `release.publish`; ordinary branch pushes retain their branch
|
|
4136
|
-
or trunk class.
|
|
5119
|
+
or trunk class.
|
|
5120
|
+
|
|
5121
|
+
`git tag` splits on its flags since APRV-397: a LISTING (`-l`, `--list`, `-n`,
|
|
5122
|
+
`-n<num>`, `--contains`, `--points-at`, `--merged`, `--sort`, `--format`,
|
|
5123
|
+
`--color`, `--ignore-case`, `--omit-empty`, or no argument at all) is
|
|
5124
|
+
`read.shell`, rule `git-tag-read`, the same class `git log`, `git status` and
|
|
5125
|
+
`git branch` take for reading local repository metadata. Everything else stays
|
|
5126
|
+
`release.publish`: `-a`, `-d`, `-f`, `-s`, `-m`, a bare tag name, and any flag
|
|
5127
|
+
the listing allowlist does not name, so a future `git tag` option cannot arrive
|
|
5128
|
+
as a read. A positional under `-l` is a pattern rather than a name.
|
|
5129
|
+
|
|
5130
|
+
The packaging and archive tools are classified too, for the same reason and in
|
|
5131
|
+
the same release (APRV-397): they were `unclassified`, so a verification could
|
|
5132
|
+
not run them and reached for `curl` and a script instead. `npm pack` is a
|
|
5133
|
+
workspace write of the tarball, scoped to `--pack-destination`, and
|
|
5134
|
+
`network.call` when a positional names a registry package; `npm init` writes
|
|
5135
|
+
`package.json`; a bare `npm --version`, `-v`, `-V`, `--help` or `-h` is a read,
|
|
5136
|
+
while every npm subcommand the table does not name keeps its `unclassified`
|
|
5137
|
+
deny; `tar -t` lists (a read) and `tar -x`/`-c` writes into `-C` or the archive
|
|
5138
|
+
`-f` names; `gunzip` reads only with `-c`/`--stdout`, `-t`/`--test` or
|
|
5139
|
+
`-l`/`--list` and otherwise replaces the file it names; `base64` reads unless
|
|
5140
|
+
`-o` names an output;
|
|
5141
|
+
`openssl dgst` (and the `md5`/`sha*` spellings) reads unless `-out` names one,
|
|
5142
|
+
while every other `openssl` subcommand stays unclassified. Each write is scoped
|
|
5143
|
+
by the arithmetic `rm` uses: a destination under a resolved scratch root or a
|
|
5144
|
+
relative one is `files.write.workspace`, and an absolute destination elsewhere, a
|
|
5145
|
+
`..` segment, an unreadable value, or a destination flag with nothing readable
|
|
5146
|
+
after it is `files.delete.out_of_scope`, rule `packaging-write-out-of-scope`,
|
|
5147
|
+
with the destination bound. A `tar` whose mode is not in its words is
|
|
5148
|
+
`hook-opaque`. `docs/claude-code-hook.md` holds the table and the reasoning.
|
|
5149
|
+
|
|
5150
|
+
Claude file tools
|
|
4137
5151
|
(Edit, Write, MultiEdit, NotebookEdit) and
|
|
4138
5152
|
Cursor Write/Delete are gated only when the file is policy-protected
|
|
4139
5153
|
(`APPROVAL.md`, `.approval/`, `CLAUDE.md`, `AGENTS.md`, `.claude/settings*`,
|
|
@@ -4170,7 +5184,8 @@ its own flags are not parsed as this verb's.
|
|
|
4170
5184
|
- `hook-rejected` — a human said no.
|
|
4171
5185
|
- `hook-revoked` — a granted approval was withdrawn.
|
|
4172
5186
|
- `hook-expired` — the TTL lapsed before a decision.
|
|
4173
|
-
- `hook-timeout` — no decision inside `--timeout
|
|
5187
|
+
- `hook-timeout` — no decision inside the wait (`--timeout`, clamped to what
|
|
5188
|
+
`--harness-cap` leaves after the 60s margin; APRV-423); the request stays live.
|
|
4174
5189
|
- `hook-gate-refused:<c>` — the gate refused intake; `<c>` is its own frozen code
|
|
4175
5190
|
(`policy-not-attested`, `budget-exceeded`, …).
|
|
4176
5191
|
- `hook-policy-unavailable` — `APPROVAL.md` could not be loaded.
|
|
@@ -4536,6 +5551,57 @@ defined over the canonical VALUE, so non-JSON input has no defined
|
|
|
4536
5551
|
could reproduce. Empty input is the same answer. A file that exists but cannot be
|
|
4537
5552
|
read is exit 4.
|
|
4538
5553
|
|
|
5554
|
+
## payload run
|
|
5555
|
+
|
|
5556
|
+
The payload `approval run` will recompute for a command, printed before any
|
|
5557
|
+
approval exists: its argv, the cwd it will run in, and the size and SHA-256 of
|
|
5558
|
+
the script that argv names.
|
|
5559
|
+
|
|
5560
|
+
```
|
|
5561
|
+
approval payload run -- bash scripts/install.sh
|
|
5562
|
+
{"argv":["bash","scripts/install.sh"],"cwd":"/repo",
|
|
5563
|
+
"script":{"argv_index":1,"path":"/repo/scripts/install.sh","bytes":412,
|
|
5564
|
+
"sha256":"fef0…"}}
|
|
5565
|
+
```
|
|
5566
|
+
|
|
5567
|
+
The `script` object is the whole point of the verb (APRV-401). A grant over
|
|
5568
|
+
`bash scripts/install.sh` used to bind that STRING, so the bytes behind the path
|
|
5569
|
+
were whatever the file held at execution time and the requester controlled the
|
|
5570
|
+
file between the request and the grant. The digest is inside the hashed value,
|
|
5571
|
+
so a script edited after the declaration is refused `payload-mismatch` before the
|
|
5572
|
+
child is spawned and before anything is appended, and the approver's card carries
|
|
5573
|
+
the path, the byte count and the digest because the payload does.
|
|
5574
|
+
|
|
5575
|
+
The argv names a script in two shapes: a known interpreter (the six shells,
|
|
5576
|
+
`node`, `python`/`python3`, `perl`, `ruby`, `deno` — each a name the command
|
|
5577
|
+
classifier already knows) followed by a path operand, or a path at `argv[0]` the
|
|
5578
|
+
kernel reads a shebang from. An inline program (`bash -c …`, `node -e …`) is
|
|
5579
|
+
already a word of the argv and binds no file.
|
|
5580
|
+
|
|
5581
|
+
An argv naming no readable script carries no `script` key and hashes exactly as
|
|
5582
|
+
it did before the rule existed, which is why every record already in a log and
|
|
5583
|
+
every declaration already written into a task file still verifies.
|
|
5584
|
+
|
|
5585
|
+
`--hash` prints the `payload_hash` instead of the bytes — the value a task file's
|
|
5586
|
+
action declaration carries. `--cwd` states the directory the run will happen in,
|
|
5587
|
+
because that directory is inside the hash and every relative argv word resolves
|
|
5588
|
+
against it. The two ends must agree, so pass the same `--cwd` the run will use.
|
|
5589
|
+
|
|
5590
|
+
What is NOT bound is stated in full in `docs/run-payload-binding.md`: nothing
|
|
5591
|
+
resolved through `PATH`, nothing a bound script itself reads or executes, and the
|
|
5592
|
+
instant between this hash and the spawn.
|
|
5593
|
+
|
|
5594
|
+
This verb reads the one file the argv names as its script and nothing else. No
|
|
5595
|
+
log, no policy, no network, no token, and it executes nothing.
|
|
5596
|
+
|
|
5597
|
+
It is **local only**: the MCP wrapper and `approval serve` withhold it, because
|
|
5598
|
+
the path it digests is one of the command's own words and no transport guard
|
|
5599
|
+
confines those the way the store confinement confines `payload hash`'s
|
|
5600
|
+
positional. A remote caller could otherwise ask for the digest of any file the
|
|
5601
|
+
server process can read (`-- bash /etc/shadow`) and learn that the path exists
|
|
5602
|
+
and what its bytes fingerprint to. Compute the binding where the command will
|
|
5603
|
+
run, which is the only place the value is true.
|
|
5604
|
+
|
|
4539
5605
|
## payload agentmail-draft
|
|
4540
5606
|
|
|
4541
5607
|
An AgentMail draft is mutable server-side state, so an approval of a draft id
|
|
@@ -4791,7 +5857,15 @@ says nothing about it.
|
|
|
4791
5857
|
it, and neither is a log to append onto. A working log that is a strict prefix
|
|
4792
5858
|
of the committed copy is an `anchor-behind` warning, not a stop. The `started`
|
|
4793
5859
|
line names the rev and seq this run is held to (`"anchor"`), and the tick line
|
|
4794
|
-
carries the comparison it made. Git is read, never fetched.
|
|
5860
|
+
carries the comparison it made. Git is read, never fetched. When the log was
|
|
5861
|
+
rewritten between the tick's opening read and the check's own read of the file
|
|
5862
|
+
(a `log sync` or a records pull replacing `events.jsonl` under the loop), the
|
|
5863
|
+
check reads the file again through the same verified read, reports one
|
|
5864
|
+
`anchor-reread` warning naming both heads, and the verdict beside it is the
|
|
5865
|
+
second read's (APRV-389). Every `anchor-diverged` stop is reached from the
|
|
5866
|
+
file's bytes and confirmed by that second read, and its message names the seq
|
|
5867
|
+
the chains parted at and both heads without also claiming either chain is a
|
|
5868
|
+
prefix of the other.
|
|
4795
5869
|
- ENVELOPE DRIFT — a task file whose `state:` contradicts the log gets an
|
|
4796
5870
|
`envelope.drift` event (actor `system:daemon`), once per claim.
|
|
4797
5871
|
- TTL SWEEP — every live request whose TTL lapsed gets an `approval.expired`
|
|
@@ -4910,8 +5984,11 @@ Warnings go to stderr as `{"event":"warning","code":"...","message":"..."}`, wit
|
|
|
4910
5984
|
`code` one of `task-unreadable`, `frontmatter-invalid`, `envelope-invalid`,
|
|
4911
5985
|
`task-id-missing`, `tasks-dir-unreadable`, `append-refused`, `expire-refused`,
|
|
4912
5986
|
`render-failed`, `watch-unavailable`, `prune-refused`, `write-back-refused`,
|
|
4913
|
-
`advance-refused`, `
|
|
4914
|
-
|
|
5987
|
+
`advance-refused`, `dark-session-undetermined`, `anchor-behind`,
|
|
5988
|
+
`anchor-reread`, `checkpoint-due`, `draw-unavailable`, `sample-deferred`,
|
|
5989
|
+
`drift-deferred`. A
|
|
5990
|
+
warning never stops the loop, and neither does
|
|
5991
|
+
`{"event":"git_evidence_failed","step":"commit",…}`.
|
|
4915
5992
|
|
|
4916
5993
|
Dangling advance cycles (APRV-264): at startup and before every trigger, the
|
|
4917
5994
|
daemon lists every advance execution nobody closed and closes each one this
|
|
@@ -4936,6 +6013,73 @@ rather than inferred from the queue's backlog. `rendered` is emitted when the qu
|
|
|
4936
6013
|
CHANGES; the file itself is rewritten every tick, because TTL countdowns move even
|
|
4937
6014
|
when the log does not.
|
|
4938
6015
|
|
|
6016
|
+
**A sample that met another writer is deferred, not dropped (APRV-381).** The
|
|
6017
|
+
audit sweep's append is a compare-and-append against the head it read, so it can
|
|
6018
|
+
meet a lock another writer holds (`lock-timeout`) or a record another writer
|
|
6019
|
+
landed in between (`head-moved`). Both are reported as `sample-deferred` on
|
|
6020
|
+
stderr, naming the action key and saying that the sweep retries on the next tick:
|
|
6021
|
+
|
|
6022
|
+
```
|
|
6023
|
+
warning sample-deferred audit sampling deferred hook:6f2a:vcs.push.main
|
|
6024
|
+
(lock-timeout): another writer holds .approval/log/events.jsonl.lock; gave up
|
|
6025
|
+
after 2000ms The sample is NOT lost: it is still pending in the log's own terms
|
|
6026
|
+
(eligible, drawn, no audit.sampled yet), and the sweep retries it on the next
|
|
6027
|
+
tick.
|
|
6028
|
+
audit.sampled: hook:6f2a:vcs.push.main drawn for review (execution.started at
|
|
6029
|
+
seq 51445) — recorded at seq 51447, the retry of the sample this run deferred
|
|
6030
|
+
earlier
|
|
6031
|
+
```
|
|
6032
|
+
|
|
6033
|
+
The second line is the same `sampled` event with `"retry":true` added, and it
|
|
6034
|
+
appears only when this run is the run that deferred the sample: a restarted
|
|
6035
|
+
daemon still makes the append, and says nothing about a promise it did not make.
|
|
6036
|
+
Nothing is remembered across processes because nothing needs to be: the sweep
|
|
6037
|
+
re-derives the pending set (eligible, drawn by the sampler, no `audit.sampled`
|
|
6038
|
+
yet) from the verified log on every tick, so a deferral is lost work only if the
|
|
6039
|
+
daemon never runs again.
|
|
6040
|
+
|
|
6041
|
+
Every other append refusal on a sample keeps the `append-refused` form, because
|
|
6042
|
+
`validation`, `canonicalization`, `corrupt-tail` and `io` are facts about the
|
|
6043
|
+
record or the file that no amount of retrying repairs.
|
|
6044
|
+
|
|
6045
|
+
**A drift record that met another writer is deferred too (APRV-403).** The drift
|
|
6046
|
+
scan's append is the same compare-and-append against the head it read, and it
|
|
6047
|
+
met the same contention: the end-to-end test that holds the append lockfile for
|
|
6048
|
+
a whole tick showed the scan printing `append-refused … (lock-timeout)` for an
|
|
6049
|
+
`envelope.drift` the next tick went on to write. So the same split applies, from
|
|
6050
|
+
the same closed list of transient codes, on both drift reasons:
|
|
6051
|
+
|
|
6052
|
+
```
|
|
6053
|
+
warning drift-deferred envelope.drift for task-042 was deferred (lock-timeout):
|
|
6054
|
+
another writer holds .approval/log/events.jsonl.lock; gave up after 2000ms The
|
|
6055
|
+
record is NOT lost: the disagreement is still there in the log's own terms, and
|
|
6056
|
+
the scan re-derives it and retries on the next tick.
|
|
6057
|
+
envelope.drift: task-042 (backlog/tasks/task-042.md) claims state approved, the
|
|
6058
|
+
log says proposed — recorded at seq 12, the retry of the record this run
|
|
6059
|
+
deferred earlier; the file is repaired to match the log later in this tick
|
|
6060
|
+
```
|
|
6061
|
+
|
|
6062
|
+
The `retry` flag has the same standing as the sample's: it is on the `drift`
|
|
6063
|
+
event only when THIS run is the one that deferred the record, it is output
|
|
6064
|
+
bookkeeping that no append consults, and a restarted daemon makes the same
|
|
6065
|
+
append and claims nothing. Deferring costs nothing because the scan carries no
|
|
6066
|
+
state between ticks: it re-reads the folder, re-derives the state from the
|
|
6067
|
+
verified log, and `driftAlreadyLogged` keeps a repeat idempotent.
|
|
6068
|
+
|
|
6069
|
+
An `envelope.drift` refused for `validation`, `canonicalization`, `corrupt-tail`
|
|
6070
|
+
or `io` keeps the `append-refused` form it always had.
|
|
6071
|
+
|
|
6072
|
+
**The sweep waits `2000ms` for the lock and no longer, by decision.** The daemon
|
|
6073
|
+
is the one writer that could afford a longer wait and deliberately takes the
|
|
6074
|
+
default. A tick is serial, so a blocking wait here delays the expiry lines, the
|
|
6075
|
+
`state:` write-back and `QUEUE.md`; the writers it contends with hold the lock
|
|
6076
|
+
across spans no polite wait covers (`approval log advance` across a whole
|
|
6077
|
+
verify-and-commit, `approval log sync` across a baseline move); and the appends it
|
|
6078
|
+
would be muscling in front of are the ones somebody is waiting on, a hook's gate
|
|
6079
|
+
verdict or a lane's execution record. Deferring costs one line and at most one
|
|
6080
|
+
tick interval, and it is provably lossless. Raise `--interval` only if you want
|
|
6081
|
+
the retry later; there is no flag for the lock wait, on purpose.
|
|
6082
|
+
|
|
4939
6083
|
## up
|
|
4940
6084
|
|
|
4941
6085
|
**Normal startup after setup and human attestation.** Run from the intended
|
|
@@ -6035,6 +7179,14 @@ STARTTLS, AUTH — and then QUIT. It proves the host answers, that the TLS mode
|
|
|
6035
7179
|
the one the server offers, and that the credential is accepted. It does not prove
|
|
6036
7180
|
delivery, and it puts no message on the wire.
|
|
6037
7181
|
|
|
7182
|
+
An `smtp.host` that is an IP address is probed without SNI (APRV-416). A server
|
|
7183
|
+
name is a name, so there is nothing to send for an address, and Node 26 refuses
|
|
7184
|
+
the session outright where earlier versions only warned. The certificate is
|
|
7185
|
+
still verified; what it is verified against is the address, which the server's
|
|
7186
|
+
certificate has to carry as an IP entry in its subject alternative names. A
|
|
7187
|
+
relay whose certificate names only a hostname therefore fails the probe when it
|
|
7188
|
+
is configured by address and passes when it is configured by that name.
|
|
7189
|
+
|
|
6038
7190
|
A failed probe keeps the values. A laptop behind a captive portal is not a reason
|
|
6039
7191
|
to make you type five things again. The refusal prints the SMTP code and the
|
|
6040
7192
|
server's first line, with the credential redacted, and the undo.
|
|
@@ -6335,6 +7487,442 @@ The guest instructions string says so, tells the caller to poll `status`, and
|
|
|
6335
7487
|
states plainly that a granted request executes nowhere: the demo is the approval
|
|
6336
7488
|
flow itself.
|
|
6337
7489
|
|
|
7490
|
+
## serve
|
|
7491
|
+
|
|
7492
|
+
`approval serve` is the same agent-facing surface as `mcp serve`, over HTTP,
|
|
7493
|
+
for a harness that has no local log and no local policy: a sandboxed tenant
|
|
7494
|
+
whose daemon runs elsewhere, one process per tenant. It is a transport and not
|
|
7495
|
+
a second gate. Every verdict, every verb and every batch of records comes from
|
|
7496
|
+
the function the CLI dispatches to; nothing under `src/serve/` classifies a
|
|
7497
|
+
command, resolves a policy, mints a token or verifies a chain.
|
|
7498
|
+
|
|
7499
|
+
STDOUT IS EMPTY. The bound address, the identity, the daemon id every record
|
|
7500
|
+
it appends carries (see [Which daemon wrote it](#which-daemon-wrote-it-aprv-448))
|
|
7501
|
+
and the store root go to stderr on one started line. SIGINT and SIGTERM close
|
|
7502
|
+
the listener and exit 0.
|
|
7503
|
+
|
|
7504
|
+
### The two credentials
|
|
7505
|
+
|
|
7506
|
+
Two bearer values, both read from the launch environment (SPEC.md §11.1
|
|
7507
|
+
invariant 7: nothing is loaded out of the working tree), both required before
|
|
7508
|
+
the listener exists, and refused when they are equal or shorter than 24
|
|
7509
|
+
characters:
|
|
7510
|
+
|
|
7511
|
+
| variable | opens |
|
|
7512
|
+
|---|---|
|
|
7513
|
+
| `APPROVAL_SERVE_AGENT_TOKEN` | `GET /verbs`, `POST /hook/<harness>`, and the allowlisted verbs below |
|
|
7514
|
+
| `APPROVAL_SERVE_TENANT_TOKEN` | `GET /verbs`, `GET /log/follow`, `GET /export`, `GET /status`, and every other published verb |
|
|
7515
|
+
|
|
7516
|
+
**The agent credential never reads the log it is judged by.** That is the
|
|
7517
|
+
sentence the split exists for. An agent that could stream the verified log
|
|
7518
|
+
could read every decision made about it and the shape of the oversight it is
|
|
7519
|
+
under; an agent that could export the store could carry all of it away.
|
|
7520
|
+
|
|
7521
|
+
Authorization is therefore per verb, and it is an ALLOWLIST. The party at the
|
|
7522
|
+
other end of this transport is a sandboxed harness on another machine, not the
|
|
7523
|
+
operator at their own laptop, so the agent credential opens only what a harness
|
|
7524
|
+
under oversight needs in order to ASK and to ACT ON a grant:
|
|
7525
|
+
|
|
7526
|
+
> `instructions`, `hook_classify`, `request`, `wait`, `withdraw`
|
|
7527
|
+
|
|
7528
|
+
Five verbs, and the shortness is the point: a harness under oversight asks and
|
|
7529
|
+
is answered, and everything else about the gate belongs to the party the gate
|
|
7530
|
+
is for.
|
|
7531
|
+
|
|
7532
|
+
Neither payload builder is on it, under the rule that a builder is the agent's
|
|
7533
|
+
only if it builds from the request's own arguments and never reaches a host
|
|
7534
|
+
resource. `payload_hash` takes a FILE, and on this transport that file is on
|
|
7535
|
+
the daemon's machine while the caller is not: every path it could name is one
|
|
7536
|
+
the host put there, so answering would be a hash oracle that also files the
|
|
7537
|
+
bytes into the payload store. `payload_agentmail-draft` takes two ids rather
|
|
7538
|
+
than a path, but reads the draft over HTTPS with `AGENTMAIL_API_KEY` from this
|
|
7539
|
+
server's own environment, so a remote caller would be spending the host's
|
|
7540
|
+
credential on ids it chose: the same fault wearing a network instead of a
|
|
7541
|
+
filesystem.
|
|
7542
|
+
|
|
7543
|
+
Everything else the catalog publishes is the tenant's. The ones easiest to wave
|
|
7544
|
+
through and most worth naming: `log_tail` and `log_export` return records,
|
|
7545
|
+
`log_verify` and `gate_status` return the log's length, its head hash and the
|
|
7546
|
+
live state of the gate, `queue` returns the tenant's pending decisions,
|
|
7547
|
+
`policy_check` and `policy_test` answer what the policy does with a class and
|
|
7548
|
+
name the absolute store path while doing it, and `status` is the tenant's view
|
|
7549
|
+
of the oversight the agent is under. So are the verbs that touch the host
|
|
7550
|
+
rather than the log: `run` and `sandbox` spawn argv on the daemon's machine,
|
|
7551
|
+
every `adapter_<name>` spends vault credentials, `log_advance` opens pull
|
|
7552
|
+
requests against the tenant's records. And so are `register` (its positional is
|
|
7553
|
+
a host path; the hook endpoint is a harness's way to register, and it
|
|
7554
|
+
synthesises the envelope itself), the two payload builders, `token`,
|
|
7555
|
+
`coverage`, `doctor`, `audit_list`, `audit_obligations`,
|
|
7556
|
+
`channel_telegram_health`, `journal_write`, `journal_read`, `feedback`,
|
|
7557
|
+
`values`, `import_agents-md`, `reindex` and `render`.
|
|
7558
|
+
|
|
7559
|
+
**No caller names a path, and no positional wears a dash.** Four rules, and
|
|
7560
|
+
each closes a route around the others.
|
|
7561
|
+
|
|
7562
|
+
1. The server appends `--dir`, `--log` and `--policy` to every verb call in
|
|
7563
|
+
every scope from its own launch configuration, and a call supplying one of
|
|
7564
|
+
them is refused `serve-path-pinned` even when the value is correct.
|
|
7565
|
+
2. A positional beginning with `-` is refused `serve-positional-flag`, in every
|
|
7566
|
+
scope. Positionals are emitted first and verbatim, and the verb reads any
|
|
7567
|
+
token starting with `-` as a flag — including the `--payload=/path`
|
|
7568
|
+
spelling — so without this rule a refused flag could be sent as a value and
|
|
7569
|
+
reach the parser through the front door. `trailing` needs no such rule: it
|
|
7570
|
+
always follows a `--` separator, and the flag parser stops there, which is
|
|
7571
|
+
what lets `hook classify -- git push -f` classify a command line that has
|
|
7572
|
+
flags in it.
|
|
7573
|
+
3. The AGENT credential may not use a path-typed flag at all
|
|
7574
|
+
(`serve-flag-not-permitted`), and may use only the flags on a short
|
|
7575
|
+
per-verb list. A harness reached through this transport has no files on the
|
|
7576
|
+
daemon's machine, so a path it names is either useless or somebody else's;
|
|
7577
|
+
payload bytes arrive through the hook route, which builds the envelope from
|
|
7578
|
+
the tool call.
|
|
7579
|
+
4. The TENANT credential's path-typed flags are confined to the store
|
|
7580
|
+
(`serve-path-outside-store`), resolved through `realpath` on the deepest
|
|
7581
|
+
existing ancestor so traversal and a symlinked directory both refuse.
|
|
7582
|
+
|
|
7583
|
+
Which flags are path-typed comes from the registry rather than from a list
|
|
7584
|
+
kept beside the server: a flag declared `"path"` publishes as an ordinary
|
|
7585
|
+
`{"type":"string"}`, so the wire contract is unchanged, and it is confined on
|
|
7586
|
+
the day it appears. A test walks every published verb and fails on any flag
|
|
7587
|
+
that is neither declared `"path"` nor reviewed as something else.
|
|
7588
|
+
|
|
7589
|
+
A verb added to the registry tomorrow is published on both surfaces the same
|
|
7590
|
+
day and is TENANT-scoped until somebody decides otherwise. The allowlist is
|
|
7591
|
+
what widens, and widening it is a diff in the server and a diff in a test.
|
|
7592
|
+
|
|
7593
|
+
An agent-credential call to anything off that list is refused
|
|
7594
|
+
`serve-agent-forbidden`, and a tenant-credential call to an allowlisted verb
|
|
7595
|
+
(`request`, `wait`, `withdraw`, `hook_classify`, or `consume`, which this
|
|
7596
|
+
surface withholds entirely) is refused `serve-tenant-forbidden`. Both are scope refusals rather
|
|
7597
|
+
than not-founds: a caller that guessed a path learns that the door exists and
|
|
7598
|
+
is not theirs.
|
|
7599
|
+
|
|
7600
|
+
Every path requires one of the two. There is no unauthenticated surface at all,
|
|
7601
|
+
not a health check and not a 404, so a caller with no credential learns nothing
|
|
7602
|
+
about what exists.
|
|
7603
|
+
|
|
7604
|
+
### The endpoints
|
|
7605
|
+
|
|
7606
|
+
`GET /verbs` is the published catalog: `approval mcp serve`'s tool list, name
|
|
7607
|
+
for name and schema for schema, because it is derived from the same registry
|
|
7608
|
+
filter by the same function. `--as` is absent from every published schema and
|
|
7609
|
+
the launch identity is appended last to every argv, so a caller can neither
|
|
7610
|
+
name an identity nor change one. `grant` is absent, as it is there and for the
|
|
7611
|
+
same reason: SPEC.md §11 makes the agent the untrusted policy, and a tool list
|
|
7612
|
+
is a statement about what a surface is for. Each entry carries the scope that
|
|
7613
|
+
opens it, so the catalog is honest about `status` being the tenant's.
|
|
7614
|
+
|
|
7615
|
+
### The response contract
|
|
7616
|
+
|
|
7617
|
+
`POST /verb/<tool_name>` and `POST /hook/<harness>` answer with ONE body:
|
|
7618
|
+
|
|
7619
|
+
```json
|
|
7620
|
+
{"exit_code": 0, "stdout": "…", "stderr": "…",
|
|
7621
|
+
"stdout_truncated": false, "stderr_truncated": false}
|
|
7622
|
+
```
|
|
7623
|
+
|
|
7624
|
+
`stdout` and `stderr` are exactly what the CLI wrote to each stream, and
|
|
7625
|
+
`exit_code` is exactly what it exited. A client reproduces the invocation by
|
|
7626
|
+
writing `stdout`, writing `stderr`, and exiting `exit_code`. A client that
|
|
7627
|
+
wants the verb's machine-readable refusal parses `stderr`, which is the stream
|
|
7628
|
+
the CLI prints refusals on. Both streams are bounded at 256 KiB and clipped at
|
|
7629
|
+
a codepoint boundary, with a flag saying whether that happened.
|
|
7630
|
+
|
|
7631
|
+
**Nothing about a verdict travels in a header.** It used to: the exit code rode
|
|
7632
|
+
on `x-approval-exit-code`, and server-authored refusals did not set it. A
|
|
7633
|
+
client following the documented rule then read a missing header as zero, and
|
|
7634
|
+
zero is ALLOW on Claude Code, Cursor, Codex and Muse — so a 1.1 MB hook
|
|
7635
|
+
envelope came back as a 413 that meant *proceed*. The exit code is now data in
|
|
7636
|
+
the body, it is present on every response including every refusal, and on a
|
|
7637
|
+
refusal it is always non-zero.
|
|
7638
|
+
|
|
7639
|
+
**A client treats a missing, unparseable or truncated body as a BLOCK.** That
|
|
7640
|
+
rule is not a nicety: a transport failure this server never saw (a proxy, a
|
|
7641
|
+
dropped connection, a process killed mid-response) has no exit code to carry,
|
|
7642
|
+
and the only safe reading of "no answer" from a gate is no.
|
|
7643
|
+
|
|
7644
|
+
`POST /hook/<harness>` takes the harness envelope the stdin form would read, for
|
|
7645
|
+
every harness this runtime speaks a hook protocol for. Its `stdout` is
|
|
7646
|
+
BYTE-FOR-BYTE what `approval hook <harness>` prints for that envelope, because
|
|
7647
|
+
it IS that stdout: the request body is handed to `commandHook` as its stdin and
|
|
7648
|
+
nothing reshapes the answer. The exit code must travel with it, because the
|
|
7649
|
+
dialects disagree about where a block lives — Claude Code, Cursor, Codex and
|
|
7650
|
+
Muse put it in the body at exit 0; Grok Build and Hermes use exit 2. Hermes's
|
|
7651
|
+
ALLOW is `{}` and carries its reason on stderr and nowhere else, which is why
|
|
7652
|
+
`stderr` is part of the contract rather than a diagnostic.
|
|
7653
|
+
|
|
7654
|
+
A refusal the SERVER raises on the hook route — an oversized body, a credential
|
|
7655
|
+
at the wrong door, a method it does not answer — carries `{"error":{…}}` and
|
|
7656
|
+
`exit_code: 2` **and** a `stdout` holding the harness's own block directive,
|
|
7657
|
+
rendered by the same function that prints one. A client that writes `stdout`
|
|
7658
|
+
and exits `exit_code` therefore blocks on both halves of every dialect at once.
|
|
7659
|
+
That holds for the refusals that fire BEFORE the URL is parsed, too — a
|
|
7660
|
+
rotated credential, a malformed `Host` — because the harness is read off the
|
|
7661
|
+
raw request target for the purpose of the refusal body and nothing else. A
|
|
7662
|
+
credential rotation must not read as a permission, and on four of the six
|
|
7663
|
+
dialects a body with no block directive at exit 0 is exactly that.
|
|
7664
|
+
|
|
7665
|
+
Where the harness is not known (an unroutable path, an unrecognised name)
|
|
7666
|
+
`stdout` is absent, and the client rule in the paragraph above is what covers
|
|
7667
|
+
it.
|
|
7668
|
+
|
|
7669
|
+
A deny is a VERDICT, so its HTTP status is 200; only a refusal by the server
|
|
7670
|
+
is not.
|
|
7671
|
+
|
|
7672
|
+
**A Hermes tenant needs `--hook-harness-cap`, or every manual class is refused
|
|
7673
|
+
(APRV-423).** `approval serve` pins `--harness-cap` on every hook call with
|
|
7674
|
+
`--hook-harness-cap`, as it pins `--timeout` with `--hook-timeout`. Without it a
|
|
7675
|
+
`POST /hook/hermes` runs as `approval hook hermes` with no flag, which assumes
|
|
7676
|
+
Hermes's 30s default `plugins.hook_callback_timeout`; 30s does not clear the 60s
|
|
7677
|
+
margin, so every manual-class call answers `hook-harness-cap-too-short` (a
|
|
7678
|
+
block, nothing registered or requested, no prompt sent) and a tenant is gated
|
|
7679
|
+
shut rather than gated. The repair for a serve operator, who does not own the
|
|
7680
|
+
tenant's `$HERMES_HOME`: have the tenant raise `plugins.hook_callback_timeout` in
|
|
7681
|
+
the harness's config above the per-entry `timeout`, then start `serve` with
|
|
7682
|
+
`--hook-harness-cap <the smaller of the two>` (`--hook-harness-cap 300s` for the
|
|
7683
|
+
documented `600` over `300`). The value is per server rather than per tenant, so
|
|
7684
|
+
a server fronting several Hermes tenants states the smallest ceiling any of them
|
|
7685
|
+
runs under; a stated cap can only shorten a window, never lengthen one. The
|
|
7686
|
+
same flag bounds the hook's WAIT on every route: `--hook-timeout` is clamped to
|
|
7687
|
+
what the ceiling leaves after the margin, so a hook that adopts an already-open
|
|
7688
|
+
question under a ceiling with no room in it answers `hook-timeout` at once
|
|
7689
|
+
(question left open for the retry grace) instead of being killed mid-wait.
|
|
7690
|
+
|
|
7691
|
+
`GET /log/follow?from=<seq>&cursor_hash=<64hex>&limit=<n>` answers one page of
|
|
7692
|
+
verified records and the cursor to ask with next time. The cursor is EXCLUSIVE
|
|
7693
|
+
and the subscription is `core/log-subscribe.ts`'s, so SPEC.md §8 holds
|
|
7694
|
+
unchanged: the chain is verified from genesis before anything is emitted, and a
|
|
7695
|
+
`cursor_hash` that does not match the retained prefix is refused with the
|
|
7696
|
+
`integrity` code, `cursor-mismatch` as its reason, and NO RECORDS, not even the
|
|
7697
|
+
ones a partial drain had already produced. `caught_up` says whether the page
|
|
7698
|
+
exhausted the log or the limit. The server keeps no subscription state: a page
|
|
7699
|
+
is a function of the cursor in the request and the bytes on disk, so a host that
|
|
7700
|
+
sleeps and wakes serves the same next page it would have served before.
|
|
7701
|
+
|
|
7702
|
+
**`from` greater than zero REQUIRES `cursor_hash`.** The subscription calls the
|
|
7703
|
+
sequence-only form a weaker bootstrap, weak in exactly one way: it cannot
|
|
7704
|
+
detect a fully recomputed replacement prefix on its first read. A streaming
|
|
7705
|
+
consumer pays that once and then retains the digest it verified. A paged one
|
|
7706
|
+
never does — every request is a first read — so the weakness would be
|
|
7707
|
+
permanent, and a caller who dropped the hash would be served a replaced prefix
|
|
7708
|
+
silently while an honest caller who kept it got the refusal. `from=0` is the
|
|
7709
|
+
only hashless form, because replaying from genesis binds nothing.
|
|
7710
|
+
|
|
7711
|
+
`from` must be a sequence number this runtime can represent and `limit` must be
|
|
7712
|
+
between 1 and 1000. Both are refused `serve-invalid-cursor` when they are not,
|
|
7713
|
+
rather than clamped: a clamp answers a different question than the one asked
|
|
7714
|
+
and the caller cannot tell it happened.
|
|
7715
|
+
|
|
7716
|
+
`GET /export` answers the store as a gzipped POSIX tar. Its contents are a
|
|
7717
|
+
positive allowlist (`APPROVAL.md`, `.approval/log/`, `.approval/payloads/`,
|
|
7718
|
+
`.approval/QUEUE.md`, `.approval/index.sqlite`), so `.approval/keys`,
|
|
7719
|
+
`.approval/env`, `.approval/daemon` and any `vault.enc` are out by not being
|
|
7720
|
+
named rather than by a denylist remembering them. `.approval/payloads/` is in,
|
|
7721
|
+
and it is the entry the reasoning turns on: the log records a `payload_hash`
|
|
7722
|
+
for every action a human was shown, and those bytes live there. An archive
|
|
7723
|
+
carrying the hashes without the bytes would hand a tenant a chain of references
|
|
7724
|
+
to evidence they no longer hold, which is a receipt for an exit rather than an
|
|
7725
|
+
exit.
|
|
7726
|
+
|
|
7727
|
+
**No link is followed, of either kind, and a link refuses the whole export.**
|
|
7728
|
+
A name-based allowlist is no defence against one: `ln -s
|
|
7729
|
+
.approval/keys/sender.key .approval/log/note.jsonl` passes every check that
|
|
7730
|
+
reads the name and hands over the key, and a link out of the store hands over
|
|
7731
|
+
any file on the host.
|
|
7732
|
+
|
|
7733
|
+
So every entry is `lstat`ed and a symbolic link refuses the export with
|
|
7734
|
+
`serve-export-symlink`; every file is then opened `O_NOFOLLOW` and `fstat`ed,
|
|
7735
|
+
so the checks are made against the descriptor that is actually read rather
|
|
7736
|
+
than against a name that could have been swapped in between; and a regular
|
|
7737
|
+
file whose link count is greater than one refuses with
|
|
7738
|
+
`serve-export-hardlink`. That last is the case with nothing to notice — a hard
|
|
7739
|
+
link is a second name for one inode, with no link to refuse to follow and no
|
|
7740
|
+
target to inspect — so the link count is the only thing that distinguishes it.
|
|
7741
|
+
Each refusal names the path and never its target.
|
|
7742
|
+
|
|
7743
|
+
The whole export is refused rather than the offending file skipped, because an
|
|
7744
|
+
archive silently missing a file is one nobody can tell from a complete one.
|
|
7745
|
+
|
|
7746
|
+
The only file the export drops on its own account is the append lockfile
|
|
7747
|
+
derived from the log path, which exists for exactly the span of the copy
|
|
7748
|
+
because the export holds that lock. It is matched as an exact path, not as a
|
|
7749
|
+
`*.lock` class: a tenant's own `.approval/payloads/x.lock` is the tenant's, and
|
|
7750
|
+
a name this runtime happens to use for bookkeeping is no reason to drop
|
|
7751
|
+
somebody else's file.
|
|
7752
|
+
|
|
7753
|
+
`GET /status` is the `status` verb, answered to the tenant credential.
|
|
7754
|
+
|
|
7755
|
+
### The bind, and where TLS is
|
|
7756
|
+
|
|
7757
|
+
`--port <n>` picks the port (default 4682) and always binds `127.0.0.1`.
|
|
7758
|
+
`--listen <[host:]port>` is the only way to bind anything else, passing both is
|
|
7759
|
+
a usage error, and a non-loopback host ALSO requires `--allow-non-loopback`: two
|
|
7760
|
+
flags, because the credentials here are bearer values and a cleartext hop hands
|
|
7761
|
+
them to whoever is on it. A widened bind prints a banner on stderr saying
|
|
7762
|
+
exactly that. This process terminates no TLS and holds no certificate; the
|
|
7763
|
+
supported deployment is a loopback bind behind a proxy the operator owns.
|
|
7764
|
+
|
|
7765
|
+
`--listen unix:<path>` (or `APPROVAL_SERVE_LISTEN=unix:<path>`, which the flag
|
|
7766
|
+
overrides) binds a unix-domain socket instead (APRV-445), for a sandbox on the
|
|
7767
|
+
same machine running as a different uid. The path must be absolute, inside an
|
|
7768
|
+
existing directory OWNED BY THE SERVING UID: that directory is the access
|
|
7769
|
+
control, so a directory somebody else owns is refused at startup (exit 2). The
|
|
7770
|
+
socket itself is opened `0666` once bound, and who may reach it is decided by
|
|
7771
|
+
the directory's mode, group or ACL. An other-writable directory (`/tmp`) is
|
|
7772
|
+
refused, since anyone could swap the socket. The widening to `0666` happens only
|
|
7773
|
+
in a directory nobody but the serving uid can write; in a group-writable one the
|
|
7774
|
+
socket keeps the umask's mode (a notice says so). The residual: between the bind
|
|
7775
|
+
and the chmod the socket carries the umask's narrower mode, which fails closed.
|
|
7776
|
+
A socket file is stale, and replaced, only when a connect to it is refused
|
|
7777
|
+
(`ECONNREFUSED`) or it vanished (`ENOENT`); a live one (another server answers)
|
|
7778
|
+
is refused rather than taken over, one whose probe fails any other way
|
|
7779
|
+
(`EACCES`, a full backlog) is left in place and refused, and anything at the
|
|
7780
|
+
path that is not a socket is refused rather than destroyed. Clients speak plain HTTP over the socket, with the same two
|
|
7781
|
+
bearer credentials. `--allow-non-loopback` has nothing to say about it.
|
|
7782
|
+
|
|
7783
|
+
### Which daemon wrote it (APRV-448)
|
|
7784
|
+
|
|
7785
|
+
Every record this server appends, from a verb call and from a hook call alike,
|
|
7786
|
+
carries the `daemon` field, and the id is the one on the started line
|
|
7787
|
+
(`records stamped daemon <id> (<source>)`). It is resolved once, at startup, by
|
|
7788
|
+
the rule the daemon loop uses: `APPROVAL_DAEMON_ID` from the launch environment
|
|
7789
|
+
when it is set, otherwise `daemon-` plus the instance id derived from the
|
|
7790
|
+
store's `.approval` directory. That is the id `approval status` reports for the
|
|
7791
|
+
same store, so a tenant who reads `daemon` on a record can look it up. No
|
|
7792
|
+
request names any part of it. A declared id that is not a usable id (lowercase
|
|
7793
|
+
letters, digits, `.`, `-`, `_`, at most 64 characters) refuses the server
|
|
7794
|
+
before it binds, exit 2.
|
|
7795
|
+
|
|
7796
|
+
The attested policy's `daemons` list governs this server at the write boundary,
|
|
7797
|
+
exactly as it governs the daemon. It is re-read from the verified log and the
|
|
7798
|
+
attested policy inside the store lock before every verb call and every hook
|
|
7799
|
+
call, so a list a human narrows and re-attests while the server runs refuses
|
|
7800
|
+
the very next call. A refused append writes nothing, and the verb answers
|
|
7801
|
+
`append-failed` with the write boundary's code under `error.append`:
|
|
7802
|
+
|
|
7803
|
+
```json
|
|
7804
|
+
{"ok":false,"error":{"code":"append-failed","append":"daemon-not-allowed",
|
|
7805
|
+
"message":"task.registered could not be appended: this daemon's id is village-goa-1 and the attested policy's `daemons` list admits village-goa-2, …"}}
|
|
7806
|
+
```
|
|
7807
|
+
|
|
7808
|
+
A hook call whose append is refused blocks, in the harness's own dialect.
|
|
7809
|
+
|
|
7810
|
+
The processes that stamp the field are three: the daemon loop (`approval daemon
|
|
7811
|
+
run`, and `approval up`, whose Telegram listener runs in the daemon's process),
|
|
7812
|
+
this server, and [`channel telegram webhook`](#channel-telegram-webhook). A
|
|
7813
|
+
session's own CLI calls, `approval hook <harness>` run as a harness's own
|
|
7814
|
+
process, and a standalone `channel telegram listen` stamp nothing. This server
|
|
7815
|
+
declares the id without marking itself the daemon: the mark is what routes an
|
|
7816
|
+
advance under the daemon's own autonomous class, and a process that dispatches
|
|
7817
|
+
the verbs an agent asks for does not hold it.
|
|
7818
|
+
|
|
7819
|
+
### What it never does
|
|
7820
|
+
|
|
7821
|
+
It appends no record on its own account. Every event in the log under it was
|
|
7822
|
+
written by a verb a caller asked for, under the identity the operator fixed at
|
|
7823
|
+
launch, and stamped with the daemon id on the started line. It reads no
|
|
7824
|
+
`.approval/env`.
|
|
7825
|
+
|
|
7826
|
+
### What it serialises, and what it does not (APRV-427)
|
|
7827
|
+
|
|
7828
|
+
One lock per store, held inside this process. It is the lock for every stretch
|
|
7829
|
+
of work that may append to the store or must read it as one snapshot:
|
|
7830
|
+
|
|
7831
|
+
| work | holds the store lock |
|
|
7832
|
+
|---|---|
|
|
7833
|
+
| `POST /verb/<name>`, including `GET /status` | for the whole call |
|
|
7834
|
+
| `GET /log/follow` | for the page, so no append this process makes lands under a verified read |
|
|
7835
|
+
| `GET /export` | for the whole snapshot, and the log's append lockfile as well, so no other process appends during it either |
|
|
7836
|
+
| `POST /hook/<harness>` | for its MUTATION sections only: everything up to its poll loop (intake, the abandoned-question sweep, register, request) and everything after it (the spend, a withdrawal) |
|
|
7837
|
+
| a hook call's WAIT | never |
|
|
7838
|
+
| `GET /verbs` | never |
|
|
7839
|
+
|
|
7840
|
+
So one hook call waiting on a human holds nothing. While it polls, the tenant's
|
|
7841
|
+
`status`, `queue` and follow answer, and a second hook call opens its own
|
|
7842
|
+
question and waits beside the first. A hook call runs on a worker thread of
|
|
7843
|
+
this process, because a hook's wait is a synchronous sleep and on the
|
|
7844
|
+
listener's own thread it would stop the listener; the verdict, its bytes and
|
|
7845
|
+
its exit code are `approval hook <harness>`'s, exactly as before. A new thread
|
|
7846
|
+
reads the log once BEFORE it asks for the store lock, so the cold walk of a
|
|
7847
|
+
mature log (hundreds of milliseconds) happens in parallel and never inside the
|
|
7848
|
+
lock; its reads under the lock are then warm.
|
|
7849
|
+
|
|
7850
|
+
**The pool is bounded, because each thread holds its own copy of the log**
|
|
7851
|
+
(about 26 MB idle, plus roughly 84 MB once it has read a 73k-record log). At
|
|
7852
|
+
most `--hook-threads` hook calls run at once, 16 by default, and no more
|
|
7853
|
+
threads than that ever exist. Up to `--hook-queue` further calls, 64 by
|
|
7854
|
+
default, wait for a slot in arrival order; a queued call has registered and
|
|
7855
|
+
requested nothing yet. A call that finds every slot and every place in line
|
|
7856
|
+
taken is refused at once with `serve-hook-saturated` (HTTP 503), carrying the
|
|
7857
|
+
harness's own block directive in `stdout` and `exit_code: 2`, and nothing is
|
|
7858
|
+
appended for it. Four finished threads are kept warm for the next call.
|
|
7859
|
+
|
|
7860
|
+
**Time in line is charged to the harness's ceiling.** The ceiling a harness
|
|
7861
|
+
puts on its hook (`--hook-harness-cap`, or the adapter's documented default)
|
|
7862
|
+
runs from the moment the call ARRIVED at this server. A call that waits for a
|
|
7863
|
+
slot or for the store lock spends that budget, and the hook judges, waits by
|
|
7864
|
+
and records on its request the ceiling less that wait, so any question it
|
|
7865
|
+
opens lapses at arrival plus ceiling minus the 60s margin, while the harness
|
|
7866
|
+
is still listening. A call that arrived with room and has less than the
|
|
7867
|
+
margin left when its slot or the lock arrives is refused
|
|
7868
|
+
`serve-hook-saturated` instead, appending nothing: a question opened then
|
|
7869
|
+
would still be on the approver's phone when the harness kills its asker. A
|
|
7870
|
+
ceiling that never had room is the hook's own `hook-harness-cap-too-short`,
|
|
7871
|
+
as before.
|
|
7872
|
+
|
|
7873
|
+
A torn read in a hook's poll (another writer's line caught half-landed, which a
|
|
7874
|
+
filesystem that grows a file a page at a time can show a reader) is read again
|
|
7875
|
+
on the next tick, up to five ticks in a row, before the hook treats the log as
|
|
7876
|
+
unreadable. This holds for the stdin form too, beside the daemon. Verb calls still run one at a time, in the
|
|
7877
|
+
order they arrive, because some of them are synchronous and blocking too (`run`
|
|
7878
|
+
spawns, and `wait` sleeps on the listener's thread, so an agent's `POST
|
|
7879
|
+
/verb/wait` still holds the listener for its own timeout; the hook route is the
|
|
7880
|
+
one a harness waits on).
|
|
7881
|
+
|
|
7882
|
+
Two appends never interleave. Inside this process the store lock is the
|
|
7883
|
+
reason: every append is inside one of the sections above, and the sections run
|
|
7884
|
+
one at a time. Across processes (a daemon, a CLI run beside this server) the
|
|
7885
|
+
reason is the one every `approval` process relies on: each append takes the
|
|
7886
|
+
log's lockfile and compares-and-appends against the head it read.
|
|
7887
|
+
|
|
7888
|
+
**A client that goes away is not answered, and does not spend.** When the HTTP
|
|
7889
|
+
connection of a waiting hook call closes before its answer, the call stops at
|
|
7890
|
+
its next poll tick without spending a grant and without withdrawing its
|
|
7891
|
+
question, and nothing is sent. A grant that lands afterwards is left for the
|
|
7892
|
+
harness's retry of the same command, which carries it and spends it once; an
|
|
7893
|
+
undecided question is adopted by that retry. A call still in line for a
|
|
7894
|
+
thread simply leaves the line. A grant already spent before the connection
|
|
7895
|
+
closed stays spent.
|
|
7896
|
+
|
|
7897
|
+
Closing the listener runs in this order: stop accepting connections; cancel
|
|
7898
|
+
every hook call the same way (waiting calls stop at their next tick, queued
|
|
7899
|
+
ones leave the line); terminate the hook threads from inside the store lock,
|
|
7900
|
+
which also waits out any verb or mutation section still running, so no thread
|
|
7901
|
+
is killed while holding the log's append lockfile; and only then destroy the
|
|
7902
|
+
remaining sockets. A question a cancelled call opened stays open for the retry
|
|
7903
|
+
grace, exactly as for an `approval hook` process that was killed mid-wait.
|
|
7904
|
+
Shutdown therefore takes as long as the longest work already holding the store
|
|
7905
|
+
lock (a `POST /verb/wait` runs to its own timeout), so a platform that kills
|
|
7906
|
+
the process a fixed time after SIGTERM (a container's stop timeout, a
|
|
7907
|
+
supervisor's kill timeout) should allow more than that, or the kill lands on
|
|
7908
|
+
work the lock was protecting. A hook call whose thread fails answers a
|
|
7909
|
+
refusal, `serve-hook-failed`, with the harness's own block directive in
|
|
7910
|
+
`stdout`.
|
|
7911
|
+
|
|
7912
|
+
## muse
|
|
7913
|
+
|
|
7914
|
+
`approval muse` starts a local, single-tenant synthetic consumer facade. The
|
|
7915
|
+
operator fixes the tenant and distinct read/propose credentials in the launch
|
|
7916
|
+
environment and the store root with `--dir` before the listener binds to
|
|
7917
|
+
loopback. The proposal
|
|
7918
|
+
scope can register an in-memory envelope and request its declared action. The
|
|
7919
|
+
read scope sees status, pending requests and the canonical rendering of a live
|
|
7920
|
+
request. Both scopes are narrow: the listener has no grant, reject, execution,
|
|
7921
|
+
token, generic verb or export route. Human decisions use the separately
|
|
7922
|
+
configured Telegram listener. A pending request does not prove delivery to
|
|
7923
|
+
Telegram. See `docs/muse-connector.md` for the local HTTP contract, native
|
|
7924
|
+
evidence limits and prerequisites.
|
|
7925
|
+
|
|
6338
7926
|
## Constrained Codex preparation
|
|
6339
7927
|
|
|
6340
7928
|
approval codex prepare is an artifact generator. It writes one fresh review
|
|
@@ -6473,7 +8061,9 @@ The deadline is the policy's `approval_ttl`, not a harness ceiling. Every hook
|
|
|
6473
8061
|
adapter answers inside a timeout its harness sets, and the retry grace exists so
|
|
6474
8062
|
a denial-by-deadline is recoverable; this transport has no timeout at all, so a
|
|
6475
8063
|
human who answers in eleven minutes is answering rather than arriving too late.
|
|
6476
|
-
`--wait` overrides it.
|
|
8064
|
+
`--wait` overrides it. The bridge therefore states no `--harness-cap` and the
|
|
8065
|
+
window shortening of APRV-423 never applies to it: there is no process here that
|
|
8066
|
+
something else will kill, so there is nothing for a cap to keep ahead of.
|
|
6477
8067
|
|
|
6478
8068
|
It answers accept or decline only, in the vocabulary the request advertised
|
|
6479
8069
|
through `availableDecisions`, matched exactly and never by prefix, so
|