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.
- package/README.md +63 -24
- package/SPEC.md +57 -11
- package/dist/src/channels/contract.d.ts +34 -1
- package/dist/src/channels/contract.js +200 -7
- package/dist/src/channels/contract.js.map +1 -1
- package/dist/src/channels/telegram.d.ts +123 -11
- package/dist/src/channels/telegram.js +218 -23
- package/dist/src/channels/telegram.js.map +1 -1
- package/dist/src/channels/web.d.ts +9 -0
- package/dist/src/channels/web.js +17 -0
- package/dist/src/channels/web.js.map +1 -1
- package/dist/src/cli/amend.js +214 -30
- package/dist/src/cli/amend.js.map +1 -1
- package/dist/src/cli/attest.d.ts +9 -0
- package/dist/src/cli/attest.js +134 -7
- package/dist/src/cli/attest.js.map +1 -1
- package/dist/src/cli/channel-telegram.d.ts +99 -26
- package/dist/src/cli/channel-telegram.js +311 -13
- package/dist/src/cli/channel-telegram.js.map +1 -1
- package/dist/src/cli/channel.d.ts +9 -0
- package/dist/src/cli/channel.js +9 -0
- package/dist/src/cli/channel.js.map +1 -1
- package/dist/src/cli/codex-bridge.d.ts +819 -0
- package/dist/src/cli/codex-bridge.js +1607 -0
- package/dist/src/cli/codex-bridge.js.map +1 -0
- package/dist/src/cli/codex.d.ts +1 -1
- package/dist/src/cli/codex.js +304 -7
- package/dist/src/cli/codex.js.map +1 -1
- package/dist/src/cli/daemon.js +4 -1
- package/dist/src/cli/daemon.js.map +1 -1
- package/dist/src/cli/doctor.js +467 -12
- package/dist/src/cli/doctor.js.map +1 -1
- package/dist/src/cli/execute.js +25 -2
- package/dist/src/cli/execute.js.map +1 -1
- package/dist/src/cli/help.d.ts +6 -2
- package/dist/src/cli/help.js +165 -60
- package/dist/src/cli/help.js.map +1 -1
- package/dist/src/cli/hook-codex.d.ts +49 -1
- package/dist/src/cli/hook-codex.js +60 -1
- package/dist/src/cli/hook-codex.js.map +1 -1
- package/dist/src/cli/hook.d.ts +459 -3
- package/dist/src/cli/hook.js +1062 -114
- package/dist/src/cli/hook.js.map +1 -1
- package/dist/src/cli/import.js +1 -1
- package/dist/src/cli/import.js.map +1 -1
- package/dist/src/cli/main.js +5 -3
- package/dist/src/cli/main.js.map +1 -1
- package/dist/src/cli/policy-apply.d.ts +195 -0
- package/dist/src/cli/policy-apply.js +573 -0
- package/dist/src/cli/policy-apply.js.map +1 -0
- package/dist/src/cli/policy.js +14 -1
- package/dist/src/cli/policy.js.map +1 -1
- package/dist/src/cli/preflight.d.ts +151 -13
- package/dist/src/cli/preflight.js +398 -41
- package/dist/src/cli/preflight.js.map +1 -1
- package/dist/src/cli/sandbox.js +17 -1
- package/dist/src/cli/sandbox.js.map +1 -1
- package/dist/src/cli/scaffold.d.ts +1 -1
- package/dist/src/cli/scaffold.js +1 -1
- package/dist/src/cli/setup-channel.d.ts +9 -0
- package/dist/src/cli/setup-channel.js +28 -1
- package/dist/src/cli/setup-channel.js.map +1 -1
- package/dist/src/cli/setup-common.d.ts +3 -1
- package/dist/src/cli/setup-common.js +3 -2
- package/dist/src/cli/setup-common.js.map +1 -1
- package/dist/src/cli/setup.d.ts +2 -0
- package/dist/src/cli/setup.js +94 -2
- package/dist/src/cli/setup.js.map +1 -1
- package/dist/src/cli/up.js +115 -51
- package/dist/src/cli/up.js.map +1 -1
- package/dist/src/cli/values.js +3 -4
- package/dist/src/cli/values.js.map +1 -1
- package/dist/src/cli/verb-registry.js +174 -9
- package/dist/src/cli/verb-registry.js.map +1 -1
- package/dist/src/cli/wordmark.d.ts +2 -2
- package/dist/src/cli/wordmark.js +2 -2
- package/dist/src/codex/broker.d.ts +229 -0
- package/dist/src/codex/broker.js +548 -0
- package/dist/src/codex/broker.js.map +1 -0
- package/dist/src/codex/runner.d.ts +178 -0
- package/dist/src/codex/runner.js +231 -0
- package/dist/src/codex/runner.js.map +1 -0
- package/dist/src/codex/serve.d.ts +56 -0
- package/dist/src/codex/serve.js +98 -0
- package/dist/src/codex/serve.js.map +1 -0
- package/dist/src/codex/workspace-commit.d.ts +219 -0
- package/dist/src/codex/workspace-commit.js +549 -0
- package/dist/src/codex/workspace-commit.js.map +1 -0
- package/dist/src/core/advance-cycle.d.ts +51 -0
- package/dist/src/core/advance-cycle.js +66 -2
- package/dist/src/core/advance-cycle.js.map +1 -1
- package/dist/src/core/agents-md.d.ts +20 -18
- package/dist/src/core/agents-md.js +33 -31
- package/dist/src/core/agents-md.js.map +1 -1
- package/dist/src/core/attest.d.ts +215 -0
- package/dist/src/core/attest.js +317 -7
- package/dist/src/core/attest.js.map +1 -1
- package/dist/src/core/audit.d.ts +18 -0
- package/dist/src/core/audit.js +13 -0
- package/dist/src/core/audit.js.map +1 -1
- package/dist/src/core/channel-owner.d.ts +213 -0
- package/dist/src/core/channel-owner.js +358 -0
- package/dist/src/core/channel-owner.js.map +1 -0
- package/dist/src/core/command-class.d.ts +154 -0
- package/dist/src/core/command-class.js +673 -20
- package/dist/src/core/command-class.js.map +1 -1
- package/dist/src/core/commit-guard.d.ts +272 -0
- package/dist/src/core/commit-guard.js +424 -0
- package/dist/src/core/commit-guard.js.map +1 -0
- package/dist/src/core/daemon-actor.d.ts +45 -0
- package/dist/src/core/daemon-actor.js +54 -0
- package/dist/src/core/daemon-actor.js.map +1 -0
- package/dist/src/core/dark-session.d.ts +109 -8
- package/dist/src/core/dark-session.js +266 -82
- package/dist/src/core/dark-session.js.map +1 -1
- package/dist/src/core/decision-refusal.d.ts +23 -2
- package/dist/src/core/decision-refusal.js +24 -2
- package/dist/src/core/decision-refusal.js.map +1 -1
- package/dist/src/core/env-file.d.ts +5 -0
- package/dist/src/core/env-file.js +60 -1
- package/dist/src/core/env-file.js.map +1 -1
- package/dist/src/core/execute.d.ts +15 -2
- package/dist/src/core/execute.js +15 -2
- package/dist/src/core/execute.js.map +1 -1
- package/dist/src/core/gate.d.ts +86 -1
- package/dist/src/core/gate.js +81 -1
- package/dist/src/core/gate.js.map +1 -1
- package/dist/src/core/gesture-refusal.d.ts +166 -0
- package/dist/src/core/gesture-refusal.js +188 -0
- package/dist/src/core/gesture-refusal.js.map +1 -0
- package/dist/src/core/harness-version.d.ts +1 -1
- package/dist/src/core/harness-version.js +3 -1
- package/dist/src/core/harness-version.js.map +1 -1
- package/dist/src/core/instance.d.ts +59 -2
- package/dist/src/core/instance.js +113 -0
- package/dist/src/core/instance.js.map +1 -1
- package/dist/src/core/log.d.ts +39 -1
- package/dist/src/core/log.js.map +1 -1
- package/dist/src/core/policy-explain.d.ts +10 -0
- package/dist/src/core/policy-explain.js +32 -0
- package/dist/src/core/policy-explain.js.map +1 -1
- package/dist/src/core/policy-load.d.ts +41 -1
- package/dist/src/core/policy-load.js +21 -3
- package/dist/src/core/policy-load.js.map +1 -1
- package/dist/src/core/policy-match.d.ts +43 -0
- package/dist/src/core/policy-match.js +52 -0
- package/dist/src/core/policy-match.js.map +1 -1
- package/dist/src/core/policy-proposal.d.ts +52 -0
- package/dist/src/core/policy-proposal.js +102 -2
- package/dist/src/core/policy-proposal.js.map +1 -1
- package/dist/src/core/protected-path-guard.d.ts +117 -4
- package/dist/src/core/protected-path-guard.js +362 -48
- package/dist/src/core/protected-path-guard.js.map +1 -1
- package/dist/src/core/question-preempted.d.ts +141 -0
- package/dist/src/core/question-preempted.js +152 -0
- package/dist/src/core/question-preempted.js.map +1 -0
- package/dist/src/core/read-scope.d.ts +172 -0
- package/dist/src/core/read-scope.js +252 -0
- package/dist/src/core/read-scope.js.map +1 -0
- package/dist/src/core/sandbox.d.ts +81 -0
- package/dist/src/core/sandbox.js +190 -1
- package/dist/src/core/sandbox.js.map +1 -1
- package/dist/src/core/sender-identity.d.ts +476 -0
- package/dist/src/core/sender-identity.js +572 -0
- package/dist/src/core/sender-identity.js.map +1 -0
- package/dist/src/core/shlex.d.ts +102 -0
- package/dist/src/core/shlex.js +159 -0
- package/dist/src/core/shlex.js.map +1 -0
- package/dist/src/core/values.d.ts +18 -8
- package/dist/src/core/values.js +36 -1
- package/dist/src/core/values.js.map +1 -1
- package/dist/src/daemon/advance.d.ts +10 -0
- package/dist/src/daemon/advance.js +25 -4
- package/dist/src/daemon/advance.js.map +1 -1
- package/dist/src/daemon/daemon.js +9 -0
- package/dist/src/daemon/daemon.js.map +1 -1
- package/dist/src/daemon/git-evidence.d.ts +2 -2
- package/dist/src/daemon/git-evidence.js +1 -1
- package/dist/src/mcp/server.js +8 -0
- package/dist/src/mcp/server.js.map +1 -1
- package/docs/cli-reference.md +932 -32
- package/docs/codex-enforced-session.md +75 -2
- package/docs/codex-workspace-broker.md +118 -0
- package/package.json +3 -1
- package/schema/event.schema.json +538 -9
- package/schema/fixtures/event/invalid/approval-granted-sender-hashed-false.json +20 -0
- package/schema/fixtures/event/invalid/approval-granted-sender-hashed-raw-id.json +20 -0
- package/schema/fixtures/event/invalid/audit-gesture-refused-human-actor.json +16 -0
- package/schema/fixtures/event/invalid/audit-gesture-refused-no-actor-no-sender.json +15 -0
- package/schema/fixtures/event/invalid/audit-gesture-refused-unknown-gesture.json +16 -0
- package/schema/fixtures/event/invalid/audit-question-preempted-agent-actor.json +16 -0
- package/schema/fixtures/event/invalid/audit-question-preempted-no-question-id.json +16 -0
- package/schema/fixtures/event/invalid/audit-question-preempted-unknown-source.json +15 -0
- package/schema/fixtures/event/invalid/gate-path-signed-off-absolute-path.json +14 -0
- package/schema/fixtures/event/invalid/gate-path-signed-off-agent-actor.json +14 -0
- package/schema/fixtures/event/invalid/gate-path-signed-off-missing-path.json +13 -0
- package/schema/fixtures/event/valid/approval-granted-sender-hashed.json +20 -0
- package/schema/fixtures/event/valid/audit-gesture-refused-review-note.json +21 -0
- package/schema/fixtures/event/valid/audit-gesture-refused-sender-key-unavailable.json +19 -0
- package/schema/fixtures/event/valid/audit-gesture-refused.json +19 -0
- package/schema/fixtures/event/valid/audit-question-preempted-no-verdict.json +16 -0
- package/schema/fixtures/event/valid/audit-question-preempted.json +20 -0
- package/schema/fixtures/event/valid/gate-path-signed-off.json +14 -0
- package/schema/fixtures/event/valid/harness-kind-claude-code.json +23 -0
- package/schema/fixtures/event/valid/harness-kind-codex.json +23 -0
- package/schema/fixtures/event/valid/harness-kind-cursor.json +23 -0
- package/schema/fixtures/event/valid/harness-kind-grok.json +23 -0
- package/schema/fixtures/event/valid/harness-kind-muse.json +23 -0
- package/schema/fixtures/policy/invalid/senders-half-keyed.json +20 -0
- package/schema/fixtures/policy/valid/canonical.json +1 -1
- package/schema/fixtures/policy/valid/senders-keyed.json +24 -0
- package/schema/fixtures/policy-md/valid/canonical.md +1 -1
- package/schema/fixtures/policy-md/valid/with-values.md +5 -7
- package/schema/fixtures/values/invalid/class-shaped.json +1 -1
- package/schema/fixtures/values/invalid/duplicate-entry.json +1 -1
- package/schema/fixtures/values/invalid/non-string-item.json +1 -1
- package/schema/fixtures/values/invalid/over-cap.json +1 -1
- package/schema/fixtures/values/invalid/unknown-key.json +1 -1
- package/schema/fixtures/values/invalid/version-float.json +1 -0
- package/schema/fixtures/values/invalid/version-integer.json +1 -0
- package/schema/fixtures/values/invalid/version-wrong-string.json +1 -0
- package/schema/fixtures/values/valid/empty-lists.json +2 -3
- package/schema/fixtures/values/valid/full.json +5 -7
- package/schema/fixtures/values/valid/minimal.json +1 -1
- package/schema/fixtures/values-md/invalid/schema-invalid.md +5 -3
- package/schema/fixtures/values-md/invalid/two-blocks.md +3 -3
- package/schema/fixtures/values-md/invalid/unterminated.md +2 -2
- package/schema/fixtures/values-md/invalid/version-1.md +69 -0
- package/schema/fixtures/values-md/invalid/version-unquoted.md +64 -0
- package/schema/fixtures/values-md/invalid/yaml-error.md +2 -2
- package/schema/fixtures/values-md/valid/absent.md +1 -1
- package/schema/fixtures/values-md/valid/with-values.md +5 -7
- package/schema/policy.schema.json +54 -2
- package/schema/values.schema.json +7 -11
- 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
|