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.
Files changed (226) hide show
  1. package/README.md +83 -914
  2. package/SPEC.md +98 -13
  3. package/cli.js +66 -7
  4. package/dist/src/adapters/smtp.d.ts +18 -0
  5. package/dist/src/adapters/smtp.js +24 -3
  6. package/dist/src/adapters/smtp.js.map +1 -1
  7. package/dist/src/channels/contract.d.ts +19 -0
  8. package/dist/src/channels/contract.js +105 -1
  9. package/dist/src/channels/contract.js.map +1 -1
  10. package/dist/src/channels/relay-server.d.ts +90 -0
  11. package/dist/src/channels/relay-server.js +295 -0
  12. package/dist/src/channels/relay-server.js.map +1 -0
  13. package/dist/src/channels/relay.d.ts +273 -0
  14. package/dist/src/channels/relay.js +564 -0
  15. package/dist/src/channels/relay.js.map +1 -0
  16. package/dist/src/channels/tagging.js +6 -1
  17. package/dist/src/channels/tagging.js.map +1 -1
  18. package/dist/src/channels/telegram-webhook.d.ts +273 -0
  19. package/dist/src/channels/telegram-webhook.js +536 -0
  20. package/dist/src/channels/telegram-webhook.js.map +1 -0
  21. package/dist/src/channels/telegram.d.ts +271 -1
  22. package/dist/src/channels/telegram.js +403 -45
  23. package/dist/src/channels/telegram.js.map +1 -1
  24. package/dist/src/cli/amend.js +350 -4
  25. package/dist/src/cli/amend.js.map +1 -1
  26. package/dist/src/cli/attest.js +43 -6
  27. package/dist/src/cli/attest.js.map +1 -1
  28. package/dist/src/cli/channel-relay.d.ts +108 -0
  29. package/dist/src/cli/channel-relay.js +334 -0
  30. package/dist/src/cli/channel-relay.js.map +1 -0
  31. package/dist/src/cli/channel-telegram-webhook.d.ts +273 -0
  32. package/dist/src/cli/channel-telegram-webhook.js +709 -0
  33. package/dist/src/cli/channel-telegram-webhook.js.map +1 -0
  34. package/dist/src/cli/channel-telegram.d.ts +272 -26
  35. package/dist/src/cli/channel-telegram.js +478 -41
  36. package/dist/src/cli/channel-telegram.js.map +1 -1
  37. package/dist/src/cli/channel.d.ts +1 -1
  38. package/dist/src/cli/channel.js +5 -1
  39. package/dist/src/cli/channel.js.map +1 -1
  40. package/dist/src/cli/daemon.js +37 -5
  41. package/dist/src/cli/daemon.js.map +1 -1
  42. package/dist/src/cli/doctor.d.ts +63 -0
  43. package/dist/src/cli/doctor.js +856 -111
  44. package/dist/src/cli/doctor.js.map +1 -1
  45. package/dist/src/cli/execute.d.ts +17 -1
  46. package/dist/src/cli/execute.js +341 -20
  47. package/dist/src/cli/execute.js.map +1 -1
  48. package/dist/src/cli/exit-codes.d.ts +12 -0
  49. package/dist/src/cli/exit-codes.js +13 -0
  50. package/dist/src/cli/exit-codes.js.map +1 -1
  51. package/dist/src/cli/gate.d.ts +2 -0
  52. package/dist/src/cli/gate.js +233 -2
  53. package/dist/src/cli/gate.js.map +1 -1
  54. package/dist/src/cli/help.d.ts +9 -1
  55. package/dist/src/cli/help.js +246 -38
  56. package/dist/src/cli/help.js.map +1 -1
  57. package/dist/src/cli/hook.d.ts +371 -3
  58. package/dist/src/cli/hook.js +1926 -44
  59. package/dist/src/cli/hook.js.map +1 -1
  60. package/dist/src/cli/init.js +16 -7
  61. package/dist/src/cli/init.js.map +1 -1
  62. package/dist/src/cli/instructions.js +24 -3
  63. package/dist/src/cli/instructions.js.map +1 -1
  64. package/dist/src/cli/log-anchor.d.ts +55 -0
  65. package/dist/src/cli/log-anchor.js +199 -60
  66. package/dist/src/cli/log-anchor.js.map +1 -1
  67. package/dist/src/cli/main.d.ts +0 -12
  68. package/dist/src/cli/main.js +51 -0
  69. package/dist/src/cli/main.js.map +1 -1
  70. package/dist/src/cli/muse.d.ts +2 -0
  71. package/dist/src/cli/muse.js +62 -0
  72. package/dist/src/cli/muse.js.map +1 -0
  73. package/dist/src/cli/payload.js +79 -1
  74. package/dist/src/cli/payload.js.map +1 -1
  75. package/dist/src/cli/scaffold.js +6 -0
  76. package/dist/src/cli/scaffold.js.map +1 -1
  77. package/dist/src/cli/serve.d.ts +53 -0
  78. package/dist/src/cli/serve.js +270 -0
  79. package/dist/src/cli/serve.js.map +1 -0
  80. package/dist/src/cli/up.js +21 -2
  81. package/dist/src/cli/up.js.map +1 -1
  82. package/dist/src/cli/verb-registry.d.ts +2 -0
  83. package/dist/src/cli/verb-registry.js +337 -39
  84. package/dist/src/cli/verb-registry.js.map +1 -1
  85. package/dist/src/cli/wordmark.d.ts +2 -2
  86. package/dist/src/cli/wordmark.js +2 -2
  87. package/dist/src/core/attest-bootstrap.d.ts +95 -0
  88. package/dist/src/core/attest-bootstrap.js +135 -0
  89. package/dist/src/core/attest-bootstrap.js.map +1 -0
  90. package/dist/src/core/attest.d.ts +20 -3
  91. package/dist/src/core/attest.js +25 -0
  92. package/dist/src/core/attest.js.map +1 -1
  93. package/dist/src/core/audit.d.ts +55 -3
  94. package/dist/src/core/audit.js +52 -5
  95. package/dist/src/core/audit.js.map +1 -1
  96. package/dist/src/core/channel-lease.d.ts +322 -0
  97. package/dist/src/core/channel-lease.js +663 -0
  98. package/dist/src/core/channel-lease.js.map +1 -0
  99. package/dist/src/core/command-class.d.ts +51 -0
  100. package/dist/src/core/command-class.js +760 -8
  101. package/dist/src/core/command-class.js.map +1 -1
  102. package/dist/src/core/daemon-host.d.ts +178 -0
  103. package/dist/src/core/daemon-host.js +228 -0
  104. package/dist/src/core/daemon-host.js.map +1 -0
  105. package/dist/src/core/daemon-identity.d.ts +238 -0
  106. package/dist/src/core/daemon-identity.js +252 -0
  107. package/dist/src/core/daemon-identity.js.map +1 -0
  108. package/dist/src/core/gate.d.ts +306 -5
  109. package/dist/src/core/gate.js +772 -24
  110. package/dist/src/core/gate.js.map +1 -1
  111. package/dist/src/core/harness-version.d.ts +128 -4
  112. package/dist/src/core/harness-version.js +179 -12
  113. package/dist/src/core/harness-version.js.map +1 -1
  114. package/dist/src/core/harness-wait.d.ts +82 -3
  115. package/dist/src/core/harness-wait.js +89 -3
  116. package/dist/src/core/harness-wait.js.map +1 -1
  117. package/dist/src/core/log-subscribe.d.ts +21 -0
  118. package/dist/src/core/log-subscribe.js +16 -6
  119. package/dist/src/core/log-subscribe.js.map +1 -1
  120. package/dist/src/core/log-write-layer.d.ts +55 -0
  121. package/dist/src/core/log-write-layer.js +91 -0
  122. package/dist/src/core/log-write-layer.js.map +1 -0
  123. package/dist/src/core/log.d.ts +50 -21
  124. package/dist/src/core/log.js +197 -19
  125. package/dist/src/core/log.js.map +1 -1
  126. package/dist/src/core/payload-census.d.ts +51 -0
  127. package/dist/src/core/payload-census.js +125 -2
  128. package/dist/src/core/payload-census.js.map +1 -1
  129. package/dist/src/core/payload-store.d.ts +31 -0
  130. package/dist/src/core/payload-store.js +113 -8
  131. package/dist/src/core/payload-store.js.map +1 -1
  132. package/dist/src/core/payload.d.ts +18 -3
  133. package/dist/src/core/payload.js +20 -4
  134. package/dist/src/core/payload.js.map +1 -1
  135. package/dist/src/core/policy-diff.d.ts +10 -0
  136. package/dist/src/core/policy-diff.js +10 -2
  137. package/dist/src/core/policy-diff.js.map +1 -1
  138. package/dist/src/core/policy-explain.d.ts +7 -1
  139. package/dist/src/core/policy-explain.js +16 -1
  140. package/dist/src/core/policy-explain.js.map +1 -1
  141. package/dist/src/core/policy-load.d.ts +24 -0
  142. package/dist/src/core/policy-load.js.map +1 -1
  143. package/dist/src/core/policy-match.d.ts +38 -0
  144. package/dist/src/core/policy-match.js +35 -0
  145. package/dist/src/core/policy-match.js.map +1 -1
  146. package/dist/src/core/policy-proposal.d.ts +23 -0
  147. package/dist/src/core/policy-proposal.js +8 -2
  148. package/dist/src/core/policy-proposal.js.map +1 -1
  149. package/dist/src/core/read-scope.d.ts +10 -0
  150. package/dist/src/core/read-scope.js +4 -0
  151. package/dist/src/core/read-scope.js.map +1 -1
  152. package/dist/src/core/run-payload.d.ts +160 -0
  153. package/dist/src/core/run-payload.js +304 -0
  154. package/dist/src/core/run-payload.js.map +1 -0
  155. package/dist/src/core/sender-identity.d.ts +35 -10
  156. package/dist/src/core/sender-identity.js +48 -11
  157. package/dist/src/core/sender-identity.js.map +1 -1
  158. package/dist/src/core/state.d.ts +52 -1
  159. package/dist/src/core/state.js +52 -6
  160. package/dist/src/core/state.js.map +1 -1
  161. package/dist/src/core/telegram-config.d.ts +79 -0
  162. package/dist/src/core/telegram-config.js +108 -0
  163. package/dist/src/core/telegram-config.js.map +1 -1
  164. package/dist/src/core/verify.d.ts +23 -1
  165. package/dist/src/core/verify.js +20 -2
  166. package/dist/src/core/verify.js.map +1 -1
  167. package/dist/src/daemon/audit.d.ts +80 -5
  168. package/dist/src/daemon/audit.js +126 -6
  169. package/dist/src/daemon/audit.js.map +1 -1
  170. package/dist/src/daemon/daemon.d.ts +174 -2
  171. package/dist/src/daemon/daemon.js +298 -7
  172. package/dist/src/daemon/daemon.js.map +1 -1
  173. package/dist/src/daemon/git-evidence.d.ts +2 -2
  174. package/dist/src/daemon/git-evidence.js +1 -1
  175. package/dist/src/daemon/projection.d.ts +13 -2
  176. package/dist/src/daemon/projection.js +39 -6
  177. package/dist/src/daemon/projection.js.map +1 -1
  178. package/dist/src/mcp/server.d.ts +39 -3
  179. package/dist/src/mcp/server.js +22 -3
  180. package/dist/src/mcp/server.js.map +1 -1
  181. package/dist/src/muse/server.d.ts +16 -0
  182. package/dist/src/muse/server.js +238 -0
  183. package/dist/src/muse/server.js.map +1 -0
  184. package/dist/src/serve/archive.d.ts +158 -0
  185. package/dist/src/serve/archive.js +354 -0
  186. package/dist/src/serve/archive.js.map +1 -0
  187. package/dist/src/serve/arguments.d.ts +93 -0
  188. package/dist/src/serve/arguments.js +228 -0
  189. package/dist/src/serve/arguments.js.map +1 -0
  190. package/dist/src/serve/credentials.d.ts +91 -0
  191. package/dist/src/serve/credentials.js +143 -0
  192. package/dist/src/serve/credentials.js.map +1 -0
  193. package/dist/src/serve/follow.d.ts +86 -0
  194. package/dist/src/serve/follow.js +101 -0
  195. package/dist/src/serve/follow.js.map +1 -0
  196. package/dist/src/serve/hook-thread.d.ts +134 -0
  197. package/dist/src/serve/hook-thread.js +333 -0
  198. package/dist/src/serve/hook-thread.js.map +1 -0
  199. package/dist/src/serve/hook-worker.d.ts +134 -0
  200. package/dist/src/serve/hook-worker.js +173 -0
  201. package/dist/src/serve/hook-worker.js.map +1 -0
  202. package/dist/src/serve/server.d.ts +412 -0
  203. package/dist/src/serve/server.js +1251 -0
  204. package/dist/src/serve/server.js.map +1 -0
  205. package/docs/README-extended.md +766 -0
  206. package/docs/cli-reference.md +1633 -43
  207. package/package.json +4 -2
  208. package/schema/event.schema.json +59 -2
  209. package/schema/fixtures/event/invalid/daemon-id-malformed.json +15 -0
  210. package/schema/fixtures/event/invalid/execution-started-truncated-policy-sha256.json +18 -0
  211. package/schema/fixtures/event/invalid/harness-cap-at-margin.json +16 -0
  212. package/schema/fixtures/event/invalid/harness-cap-fractional.json +16 -0
  213. package/schema/fixtures/event/invalid/harness-cap-negative.json +16 -0
  214. package/schema/fixtures/event/invalid/harness-cap-string.json +16 -0
  215. package/schema/fixtures/event/invalid/harness-cap-without-harness-execution.json +16 -0
  216. package/schema/fixtures/event/invalid/harness-cap-zero.json +16 -0
  217. package/schema/fixtures/event/valid/approval-requested-harness-cap.json +18 -0
  218. package/schema/fixtures/event/valid/daemon-identity-declared.json +17 -0
  219. package/schema/fixtures/event/valid/daemon-identity.json +15 -0
  220. package/schema/fixtures/event/valid/execution-started-policy-sha256.json +18 -0
  221. package/schema/fixtures/event/valid/harness-kind-hermes.json +23 -0
  222. package/schema/fixtures/policy/invalid/daemons-malformed-id.json +10 -0
  223. package/schema/fixtures/policy/valid/daemons-allowlist.json +19 -0
  224. package/schema/fixtures/policy-md/invalid/schema-invalid-agent-may-request.md +21 -0
  225. package/schema/fixtures/policy-md/valid/agent-may-request.md +24 -0
  226. package/schema/policy.schema.json +50 -6
@@ -0,0 +1,160 @@
1
+ /**
2
+ * The `approval run` payload: the argv, the cwd, and the bytes of the script
3
+ * the argv names (SPEC.md §6.2, §10.4; APRV-401).
4
+ *
5
+ * ## The hazard this closes
6
+ *
7
+ * §6.2 defined this payload as "the argv array and cwd", and `approval run`
8
+ * recomputes it from the argv and cwd it is about to spawn. For a command that
9
+ * IS its own bytes (`npm update @types/node`) that is the whole action. For a
10
+ * command that names a script it is not: the payload binds a NAME, and the
11
+ * bytes behind that name are whatever the path holds at execution time. The
12
+ * requester controls the file between the request and the grant, so a human
13
+ * approved a path and the runtime executed a file. Found on a live grant during
14
+ * APRV-398, exploited by nobody, filed as APRV-401.
15
+ *
16
+ * The payload hash is supposed to BE the binding (§10.4: "A grant therefore
17
+ * approves specific bytes"), so the repair is to put the bytes inside it. A
18
+ * script edited between the declaration and the execution hashes differently,
19
+ * and the difference refuses `payload-mismatch` before the child is spawned and
20
+ * before anything is appended, exactly as a changed argv already did.
21
+ *
22
+ * ## The shape, as the operator ruled it (APRV-401 notes, 2026-09-21)
23
+ *
24
+ * > the script digest is PART OF THE BINDING, never advisory. Shape: when argv
25
+ * > names an interpreter (bash, sh, zsh, node, python3 and kin) followed by a
26
+ * > path that resolves to a regular file at request time, the runtime reads and
27
+ * > hashes the file into the bound payload, the card shows the digest, byte
28
+ * > count and path, and approval run re-reads the file and refuses
29
+ * > payload-mismatch if the bytes changed. No interpretation of the script and
30
+ * > no second pass for files it sources; the limit is documented. Out of scope:
31
+ * > piped installers (already opaque), sourced files, interpreters the
32
+ * > classifier does not name.
33
+ *
34
+ * So this is an INTERPRETER-KEYED rule and not "every argv word that happens to
35
+ * name a file". One script per invocation: the first word after the interpreter
36
+ * that resolves to a readable regular file, which is the interpreter's operand
37
+ * whether or not a subcommand comes first (`deno run job.ts`).
38
+ * {@link SCRIPT_INTERPRETERS} is the list, and every member of it is a name
39
+ * `core/command-class.ts` already knows: the six shells of its unwrappable set,
40
+ * `node` from its own branch, and `python`, `python3`, `perl`, `ruby` and
41
+ * `deno` from its inline-source table. A launcher neither file names binds
42
+ * nothing, which is the documented limit rather than an oversight.
43
+ *
44
+ * The one thing here that the ruling does not spell out is the DEGENERATE case:
45
+ * `./install.sh` with no interpreter word at all, where the kernel reads the
46
+ * shebang and the script is `argv[0]` itself. It is bound, because it is the
47
+ * same fact with the interpreter implied rather than typed, and leaving it out
48
+ * would be a hole in the rule shaped exactly like the rule.
49
+ *
50
+ * ## What is NOT bound, stated plainly
51
+ *
52
+ * - **An inline program.** `bash -c '…'`, `node -e '…'`, `python3 -c '…'`: the
53
+ * program is a word of the argv, so it was always bound, and there is no file
54
+ * to read. The inline flags per interpreter are in {@link SCRIPT_INTERPRETERS}.
55
+ * - **Nothing resolved through PATH.** The interpreter itself is not hashed. Its
56
+ * bytes are the host's rather than the requester's, and resolving a program
57
+ * name through `PATH` would make the payload a function of the environment the
58
+ * process happens to hold, which §11 says is never read implicitly.
59
+ * - **Nothing transitive.** A bound script that sources another file, curls a
60
+ * second one, or execs a third binds only its own bytes. The digest is a
61
+ * binding, not a sandbox.
62
+ * - **A piped installer.** `curl … | sh` has no file operand and is opaque to
63
+ * the classifier for the same reason it is unbindable here.
64
+ * - **The instant between hashing and spawning.** The digest is taken, then
65
+ * `execution.started` is appended, then the child is spawned. A file rewritten
66
+ * inside that window runs unbound. Closing it needs the executor to hold the
67
+ * bytes rather than the path, which is the adapter contract of §10.4.
68
+ *
69
+ * ## Absence is not a claim
70
+ *
71
+ * `script` is omitted when nothing is bound, and that omission is deliberate: an
72
+ * invocation naming no readable script hashes to exactly the value it hashed
73
+ * before this module existed, so every record already in the log, and every
74
+ * declaration already written into a task file, verifies unchanged. What the
75
+ * absence does NOT say is "this runtime checked and there was nothing" — it is
76
+ * the ordinary shape of a payload with no script to bind, and a reader wanting
77
+ * to know whether a digest was possible reads the argv, which is right there.
78
+ *
79
+ * Every skip is safe for one reason, and it is the same reason in each case: the
80
+ * value is recomputed at execution by this same rule. A file that was
81
+ * unreadable at declaration time and readable at execution time gains an entry
82
+ * and refuses; one that existed and was deleted loses one and refuses. The rule
83
+ * does not have to decide correctly what a command will do, because both ends
84
+ * apply it to the same tree and any disagreement is a refusal.
85
+ *
86
+ * Reads the filesystem, by design and by necessity: at most one file per call,
87
+ * and only the one the argv names. Writes nothing, appends nothing, reads no
88
+ * log, no policy, no environment and no clock.
89
+ */
90
+ /** The payload key carrying the bound script. Omitted when there is none. */
91
+ export declare const BOUND_SCRIPT_KEY = "script";
92
+ /**
93
+ * How much of a file is read at once while digesting it.
94
+ *
95
+ * Chunked rather than `readFileSync`, because a script may be of any size and
96
+ * the executor must not be the thing that runs out of memory: a refusal to
97
+ * compute the binding is a refusal to execute.
98
+ */
99
+ export declare const DIGEST_CHUNK_BYTES: number;
100
+ /**
101
+ * The interpreters whose script operand is bound, and the flags that mean
102
+ * "the program is inline, there is no file".
103
+ *
104
+ * Every name here is one `core/command-class.ts` already knows, which is the
105
+ * ruling's "interpreters the classifier does not name" read as a positive list:
106
+ * the six shells of its unwrappable set, `node` from its own classification
107
+ * branch, and `python`, `python3`, `perl`, `ruby`, `deno` from its
108
+ * inline-source table. The list is duplicated rather than imported because
109
+ * those tables are private to that module and a public export of them would be
110
+ * a second consumer of a decision that belongs to classification; the comment
111
+ * is the seam, and a name added there without being added here binds nothing,
112
+ * which fails toward the old behaviour rather than toward a wrong digest.
113
+ *
114
+ * The shell inline flag is `-c` with any run of `l` and `i` before it, which is
115
+ * `INLINE_SCRIPT_FLAG` in that module, spelled here as the same regular
116
+ * expression for the same reason.
117
+ */
118
+ export declare const SCRIPT_INTERPRETERS: ReadonlyMap<string, readonly (string | RegExp)[]>;
119
+ /** One script the argv names, bound by its bytes. */
120
+ export interface BoundScript {
121
+ /** Which argv word named it. */
122
+ argv_index: number;
123
+ /** The path as resolved against the payload's `cwd`: absolute, always. */
124
+ path: string;
125
+ /** The file's size in bytes, which the approver's card shows beside the digest. */
126
+ bytes: number;
127
+ /** SHA-256 (lowercase hex) of the file's bytes. */
128
+ sha256: string;
129
+ }
130
+ /** The `approval run` payload value, canonicalized and hashed by the caller. */
131
+ export interface RunPayload {
132
+ argv: string[];
133
+ cwd: string;
134
+ /** Present only when the argv named a readable script. */
135
+ script?: BoundScript;
136
+ }
137
+ /**
138
+ * The script this argv runs, bound by its bytes, or `null` (APRV-401).
139
+ *
140
+ * Two shapes, and only two: an interpreter this module names followed by a path
141
+ * operand, and a path at `argv[0]` that the kernel will read a shebang from.
142
+ * Anything else binds nothing and the payload is what it always was.
143
+ */
144
+ export declare function boundScript(argv: readonly string[], cwd: string): BoundScript | null;
145
+ /**
146
+ * The value `approval run`'s `payload_hash` is taken over (SPEC.md §6.2).
147
+ *
148
+ * `{argv, cwd}` when the argv names no readable script, byte for byte what it
149
+ * has always been; `{argv, cwd, script}` when it does. The caller canonicalizes
150
+ * and hashes it — this function decides WHAT is bound and never how it is
151
+ * digested into an approval.
152
+ */
153
+ export declare function runPayloadValue(argv: readonly string[], cwd: string): RunPayload;
154
+ /**
155
+ * One line naming the bound script, for a refusal message or a terminal.
156
+ *
157
+ * Not a rendering an approver signs: the canonical renderer owns that, and this
158
+ * is the executor explaining to an agent WHICH file moved.
159
+ */
160
+ export declare function describeBoundScript(script: BoundScript | null): string;
@@ -0,0 +1,304 @@
1
+ /**
2
+ * The `approval run` payload: the argv, the cwd, and the bytes of the script
3
+ * the argv names (SPEC.md §6.2, §10.4; APRV-401).
4
+ *
5
+ * ## The hazard this closes
6
+ *
7
+ * §6.2 defined this payload as "the argv array and cwd", and `approval run`
8
+ * recomputes it from the argv and cwd it is about to spawn. For a command that
9
+ * IS its own bytes (`npm update @types/node`) that is the whole action. For a
10
+ * command that names a script it is not: the payload binds a NAME, and the
11
+ * bytes behind that name are whatever the path holds at execution time. The
12
+ * requester controls the file between the request and the grant, so a human
13
+ * approved a path and the runtime executed a file. Found on a live grant during
14
+ * APRV-398, exploited by nobody, filed as APRV-401.
15
+ *
16
+ * The payload hash is supposed to BE the binding (§10.4: "A grant therefore
17
+ * approves specific bytes"), so the repair is to put the bytes inside it. A
18
+ * script edited between the declaration and the execution hashes differently,
19
+ * and the difference refuses `payload-mismatch` before the child is spawned and
20
+ * before anything is appended, exactly as a changed argv already did.
21
+ *
22
+ * ## The shape, as the operator ruled it (APRV-401 notes, 2026-09-21)
23
+ *
24
+ * > the script digest is PART OF THE BINDING, never advisory. Shape: when argv
25
+ * > names an interpreter (bash, sh, zsh, node, python3 and kin) followed by a
26
+ * > path that resolves to a regular file at request time, the runtime reads and
27
+ * > hashes the file into the bound payload, the card shows the digest, byte
28
+ * > count and path, and approval run re-reads the file and refuses
29
+ * > payload-mismatch if the bytes changed. No interpretation of the script and
30
+ * > no second pass for files it sources; the limit is documented. Out of scope:
31
+ * > piped installers (already opaque), sourced files, interpreters the
32
+ * > classifier does not name.
33
+ *
34
+ * So this is an INTERPRETER-KEYED rule and not "every argv word that happens to
35
+ * name a file". One script per invocation: the first word after the interpreter
36
+ * that resolves to a readable regular file, which is the interpreter's operand
37
+ * whether or not a subcommand comes first (`deno run job.ts`).
38
+ * {@link SCRIPT_INTERPRETERS} is the list, and every member of it is a name
39
+ * `core/command-class.ts` already knows: the six shells of its unwrappable set,
40
+ * `node` from its own branch, and `python`, `python3`, `perl`, `ruby` and
41
+ * `deno` from its inline-source table. A launcher neither file names binds
42
+ * nothing, which is the documented limit rather than an oversight.
43
+ *
44
+ * The one thing here that the ruling does not spell out is the DEGENERATE case:
45
+ * `./install.sh` with no interpreter word at all, where the kernel reads the
46
+ * shebang and the script is `argv[0]` itself. It is bound, because it is the
47
+ * same fact with the interpreter implied rather than typed, and leaving it out
48
+ * would be a hole in the rule shaped exactly like the rule.
49
+ *
50
+ * ## What is NOT bound, stated plainly
51
+ *
52
+ * - **An inline program.** `bash -c '…'`, `node -e '…'`, `python3 -c '…'`: the
53
+ * program is a word of the argv, so it was always bound, and there is no file
54
+ * to read. The inline flags per interpreter are in {@link SCRIPT_INTERPRETERS}.
55
+ * - **Nothing resolved through PATH.** The interpreter itself is not hashed. Its
56
+ * bytes are the host's rather than the requester's, and resolving a program
57
+ * name through `PATH` would make the payload a function of the environment the
58
+ * process happens to hold, which §11 says is never read implicitly.
59
+ * - **Nothing transitive.** A bound script that sources another file, curls a
60
+ * second one, or execs a third binds only its own bytes. The digest is a
61
+ * binding, not a sandbox.
62
+ * - **A piped installer.** `curl … | sh` has no file operand and is opaque to
63
+ * the classifier for the same reason it is unbindable here.
64
+ * - **The instant between hashing and spawning.** The digest is taken, then
65
+ * `execution.started` is appended, then the child is spawned. A file rewritten
66
+ * inside that window runs unbound. Closing it needs the executor to hold the
67
+ * bytes rather than the path, which is the adapter contract of §10.4.
68
+ *
69
+ * ## Absence is not a claim
70
+ *
71
+ * `script` is omitted when nothing is bound, and that omission is deliberate: an
72
+ * invocation naming no readable script hashes to exactly the value it hashed
73
+ * before this module existed, so every record already in the log, and every
74
+ * declaration already written into a task file, verifies unchanged. What the
75
+ * absence does NOT say is "this runtime checked and there was nothing" — it is
76
+ * the ordinary shape of a payload with no script to bind, and a reader wanting
77
+ * to know whether a digest was possible reads the argv, which is right there.
78
+ *
79
+ * Every skip is safe for one reason, and it is the same reason in each case: the
80
+ * value is recomputed at execution by this same rule. A file that was
81
+ * unreadable at declaration time and readable at execution time gains an entry
82
+ * and refuses; one that existed and was deleted loses one and refuses. The rule
83
+ * does not have to decide correctly what a command will do, because both ends
84
+ * apply it to the same tree and any disagreement is a refusal.
85
+ *
86
+ * Reads the filesystem, by design and by necessity: at most one file per call,
87
+ * and only the one the argv names. Writes nothing, appends nothing, reads no
88
+ * log, no policy, no environment and no clock.
89
+ */
90
+ import { createHash } from "node:crypto";
91
+ import { closeSync, openSync, readSync, statSync } from "node:fs";
92
+ import { isAbsolute, resolve as resolvePath } from "node:path";
93
+ /** The payload key carrying the bound script. Omitted when there is none. */
94
+ export const BOUND_SCRIPT_KEY = "script";
95
+ /**
96
+ * How much of a file is read at once while digesting it.
97
+ *
98
+ * Chunked rather than `readFileSync`, because a script may be of any size and
99
+ * the executor must not be the thing that runs out of memory: a refusal to
100
+ * compute the binding is a refusal to execute.
101
+ */
102
+ export const DIGEST_CHUNK_BYTES = 64 * 1024;
103
+ /**
104
+ * The interpreters whose script operand is bound, and the flags that mean
105
+ * "the program is inline, there is no file".
106
+ *
107
+ * Every name here is one `core/command-class.ts` already knows, which is the
108
+ * ruling's "interpreters the classifier does not name" read as a positive list:
109
+ * the six shells of its unwrappable set, `node` from its own classification
110
+ * branch, and `python`, `python3`, `perl`, `ruby`, `deno` from its
111
+ * inline-source table. The list is duplicated rather than imported because
112
+ * those tables are private to that module and a public export of them would be
113
+ * a second consumer of a decision that belongs to classification; the comment
114
+ * is the seam, and a name added there without being added here binds nothing,
115
+ * which fails toward the old behaviour rather than toward a wrong digest.
116
+ *
117
+ * The shell inline flag is `-c` with any run of `l` and `i` before it, which is
118
+ * `INLINE_SCRIPT_FLAG` in that module, spelled here as the same regular
119
+ * expression for the same reason.
120
+ */
121
+ export const SCRIPT_INTERPRETERS = new Map([
122
+ ["bash", [/^-[li]*c$/u]],
123
+ ["sh", [/^-[li]*c$/u]],
124
+ ["zsh", [/^-[li]*c$/u]],
125
+ ["dash", [/^-[li]*c$/u]],
126
+ ["ksh", [/^-[li]*c$/u]],
127
+ ["fish", [/^-[li]*c$/u, "--command"]],
128
+ ["node", ["-e", "--eval", "-p", "--print", "--input-type=module"]],
129
+ ["nodejs", ["-e", "--eval", "-p", "--print"]],
130
+ ["python", ["-c"]],
131
+ ["python3", ["-c"]],
132
+ ["perl", ["-e", "-E"]],
133
+ ["ruby", ["-e"]],
134
+ ["deno", ["eval"]],
135
+ ]);
136
+ /**
137
+ * The digest and size of one file, read in bounded chunks, or `null` when it
138
+ * cannot be read at all.
139
+ *
140
+ * `null` for every failure — gone, a directory, a device, permission denied, a
141
+ * disappearing file, a read error partway — and the reason is not recorded
142
+ * anywhere. Recording it would put a local errno inside hashed material, so two
143
+ * machines that agree about the bytes could disagree about the payload; and the
144
+ * only thing a reader could do with it is treat "unreadable" as a state, which
145
+ * the recomputation already handles better than any field could.
146
+ */
147
+ function fileDigest(path) {
148
+ let fd;
149
+ try {
150
+ const stats = statSync(path);
151
+ if (!stats.isFile())
152
+ return null;
153
+ fd = openSync(path, "r");
154
+ }
155
+ catch {
156
+ return null;
157
+ }
158
+ const hash = createHash("sha256");
159
+ const buffer = Buffer.allocUnsafe(DIGEST_CHUNK_BYTES);
160
+ let bytes = 0;
161
+ try {
162
+ let read = readSync(fd, buffer, 0, DIGEST_CHUNK_BYTES, null);
163
+ while (read > 0) {
164
+ hash.update(buffer.subarray(0, read));
165
+ bytes += read;
166
+ read = readSync(fd, buffer, 0, DIGEST_CHUNK_BYTES, null);
167
+ }
168
+ }
169
+ catch {
170
+ return null;
171
+ }
172
+ finally {
173
+ try {
174
+ closeSync(fd);
175
+ }
176
+ catch {
177
+ // A close that fails has nothing to do with the bytes already hashed.
178
+ }
179
+ }
180
+ return { bytes, sha256: hash.digest("hex") };
181
+ }
182
+ /** The last path segment of an argv word: `/usr/bin/bash` reads as `bash`. */
183
+ function basenameOf(word) {
184
+ const segments = word.split("/");
185
+ return segments[segments.length - 1] ?? word;
186
+ }
187
+ /** Does `word` mean "the program is inline" for this interpreter? */
188
+ function isInlineProgramFlag(word, markers) {
189
+ return markers.some((marker) => typeof marker === "string" ? word === marker : marker.test(word));
190
+ }
191
+ /**
192
+ * The words of an interpreter invocation that could be its script, in order,
193
+ * or `null` when the program is inline and there is no file at all.
194
+ *
195
+ * An inline-program marker anywhere in the options answers `null` rather than
196
+ * "keep looking": the program is a word of the argv and already bound, and a
197
+ * file that happens to share its spelling is a file the command never opens.
198
+ *
199
+ * Otherwise every non-option word is a candidate, in argv order, and the caller
200
+ * binds the first that resolves to a readable regular file. Not just the first
201
+ * non-option word, because interpreters take SUBCOMMANDS: `deno run job.ts`
202
+ * would otherwise offer `run` and stop, and `python3 -m pkg job.py` would offer
203
+ * `pkg`. Walking on cannot bind anything the argv does not name, and the worst
204
+ * case is the harmless direction — a command whose script is missing and whose
205
+ * later argument happens to be a file binds that file, which is one more thing
206
+ * bound rather than one fewer.
207
+ *
208
+ * An option that takes a separate path value is the one place this can pick the
209
+ * wrong file (`node --require ./a.js job.js` binds `./a.js`), and both are
210
+ * files that invocation executes.
211
+ */
212
+ function scriptCandidateIndexes(argv, markers) {
213
+ const candidates = [];
214
+ for (let index = 1; index < argv.length; index += 1) {
215
+ const word = argv[index];
216
+ if (isInlineProgramFlag(word, markers))
217
+ return null;
218
+ if (word === "--")
219
+ continue;
220
+ if (word.startsWith("-") && word.length > 1)
221
+ continue;
222
+ candidates.push(index);
223
+ }
224
+ return candidates;
225
+ }
226
+ /**
227
+ * Where an argv word points, resolved against the payload's own `cwd`.
228
+ *
229
+ * The cwd is the payload's, never `process.cwd()`: the payload already carries
230
+ * the directory the child will run in, and resolving against anything else
231
+ * would make the binding a function of where the hashing process happened to
232
+ * stand.
233
+ */
234
+ function resolveArgvPath(word, cwd) {
235
+ if (word === "" || word === "-" || word === "--")
236
+ return null;
237
+ if (word.includes("\0"))
238
+ return null;
239
+ return isAbsolute(word) ? word : resolvePath(cwd, word);
240
+ }
241
+ /**
242
+ * The script this argv runs, bound by its bytes, or `null` (APRV-401).
243
+ *
244
+ * Two shapes, and only two: an interpreter this module names followed by a path
245
+ * operand, and a path at `argv[0]` that the kernel will read a shebang from.
246
+ * Anything else binds nothing and the payload is what it always was.
247
+ */
248
+ export function boundScript(argv, cwd) {
249
+ const program = argv[0];
250
+ if (program === undefined || program === "")
251
+ return null;
252
+ const markers = SCRIPT_INTERPRETERS.get(basenameOf(program));
253
+ if (markers !== undefined) {
254
+ const candidates = scriptCandidateIndexes(argv, markers);
255
+ if (candidates === null)
256
+ return null;
257
+ for (const index of candidates) {
258
+ const path = resolveArgvPath(argv[index], cwd);
259
+ if (path === null)
260
+ continue;
261
+ const digest = fileDigest(path);
262
+ if (digest !== null)
263
+ return { argv_index: index, path, ...digest };
264
+ }
265
+ return null;
266
+ }
267
+ // The degenerate case: no interpreter word, so the script IS argv[0] and the
268
+ // interpreter is whatever its shebang names. A program with no separator in
269
+ // it is a PATH lookup and is not this (see the module header).
270
+ if (!isAbsolute(program) && !program.includes("/"))
271
+ return null;
272
+ const path = resolveArgvPath(program, cwd);
273
+ if (path === null)
274
+ return null;
275
+ const digest = fileDigest(path);
276
+ return digest === null ? null : { argv_index: 0, path, ...digest };
277
+ }
278
+ /**
279
+ * The value `approval run`'s `payload_hash` is taken over (SPEC.md §6.2).
280
+ *
281
+ * `{argv, cwd}` when the argv names no readable script, byte for byte what it
282
+ * has always been; `{argv, cwd, script}` when it does. The caller canonicalizes
283
+ * and hashes it — this function decides WHAT is bound and never how it is
284
+ * digested into an approval.
285
+ */
286
+ export function runPayloadValue(argv, cwd) {
287
+ const script = boundScript(argv, cwd);
288
+ const payload = { argv: [...argv], cwd };
289
+ if (script !== null)
290
+ payload.script = script;
291
+ return payload;
292
+ }
293
+ /**
294
+ * One line naming the bound script, for a refusal message or a terminal.
295
+ *
296
+ * Not a rendering an approver signs: the canonical renderer owns that, and this
297
+ * is the executor explaining to an agent WHICH file moved.
298
+ */
299
+ export function describeBoundScript(script) {
300
+ if (script === null)
301
+ return "no script";
302
+ return `argv[${String(script.argv_index)}] ${script.path}, ${String(script.bytes)} bytes, sha256 ${script.sha256}`;
303
+ }
304
+ //# sourceMappingURL=run-payload.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"run-payload.js","sourceRoot":"","sources":["../../../src/core/run-payload.ts"],"names":[],"mappings":"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"}
@@ -13,10 +13,12 @@
13
13
  *
14
14
  * ## What is evidence here, and what is not
15
15
  *
16
- * Exactly one field in the whole system is a sender fact worth mapping:
17
- * Telegram's `callback_query.from.id`, the stable numeric account id the Bot
18
- * API attributes the tap to. {@link SENDER_CHANNELS} is closed to that one
19
- * channel for that reason. A web form post authenticates nobody and a CLI
16
+ * Two fields in the whole system are sender facts worth mapping. Telegram's
17
+ * `callback_query.from.id`, the stable numeric account id the Bot API
18
+ * attributes the tap to, and (APRV-455) the EdgeOS `/humans/me` id that an
19
+ * operator's control plane attributes a gesture to when it carries it over
20
+ * `approval channel relay`. {@link SENDER_CHANNELS} is closed to those two
21
+ * channels for that reason. A web form post authenticates nobody and a CLI
20
22
  * process authenticates local machine control, so neither may be given an
21
23
  * identity key it cannot support: a `senders` entry for either is a schema
22
24
  * violation rather than a mapping nothing backs.
@@ -144,16 +146,39 @@ export declare function senderKeyFrom(env?: NodeJS.ProcessEnv): string | null;
144
146
  *
145
147
  * Closed, and short on purpose. `telegram` is here because the Bot API reports
146
148
  * `callback_query.from.id`, a stable numeric account id the sender cannot
147
- * choose. `web` and `cli` are absent because neither authenticates a person:
148
- * the web page takes an unauthenticated form post and the CLI takes whoever
149
- * controls the process. A policy that named either would be asserting a binding
150
- * the runtime cannot check, so the schema refuses the key rather than carrying
151
- * a mapping that reads like identity.
149
+ * choose. `edgeos` is here (APRV-455) on the same ground: a gesture on it
150
+ * arrives through `approval channel relay`, authenticated by a shared secret
151
+ * from the daemon's launch environment, and the control plane holding that
152
+ * secret attributes it to the EdgeOS `/humans/me` id of the session the person
153
+ * signed in with, which the person cannot choose. In both cases the daemon
154
+ * trusts a relay's report of who acted; that is operator trust, and it is the
155
+ * same trust whichever relay carries the report.
156
+ *
157
+ * `web` and `cli` are absent because neither authenticates a person: the web
158
+ * page takes an unauthenticated form post and the CLI takes whoever controls
159
+ * the process. A policy that named either would be asserting a binding the
160
+ * runtime cannot check, so the schema refuses the key rather than carrying a
161
+ * mapping that reads like identity.
152
162
  */
153
- export declare const SENDER_CHANNELS: readonly ["telegram"];
163
+ export declare const SENDER_CHANNELS: readonly ["telegram", "edgeos"];
154
164
  export type SenderChannel = (typeof SENDER_CHANNELS)[number];
155
165
  /** Is `name` a channel whose transport can attribute a gesture (§2)? */
156
166
  export declare function isSenderChannel(name: string): name is SenderChannel;
167
+ /**
168
+ * The grammar of a RAW EdgeOS sender id (APRV-455), the same one
169
+ * `schema/policy.schema.json` pins on `senders.edgeos`.
170
+ *
171
+ * The id is the one EdgeOS returns from `/humans/me`, and this runtime treats
172
+ * it as opaque: it is compared byte for byte and never parsed. The grammar is a
173
+ * bound on what may be compared, not a reading of what the id means. An ASCII
174
+ * letter or digit, then up to 127 letters, digits, `.`, `_` or `-`: enough
175
+ * for a UUID or a decimal id, and deliberately without `:` or whitespace, so a
176
+ * raw id can never be mistaken for the {@link SENDER_HASH_PREFIX} form and can
177
+ * never contain the separator {@link senderIndex} joins its keys with.
178
+ */
179
+ export declare const EDGEOS_SENDER_ID_PATTERN: RegExp;
180
+ /** Is `id` a raw EdgeOS sender id this runtime will compare (APRV-455)? */
181
+ export declare function isEdgeosSenderId(id: string): boolean;
157
182
  /**
158
183
  * One observed sender: the channel that saw it, and the id that channel's
159
184
  * transport attributed the gesture to.
@@ -13,10 +13,12 @@
13
13
  *
14
14
  * ## What is evidence here, and what is not
15
15
  *
16
- * Exactly one field in the whole system is a sender fact worth mapping:
17
- * Telegram's `callback_query.from.id`, the stable numeric account id the Bot
18
- * API attributes the tap to. {@link SENDER_CHANNELS} is closed to that one
19
- * channel for that reason. A web form post authenticates nobody and a CLI
16
+ * Two fields in the whole system are sender facts worth mapping. Telegram's
17
+ * `callback_query.from.id`, the stable numeric account id the Bot API
18
+ * attributes the tap to, and (APRV-455) the EdgeOS `/humans/me` id that an
19
+ * operator's control plane attributes a gesture to when it carries it over
20
+ * `approval channel relay`. {@link SENDER_CHANNELS} is closed to those two
21
+ * channels for that reason. A web form post authenticates nobody and a CLI
20
22
  * process authenticates local machine control, so neither may be given an
21
23
  * identity key it cannot support: a `senders` entry for either is a schema
22
24
  * violation rather than a mapping nothing backs.
@@ -153,17 +155,42 @@ export function senderKeyFrom(env = process.env) {
153
155
  *
154
156
  * Closed, and short on purpose. `telegram` is here because the Bot API reports
155
157
  * `callback_query.from.id`, a stable numeric account id the sender cannot
156
- * choose. `web` and `cli` are absent because neither authenticates a person:
157
- * the web page takes an unauthenticated form post and the CLI takes whoever
158
- * controls the process. A policy that named either would be asserting a binding
159
- * the runtime cannot check, so the schema refuses the key rather than carrying
160
- * a mapping that reads like identity.
158
+ * choose. `edgeos` is here (APRV-455) on the same ground: a gesture on it
159
+ * arrives through `approval channel relay`, authenticated by a shared secret
160
+ * from the daemon's launch environment, and the control plane holding that
161
+ * secret attributes it to the EdgeOS `/humans/me` id of the session the person
162
+ * signed in with, which the person cannot choose. In both cases the daemon
163
+ * trusts a relay's report of who acted; that is operator trust, and it is the
164
+ * same trust whichever relay carries the report.
165
+ *
166
+ * `web` and `cli` are absent because neither authenticates a person: the web
167
+ * page takes an unauthenticated form post and the CLI takes whoever controls
168
+ * the process. A policy that named either would be asserting a binding the
169
+ * runtime cannot check, so the schema refuses the key rather than carrying a
170
+ * mapping that reads like identity.
161
171
  */
162
- export const SENDER_CHANNELS = ["telegram"];
172
+ export const SENDER_CHANNELS = ["telegram", "edgeos"];
163
173
  /** Is `name` a channel whose transport can attribute a gesture (§2)? */
164
174
  export function isSenderChannel(name) {
165
175
  return SENDER_CHANNELS.includes(name);
166
176
  }
177
+ /**
178
+ * The grammar of a RAW EdgeOS sender id (APRV-455), the same one
179
+ * `schema/policy.schema.json` pins on `senders.edgeos`.
180
+ *
181
+ * The id is the one EdgeOS returns from `/humans/me`, and this runtime treats
182
+ * it as opaque: it is compared byte for byte and never parsed. The grammar is a
183
+ * bound on what may be compared, not a reading of what the id means. An ASCII
184
+ * letter or digit, then up to 127 letters, digits, `.`, `_` or `-`: enough
185
+ * for a UUID or a decimal id, and deliberately without `:` or whitespace, so a
186
+ * raw id can never be mistaken for the {@link SENDER_HASH_PREFIX} form and can
187
+ * never contain the separator {@link senderIndex} joins its keys with.
188
+ */
189
+ export const EDGEOS_SENDER_ID_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/u;
190
+ /** Is `id` a raw EdgeOS sender id this runtime will compare (APRV-455)? */
191
+ export function isEdgeosSenderId(id) {
192
+ return EDGEOS_SENDER_ID_PATTERN.test(id);
193
+ }
167
194
  /** Where a decision's actor came from, recorded on the decision (design §4). */
168
195
  export const SENDER_SOURCES = ["policy"];
169
196
  /**
@@ -377,7 +404,17 @@ export function resolveSender(load, sender, key = null) {
377
404
  // digits — so asking both questions widens nothing and lets one policy carry
378
405
  // a migration in progress. They are kept apart rather than concatenated
379
406
  // because WHICH one matched decides the form the record takes.
380
- const rawClaimed = index.get(indexKey(sender.channel, sender.id)) ?? [];
407
+ //
408
+ // APRV-455: an OBSERVED id that already wears the keyed prefix is never
409
+ // compared raw. A Telegram id is digits and cannot wear it, so this changes
410
+ // nothing there; on a channel whose ids a relay reports, a caller that copied
411
+ // a digest out of the published policy and reported it as the account would
412
+ // otherwise match the keyed entry by string equality, without the key and
413
+ // without being the account. The keyed comparison below still runs, over the
414
+ // digest OF that string, which no approver's entry is.
415
+ const rawClaimed = isHashedSenderId(sender.id)
416
+ ? []
417
+ : (index.get(indexKey(sender.channel, sender.id)) ?? []);
381
418
  const keyedClaimed = key === null ? [] : (index.get(indexKey(sender.channel, hashedSenderId(key, sender.id))) ?? []);
382
419
  const claimed = [...rawClaimed, ...keyedClaimed];
383
420
  // THE FORM FOLLOWS THE ENTRY THAT MATCHED, so the id in the record is the