approval-md 0.2.0 → 0.3.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 (235) hide show
  1. package/README.md +63 -24
  2. package/SPEC.md +57 -11
  3. package/dist/src/channels/contract.d.ts +34 -1
  4. package/dist/src/channels/contract.js +200 -7
  5. package/dist/src/channels/contract.js.map +1 -1
  6. package/dist/src/channels/telegram.d.ts +123 -11
  7. package/dist/src/channels/telegram.js +218 -23
  8. package/dist/src/channels/telegram.js.map +1 -1
  9. package/dist/src/channels/web.d.ts +9 -0
  10. package/dist/src/channels/web.js +17 -0
  11. package/dist/src/channels/web.js.map +1 -1
  12. package/dist/src/cli/amend.js +214 -30
  13. package/dist/src/cli/amend.js.map +1 -1
  14. package/dist/src/cli/attest.d.ts +9 -0
  15. package/dist/src/cli/attest.js +134 -7
  16. package/dist/src/cli/attest.js.map +1 -1
  17. package/dist/src/cli/channel-telegram.d.ts +99 -26
  18. package/dist/src/cli/channel-telegram.js +311 -13
  19. package/dist/src/cli/channel-telegram.js.map +1 -1
  20. package/dist/src/cli/channel.d.ts +9 -0
  21. package/dist/src/cli/channel.js +9 -0
  22. package/dist/src/cli/channel.js.map +1 -1
  23. package/dist/src/cli/codex-bridge.d.ts +819 -0
  24. package/dist/src/cli/codex-bridge.js +1607 -0
  25. package/dist/src/cli/codex-bridge.js.map +1 -0
  26. package/dist/src/cli/codex.d.ts +1 -1
  27. package/dist/src/cli/codex.js +304 -7
  28. package/dist/src/cli/codex.js.map +1 -1
  29. package/dist/src/cli/daemon.js +4 -1
  30. package/dist/src/cli/daemon.js.map +1 -1
  31. package/dist/src/cli/doctor.js +467 -12
  32. package/dist/src/cli/doctor.js.map +1 -1
  33. package/dist/src/cli/execute.js +25 -2
  34. package/dist/src/cli/execute.js.map +1 -1
  35. package/dist/src/cli/help.d.ts +6 -2
  36. package/dist/src/cli/help.js +165 -60
  37. package/dist/src/cli/help.js.map +1 -1
  38. package/dist/src/cli/hook-codex.d.ts +49 -1
  39. package/dist/src/cli/hook-codex.js +60 -1
  40. package/dist/src/cli/hook-codex.js.map +1 -1
  41. package/dist/src/cli/hook.d.ts +459 -3
  42. package/dist/src/cli/hook.js +1062 -114
  43. package/dist/src/cli/hook.js.map +1 -1
  44. package/dist/src/cli/import.js +1 -1
  45. package/dist/src/cli/import.js.map +1 -1
  46. package/dist/src/cli/main.js +5 -3
  47. package/dist/src/cli/main.js.map +1 -1
  48. package/dist/src/cli/policy-apply.d.ts +195 -0
  49. package/dist/src/cli/policy-apply.js +573 -0
  50. package/dist/src/cli/policy-apply.js.map +1 -0
  51. package/dist/src/cli/policy.js +14 -1
  52. package/dist/src/cli/policy.js.map +1 -1
  53. package/dist/src/cli/preflight.d.ts +151 -13
  54. package/dist/src/cli/preflight.js +398 -41
  55. package/dist/src/cli/preflight.js.map +1 -1
  56. package/dist/src/cli/sandbox.js +17 -1
  57. package/dist/src/cli/sandbox.js.map +1 -1
  58. package/dist/src/cli/scaffold.d.ts +1 -1
  59. package/dist/src/cli/scaffold.js +1 -1
  60. package/dist/src/cli/setup-channel.d.ts +9 -0
  61. package/dist/src/cli/setup-channel.js +28 -1
  62. package/dist/src/cli/setup-channel.js.map +1 -1
  63. package/dist/src/cli/setup-common.d.ts +3 -1
  64. package/dist/src/cli/setup-common.js +3 -2
  65. package/dist/src/cli/setup-common.js.map +1 -1
  66. package/dist/src/cli/setup.d.ts +2 -0
  67. package/dist/src/cli/setup.js +94 -2
  68. package/dist/src/cli/setup.js.map +1 -1
  69. package/dist/src/cli/up.js +115 -51
  70. package/dist/src/cli/up.js.map +1 -1
  71. package/dist/src/cli/values.js +3 -4
  72. package/dist/src/cli/values.js.map +1 -1
  73. package/dist/src/cli/verb-registry.js +174 -9
  74. package/dist/src/cli/verb-registry.js.map +1 -1
  75. package/dist/src/cli/wordmark.d.ts +2 -2
  76. package/dist/src/cli/wordmark.js +2 -2
  77. package/dist/src/codex/broker.d.ts +229 -0
  78. package/dist/src/codex/broker.js +548 -0
  79. package/dist/src/codex/broker.js.map +1 -0
  80. package/dist/src/codex/runner.d.ts +178 -0
  81. package/dist/src/codex/runner.js +231 -0
  82. package/dist/src/codex/runner.js.map +1 -0
  83. package/dist/src/codex/serve.d.ts +56 -0
  84. package/dist/src/codex/serve.js +98 -0
  85. package/dist/src/codex/serve.js.map +1 -0
  86. package/dist/src/codex/workspace-commit.d.ts +219 -0
  87. package/dist/src/codex/workspace-commit.js +549 -0
  88. package/dist/src/codex/workspace-commit.js.map +1 -0
  89. package/dist/src/core/advance-cycle.d.ts +51 -0
  90. package/dist/src/core/advance-cycle.js +66 -2
  91. package/dist/src/core/advance-cycle.js.map +1 -1
  92. package/dist/src/core/agents-md.d.ts +20 -18
  93. package/dist/src/core/agents-md.js +33 -31
  94. package/dist/src/core/agents-md.js.map +1 -1
  95. package/dist/src/core/attest.d.ts +215 -0
  96. package/dist/src/core/attest.js +317 -7
  97. package/dist/src/core/attest.js.map +1 -1
  98. package/dist/src/core/audit.d.ts +18 -0
  99. package/dist/src/core/audit.js +13 -0
  100. package/dist/src/core/audit.js.map +1 -1
  101. package/dist/src/core/channel-owner.d.ts +213 -0
  102. package/dist/src/core/channel-owner.js +358 -0
  103. package/dist/src/core/channel-owner.js.map +1 -0
  104. package/dist/src/core/command-class.d.ts +154 -0
  105. package/dist/src/core/command-class.js +673 -20
  106. package/dist/src/core/command-class.js.map +1 -1
  107. package/dist/src/core/commit-guard.d.ts +272 -0
  108. package/dist/src/core/commit-guard.js +424 -0
  109. package/dist/src/core/commit-guard.js.map +1 -0
  110. package/dist/src/core/daemon-actor.d.ts +45 -0
  111. package/dist/src/core/daemon-actor.js +54 -0
  112. package/dist/src/core/daemon-actor.js.map +1 -0
  113. package/dist/src/core/dark-session.d.ts +109 -8
  114. package/dist/src/core/dark-session.js +266 -82
  115. package/dist/src/core/dark-session.js.map +1 -1
  116. package/dist/src/core/decision-refusal.d.ts +23 -2
  117. package/dist/src/core/decision-refusal.js +24 -2
  118. package/dist/src/core/decision-refusal.js.map +1 -1
  119. package/dist/src/core/env-file.d.ts +5 -0
  120. package/dist/src/core/env-file.js +60 -1
  121. package/dist/src/core/env-file.js.map +1 -1
  122. package/dist/src/core/execute.d.ts +15 -2
  123. package/dist/src/core/execute.js +15 -2
  124. package/dist/src/core/execute.js.map +1 -1
  125. package/dist/src/core/gate.d.ts +86 -1
  126. package/dist/src/core/gate.js +81 -1
  127. package/dist/src/core/gate.js.map +1 -1
  128. package/dist/src/core/gesture-refusal.d.ts +166 -0
  129. package/dist/src/core/gesture-refusal.js +188 -0
  130. package/dist/src/core/gesture-refusal.js.map +1 -0
  131. package/dist/src/core/harness-version.d.ts +1 -1
  132. package/dist/src/core/harness-version.js +3 -1
  133. package/dist/src/core/harness-version.js.map +1 -1
  134. package/dist/src/core/instance.d.ts +59 -2
  135. package/dist/src/core/instance.js +113 -0
  136. package/dist/src/core/instance.js.map +1 -1
  137. package/dist/src/core/log.d.ts +39 -1
  138. package/dist/src/core/log.js.map +1 -1
  139. package/dist/src/core/policy-explain.d.ts +10 -0
  140. package/dist/src/core/policy-explain.js +32 -0
  141. package/dist/src/core/policy-explain.js.map +1 -1
  142. package/dist/src/core/policy-load.d.ts +41 -1
  143. package/dist/src/core/policy-load.js +21 -3
  144. package/dist/src/core/policy-load.js.map +1 -1
  145. package/dist/src/core/policy-match.d.ts +43 -0
  146. package/dist/src/core/policy-match.js +52 -0
  147. package/dist/src/core/policy-match.js.map +1 -1
  148. package/dist/src/core/policy-proposal.d.ts +52 -0
  149. package/dist/src/core/policy-proposal.js +102 -2
  150. package/dist/src/core/policy-proposal.js.map +1 -1
  151. package/dist/src/core/protected-path-guard.d.ts +117 -4
  152. package/dist/src/core/protected-path-guard.js +362 -48
  153. package/dist/src/core/protected-path-guard.js.map +1 -1
  154. package/dist/src/core/question-preempted.d.ts +141 -0
  155. package/dist/src/core/question-preempted.js +152 -0
  156. package/dist/src/core/question-preempted.js.map +1 -0
  157. package/dist/src/core/read-scope.d.ts +172 -0
  158. package/dist/src/core/read-scope.js +252 -0
  159. package/dist/src/core/read-scope.js.map +1 -0
  160. package/dist/src/core/sandbox.d.ts +81 -0
  161. package/dist/src/core/sandbox.js +190 -1
  162. package/dist/src/core/sandbox.js.map +1 -1
  163. package/dist/src/core/sender-identity.d.ts +476 -0
  164. package/dist/src/core/sender-identity.js +572 -0
  165. package/dist/src/core/sender-identity.js.map +1 -0
  166. package/dist/src/core/shlex.d.ts +102 -0
  167. package/dist/src/core/shlex.js +159 -0
  168. package/dist/src/core/shlex.js.map +1 -0
  169. package/dist/src/core/values.d.ts +18 -8
  170. package/dist/src/core/values.js +36 -1
  171. package/dist/src/core/values.js.map +1 -1
  172. package/dist/src/daemon/advance.d.ts +10 -0
  173. package/dist/src/daemon/advance.js +25 -4
  174. package/dist/src/daemon/advance.js.map +1 -1
  175. package/dist/src/daemon/daemon.js +9 -0
  176. package/dist/src/daemon/daemon.js.map +1 -1
  177. package/dist/src/daemon/git-evidence.d.ts +2 -2
  178. package/dist/src/daemon/git-evidence.js +1 -1
  179. package/dist/src/mcp/server.js +8 -0
  180. package/dist/src/mcp/server.js.map +1 -1
  181. package/docs/cli-reference.md +932 -32
  182. package/docs/codex-enforced-session.md +75 -2
  183. package/docs/codex-workspace-broker.md +118 -0
  184. package/package.json +3 -1
  185. package/schema/event.schema.json +538 -9
  186. package/schema/fixtures/event/invalid/approval-granted-sender-hashed-false.json +20 -0
  187. package/schema/fixtures/event/invalid/approval-granted-sender-hashed-raw-id.json +20 -0
  188. package/schema/fixtures/event/invalid/audit-gesture-refused-human-actor.json +16 -0
  189. package/schema/fixtures/event/invalid/audit-gesture-refused-no-actor-no-sender.json +15 -0
  190. package/schema/fixtures/event/invalid/audit-gesture-refused-unknown-gesture.json +16 -0
  191. package/schema/fixtures/event/invalid/audit-question-preempted-agent-actor.json +16 -0
  192. package/schema/fixtures/event/invalid/audit-question-preempted-no-question-id.json +16 -0
  193. package/schema/fixtures/event/invalid/audit-question-preempted-unknown-source.json +15 -0
  194. package/schema/fixtures/event/invalid/gate-path-signed-off-absolute-path.json +14 -0
  195. package/schema/fixtures/event/invalid/gate-path-signed-off-agent-actor.json +14 -0
  196. package/schema/fixtures/event/invalid/gate-path-signed-off-missing-path.json +13 -0
  197. package/schema/fixtures/event/valid/approval-granted-sender-hashed.json +20 -0
  198. package/schema/fixtures/event/valid/audit-gesture-refused-review-note.json +21 -0
  199. package/schema/fixtures/event/valid/audit-gesture-refused-sender-key-unavailable.json +19 -0
  200. package/schema/fixtures/event/valid/audit-gesture-refused.json +19 -0
  201. package/schema/fixtures/event/valid/audit-question-preempted-no-verdict.json +16 -0
  202. package/schema/fixtures/event/valid/audit-question-preempted.json +20 -0
  203. package/schema/fixtures/event/valid/gate-path-signed-off.json +14 -0
  204. package/schema/fixtures/event/valid/harness-kind-claude-code.json +23 -0
  205. package/schema/fixtures/event/valid/harness-kind-codex.json +23 -0
  206. package/schema/fixtures/event/valid/harness-kind-cursor.json +23 -0
  207. package/schema/fixtures/event/valid/harness-kind-grok.json +23 -0
  208. package/schema/fixtures/event/valid/harness-kind-muse.json +23 -0
  209. package/schema/fixtures/policy/invalid/senders-half-keyed.json +20 -0
  210. package/schema/fixtures/policy/valid/canonical.json +1 -1
  211. package/schema/fixtures/policy/valid/senders-keyed.json +24 -0
  212. package/schema/fixtures/policy-md/valid/canonical.md +1 -1
  213. package/schema/fixtures/policy-md/valid/with-values.md +5 -7
  214. package/schema/fixtures/values/invalid/class-shaped.json +1 -1
  215. package/schema/fixtures/values/invalid/duplicate-entry.json +1 -1
  216. package/schema/fixtures/values/invalid/non-string-item.json +1 -1
  217. package/schema/fixtures/values/invalid/over-cap.json +1 -1
  218. package/schema/fixtures/values/invalid/unknown-key.json +1 -1
  219. package/schema/fixtures/values/invalid/version-float.json +1 -0
  220. package/schema/fixtures/values/invalid/version-integer.json +1 -0
  221. package/schema/fixtures/values/invalid/version-wrong-string.json +1 -0
  222. package/schema/fixtures/values/valid/empty-lists.json +2 -3
  223. package/schema/fixtures/values/valid/full.json +5 -7
  224. package/schema/fixtures/values/valid/minimal.json +1 -1
  225. package/schema/fixtures/values-md/invalid/schema-invalid.md +5 -3
  226. package/schema/fixtures/values-md/invalid/two-blocks.md +3 -3
  227. package/schema/fixtures/values-md/invalid/unterminated.md +2 -2
  228. package/schema/fixtures/values-md/invalid/version-1.md +69 -0
  229. package/schema/fixtures/values-md/invalid/version-unquoted.md +64 -0
  230. package/schema/fixtures/values-md/invalid/yaml-error.md +2 -2
  231. package/schema/fixtures/values-md/valid/absent.md +1 -1
  232. package/schema/fixtures/values-md/valid/with-values.md +5 -7
  233. package/schema/policy.schema.json +54 -2
  234. package/schema/values.schema.json +7 -11
  235. package/schema/fixtures/values/invalid/version-string.json +0 -1
@@ -0,0 +1,1607 @@
1
+ /**
2
+ * `approval codex bridge`: approval.md as the client of Codex's app-server
3
+ * protocol (APRV-361, adopting APRV-349's recommendation).
4
+ *
5
+ * ## Why this exists
6
+ *
7
+ * The native Codex hook refuses every shell call. A `Bash` pre-event carries
8
+ * `tool_input` keys exactly `["command"]`, so the adapter cannot bind the
9
+ * directory the command will run in, and it answers
10
+ * `hook-unsupported-execution-context` rather than approve bytes whose meaning
11
+ * it does not know (APRV-310, APRV-311). The app-server protocol is a different
12
+ * shape: Codex stops before it acts, asks its client, and waits, and the
13
+ * question it asks carries `cwd` on the same frame as the command, minted by
14
+ * the harness runtime rather than reported by the model.
15
+ *
16
+ * `docs/codex-app-server-bridge.md` is the evidence and the recommendation.
17
+ * Read the recommendation before changing anything here: this is an ADVISORY
18
+ * checkpoint for everyday Codex sessions this runtime starts, and it is not a
19
+ * boundary. The auto-reviewer can resolve a question before this client sees
20
+ * it, and the approval policy and sandbox posture decide how many questions
21
+ * exist at all. Both are governed by the harness's own configuration, which
22
+ * this project does not attest. The claim this verb supports is "this client
23
+ * decided every question this app-server child asked in this session", and
24
+ * nothing wider. See the custody section below for which word in that sentence
25
+ * is load-bearing.
26
+ *
27
+ * ## Custody: the server is this process's own child (APRV-365)
28
+ *
29
+ * A pending approval request is replayed to whatever connects NEXT, so "who
30
+ * may connect" is a real question about any app-server, and it is the question
31
+ * the follow-up list filed. For this verb it is answered by construction: the
32
+ * server is started here, by {@link driveSession}, as a child process over
33
+ * stdio pipes. There is no socket, nothing binds a path, and no other process
34
+ * has a file descriptor to speak on. The custody rule is the operating
35
+ * system's rather than this runtime's, which is the strongest kind available
36
+ * and the only kind this project would not have to attest.
37
+ *
38
+ * Two consequences worth stating rather than leaving to be inferred. Within
39
+ * one run there is no replay hazard at all: a question this client is asked
40
+ * cannot reach another client, because there is no other client. And the claim
41
+ * is scoped to THIS CHILD and THIS SESSION: a Codex started outside this
42
+ * arrangement is a different process with a different connection, and nothing
43
+ * here observes it, exactly as `docs/codex-activation.md` says of a Codex
44
+ * started outside the confined session.
45
+ *
46
+ * What this verb deliberately does NOT do is inspect the server command for a
47
+ * shape that would attach to something already running instead of starting a
48
+ * child. That would be a guess at another program's command line, which this
49
+ * repository has no record of, and a check written against a guessed shape
50
+ * finds nothing while reporting that it looked (the trap APRV-379 names and
51
+ * APRV-364's item reader is careful about). The property is stated and true;
52
+ * an operator who passes `-- <something that attaches>` after the separator has
53
+ * left the arrangement this section describes, and the report's claim is then
54
+ * about a session this verb did not establish.
55
+ *
56
+ * ## It reuses the hook's flow; it does not fork it
57
+ *
58
+ * Every decision is `cli/hook.ts`'s {@link decideHarnessCall}: the same
59
+ * classifier over `{command, cwd}`, the same human-only refusal, the same
60
+ * unruled `harness.launch.*` refusal, the same sandbox requirement, the same
61
+ * loop floor and unattended guard, and the same register, request and wait
62
+ * against the verified view. What changes is only where the answer goes: a
63
+ * JSON-RPC reply on the connection instead of a decision object on stdout.
64
+ *
65
+ * The request is translated into the hook's own `HookInput` — tool `Bash`,
66
+ * `tool_input.command` the string the server sent, `cwd` the directory the
67
+ * server named — and nothing else is invented. Both fields come from the
68
+ * server, which is what makes them usable: a `cwd` the model reported would be
69
+ * a self-reported field reducing scrutiny (SPEC §11.1 invariant 4).
70
+ *
71
+ * ## The deadline is the policy's, not a harness ceiling
72
+ *
73
+ * Every hook adapter answers inside a ceiling its harness sets, and the retry
74
+ * grace and adopt-on-retry machinery exist so a denial-by-deadline is
75
+ * recoverable. This transport has no timeout at all (`docs/codex-app-server-bridge.md`,
76
+ * question 2), so the wait here defaults to the policy's `approval_ttl`: a
77
+ * human who answers in eleven minutes is answering rather than arriving too
78
+ * late. `--wait` overrides it; nothing shortens the request's own TTL.
79
+ *
80
+ * ## What it answers, and what it never answers
81
+ *
82
+ * `accept` and `decline` only, in the vocabulary the request advertised through
83
+ * `availableDecisions`, and never `acceptForSession`, `cancel` or `abort`.
84
+ * `acceptForSession` converts one decision into standing authority for a whole
85
+ * session, which is a grant shape this project does not have. `cancel` and
86
+ * `abort` mean "stop the turn", which is a different act from "no to this
87
+ * action", and sending one would record an interruption as a denial.
88
+ *
89
+ * That rule is carried by the TYPE since APRV-367, not by a reviewer's memory:
90
+ * every reply word is a {@link BridgeDecisionWord}, whose eight inhabitants are
91
+ * the four spellings of yes and the four of no, and {@link encodeDecision} is
92
+ * the one place a decision becomes bytes and re-asks the question at runtime. A
93
+ * word outside the vocabulary is unconstructible, and were one to arrive anyway
94
+ * the reply becomes a decline, since the only safe substitute for a word you
95
+ * cannot name is no.
96
+ *
97
+ * ## The two file-change APIs, and the correlation (APRV-363, APRV-379)
98
+ *
99
+ * The LEGACY `applyPatchApproval` carries its `fileChanges` map on the request
100
+ * itself, so there is nothing to correlate and nothing to re-render. Since
101
+ * APRV-363 it goes through the same `decideHarnessCall` the exec half uses,
102
+ * classified by the paths it names and bound with the change as it arrived plus
103
+ * its digest. A request carrying a map and no directory is refused exactly as
104
+ * an exec request with no `cwd` is: a relative path resolves somewhere, and a
105
+ * directory this client guessed would be a guess the grant is bound to.
106
+ *
107
+ * An `item/fileChange/requestApproval` on the ITEM-BASED API carries no
108
+ * content: `threadId`, `turnId`, `itemId`, `startedAtMs`, `reason` and
109
+ * `grantRoot`, and nothing else. The bytes arrived EARLIER, on the
110
+ * `item/started` notification for that item, whose `item.changes` is an array
111
+ * of `{path, kind, diff}` (observed on 0.155.0; the frame is in
112
+ * `docs/codex-app-server-bridge.md`, question 1). Since APRV-379 this verb
113
+ * keeps every item the thread announces, by item id, and answers the request
114
+ * against the frame that id names. What reaches the classifier is the change
115
+ * set the server sent, in the shape it sent it: the paths take their classes
116
+ * and the payload binds the changes verbatim with a digest over them as
117
+ * received. Nothing is re-rendered into an `apply_patch` envelope and nothing
118
+ * parses `diff`.
119
+ *
120
+ * The correlation is where an approval could authorize bytes nobody classified,
121
+ * so every way the two frames could fail to be about one change is a refusal:
122
+ * an id no `item/started` announced, an id naming an item that is not a
123
+ * `fileChange`, a frame with no readable change set, and a request naming a
124
+ * thread or a turn the frame does not, are all `bridge-file-change-unbound`.
125
+ * An item whose `item/completed` arrived BEFORE the question is
126
+ * `bridge-file-change-already-completed`: a change that finished before it was
127
+ * asked about is not a change this client is in a position to decide.
128
+ *
129
+ * A server request this verb does not recognise is declined too, on the same
130
+ * rule: a question nobody classified is not a question to answer yes to.
131
+ *
132
+ * ## The approval policy is pinned, and the pin is checked (APRV-366)
133
+ *
134
+ * The thread starts with `approvalPolicy: "untrusted"`, which is `UnlessTrusted`
135
+ * on the wire and the only variant under which every command and every patch
136
+ * asks. Under `on-request` or `never` an unknown fraction of the session never
137
+ * reaches this client, and "this client decided every question it was asked"
138
+ * would still be true while meaning nothing. There is no flag.
139
+ *
140
+ * What the verb can prove about it depends on the server. A `thread/start` the
141
+ * server refuses stops the run, carrying its error verbatim, which is where a
142
+ * refusal of the value itself lands. A server that reports an effective policy
143
+ * of its own, on `thread/start`'s result or on a thread notification, stops the
144
+ * run when that policy is not the pinned one. A server that reports nothing is
145
+ * run against, and the report then claims only what happened: the pin was
146
+ * requested and no frame confirmed it.
147
+ *
148
+ * ## A probe turn runs first, and it can stop the session (APRV-364)
149
+ *
150
+ * Codex carries a server-side auto-reviewer that can resolve an approval with a
151
+ * model call before this client is asked, and tells the client afterwards
152
+ * through `item/autoApprovalReview` notifications. Whether it runs is a setting
153
+ * in the harness's own configuration, which this project does not attest, and
154
+ * there is no frame in the observed vocabulary where the server reports it. So
155
+ * it cannot be READ, and the only way to establish anything is to watch what
156
+ * happens to one command.
157
+ *
158
+ * Every start therefore runs a PREFLIGHT turn asking for one harmless command
159
+ * ({@link PROBE_COMMAND}) before the operator's own turn, with no flag to skip
160
+ * it. Three outcomes, told apart by the command-item notifications:
161
+ *
162
+ * - an approval request for it reaches this client: the run continues, and the
163
+ * report records the pin as confirmed by OBSERVATION;
164
+ * - a command ran and no request arrived: `bridge-approval-policy-mismatch`,
165
+ * because a policy under which one command did not ask is not `untrusted`
166
+ * whatever the server says about itself;
167
+ * - no command ran at all: `bridge-preflight-void`, carrying the turn's frames
168
+ * verbatim. It is never retried and never reported as a pass.
169
+ *
170
+ * An `item/autoApprovalReview` notification in either turn is
171
+ * `bridge-auto-reviewer-active` and ends the run, after one
172
+ * `audit.question_preempted` is appended for it (APRV-378): the moment
173
+ * something other than this gate answered a question this gate exists to ask is
174
+ * the moment this project most wants in the log.
175
+ *
176
+ * THE PROBE'S OWN REQUEST NEVER REACHES THE GATE. It is declined immediately,
177
+ * as an observation. Routing it through `decideHarnessCall` would register an
178
+ * action and could put `true` on a human's phone at every bridge start, and a
179
+ * preflight that spends a person's attention is not a harmless one.
180
+ *
181
+ * WHAT A PASS MEANS, exactly: one question reached this client unanswered by
182
+ * anything else. It is not a proof that the auto-reviewer is off for every
183
+ * question, and nothing here says that it is.
184
+ *
185
+ * ## One at a time, on purpose
186
+ *
187
+ * The gate's wait is synchronous, so while one question is being decided this
188
+ * process is not reading frames. That is the fail-closed direction: frames
189
+ * queue and are answered in arrival order, and a second request cannot be
190
+ * answered from a decision made about the first. The observed protocol asks one
191
+ * question at a time (the turn does not move past an unanswered one).
192
+ *
193
+ * ## Limitations stated rather than implied
194
+ *
195
+ * An OPEN GATE WINDOW is not honoured here. The hook's bypass prints a hook
196
+ * verdict and appends a record shaped for the hook; wiring it into this
197
+ * transport is more surface than this task carries, and ignoring it is the
198
+ * strict direction — a window widens authority, and this verb simply does not
199
+ * widen. An operator who opens a window and expects this verb to fall through
200
+ * will find it still asking.
201
+ */
202
+ import { spawn } from "node:child_process";
203
+ import { resolve } from "node:path";
204
+ import { boolFlag, parseFlags, stringFlag } from "./args.js";
205
+ import { EXIT_IO, EXIT_OK, EXIT_USAGE } from "./exit-codes.js";
206
+ import { HARNESS_ADAPTERS, decideHarnessCall, hookScope, } from "./hook.js";
207
+ import { HOOK_RETRY_GRACE_MS } from "../core/harness-wait.js";
208
+ import { canonicalize } from "../core/jcs.js";
209
+ import { loadPolicy, parseDuration } from "../core/policy-load.js";
210
+ import { recordPreemptedQuestion } from "../core/question-preempted.js";
211
+ import { shlexJoin, shlexRoundTrips, shlexSplit } from "../core/shlex.js";
212
+ /** The adapter every decision here is made under: Codex, through its own protocol. */
213
+ const ADAPTER = HARNESS_ADAPTERS["codex"];
214
+ /**
215
+ * The approval policy this verb starts a thread under (`untrusted` on the
216
+ * wire).
217
+ *
218
+ * `UnlessTrusted` is the only variant under which every command asks
219
+ * (`docs/codex-app-server-bridge.md`, question 5), and `unless-trusted` is
220
+ * REFUSED by the server: the accepted spelling is `untrusted`, established by
221
+ * the 2026-09-18 probe. There is no flag: a session gating an unknown fraction
222
+ * of itself is the thing this pin exists to prevent, and an operator who could
223
+ * pass `on-request` would have exactly that session (APRV-366).
224
+ */
225
+ export const APPROVAL_POLICY = "untrusted";
226
+ /** The sandbox posture the thread starts under. */
227
+ export const SANDBOX = "read-only";
228
+ /** Polling interval for the gate's verified read, in milliseconds. */
229
+ const DEFAULT_INTERVAL_MS = 2000;
230
+ /**
231
+ * The decision words this verb will send, most literal first.
232
+ *
233
+ * `accept`/`decline` are the item-based API's spelling and `approved`/`denied`
234
+ * the legacy one. The session-wide and amendment-carrying variants are absent
235
+ * from the accept list, and `cancel`/`abort` from the decline list, for the
236
+ * reasons in this module's header. A value this runtime does not name is never
237
+ * sent, however loudly the server advertises it.
238
+ */
239
+ export const ACCEPT_WORDS = ["accept", "approved", "approve", "allow"];
240
+ export const DECLINE_WORDS = ["decline", "denied", "deny", "reject"];
241
+ /** Is this one of the eight words? The runtime face of {@link BridgeDecisionWord}. */
242
+ export function isBridgeDecisionWord(value) {
243
+ return (typeof value === "string" &&
244
+ (ACCEPT_WORDS.includes(value) ||
245
+ DECLINE_WORDS.includes(value)));
246
+ }
247
+ /**
248
+ * The reply payload for one word: the ONE place a decision becomes bytes.
249
+ *
250
+ * `null` for anything this runtime does not name. The types make such a value
251
+ * unconstructible, so this is the defence against the code changing out from
252
+ * under the types rather than against any input a server can send: no
253
+ * `availableDecisions` list can reach it, because {@link chooseDecision} only
254
+ * ever returns a member.
255
+ *
256
+ * The caller answers `null` by sending a DECLINE, and that is the whole
257
+ * reasoning: the only safe substitute for a word you cannot name is no. It gets
258
+ * no refusal code of its own, because a code in a closed union that no input
259
+ * can produce is a string a second implementation cannot exercise and would
260
+ * have to take on trust.
261
+ */
262
+ export function encodeDecision(word) {
263
+ return isBridgeDecisionWord(word) ? { decision: word } : null;
264
+ }
265
+ /** Server request methods this verb recognises as approval questions. */
266
+ const EXEC_APPROVAL_METHODS = [
267
+ "item/commandExecution/requestApproval",
268
+ "execCommandApproval",
269
+ ];
270
+ const FILE_CHANGE_APPROVAL_METHODS = [
271
+ "item/fileChange/requestApproval",
272
+ "applyPatchApproval",
273
+ ];
274
+ /**
275
+ * Every refusal this verb can answer with that is NOT a gate verdict, closed
276
+ * and machine-readable (SPEC §11.1 invariant 7).
277
+ *
278
+ * A gate verdict carries the gate's own code (`hook-class-human-only`,
279
+ * `hook-rejected`, `hook-timeout`, and the rest); these are the refusals the
280
+ * bridge reaches on its own, before or instead of asking.
281
+ */
282
+ export const BRIDGE_REFUSAL_CODES = [
283
+ /** A file-change request whose content this verb cannot produce (APRV-363). */
284
+ "bridge-file-change-unbound",
285
+ /** A server request this verb has no reading for. */
286
+ "bridge-unknown-request",
287
+ /**
288
+ * A file-change request whose item had already COMPLETED when it arrived
289
+ * (APRV-379).
290
+ *
291
+ * Distinct from `bridge-file-change-unbound`, which says the content could
292
+ * not be produced. Here the content was produced: the `item/started` frame is
293
+ * held, the item id correlates, and the change set is right there. What is
294
+ * wrong is the ORDER. `item/completed` for that item arrived before the
295
+ * question about it did, and a change that finished before it was asked about
296
+ * is not a change this client is in a position to decide. Answering yes would
297
+ * put a grant in the log for an effect that had already happened, and
298
+ * answering the ordinary no would tell an operator to go and stop something
299
+ * that is over.
300
+ *
301
+ * The repairs differ, which is why the codes do: an unbound change is a
302
+ * correlation that did not happen and points at this client or at a protocol
303
+ * that changed shape, and this one points at a session whose approval policy
304
+ * is not the one it was pinned to, or at a server that reordered its frames.
305
+ */
306
+ "bridge-file-change-already-completed",
307
+ /**
308
+ * An exec request whose command string names no argv this client can bind
309
+ * (APRV-362).
310
+ *
311
+ * Distinct from `bridge-request-unbound`, which says a field is MISSING. This
312
+ * one says the field arrived and could not be read as the rendering of an
313
+ * argv: an unterminated quote, a bare double quote, a trailing backslash, or
314
+ * whitespace no join produces. The repairs differ, which is why the codes do:
315
+ * a missing `cwd` is a server that changed shape, and this is a command
316
+ * string that did not come from joining the words that will run.
317
+ */
318
+ "bridge-command-unbound",
319
+ /** An exec request carrying no command string, or no cwd. */
320
+ "bridge-request-unbound",
321
+ ];
322
+ /**
323
+ * Every way this verb STOPS a session instead of answering a question
324
+ * (APRV-366), closed and machine-readable.
325
+ *
326
+ * A separate array from {@link BRIDGE_REFUSAL_CODES}, and deliberately not a
327
+ * member of it. Those are answers: one approval request declined, the turn
328
+ * carrying on. These end the run before or instead of a turn, because the
329
+ * session could not be established as the kind of session this verb is willing
330
+ * to sit in front of. The conformance union `bridge_refusal_codes` is
331
+ * documented as "every way the bridge can decline an app-server approval
332
+ * request", so a stop code inside it would describe a different boundary, which
333
+ * is the reasoning that kept these out of `hook_deny_codes` too.
334
+ *
335
+ * The process exit for both is {@link EXIT_IO}, as it is for every other
336
+ * protocol stop here: the exit codes are frozen public API and a session that
337
+ * could not be started is not a new number. The code below is the distinct part
338
+ * a caller branches on.
339
+ */
340
+ export const BRIDGE_STOP_CODES = [
341
+ /**
342
+ * The server refused `thread/start`, so no thread exists and the approval
343
+ * policy this verb requires was never established. The server's own error is
344
+ * carried verbatim in the detail, which is where a refusal of the policy
345
+ * VALUE shows up (the 2026-09-18 probe's `unknown variant \`unless-trusted\`,
346
+ * expected one of \`untrusted\`, \`on-request\`, \`granular\`, \`never\``).
347
+ */
348
+ "bridge-thread-start-refused",
349
+ /**
350
+ * The server started a thread and reported an effective approval policy that
351
+ * is not {@link APPROVAL_POLICY}. Under any other variant an unknown fraction
352
+ * of the session never produces a question at all, so "this client decided
353
+ * every question it was asked" would be true and would mean nothing.
354
+ */
355
+ "bridge-approval-policy-mismatch",
356
+ /**
357
+ * An `item/autoApprovalReview` notification arrived, in the preflight turn or
358
+ * in the real one (APRV-364).
359
+ *
360
+ * Codex carries a server-side auto-reviewer that can resolve an approval with
361
+ * a model call BEFORE the client path runs, and tells the client afterwards
362
+ * through these notifications (`docs/codex-app-server-bridge.md`, question
363
+ * 3). A session with a reviewer in front of the gate is a session whose
364
+ * silence means nothing: the questions this client was not asked are
365
+ * indistinguishable from questions nobody wanted to ask. So the run stops
366
+ * rather than gating whatever is left over.
367
+ *
368
+ * It leaves a RECORD since APRV-378: one `audit.question_preempted`,
369
+ * appended through the real append path before the stop, naming the source,
370
+ * the question as Codex identified it, and the verdict the reviewer reached
371
+ * where the notification stated one. The write is best-effort and the stop
372
+ * does not depend on it; a failure to append is reported on stderr beside
373
+ * the stop rather than swallowed.
374
+ */
375
+ "bridge-auto-reviewer-active",
376
+ /**
377
+ * The preflight turn ran no command at all, so the probe established nothing
378
+ * (APRV-364).
379
+ *
380
+ * The preflight is a prompt, and a model is free to answer a prompt in prose.
381
+ * When that happens no approval request arrives AND no command executes, and
382
+ * the fact AC1 wants — that a command reached this client as a question —
383
+ * was not observed. Reporting it as a pass would be reporting a verdict
384
+ * nobody established, which is the APRV-359 lesson; reporting it as the
385
+ * policy mismatch would blame a healthy session for a model's choice of
386
+ * words. So it is its own code, the report carries the turn's frames
387
+ * verbatim, and nothing is retried: an operator runs the verb again.
388
+ */
389
+ "bridge-preflight-void",
390
+ ];
391
+ /**
392
+ * Where the report's claim about the approval policy COMES FROM (APRV-364).
393
+ *
394
+ * APRV-366 wrote this as a boolean, and a boolean could say only that some
395
+ * frame echoed the pin back. The preflight probe establishes the same thing a
396
+ * different way, by watching what happens to one command, and the two are not
397
+ * the same strength of evidence: an echo is the server describing itself, and
398
+ * an observation is a thing that happened. A reader who is told `true` cannot
399
+ * tell them apart, so the field names its source instead.
400
+ *
401
+ * - `unconfirmed` — nothing has confirmed the pin. Where every run starts, and
402
+ * where a run that stopped before the probe finished stays.
403
+ * - `reported` — a server frame named the pinned policy as the effective one.
404
+ * The observed 0.155.0 server names none, so this is rare in practice.
405
+ * - `observed` — a probe command produced an approval request that reached
406
+ * this client. THE HONESTY LINE, and it is narrow on purpose: it proves that
407
+ * ONE question reached this client unanswered by anything else. It is not a
408
+ * proof that the auto-reviewer is off, and no code or document here may say
409
+ * that it is.
410
+ */
411
+ export const BRIDGE_PIN_SOURCES = ["unconfirmed", "reported", "observed"];
412
+ /**
413
+ * The one command the preflight turn asks for (APRV-364).
414
+ *
415
+ * Chosen for having no effect: it writes nothing, reads nothing, prints
416
+ * nothing, and exits zero. The point of the probe is the QUESTION it raises,
417
+ * and a probe whose command mattered would be a probe an operator had to think
418
+ * about before running.
419
+ */
420
+ export const PROBE_COMMAND = "true";
421
+ /**
422
+ * The preflight prompt, written to leave a model as little room as a prompt can
423
+ * (APRV-364).
424
+ *
425
+ * It cannot leave none, which is why {@link BRIDGE_STOP_CODES} carries
426
+ * `bridge-preflight-void`: a model that answers in prose has run no command,
427
+ * and that outcome is reported rather than guessed at.
428
+ */
429
+ export const PROBE_PROMPT = [
430
+ `Run exactly one shell command: ${PROBE_COMMAND}`,
431
+ "Run nothing else. Do not read or write any file, do not look around the workspace,",
432
+ "and do not explain. Running that one command is the whole task.",
433
+ ].join(" ");
434
+ /** What the preflight turn established, once it ended. */
435
+ export const BRIDGE_PROBE_OUTCOMES = ["pending", "asked", "executed", "void"];
436
+ /**
437
+ * The effective approval policy a server frame reports, or `null` when it
438
+ * reports none.
439
+ *
440
+ * The locations are a documented short list rather than a generic walk: this
441
+ * value can STOP a session, so it is read from places whose meaning is known,
442
+ * and a stray `approvalPolicy` nested inside some unrelated structure must not
443
+ * be able to end a run. The observed 0.155.0 server echoes none of them, which
444
+ * is why an absent value is not itself a stop.
445
+ */
446
+ export function effectiveApprovalPolicy(value) {
447
+ const object = (candidate) => candidate !== null && typeof candidate === "object"
448
+ ? candidate
449
+ : null;
450
+ const top = object(value);
451
+ if (top === null)
452
+ return null;
453
+ for (const holder of [top, object(top["thread"]), object(top["config"]), object(top["settings"])]) {
454
+ if (holder === null)
455
+ continue;
456
+ const named = holder["approvalPolicy"] ?? holder["approval_policy"];
457
+ if (typeof named === "string" && named.length > 0)
458
+ return named;
459
+ }
460
+ return null;
461
+ }
462
+ /**
463
+ * The decision words a request says are legal, if it says.
464
+ *
465
+ * Walked generically rather than read from one key path, so a renamed field of
466
+ * the same shape still answers. This is the server describing its own
467
+ * vocabulary, which is the one thing a client should never pin.
468
+ */
469
+ export function advertisedDecisions(params) {
470
+ const found = [];
471
+ const walk = (value, depth) => {
472
+ if (depth > 8 || value === null || typeof value !== "object")
473
+ return;
474
+ for (const [key, entry] of Object.entries(value)) {
475
+ if (/decision/iu.test(key)) {
476
+ if (Array.isArray(entry)) {
477
+ for (const candidate of entry)
478
+ if (typeof candidate === "string")
479
+ found.push(candidate);
480
+ }
481
+ else if (typeof entry === "string") {
482
+ found.push(entry);
483
+ }
484
+ }
485
+ walk(entry, depth + 1);
486
+ }
487
+ };
488
+ walk(params, 0);
489
+ return [...new Set(found)];
490
+ }
491
+ /**
492
+ * The word to send for this outcome, and where it came from (AC4).
493
+ *
494
+ * An advertised word wins, matched case-insensitively and NEVER by prefix, so a
495
+ * server that offers `acceptWithExecpolicyAmendment` is not read as offering
496
+ * `accept`: an amendment carries terms nobody approved. A request advertising
497
+ * nothing gets this verb's own first word, and the report says `fallback` so
498
+ * the choice is visible rather than assumed.
499
+ *
500
+ * What it returns is this runtime's own spelling of the matched word rather
501
+ * than the server's (APRV-367). That is the point of the type: a
502
+ * {@link BridgeDecisionWord} has eight inhabitants, all of them named here, so
503
+ * no path through this function can produce a word this project did not choose
504
+ * to be able to send. The two spellings differ only in letter case, since the
505
+ * match is case-insensitive equality with one of the eight.
506
+ */
507
+ export function chooseDecision(params, outcome) {
508
+ const offered = advertisedDecisions(params);
509
+ const order = outcome === "accept" ? ACCEPT_WORDS : DECLINE_WORDS;
510
+ for (const candidate of order) {
511
+ const match = offered.find((value) => value.toLowerCase() === candidate);
512
+ if (match !== undefined)
513
+ return { decision: candidate, decisionSource: "advertised" };
514
+ }
515
+ return { decision: order[0], decisionSource: "fallback" };
516
+ }
517
+ function stringField(source, key) {
518
+ if (source === null || typeof source !== "object")
519
+ return null;
520
+ const value = source[key];
521
+ return typeof value === "string" && value.length > 0 ? value : null;
522
+ }
523
+ export function bindCommand(params) {
524
+ if (params === null || typeof params !== "object")
525
+ return null;
526
+ const value = params["command"];
527
+ if (typeof value === "string" && value.length > 0) {
528
+ const split = shlexSplit(value);
529
+ if (!split.ok)
530
+ return { ok: false, reason: split.reason };
531
+ // An all-whitespace string carries a command field and no command, which is
532
+ // the missing-field answer rather than this one.
533
+ if (split.argv.length === 0)
534
+ return null;
535
+ if (!split.joinShaped) {
536
+ return {
537
+ ok: false,
538
+ reason: "its words are not separated the way a join separates them (one space each, none leading or trailing), so the string did not come from joining the argv that will run",
539
+ };
540
+ }
541
+ if (!shlexRoundTrips(split.argv)) {
542
+ return { ok: false, reason: "the words it names cannot be rendered and read back unchanged" };
543
+ }
544
+ return { ok: true, command: value, argv: split.argv, source: "rendering" };
545
+ }
546
+ if (Array.isArray(value)) {
547
+ const argv = value.filter((entry) => typeof entry === "string");
548
+ if (argv.length === 0 || argv.length !== value.length)
549
+ return null;
550
+ if (!shlexRoundTrips(argv)) {
551
+ return { ok: false, reason: "the words it names cannot be rendered and read back unchanged" };
552
+ }
553
+ // Rendered, not concatenated. `argv.join(" ")` hands the classifier
554
+ // `bash -lc rm -rf build` for `["bash","-lc","rm -rf build"]`, which is
555
+ // four more words than the kernel will ever see and a different command.
556
+ return { ok: true, command: shlexJoin(argv), argv, source: "argv" };
557
+ }
558
+ return null;
559
+ }
560
+ /**
561
+ * A stable identity for the call, so two frames about one action are one
562
+ * question.
563
+ *
564
+ * `itemId` on the item-based API, `callId` on the legacy one, and `approvalId`
565
+ * where neither is present. It becomes the hook's `tool_use_id`, which is what
566
+ * the task id is derived from.
567
+ */
568
+ function callIdOf(params) {
569
+ return (stringField(params, "itemId") ??
570
+ stringField(params, "callId") ??
571
+ stringField(params, "approvalId"));
572
+ }
573
+ /**
574
+ * The turn a frame belongs to, where it names one (APRV-364).
575
+ *
576
+ * The preflight and the real turn are told apart by this value, and a frame
577
+ * that names no turn is decided by which turn is running instead. Both
578
+ * spellings are read because the protocol has used both casings elsewhere and
579
+ * neither reading can widen anything: a turn id is only ever used to decide
580
+ * which of two phases a frame belongs to.
581
+ */
582
+ export function turnIdOf(params) {
583
+ return stringField(params, "turnId") ?? stringField(params, "turn_id");
584
+ }
585
+ /**
586
+ * Is this method one of Codex's auto-approval-review notifications (APRV-364)?
587
+ *
588
+ * The recorded names are `item/autoApprovalReview/started` and
589
+ * `item/autoApprovalReview/completed`
590
+ * (`docs/codex-app-server-bridge.md`, question 3). The match is on the
591
+ * SUBSTRING rather than on those two exact names, case-folded, because a
592
+ * reviewer notification this runtime failed to recognise would be a session
593
+ * that ran with a reviewer in front of the gate: over-matching costs a stop
594
+ * that an operator can read and re-run, and under-matching costs the whole
595
+ * point of the check.
596
+ */
597
+ export function isAutoReviewNotification(method) {
598
+ return method.toLowerCase().includes("autoapprovalreview");
599
+ }
600
+ /**
601
+ * The verdict an auto-review notification states, or `null` (APRV-378).
602
+ *
603
+ * Read from a short list of named places rather than by a generic walk, for the
604
+ * reason {@link effectiveApprovalPolicy} is: this value goes into the log as
605
+ * another party's decision, and a string picked up from some unrelated
606
+ * structure would be this runtime putting words in their mouth. `null` is
607
+ * recorded as an ABSENT verdict, never as a default one.
608
+ */
609
+ export function autoReviewVerdict(params) {
610
+ if (params === null || typeof params !== "object")
611
+ return null;
612
+ const top = params;
613
+ const holder = top["review"] ?? top["assessment"] ?? top["result"];
614
+ const nested = holder !== null && typeof holder === "object" ? holder : null;
615
+ for (const source of [top, nested]) {
616
+ if (source === null)
617
+ continue;
618
+ for (const key of ["decision", "verdict", "outcome"]) {
619
+ const named = source[key];
620
+ if (typeof named === "string" && named.length > 0)
621
+ return named;
622
+ }
623
+ }
624
+ return null;
625
+ }
626
+ /**
627
+ * Does this notification say a COMMAND was executed (APRV-364)?
628
+ *
629
+ * The one reader in this file written against a shape nobody recorded in full.
630
+ * The 2026-09-18 vocabulary carries `item/started` and `item/completed`, and it
631
+ * does not record the item object they carry, so this looks for an item whose
632
+ * type reads as a command execution, or, failing that, for an item carrying a
633
+ * `command` string.
634
+ *
635
+ * That is a guess, and the reason it is an acceptable one is the direction it
636
+ * fails in. This value only ever chooses BETWEEN TWO STOPS: a preflight turn
637
+ * where a command ran without asking stops under
638
+ * `bridge-approval-policy-mismatch`, and one where nothing ran stops under
639
+ * `bridge-preflight-void`. A guess that misses turns the first into the
640
+ * second; it can never turn either into a pass, because a pass needs an
641
+ * approval request to have ARRIVED, which is a frame this client was handed
642
+ * rather than one it went looking for. The void report carries the frames
643
+ * verbatim, which is also how the real item shape gets recorded here at last.
644
+ */
645
+ export function namesCommandExecution(params) {
646
+ if (params === null || typeof params !== "object")
647
+ return false;
648
+ const holder = params["item"];
649
+ const item = holder !== null && typeof holder === "object" ? holder : null;
650
+ if (item === null)
651
+ return false;
652
+ for (const key of ["type", "itemType", "item_type"]) {
653
+ const named = item[key];
654
+ if (typeof named !== "string")
655
+ continue;
656
+ if (named.toLowerCase().replace(/[^a-z]/gu, "").startsWith("commandexecution"))
657
+ return true;
658
+ }
659
+ return typeof item["command"] === "string" && item["command"].length > 0;
660
+ }
661
+ /** A line-delimited and `Content-Length`-delimited frame reader. */
662
+ class Connection {
663
+ child;
664
+ onFrame;
665
+ buffer = Buffer.alloc(0);
666
+ nextId = 1;
667
+ constructor(child, onFrame) {
668
+ this.child = child;
669
+ this.onFrame = onFrame;
670
+ this.child.stdout.on("data", (chunk) => {
671
+ this.absorb(chunk);
672
+ });
673
+ this.child.stdout.on("error", () => { });
674
+ this.child.stdin.on("error", () => { });
675
+ }
676
+ absorb(chunk) {
677
+ this.buffer = Buffer.concat([this.buffer, chunk]);
678
+ for (;;) {
679
+ if (this.buffer.indexOf("Content-Length:") === 0) {
680
+ const end = this.buffer.indexOf("\r\n\r\n");
681
+ if (end === -1)
682
+ return;
683
+ const header = this.buffer.subarray(0, end).toString("utf8");
684
+ const match = /Content-Length:\s*(\d+)/iu.exec(header);
685
+ if (match === null) {
686
+ this.buffer = this.buffer.subarray(end + 4);
687
+ continue;
688
+ }
689
+ const length = Number(match[1]);
690
+ if (this.buffer.length < end + 4 + length)
691
+ return;
692
+ const body = this.buffer.subarray(end + 4, end + 4 + length).toString("utf8");
693
+ this.buffer = this.buffer.subarray(end + 4 + length);
694
+ this.deliver(body);
695
+ continue;
696
+ }
697
+ const newline = this.buffer.indexOf("\n");
698
+ if (newline === -1)
699
+ return;
700
+ const line = this.buffer.subarray(0, newline).toString("utf8").trim();
701
+ this.buffer = this.buffer.subarray(newline + 1);
702
+ if (line.length > 0)
703
+ this.deliver(line);
704
+ }
705
+ }
706
+ deliver(text) {
707
+ let frame;
708
+ try {
709
+ frame = JSON.parse(text);
710
+ }
711
+ catch {
712
+ // Not JSON. A client that threw here would take the server down with it;
713
+ // an unreadable line is noise on a stream that also carries logs.
714
+ return;
715
+ }
716
+ if (frame !== null && typeof frame === "object")
717
+ this.onFrame(frame);
718
+ }
719
+ write(value) {
720
+ if (this.child.stdin.destroyed || !this.child.stdin.writable)
721
+ return;
722
+ try {
723
+ this.child.stdin.write(`${JSON.stringify(value)}\n`);
724
+ }
725
+ catch {
726
+ // The server went away; the caller's own exit path reports that.
727
+ }
728
+ }
729
+ /** The envelope has no `jsonrpc` member: a request is `{id, method, params}`. */
730
+ request(method, params) {
731
+ const id = this.nextId;
732
+ this.nextId += 1;
733
+ this.write({ id, method, ...(params === undefined ? {} : { params }) });
734
+ return id;
735
+ }
736
+ notify(method, params) {
737
+ this.write({ method, ...(params === undefined ? {} : { params }) });
738
+ }
739
+ /** A reply is `{id, result}`. */
740
+ respond(id, result) {
741
+ this.write({ id, result });
742
+ }
743
+ }
744
+ function usage(streams, json, message) {
745
+ if (json)
746
+ streams.err(`${JSON.stringify({ error: { code: "usage", message } })}\n`);
747
+ else
748
+ streams.err(`approval: ${message}\n`);
749
+ return EXIT_USAGE;
750
+ }
751
+ /**
752
+ * Decide one exec approval request through the gate (AC2, AC3).
753
+ *
754
+ * Exported for the tests, which drive it without a server so the DECISION can
755
+ * be asserted apart from the transport.
756
+ */
757
+ export function decideExecRequest(streams, plan, params) {
758
+ const bound = bindCommand(params);
759
+ const cwd = stringField(params, "cwd");
760
+ const callId = callIdOf(params);
761
+ const threadId = stringField(params, "threadId") ?? stringField(params, "conversationId");
762
+ if (bound === null || cwd === null || callId === null) {
763
+ // The three fields a decision needs. Missing any one of them, there is
764
+ // nothing to bind and nothing to classify, and the answer is no.
765
+ const missing = [
766
+ bound === null ? "command" : null,
767
+ cwd === null ? "cwd" : null,
768
+ callId === null ? "a call identity (itemId, callId or approvalId)" : null,
769
+ ]
770
+ .filter((entry) => entry !== null)
771
+ .join(", ");
772
+ return {
773
+ threadId,
774
+ verdict: {
775
+ permission: "deny",
776
+ code: "bridge-request-unbound",
777
+ detail: `the approval request carries no ${missing}; a decision here would authorize bytes this client cannot name, so it is declined and nothing was appended`,
778
+ },
779
+ };
780
+ }
781
+ if (!bound.ok) {
782
+ // APRV-362. The command arrived and this client cannot say which words it
783
+ // renders. Approving it would approve a parse, so it is declined before
784
+ // anything is classified and nothing is appended.
785
+ return {
786
+ threadId,
787
+ verdict: {
788
+ permission: "deny",
789
+ code: "bridge-command-unbound",
790
+ detail: `the approval request's command cannot be bound to the argv it will run: ${bound.reason}; a decision here would authorize this client's own re-parse rather than the words the server holds, so it is declined and nothing was appended`,
791
+ },
792
+ };
793
+ }
794
+ const input = {
795
+ sessionId: threadId ?? "codex-bridge",
796
+ sessionIdPresent: threadId !== null,
797
+ cwd,
798
+ toolName: ADAPTER.shellTool,
799
+ // Both accounts of the call, so the registered payload names the words and
800
+ // the rendering side by side (APRV-362). `codexArgv` re-splits the command
801
+ // and accepts the argv only when the two agree, so what reaches the payload
802
+ // is a derivation of bytes already bound rather than a second claim.
803
+ toolInput: { command: bound.command, argv: bound.argv },
804
+ toolUseId: callId,
805
+ hookEventName: null,
806
+ model: null,
807
+ toolResponse: null,
808
+ toolResponseRaw: undefined,
809
+ interrupted: false,
810
+ harnessVersion: null,
811
+ };
812
+ return {
813
+ threadId,
814
+ verdict: decideHarnessCall({
815
+ streams,
816
+ input,
817
+ adapter: ADAPTER,
818
+ // The directory the SERVER named, which is the whole reason this verb
819
+ // exists: the native hook has no such field and refuses for want of it.
820
+ cwd,
821
+ logPath: plan.logPath,
822
+ root: plan.root,
823
+ options: plan.options,
824
+ actor: plan.actor,
825
+ timeoutMs: plan.waitMs,
826
+ intervalMs: plan.intervalMs,
827
+ graceMs: HOOK_RETRY_GRACE_MS,
828
+ // No open-window lookup was performed, so nothing is carried; the floor
829
+ // and the unattended guard read the log themselves. See the module header
830
+ // for why a window is not honoured here.
831
+ windowRecords: null,
832
+ }),
833
+ };
834
+ }
835
+ /**
836
+ * The change set an `item/started` frame carries for a `fileChange` item, or
837
+ * `null` (APRV-379).
838
+ *
839
+ * The observed shape is an ARRAY of `{path, kind, diff}` under `item.changes`
840
+ * (`docs/codex-app-server-bridge.md`, question 1). It is read as an array and
841
+ * carried whole; nothing here looks inside an entry, because the classifier
842
+ * reads the paths and the payload binds the bytes, and a second reader of the
843
+ * same material in this module would be a second account of one change.
844
+ */
845
+ export function itemChanges(item) {
846
+ const value = item["changes"];
847
+ if (!Array.isArray(value) || value.length === 0)
848
+ return null;
849
+ return value;
850
+ }
851
+ /**
852
+ * Record what an `item/started` or `item/completed` notification says
853
+ * (APRV-379).
854
+ *
855
+ * `item/started` writes the entry, `item/completed` marks it completed and
856
+ * leaves the recorded content ALONE. Refreshing the change set from the
857
+ * completion frame would let a server hand this client one change set, be asked
858
+ * about it, and have a different one in the record afterwards; the frame this
859
+ * client decides against is the one it was holding when the question arrived.
860
+ */
861
+ export function recordItemFrame(index, method, params) {
862
+ if (method !== "item/started" && method !== "item/completed")
863
+ return;
864
+ if (params === null || typeof params !== "object")
865
+ return;
866
+ const holder = params["item"];
867
+ if (holder === null || typeof holder !== "object" || Array.isArray(holder))
868
+ return;
869
+ const item = holder;
870
+ const id = typeof item["id"] === "string" ? item["id"] : null;
871
+ if (id === null || id.length === 0)
872
+ return;
873
+ const existing = index.get(id);
874
+ if (method === "item/completed") {
875
+ if (existing !== undefined)
876
+ index.set(id, { ...existing, completed: true });
877
+ return;
878
+ }
879
+ if (existing !== undefined)
880
+ return;
881
+ index.set(id, {
882
+ id,
883
+ type: typeof item["type"] === "string" ? item["type"] : null,
884
+ changes: itemChanges(item),
885
+ threadId: stringField(params, "threadId"),
886
+ turnId: turnIdOf(params),
887
+ completed: false,
888
+ });
889
+ }
890
+ /**
891
+ * Find the `item/started` frame an item-based file-change request refers to
892
+ * (APRV-379).
893
+ *
894
+ * THE WHOLE RISK OF THIS TASK LIVES HERE. The request names an identifier, the
895
+ * bytes arrived on another frame, and a correlation that matched the wrong item
896
+ * would let a grant authorize bytes nobody classified. So every way the two
897
+ * frames could fail to be about the same change is a refusal, and none of them
898
+ * is resolved in favour of going ahead:
899
+ *
900
+ * - no `item/started` for that id was ever seen: `bridge-file-change-unbound`,
901
+ * which is the refusal this verb has answered since APRV-361;
902
+ * - the id names an item that is not a `fileChange`: same code, its own detail.
903
+ * An id that points at a `userMessage` is a request this client has no
904
+ * content for, however much content that item has;
905
+ * - the item carried no readable change set: same code. A `fileChange` frame
906
+ * with nothing in `changes` is an identifier again;
907
+ * - the request names a THREAD or a TURN the frame does not: same code.
908
+ * Item ids are server-minted and observed unique, so this should never fire,
909
+ * and that is exactly why it is checked rather than assumed. Two frames that
910
+ * disagree about which conversation they belong to are not established to be
911
+ * about one change, and "should never happen" is the reasoning that lets a
912
+ * wrong match through. A frame or a request that names NEITHER field is not
913
+ * held to it: absence is not disagreement, and the observed frames carry
914
+ * both;
915
+ * - the item already COMPLETED: `bridge-file-change-already-completed`, for the
916
+ * reasons that code carries.
917
+ */
918
+ export function correlateFileChange(index, params) {
919
+ const itemId = stringField(params, "itemId") ?? stringField(params, "callId");
920
+ if (itemId === null) {
921
+ return {
922
+ ok: false,
923
+ code: "bridge-file-change-unbound",
924
+ detail: "the file-change request names no item this client could look up, and the bytes arrived on an earlier frame; approving a request that refers to nothing is not approving a change, so it is declined and nothing was appended",
925
+ };
926
+ }
927
+ const item = index.get(itemId);
928
+ if (item === undefined) {
929
+ return {
930
+ ok: false,
931
+ code: "bridge-file-change-unbound",
932
+ detail: `the file-change request names item ${JSON.stringify(itemId)} and no item/started for it reached this client, so the change it asks about is an identifier and nothing else; approving an identifier is not approving a change, so it is declined and nothing was appended`,
933
+ };
934
+ }
935
+ if (item.type !== "fileChange") {
936
+ return {
937
+ ok: false,
938
+ code: "bridge-file-change-unbound",
939
+ detail: `the file-change request names item ${JSON.stringify(itemId)}, which this client recorded as ${JSON.stringify(item.type)} and not a fileChange; a change set cannot be produced from it, so it is declined and nothing was appended`,
940
+ };
941
+ }
942
+ if (item.changes === null) {
943
+ return {
944
+ ok: false,
945
+ code: "bridge-file-change-unbound",
946
+ detail: `the file-change request names item ${JSON.stringify(itemId)}, whose item/started carried no change set this client could read; there are no bytes to classify, so it is declined and nothing was appended`,
947
+ };
948
+ }
949
+ const threadId = stringField(params, "threadId") ?? stringField(params, "conversationId");
950
+ if (threadId !== null && item.threadId !== null && threadId !== item.threadId) {
951
+ return {
952
+ ok: false,
953
+ code: "bridge-file-change-unbound",
954
+ detail: `the file-change request names item ${JSON.stringify(itemId)} on thread ${JSON.stringify(threadId)} and the frame carrying that item's content named thread ${JSON.stringify(item.threadId)}; two frames that disagree about which conversation they belong to are not established to be about one change, so it is declined and nothing was appended`,
955
+ };
956
+ }
957
+ const turnId = turnIdOf(params);
958
+ if (turnId !== null && item.turnId !== null && turnId !== item.turnId) {
959
+ return {
960
+ ok: false,
961
+ code: "bridge-file-change-unbound",
962
+ detail: `the file-change request names item ${JSON.stringify(itemId)} on turn ${JSON.stringify(turnId)} and the frame carrying that item's content named turn ${JSON.stringify(item.turnId)}; two frames that disagree about which turn they belong to are not established to be about one change, so it is declined and nothing was appended`,
963
+ };
964
+ }
965
+ if (item.completed) {
966
+ return {
967
+ ok: false,
968
+ code: "bridge-file-change-already-completed",
969
+ detail: `item/completed for ${JSON.stringify(itemId)} arrived BEFORE the approval request for it, so the change had already finished by the time this client was asked about it; a change applied before the question is not one this client can decide, and a grant appended for it would name an effect that had already happened. It is declined and nothing was appended`,
970
+ };
971
+ }
972
+ return { ok: true, item, changes: item.changes };
973
+ }
974
+ /**
975
+ * The change map a file-change request carries INLINE, or `null` (APRV-363).
976
+ *
977
+ * The LEGACY `applyPatchApproval` carries `fileChanges`, a map of path to
978
+ * change, on the request itself. There is nothing to correlate and nothing
979
+ * arrives on another frame, so it is the one file-change shape this verb can
980
+ * bind: the bytes it decides about are the bytes it was sent.
981
+ */
982
+ export function inlineFileChanges(params) {
983
+ if (params === null || typeof params !== "object")
984
+ return null;
985
+ const value = params["fileChanges"];
986
+ if (value === null || typeof value !== "object" || Array.isArray(value))
987
+ return null;
988
+ const map = value;
989
+ return Object.keys(map).length === 0 ? null : map;
990
+ }
991
+ /**
992
+ * Decide one file-change request, whichever API it arrived on (APRV-363,
993
+ * APRV-379).
994
+ *
995
+ * Through the SAME path an exec request takes: the hook's `decideHarnessCall`,
996
+ * so the human-only refusal, the loop floor, the unattended guard, the
997
+ * registration and the wait are one implementation. What differs is the tool
998
+ * name and the bound material, and the description of both is `cli/hook.ts`'s,
999
+ * never this module's.
1000
+ *
1001
+ * TWO SOURCES FOR THE CHANGE SET, one decision path. The LEGACY
1002
+ * `applyPatchApproval` carries it inline, so it is read off the request. The
1003
+ * ITEM-BASED `item/fileChange/requestApproval` carries an identifier, so it is
1004
+ * correlated to the `item/started` frame this client recorded, by
1005
+ * {@link correlateFileChange}, which refuses rather than guessing. Either way
1006
+ * what reaches the describer is the change set the server sent, in the shape it
1007
+ * sent it.
1008
+ *
1009
+ * THE DIRECTORY, and the two halves differ here for a recorded reason. The
1010
+ * legacy request carries one (`cwd`, or `grantRoot` for the same purpose) and
1011
+ * is refused without it, exactly as APRV-363 left it. The item-based request
1012
+ * carries neither: `grantRoot` was `null` in both captures and there is no
1013
+ * `cwd` on that shape at all (`docs/codex-app-server-bridge.md`, question 1).
1014
+ * So it falls back to the workspace THIS CLIENT named on `thread/start`, which
1015
+ * is this client's own binding rather than a guess or a server claim, and the
1016
+ * fallback widens nothing: the observed change paths are absolute, the
1017
+ * describer resolves every path against that directory, and one landing
1018
+ * outside it is refused `hook-io` whichever way it was spelled.
1019
+ */
1020
+ export function decideFileChangeRequest(streams, plan, params, items) {
1021
+ const inline = inlineFileChanges(params);
1022
+ const callId = callIdOf(params);
1023
+ const threadId = stringField(params, "threadId") ?? stringField(params, "conversationId");
1024
+ const named = stringField(params, "cwd") ?? stringField(params, "grantRoot");
1025
+ let changes;
1026
+ let cwd;
1027
+ if (inline !== null) {
1028
+ if (named === null || callId === null) {
1029
+ const missing = [
1030
+ named === null ? "a directory (cwd or grantRoot)" : null,
1031
+ callId === null ? "a call identity (itemId, callId or approvalId)" : null,
1032
+ ]
1033
+ .filter((entry) => entry !== null)
1034
+ .join(", ");
1035
+ return {
1036
+ threadId,
1037
+ verdict: {
1038
+ permission: "deny",
1039
+ code: "bridge-request-unbound",
1040
+ detail: `the file-change request carries no ${missing}; a decision here would authorize bytes this client cannot name, so it is declined and nothing was appended`,
1041
+ },
1042
+ };
1043
+ }
1044
+ changes = inline;
1045
+ cwd = named;
1046
+ }
1047
+ else {
1048
+ const correlated = correlateFileChange(items, params);
1049
+ if (!correlated.ok) {
1050
+ return { threadId, verdict: { permission: "deny", ...correlated } };
1051
+ }
1052
+ if (callId === null) {
1053
+ return {
1054
+ threadId,
1055
+ verdict: {
1056
+ permission: "deny",
1057
+ code: "bridge-request-unbound",
1058
+ detail: "the file-change request carries no call identity (itemId, callId or approvalId); a decision here could not be tied to the action it decides, so it is declined and nothing was appended",
1059
+ },
1060
+ };
1061
+ }
1062
+ changes = correlated.changes;
1063
+ cwd = named ?? plan.workspace;
1064
+ }
1065
+ const input = {
1066
+ sessionId: threadId ?? "codex-bridge",
1067
+ sessionIdPresent: threadId !== null,
1068
+ cwd,
1069
+ toolName: "apply_patch",
1070
+ // The change set VERBATIM, under the key the hook's describer reads.
1071
+ // Nothing is re-rendered into an `apply_patch` envelope: the classifier is
1072
+ // given the paths the server named, and the grant binds the change as it
1073
+ // arrived, map or array.
1074
+ //
1075
+ // `command` beside it is the change's CANONICAL JSON, and it is identity
1076
+ // rather than content: the Codex adapter derives one task id per tool call
1077
+ // from the call's own bytes (`hook-codex.ts`'s `codexBinding`), and a call
1078
+ // with no such string could not be identified at all. Canonical so the same
1079
+ // change is the same call, whatever key order the server used. Nothing
1080
+ // classifies it and nothing executes it: the description above is built
1081
+ // from the change set, and the payload a human sees is the change set.
1082
+ toolInput: { file_changes: changes, command: canonicalize(changes) },
1083
+ toolUseId: callId,
1084
+ hookEventName: null,
1085
+ model: null,
1086
+ toolResponse: null,
1087
+ toolResponseRaw: undefined,
1088
+ interrupted: false,
1089
+ harnessVersion: null,
1090
+ };
1091
+ return {
1092
+ threadId,
1093
+ verdict: decideHarnessCall({
1094
+ streams,
1095
+ input,
1096
+ adapter: ADAPTER,
1097
+ cwd,
1098
+ logPath: plan.logPath,
1099
+ root: plan.root,
1100
+ options: plan.options,
1101
+ actor: plan.actor,
1102
+ timeoutMs: plan.waitMs,
1103
+ intervalMs: plan.intervalMs,
1104
+ graceMs: HOOK_RETRY_GRACE_MS,
1105
+ windowRecords: null,
1106
+ }),
1107
+ };
1108
+ }
1109
+ export async function runCodexBridge(argv, streams, cwd) {
1110
+ const json = argv.includes("--json");
1111
+ const parsed = parseFlags(argv, {
1112
+ "--dir": "string",
1113
+ "--policy": "string",
1114
+ "--log": "string",
1115
+ "--as": "string",
1116
+ "--workspace": "string",
1117
+ "--prompt": "string",
1118
+ "--wait": "string",
1119
+ "--interval": "string",
1120
+ "--json": "boolean",
1121
+ "--help": "boolean",
1122
+ "-h": "boolean",
1123
+ });
1124
+ if (!parsed.ok)
1125
+ return usage(streams, json, parsed.message);
1126
+ if (boolFlag(parsed.flags, "--help") || boolFlag(parsed.flags, "-h")) {
1127
+ streams.out(`${CODEX_BRIDGE_HELP}\n`);
1128
+ return EXIT_OK;
1129
+ }
1130
+ const prompt = stringFlag(parsed.flags, "--prompt") ?? "";
1131
+ if (prompt.trim().length === 0) {
1132
+ return usage(streams, json, "bridge requires a prompt: `approval codex bridge --prompt <text>`");
1133
+ }
1134
+ /**
1135
+ * The app-server to start, after `--`, as `approval run` takes a command.
1136
+ *
1137
+ * Default `codex app-server`. The flag form exists for the tests, which drive
1138
+ * a stub that speaks the recorded shape — the script an operator runs once
1139
+ * has already been run, which is the same rule the probe keeps.
1140
+ */
1141
+ const server = parsed.positionals.length > 0 ? parsed.positionals : ["codex", "app-server"];
1142
+ const scope = hookScope(parsed.flags, cwd);
1143
+ const workspaceFlag = stringFlag(parsed.flags, "--workspace");
1144
+ const workspace = workspaceFlag === null ? cwd : resolve(cwd, workspaceFlag);
1145
+ const load = loadPolicy(scope.options.policy?.file === undefined
1146
+ ? { dir: scope.options.policy?.dir ?? cwd }
1147
+ : { file: scope.options.policy.file });
1148
+ if (!load.ok) {
1149
+ // Fail closed before a server is started: a bridge that could not read the
1150
+ // policy would open a connection it must refuse every question on.
1151
+ const message = `${load.code}: ${load.message}; the bridge decides against the policy in force and will not start a session it cannot decide for`;
1152
+ if (json)
1153
+ streams.err(`${JSON.stringify({ error: { code: "bridge-policy-unavailable", message } })}\n`);
1154
+ else
1155
+ streams.err(`approval: ${message}\n`);
1156
+ return EXIT_IO;
1157
+ }
1158
+ const waitText = stringFlag(parsed.flags, "--wait");
1159
+ const waitMs = waitText === null ? load.durations.approvalTtlMs : parseDuration(waitText);
1160
+ if (waitMs === null || waitMs <= 0) {
1161
+ return usage(streams, json, waitText === null
1162
+ ? "this policy declares no defaults.approval_ttl, so the bridge has no deadline to wait to: pass --wait <duration>"
1163
+ : `--wait expects a duration like 30s, 10m, 6h, got ${JSON.stringify(waitText)}`);
1164
+ }
1165
+ const intervalText = stringFlag(parsed.flags, "--interval");
1166
+ const intervalMs = intervalText === null ? DEFAULT_INTERVAL_MS : parseDuration(intervalText);
1167
+ if (intervalMs === null || intervalMs <= 0) {
1168
+ return usage(streams, json, `--interval expects a duration like 500ms, 2s, got ${JSON.stringify(intervalText)}`);
1169
+ }
1170
+ const plan = {
1171
+ logPath: scope.logPath,
1172
+ root: scope.root,
1173
+ options: scope.options,
1174
+ actor: stringFlag(parsed.flags, "--as") ?? ADAPTER.defaultActor,
1175
+ workspace,
1176
+ prompt,
1177
+ waitMs,
1178
+ intervalMs,
1179
+ serverCommand: server[0],
1180
+ serverArgs: server.slice(1),
1181
+ json,
1182
+ };
1183
+ return await driveSession(streams, plan);
1184
+ }
1185
+ /**
1186
+ * Run the preflight turn and then the real one, answering every approval
1187
+ * question either of them raises.
1188
+ *
1189
+ * Resolves when the turn completes, the server exits, or a protocol step is
1190
+ * refused. Nothing here retries: a bridge that reconnected would be answering
1191
+ * questions a previous connection was asked, which is exactly the custody
1192
+ * problem APRV-365 exists to settle. A void preflight is not retried either,
1193
+ * for the reason {@link BRIDGE_STOP_CODES} gives.
1194
+ */
1195
+ function driveSession(streams, plan) {
1196
+ return new Promise((done) => {
1197
+ const answers = [];
1198
+ let settled = false;
1199
+ let child;
1200
+ try {
1201
+ child = spawn(plan.serverCommand, plan.serverArgs, {
1202
+ cwd: plan.workspace,
1203
+ stdio: ["pipe", "pipe", "pipe"],
1204
+ });
1205
+ }
1206
+ catch (cause) {
1207
+ streams.err(`approval: the app-server could not be started: ${String(cause)}\n`);
1208
+ done(EXIT_IO);
1209
+ return;
1210
+ }
1211
+ // What this session asked for, before the server has said anything about
1212
+ // it. Recorded from the start so a run that stops at `thread/start` still
1213
+ // reports which pin it was refused over (APRV-366).
1214
+ const thread = {
1215
+ id: null,
1216
+ cwd: plan.workspace,
1217
+ requested: { approvalPolicy: APPROVAL_POLICY, sandbox: SANDBOX },
1218
+ effective: { approvalPolicy: null },
1219
+ confirmed: "unconfirmed",
1220
+ };
1221
+ // The probe turn, before it has run (APRV-364). Recorded from the start for
1222
+ // the reason the thread is: a run that stops early still says which proof
1223
+ // it was reaching for.
1224
+ const preflight = {
1225
+ turnId: null,
1226
+ command: PROBE_COMMAND,
1227
+ outcome: "pending",
1228
+ decision: null,
1229
+ };
1230
+ /** Every frame the preflight turn produced, for the void report. */
1231
+ const preflightFrames = [];
1232
+ /** Which turn is running: the probe's, or the operator's. */
1233
+ let phase = "preflight";
1234
+ /** How the pin was confirmed, in words, for the human report. */
1235
+ const pinLine = () => {
1236
+ if (thread.confirmed === "observed") {
1237
+ return `confirmed by observation of one probe command (${PROBE_COMMAND}); that one question reached this client, which is not a proof that the auto-reviewer is off`;
1238
+ }
1239
+ if (thread.confirmed === "reported")
1240
+ return "reported by the server, not observed";
1241
+ return `requested; the server reported ${thread.effective.approvalPolicy ?? "none"}`;
1242
+ };
1243
+ const finish = (code, reason, stop = null) => {
1244
+ if (settled)
1245
+ return;
1246
+ settled = true;
1247
+ child.kill("SIGTERM");
1248
+ // The frames ride only on the void stop, where they are the evidence for
1249
+ // a fact that could not be established. See `BridgePreflightRecord`.
1250
+ const preflightReport = stop === "bridge-preflight-void" ? { ...preflight, frames: preflightFrames } : preflight;
1251
+ if (plan.json) {
1252
+ streams.out(`${JSON.stringify({ ok: code === EXIT_OK, reason, ...(stop === null ? {} : { code: stop }), thread, preflight: preflightReport, answers })}\n`);
1253
+ }
1254
+ else {
1255
+ streams.out(`${stop === null ? reason : `${stop}: ${reason}`}\n`);
1256
+ streams.out(` thread ${thread.id ?? "(none)"} approvalPolicy ${thread.requested.approvalPolicy}` +
1257
+ ` (${pinLine()})` +
1258
+ ` sandbox ${thread.requested.sandbox}\n`);
1259
+ streams.out(` preflight ${preflight.turnId ?? "(none)"} ${preflight.command} ${preflight.outcome}\n`);
1260
+ for (const answer of answers) {
1261
+ streams.out(` ${answer.outcome === "accept" ? "granted" : "declined"} ${answer.decision}` +
1262
+ ` (${answer.decisionSource}) ${answer.code ?? "-"} ${answer.detail}\n`);
1263
+ }
1264
+ }
1265
+ done(code);
1266
+ };
1267
+ /**
1268
+ * Take what a frame says about the effective approval policy, and stop the
1269
+ * session when it names one this verb did not ask for.
1270
+ *
1271
+ * Returns true when the caller should stop. A frame naming nothing leaves
1272
+ * `confirmed` false and is NOT a stop: the observed server echoes no policy
1273
+ * at all, and a client that demanded an echo could not run against it. What
1274
+ * the report then claims is only that the pin was requested.
1275
+ */
1276
+ const pinnedOrStop = (value, where) => {
1277
+ const named = effectiveApprovalPolicy(value);
1278
+ if (named === null)
1279
+ return false;
1280
+ thread.effective.approvalPolicy = named;
1281
+ if (named === APPROVAL_POLICY) {
1282
+ // Never downgrades an observation: the probe is the stronger of the two
1283
+ // proofs and a later echo says nothing it did not already say.
1284
+ if (thread.confirmed !== "observed")
1285
+ thread.confirmed = "reported";
1286
+ return false;
1287
+ }
1288
+ finish(EXIT_IO, `${where} reports the thread's effective approval policy as ${JSON.stringify(named)}, and this verb starts a session only under ${JSON.stringify(APPROVAL_POLICY)}, the one variant under which every command and every patch asks. Under any other variant an unknown part of the session never reaches this client at all, so nothing was answered and the session was stopped`, "bridge-approval-policy-mismatch");
1289
+ return true;
1290
+ };
1291
+ let threadId = null;
1292
+ let initializeId = -1;
1293
+ let threadStartId = -1;
1294
+ /** The `turn/start` this client sent for the probe, and for the real turn. */
1295
+ let preflightStartId = -1;
1296
+ let liveStartId = -1;
1297
+ /**
1298
+ * Every item this thread has announced, for the thread's life (APRV-379).
1299
+ *
1300
+ * The content of a file change arrives on `item/started` and the question
1301
+ * about it arrives later, by item id, so this is what makes an item-based
1302
+ * file-change request answerable at all. It is written from the
1303
+ * notification path below and read only by
1304
+ * {@link correlateFileChange}, which refuses every way the two frames could
1305
+ * fail to be about one change.
1306
+ */
1307
+ const items = new Map();
1308
+ /**
1309
+ * Does this frame belong to the PREFLIGHT turn (APRV-364)?
1310
+ *
1311
+ * By turn id where the frame names one, which is the answer that survives
1312
+ * frames arriving out of order. A frame naming no turn is decided by which
1313
+ * turn is running, which is the only reading available and is also the
1314
+ * strict one: during the probe, an unlabelled approval request is treated
1315
+ * as the probe's and is therefore DECLINED without reaching the gate.
1316
+ */
1317
+ const isPreflightFrame = (params) => {
1318
+ const named = turnIdOf(params);
1319
+ if (named !== null && preflight.turnId !== null)
1320
+ return named === preflight.turnId;
1321
+ return phase === "preflight";
1322
+ };
1323
+ const answer = (id, method, outcome, code, detail, params) => {
1324
+ const chosen = chooseDecision(params, outcome);
1325
+ // The send boundary re-asks what the type already answered (APRV-367). A
1326
+ // word this runtime cannot name is never put on the wire; the reply
1327
+ // becomes a decline, because the only safe substitute for a word you
1328
+ // cannot name is no. Unreachable while the types hold, which is why it
1329
+ // carries no code of its own.
1330
+ const encoded = encodeDecision(chosen.decision) ?? { decision: DECLINE_WORDS[0] };
1331
+ answers.push({ method, id, outcome, ...chosen, decision: encoded.decision, code, detail });
1332
+ connection.respond(id, encoded);
1333
+ };
1334
+ /**
1335
+ * Answer the PROBE's own approval request, and never through the gate
1336
+ * (APRV-364).
1337
+ *
1338
+ * A decline, immediately, recorded as an observation. Two reasons it does
1339
+ * not take the gate's path. It would register an action and open a request,
1340
+ * which means the probe command on a human's phone at every bridge start,
1341
+ * and a preflight that spent a person's attention would be the opposite of
1342
+ * harmless. And the fact wanted here is only that the question ARRIVED:
1343
+ * what the policy would have said about `true` is beside the point.
1344
+ */
1345
+ const observeProbe = (id, params, aboutACommand) => {
1346
+ const chosen = chooseDecision(params, "decline");
1347
+ const encoded = encodeDecision(chosen.decision) ?? { decision: DECLINE_WORDS[0] };
1348
+ if (aboutACommand) {
1349
+ preflight.outcome = "asked";
1350
+ preflight.decision = encoded.decision;
1351
+ // The one place this becomes `observed`, and the claim it licenses is
1352
+ // written down beside it in `BRIDGE_PIN_SOURCES`: one question reached
1353
+ // this client unanswered by anything else.
1354
+ thread.confirmed = "observed";
1355
+ streams.err(`approval: the preflight probe (${PROBE_COMMAND}) was asked about, so one question reached this client; declining it and starting the turn\n`);
1356
+ }
1357
+ else {
1358
+ // The probe asks for one command and the prompt forbids everything
1359
+ // else, so a file change here is a turn that went its own way. It is
1360
+ // declined like the rest of the preflight and it proves nothing about
1361
+ // a command, so the outcome is left for the turn's end to decide.
1362
+ streams.err("approval: the preflight turn raised a file-change approval, which the probe never asks for; declining it\n");
1363
+ }
1364
+ connection.respond(id, encoded);
1365
+ };
1366
+ const connection = new Connection(child, (frame) => {
1367
+ const method = typeof frame.method === "string" ? frame.method : null;
1368
+ // Codex's own reviewer answered something before this client saw it. It
1369
+ // ends the run wherever it appears, because from here a resolved question
1370
+ // and a question nobody asked look the same (APRV-364).
1371
+ if (method !== null && isAutoReviewNotification(method)) {
1372
+ // The RECORD first, then the stop (APRV-378). A verdict with nothing
1373
+ // behind it is the shape of claim this project is built against, and
1374
+ // the write is best-effort: it never changes the stop, and a failure to
1375
+ // write is reported beside it rather than swallowed.
1376
+ const recorded = recordPreemptedQuestion(plan.logPath, {
1377
+ source: "codex-auto-reviewer",
1378
+ id: callIdOf(frame.params) ?? "",
1379
+ method,
1380
+ ...(stringField(frame.params, "threadId") === null
1381
+ ? {}
1382
+ : { thread: stringField(frame.params, "threadId") }),
1383
+ ...(turnIdOf(frame.params) === null ? {} : { turn: turnIdOf(frame.params) }),
1384
+ ...(autoReviewVerdict(frame.params) === null
1385
+ ? {}
1386
+ : { verdict: autoReviewVerdict(frame.params) }),
1387
+ detail: "approval codex bridge stopped the session under bridge-auto-reviewer-active",
1388
+ }, plan.options);
1389
+ if (!recorded.ok) {
1390
+ streams.err(`approval: the auto-review record could not be appended (${recorded.code}: ${recorded.message}); the session is still stopped\n`);
1391
+ }
1392
+ finish(EXIT_IO, `the server sent ${method}, so Codex's own auto-reviewer resolved an approval before this client was asked: ${JSON.stringify(frame.params ?? null)}. A session with a reviewer in front of the gate is one whose silence means nothing, so the run was stopped rather than gating what was left`, "bridge-auto-reviewer-active");
1393
+ return;
1394
+ }
1395
+ // Kept from the moment the probe turn is asked for, so a void report
1396
+ // carries the turn and not the handshake before it.
1397
+ if (phase === "preflight" && preflightStartId !== -1)
1398
+ preflightFrames.push(frame);
1399
+ // Every item notification is recorded, here, ABOVE the request dispatch
1400
+ // and above every phase test (APRV-379). A notification is a frame with a
1401
+ // method and no id, so this never sees a question. It records rather than
1402
+ // decides: what the index holds is what the server said, and every
1403
+ // judgement about whether two frames are about one change is made at
1404
+ // correlation time by `correlateFileChange`.
1405
+ if (method !== null && frame.id === undefined)
1406
+ recordItemFrame(items, method, frame.params);
1407
+ // A server REQUEST: it carries both a method and an id, and it is waiting.
1408
+ if (method !== null && frame.id !== undefined) {
1409
+ // An approval question raised by the PROBE turn is observed and
1410
+ // declined here, above every gate path below it (APRV-364).
1411
+ const execApproval = EXEC_APPROVAL_METHODS.includes(method);
1412
+ const fileApproval = FILE_CHANGE_APPROVAL_METHODS.includes(method);
1413
+ if ((execApproval || fileApproval) && isPreflightFrame(frame.params)) {
1414
+ observeProbe(frame.id, frame.params, execApproval);
1415
+ return;
1416
+ }
1417
+ if (execApproval) {
1418
+ const decided = decideExecRequest(streams, plan, frame.params);
1419
+ if (decided.verdict.permission === "allow") {
1420
+ answer(frame.id, method, "accept", null, decided.verdict.reason, frame.params);
1421
+ }
1422
+ else {
1423
+ answer(frame.id, method, "decline", decided.verdict.code, decided.verdict.detail, frame.params);
1424
+ }
1425
+ return;
1426
+ }
1427
+ if (fileApproval) {
1428
+ // A change carried INLINE is decided like any other call (APRV-363);
1429
+ // one that is an identifier is correlated to the `item/started` frame
1430
+ // this client recorded and decided against THAT (APRV-379), or
1431
+ // declined when the correlation cannot be established.
1432
+ const decided = decideFileChangeRequest(streams, plan, frame.params, items);
1433
+ if (decided.verdict.permission === "allow") {
1434
+ answer(frame.id, method, "accept", null, decided.verdict.reason, frame.params);
1435
+ }
1436
+ else {
1437
+ answer(frame.id, method, "decline", decided.verdict.code, decided.verdict.detail, frame.params);
1438
+ }
1439
+ return;
1440
+ }
1441
+ // A question with no reading. Declining it is the same rule the file
1442
+ // change is declined under: an unclassified action is not one to say
1443
+ // yes to.
1444
+ answer(frame.id, method, "decline", "bridge-unknown-request", `this client has no reading for the server request ${method}, and a question nobody classified is not one to answer yes to`, frame.params);
1445
+ return;
1446
+ }
1447
+ // A reply to one of this client's own requests.
1448
+ if (frame.id === initializeId && initializeId !== -1) {
1449
+ if (frame.error !== undefined) {
1450
+ finish(EXIT_IO, `the app-server refused initialize: ${JSON.stringify(frame.error)}`);
1451
+ return;
1452
+ }
1453
+ connection.notify("initialized", {});
1454
+ threadStartId = connection.request("thread/start", {
1455
+ cwd: plan.workspace,
1456
+ approvalPolicy: APPROVAL_POLICY,
1457
+ sandbox: SANDBOX,
1458
+ });
1459
+ return;
1460
+ }
1461
+ if (frame.id === threadStartId && threadStartId !== -1) {
1462
+ if (frame.error !== undefined) {
1463
+ // The request that carries the pin was refused, so no thread exists
1464
+ // and nothing about this session's approval policy was established.
1465
+ // A refusal of the VALUE arrives here too, in the server's own words.
1466
+ finish(EXIT_IO, `the app-server refused thread/start with approvalPolicy ${JSON.stringify(APPROVAL_POLICY)} and sandbox ${JSON.stringify(SANDBOX)}: ${JSON.stringify(frame.error)}`, "bridge-thread-start-refused");
1467
+ return;
1468
+ }
1469
+ if (pinnedOrStop(frame.result, "thread/start"))
1470
+ return;
1471
+ threadId =
1472
+ stringField(frame.result, "threadId") ??
1473
+ stringField(frame.result?.["thread"], "id");
1474
+ if (threadId === null) {
1475
+ finish(EXIT_IO, "thread/start succeeded and named no thread this client could find");
1476
+ return;
1477
+ }
1478
+ thread.id = threadId;
1479
+ // The PROBE turn first, always, whatever the server said about its
1480
+ // approval policy (APRV-364). The pin's echo and the auto-reviewer are
1481
+ // two different questions, and only the probe answers the second.
1482
+ streams.err(`approval: running the preflight probe (${PROBE_COMMAND}) before the turn; it costs one turn and it is what makes this session's silence mean anything\n`);
1483
+ preflightStartId = connection.request("turn/start", {
1484
+ threadId,
1485
+ input: [{ type: "text", text: PROBE_PROMPT }],
1486
+ });
1487
+ return;
1488
+ }
1489
+ if (frame.id === preflightStartId && preflightStartId !== -1) {
1490
+ if (frame.error !== undefined) {
1491
+ preflight.error = frame.error;
1492
+ finish(EXIT_IO, `the app-server refused the preflight turn/start: ${JSON.stringify(frame.error)}; no probe ran, so nothing was established about whether a question reaches this client`, "bridge-preflight-void");
1493
+ return;
1494
+ }
1495
+ preflight.turnId = turnIdOf(frame.result);
1496
+ return;
1497
+ }
1498
+ if (frame.id === liveStartId && liveStartId !== -1 && frame.error !== undefined) {
1499
+ finish(EXIT_IO, `the app-server refused turn/start: ${JSON.stringify(frame.error)}`);
1500
+ return;
1501
+ }
1502
+ // Notifications. The turn's end is acted on, and so is anything the
1503
+ // server says about the thread's own approval policy: `thread/started` is
1504
+ // where a server that reports one is most likely to (APRV-366). That is
1505
+ // not a client branching on narration, which is the thing this verb does
1506
+ // not do: it is the one fact that decides whether this session is the
1507
+ // kind of session the verb will sit in front of at all.
1508
+ if (method === "thread/started" || method === "thread/status/changed") {
1509
+ if (pinnedOrStop(frame.params, method))
1510
+ return;
1511
+ }
1512
+ // A command item in the PROBE turn. It is read for one purpose: to tell a
1513
+ // probe that ran without asking from a probe that never ran (APRV-364).
1514
+ if (phase === "preflight" &&
1515
+ (method === "item/started" || method === "item/completed") &&
1516
+ isPreflightFrame(frame.params) &&
1517
+ namesCommandExecution(frame.params) &&
1518
+ preflight.outcome === "pending") {
1519
+ preflight.outcome = "executed";
1520
+ }
1521
+ if (method === "turn/completed" || method === "turn/failed") {
1522
+ if (phase === "preflight" && isPreflightFrame(frame.params)) {
1523
+ if (method === "turn/failed")
1524
+ preflight.error = frame.params ?? null;
1525
+ if (preflight.outcome === "asked") {
1526
+ // The only way past here. One question reached this client, so the
1527
+ // operator's own turn runs.
1528
+ phase = "live";
1529
+ liveStartId = connection.request("turn/start", {
1530
+ threadId,
1531
+ input: [{ type: "text", text: plan.prompt }],
1532
+ });
1533
+ return;
1534
+ }
1535
+ if (preflight.outcome === "executed") {
1536
+ finish(EXIT_IO, `the preflight probe (${PROBE_COMMAND}) ran and no approval request for it reached this client. A policy under which one command did not ask is not ${JSON.stringify(APPROVAL_POLICY)} whatever the server reports, so the session was stopped before the turn ran and nothing was answered`, "bridge-approval-policy-mismatch");
1537
+ return;
1538
+ }
1539
+ preflight.outcome = "void";
1540
+ finish(EXIT_IO, `the preflight turn ended having run no command at all, so nothing was established: a question that never arose is not a question that reached this client. The turn's frames are in the report verbatim. Nothing is retried here; run the verb again`, "bridge-preflight-void");
1541
+ return;
1542
+ }
1543
+ finish(EXIT_OK, `turn ${method === "turn/completed" ? "completed" : "failed"}: ${String(answers.length)} approval request(s) answered`);
1544
+ }
1545
+ });
1546
+ child.stderr.on("data", (chunk) => {
1547
+ streams.err(chunk.toString("utf8"));
1548
+ });
1549
+ child.on("error", (cause) => {
1550
+ finish(EXIT_IO, `the app-server could not be started: ${cause.message}`);
1551
+ });
1552
+ child.on("exit", (code) => {
1553
+ finish(answers.length > 0 ? EXIT_OK : EXIT_IO, `the app-server exited (code ${String(code)}): ${String(answers.length)} approval request(s) answered`);
1554
+ });
1555
+ initializeId = connection.request("initialize", {
1556
+ clientInfo: { name: "approval.md", title: "approval.md codex bridge", version: "0" },
1557
+ capabilities: {},
1558
+ });
1559
+ });
1560
+ }
1561
+ /** The help text, printed by `approval codex bridge --help`. */
1562
+ export const CODEX_BRIDGE_HELP = [
1563
+ "approval codex bridge --prompt <text> [--workspace <dir>] [-- <server command>]",
1564
+ "",
1565
+ "Start `codex app-server` and answer every approval request it raises through",
1566
+ "the policy and the log: classify {command, cwd}, register, request, wait on the",
1567
+ "verified view, then reply accept or decline in the server's own vocabulary.",
1568
+ "",
1569
+ " --prompt <text> the turn to run (required)",
1570
+ " --workspace <dir> the thread's working directory (default: cwd)",
1571
+ " --as <agent:id> the acting identity (default: agent:codex)",
1572
+ " --dir/--policy/--log where the policy and the log are, as the hook resolves them",
1573
+ " --wait <duration> the deadline (default: the policy's approval_ttl)",
1574
+ " --interval <duration> how often the verified view is re-read (default: 2s)",
1575
+ " --json one object: {ok, reason, code?, thread, preflight, answers[]}",
1576
+ " -- <command...> the app-server to start (default: codex app-server)",
1577
+ "",
1578
+ "It answers accept or decline only, never acceptForSession, cancel or abort.",
1579
+ "A file-change request on the item-based API carries an item id and no bytes;",
1580
+ "the change set is taken from the item/started frame that id names, and the",
1581
+ "request is declined (bridge-file-change-unbound) when that frame was never",
1582
+ "seen, names another item, or belongs to another thread or turn, and",
1583
+ "(bridge-file-change-already-completed) when the item finished before the",
1584
+ "question arrived. An open gate window is not honoured.",
1585
+ "",
1586
+ "The server is this process's own child over stdio: no socket, nothing bound,",
1587
+ "no other client to replay a pending question to. The claim is scoped to that",
1588
+ "child and this session and says so.",
1589
+ "",
1590
+ "The thread is started with approvalPolicy untrusted, the only variant under",
1591
+ "which every command and every patch asks, and there is no flag for it. A",
1592
+ "server that refuses thread/start stops the run (bridge-thread-start-refused),",
1593
+ "and one that reports another effective policy stops it too",
1594
+ "(bridge-approval-policy-mismatch). A server that reports no policy at all is",
1595
+ "run against, and the report says the pin was requested and not confirmed.",
1596
+ "",
1597
+ "Every start runs a preflight turn first, asking for one harmless command",
1598
+ "(true), and there is no flag to skip it. An approval request for it means one",
1599
+ "question reached this client and the real turn runs; a command that ran",
1600
+ "without asking is bridge-approval-policy-mismatch; a turn that ran no command",
1601
+ "is bridge-preflight-void, reported with the turn's frames and never retried.",
1602
+ "An item/autoApprovalReview notification in either turn is",
1603
+ "bridge-auto-reviewer-active. A pass means one question reached this client,",
1604
+ "not that the auto-reviewer is off.",
1605
+ "See docs/codex-app-server-bridge.md.",
1606
+ ].join("\n");
1607
+ //# sourceMappingURL=codex-bridge.js.map