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
|
@@ -14,12 +14,18 @@
|
|
|
14
14
|
* are read here and passed to the channel as values (SPEC.md §5.1: policy
|
|
15
15
|
* carries the env-var *names*, never the secrets). Nothing in `channels/`
|
|
16
16
|
* touches `process.env`.
|
|
17
|
-
* 2. **It declares
|
|
18
|
-
*
|
|
19
|
-
*
|
|
20
|
-
*
|
|
21
|
-
* configured chat can approve as that
|
|
22
|
-
*
|
|
17
|
+
* 2. **It declares the identity the process itself acts under.** `--as` /
|
|
18
|
+
* `APPROVAL_HUMAN` is what this listener is, and it is what a decision is
|
|
19
|
+
* recorded as whenever the policy maps no sender for this channel: SPEC.md
|
|
20
|
+
* §11's config-declared identity, where the trust boundary is the local
|
|
21
|
+
* machine and everyone who can reach the configured chat can approve as that
|
|
22
|
+
* actor. Since APRV-324 a policy MAY map Telegram account ids to approvers,
|
|
23
|
+
* and then the decision is recorded against the person the operator attested
|
|
24
|
+
* the tapping account to, and an unmapped account is refused rather than
|
|
25
|
+
* recorded as this listener. Either way the choosing happens in
|
|
26
|
+
* `channels/contract.ts` against the attested policy, never here and never
|
|
27
|
+
* in the channel. Both settings are stated in `--help` because an operator
|
|
28
|
+
* has to be able to see which one they are in without reading the source.
|
|
23
29
|
* 3. **It holds the token.** A grant mints a single-use execution token;
|
|
24
30
|
* `recordChannelDecision` returns it to *this* handler, which prints it on
|
|
25
31
|
* **stdout** and never hands it back to the channel. It is never sent to
|
|
@@ -107,6 +113,8 @@ import { type ChannelTagRefusalCode, type TagOptions } from "../channels/tagging
|
|
|
107
113
|
import { type CheckpointTap } from "./checkpoint-tap.js";
|
|
108
114
|
import { TelegramChannel, type CheckpointTapResponse, type ReviewCard, type ReviewTapResponse, type TelegramCommand, type TelegramTerminalState } from "../channels/telegram.js";
|
|
109
115
|
import { type TelegramDelivery } from "../core/telegram-config.js";
|
|
116
|
+
import { type InstanceFinding } from "../core/instance.js";
|
|
117
|
+
import { type ChannelSender } from "../core/sender-identity.js";
|
|
110
118
|
import { type ParsedFlags } from "./args.js";
|
|
111
119
|
import { type GlossRunner } from "./gloss.js";
|
|
112
120
|
import { type GlossRunnerFactoryOptions } from "./gloss-options.js";
|
|
@@ -151,6 +159,32 @@ export interface ListenSetup {
|
|
|
151
159
|
* `core/telegram-config.ts` beside the other Telegram policy readings.
|
|
152
160
|
*/
|
|
153
161
|
delivery: TelegramDelivery;
|
|
162
|
+
/**
|
|
163
|
+
* Cross-instance findings `--allow-cross-instance` let through (APRV-390).
|
|
164
|
+
*
|
|
165
|
+
* Empty on every ordinary start, because a finding without the flag is a
|
|
166
|
+
* refusal. Carried on the setup rather than printed inside `prepareListen`
|
|
167
|
+
* for the reason that function prints nothing at all: `approval up` and
|
|
168
|
+
* `approval channel telegram listen` choose their own stream, and this is
|
|
169
|
+
* the one thing an overridden refusal still owes the operator.
|
|
170
|
+
*/
|
|
171
|
+
crossInstance: readonly InstanceFinding[];
|
|
172
|
+
/**
|
|
173
|
+
* `--allow-cross-instance` as passed (APRV-390).
|
|
174
|
+
*
|
|
175
|
+
* Carried as well as applied, because the flag overrides TWO refusals and
|
|
176
|
+
* only one of them can be decided synchronously. The credential check is
|
|
177
|
+
* `prepareListen`'s; the bot-ownership claim needs a `getMe`, so it happens
|
|
178
|
+
* later and has to be able to ask whether the operator already said yes.
|
|
179
|
+
*/
|
|
180
|
+
allowCrossInstance: boolean;
|
|
181
|
+
/**
|
|
182
|
+
* The Bot API base this listener talks to (APRV-390).
|
|
183
|
+
*
|
|
184
|
+
* Part of the bot's identity in the ownership registry, because a bot id is
|
|
185
|
+
* unique within one Bot API deployment and nothing more.
|
|
186
|
+
*/
|
|
187
|
+
apiBase: string;
|
|
154
188
|
/**
|
|
155
189
|
* How the one-sentence model gloss is obtained (APRV-144).
|
|
156
190
|
*
|
|
@@ -178,6 +212,42 @@ export interface ListenSetup {
|
|
|
178
212
|
*/
|
|
179
213
|
checkpoint: CheckpointTap;
|
|
180
214
|
}
|
|
215
|
+
/**
|
|
216
|
+
* Claim this listener's bot before it polls (APRV-390).
|
|
217
|
+
*
|
|
218
|
+
* ONE `getMe`, once per process, before the first `getUpdates`. Two things
|
|
219
|
+
* come out of it: the bot's identity, which is the only thing that can tell
|
|
220
|
+
* two instances holding two differently-named copies of one token apart; and
|
|
221
|
+
* a claim in the per-machine registry, which is what makes the SECOND such
|
|
222
|
+
* listener refuse instead of joining a 409 loop.
|
|
223
|
+
*
|
|
224
|
+
* ## Why an unreachable Bot API is not a refusal
|
|
225
|
+
*
|
|
226
|
+
* A `getMe` that fails says nothing about ownership. The machine may be behind
|
|
227
|
+
* a captive portal, the API may be rate-limiting, the network may be down for
|
|
228
|
+
* four seconds. Refusing to start on that would mean a transient network fault
|
|
229
|
+
* took the phone channel down until somebody noticed, in exchange for no
|
|
230
|
+
* safety: an unreachable Bot API is also a `getUpdates` that cannot conflict
|
|
231
|
+
* with anything. So the preflight says what happened and lets the listener
|
|
232
|
+
* start, where the existing retry loop is already the right behaviour. The
|
|
233
|
+
* refusal is reserved for the case this task is about, which is a bot that is
|
|
234
|
+
* demonstrably somebody else's.
|
|
235
|
+
*/
|
|
236
|
+
export declare function claimListenerBot(setup: ListenSetup, report: (message: string) => void): Promise<{
|
|
237
|
+
ok: true;
|
|
238
|
+
} | {
|
|
239
|
+
ok: false;
|
|
240
|
+
code: ListenRefusalCode;
|
|
241
|
+
message: string;
|
|
242
|
+
}>;
|
|
243
|
+
/**
|
|
244
|
+
* What to append to a runtime 409, naming the other instance if one is known.
|
|
245
|
+
*
|
|
246
|
+
* Read at the moment of the conflict rather than captured at start-up, because
|
|
247
|
+
* the other gate may have claimed the bot in between: the registry is the only
|
|
248
|
+
* thing that knows, and it is cheap to re-read.
|
|
249
|
+
*/
|
|
250
|
+
export declare function conflictAdviceFor(setup: ListenSetup): () => string | null;
|
|
181
251
|
/**
|
|
182
252
|
* Why a listener could not be built. A closed union, because more than one
|
|
183
253
|
* caller now branches on it: the verb turns each into an exit code, and
|
|
@@ -194,7 +264,22 @@ export declare const LISTEN_REFUSAL_CODES: readonly [
|
|
|
194
264
|
/** The log could not be read (or its directory does not exist). */
|
|
195
265
|
"log-unreadable",
|
|
196
266
|
/** `--payloads` did not hold a JSON object of action key -> payload. */
|
|
197
|
-
"payloads-unreadable"
|
|
267
|
+
"payloads-unreadable",
|
|
268
|
+
/**
|
|
269
|
+
* A credential variable holds a value this instance did not configure
|
|
270
|
+
* (APRV-390): it was exported before the process started and is not this
|
|
271
|
+
* instance's own `approval env` export, or `.approval/env` names another
|
|
272
|
+
* instance's keystore item. Refused rather than warned since APRV-390 — the
|
|
273
|
+
* warning is what let a demo gate spend an evening on the production bot.
|
|
274
|
+
* `--allow-cross-instance` is the deliberate case.
|
|
275
|
+
*/
|
|
276
|
+
"cross-instance-credential",
|
|
277
|
+
/**
|
|
278
|
+
* `getMe` named a bot another instance on this machine has already claimed
|
|
279
|
+
* (APRV-390). Refused BEFORE the first `getUpdates`, so the operator reads
|
|
280
|
+
* which two gates are involved instead of an HTTP 409 loop.
|
|
281
|
+
*/
|
|
282
|
+
"bot-owned-elsewhere"];
|
|
198
283
|
export type ListenRefusalCode = (typeof LISTEN_REFUSAL_CODES)[number];
|
|
199
284
|
/** Everything {@link prepareListen} needs, already resolved to absolute paths. */
|
|
200
285
|
export interface ListenRequest {
|
|
@@ -215,6 +300,11 @@ export interface ListenRequest {
|
|
|
215
300
|
pollTimeout: string | null;
|
|
216
301
|
once: boolean;
|
|
217
302
|
json: boolean;
|
|
303
|
+
/**
|
|
304
|
+
* `--allow-cross-instance` (APRV-390): start on a credential this instance
|
|
305
|
+
* did not configure, saying so, instead of refusing.
|
|
306
|
+
*/
|
|
307
|
+
allowCrossInstance?: boolean;
|
|
218
308
|
/** Where the channel's operational complaints go. Ordinarily stderr. */
|
|
219
309
|
log(message: string): void;
|
|
220
310
|
/** The gloss runner, if the caller wants one. See {@link ListenSetup.gloss}. */
|
|
@@ -660,31 +750,13 @@ export declare function dispatchPending(setup: ListenSetup, streams: Streams, st
|
|
|
660
750
|
export declare const COLLAPSE_STALE_AFTER_MS: number;
|
|
661
751
|
/** The computed lines a collapsed re-delivery leads with (APRV-287). */
|
|
662
752
|
export declare function staleLines(requests: ChannelRequest[], now: string): string[];
|
|
663
|
-
/**
|
|
664
|
-
* What a checkpoint tap does, on the machine the listener runs on (APRV-257).
|
|
665
|
-
*
|
|
666
|
-
* The signing happens HERE, in the listener's process, and that is the whole
|
|
667
|
-
* point of the tap: this process holds the vault passphrase because a HUMAN
|
|
668
|
-
* exported it into the shell they started it from, and `core/child-env.ts`
|
|
669
|
-
* strips that variable from every child an agent's session spawns. No agent can
|
|
670
|
-
* arrange for a process that reaches this function with a key.
|
|
671
|
-
*
|
|
672
|
-
* Nothing about the head is re-derived. The `(seq, hash)` comes back from the
|
|
673
|
-
* channel exactly as it was put on the screen, and
|
|
674
|
-
* {@link ../core/checkpoint.js appendCheckpointAt} signs that and checks the
|
|
675
|
-
* log still carries it. A handler that quietly re-read the head would be
|
|
676
|
-
* putting a human's key over bytes nobody looked at.
|
|
677
|
-
*
|
|
678
|
-
* `Not now` appends nothing and says so. It is not a rejection: there is no
|
|
679
|
-
* request here to reject, and a checkpoint that is owed is a warning at every
|
|
680
|
-
* layer and a refusal at none.
|
|
681
|
-
*/
|
|
682
753
|
export declare function checkpointHandlerFor(setup: ListenSetup, streams: Streams): (tap: {
|
|
683
754
|
sign: boolean;
|
|
684
755
|
head: {
|
|
685
756
|
seq: number;
|
|
686
757
|
hash: string;
|
|
687
758
|
};
|
|
759
|
+
sender?: ChannelSender;
|
|
688
760
|
}) => CheckpointTapResponse;
|
|
689
761
|
/**
|
|
690
762
|
* What a review tap does: the human-only `reviewSample`, and nothing else
|
|
@@ -712,6 +784,7 @@ export declare function reviewHandlerFor(setup: ListenSetup, streams: Streams):
|
|
|
712
784
|
verdict: "ok" | "denied";
|
|
713
785
|
reaction?: "disliked" | "indifferent" | "liked" | "loved";
|
|
714
786
|
note?: string;
|
|
787
|
+
sender?: ChannelSender;
|
|
715
788
|
}) => ReviewTapResponse;
|
|
716
789
|
/**
|
|
717
790
|
* `/queue`, `/skip`, `/next` — the paced walkthrough's three verbs (APRV-216).
|
|
@@ -14,12 +14,18 @@
|
|
|
14
14
|
* are read here and passed to the channel as values (SPEC.md §5.1: policy
|
|
15
15
|
* carries the env-var *names*, never the secrets). Nothing in `channels/`
|
|
16
16
|
* touches `process.env`.
|
|
17
|
-
* 2. **It declares
|
|
18
|
-
*
|
|
19
|
-
*
|
|
20
|
-
*
|
|
21
|
-
* configured chat can approve as that
|
|
22
|
-
*
|
|
17
|
+
* 2. **It declares the identity the process itself acts under.** `--as` /
|
|
18
|
+
* `APPROVAL_HUMAN` is what this listener is, and it is what a decision is
|
|
19
|
+
* recorded as whenever the policy maps no sender for this channel: SPEC.md
|
|
20
|
+
* §11's config-declared identity, where the trust boundary is the local
|
|
21
|
+
* machine and everyone who can reach the configured chat can approve as that
|
|
22
|
+
* actor. Since APRV-324 a policy MAY map Telegram account ids to approvers,
|
|
23
|
+
* and then the decision is recorded against the person the operator attested
|
|
24
|
+
* the tapping account to, and an unmapped account is refused rather than
|
|
25
|
+
* recorded as this listener. Either way the choosing happens in
|
|
26
|
+
* `channels/contract.ts` against the attested policy, never here and never
|
|
27
|
+
* in the channel. Both settings are stated in `--help` because an operator
|
|
28
|
+
* has to be able to see which one they are in without reading the source.
|
|
23
29
|
* 3. **It holds the token.** A grant mints a single-use execution token;
|
|
24
30
|
* `recordChannelDecision` returns it to *this* handler, which prints it on
|
|
25
31
|
* **stdout** and never hands it back to the channel. It is never sent to
|
|
@@ -105,15 +111,21 @@ import { readFileSync, statSync } from "node:fs";
|
|
|
105
111
|
import { isAbsolute, resolve as resolvePathSegments } from "node:path";
|
|
106
112
|
import { HUMAN_ACTOR_ENV, resolveHumanActor } from "../core/attest.js";
|
|
107
113
|
import { assembleBatch } from "../channels/batch.js";
|
|
108
|
-
import { recordChannelDecision, } from "../channels/contract.js";
|
|
114
|
+
import { recordChannelDecision, refusedDecisionLine, } from "../channels/contract.js";
|
|
109
115
|
import { ageText, buildPendingQueue, } from "../channels/tagging.js";
|
|
110
116
|
import { checkpointOfferFor, checkpointPromptLines, checkpointSignedLines, signCheckpointOffer, } from "./checkpoint-tap.js";
|
|
111
|
-
import { actionRefOf, decidedLine, groupForDigest, isMessageNotModified, isTelegramTerminalState, TelegramChannel, telegramChatEnvFor, telegramTokenEnvFor, TELEGRAM_NOT_RECORDED, TELEGRAM_REVIEW_DENIED, TELEGRAM_REVIEW_RECORDED, TELEGRAM_TERMINAL_HEADLINES, utcClock, } from "../channels/telegram.js";
|
|
117
|
+
import { actionRefOf, decidedLine, groupForDigest, isMessageNotModified, isTelegramTerminalState, TelegramChannel, telegramChatEnvFor, telegramTokenEnvFor, TELEGRAM_NOT_RECORDED, TELEGRAM_REVIEW_DENIED, TELEGRAM_REVIEW_RECORDED, TELEGRAM_DEFAULT_API_BASE, TELEGRAM_TERMINAL_HEADLINES, utcClock, } from "../channels/telegram.js";
|
|
112
118
|
import { openReviewCards } from "./audit-card.js";
|
|
113
119
|
import { reviewSample } from "../core/audit.js";
|
|
114
120
|
import { abandonedAfterMs, HOOK_DEFAULT_WAIT_MS, HOOK_RETRY_GRACE_MS, } from "../core/harness-wait.js";
|
|
115
121
|
import { telegramDeliveryFor } from "../core/telegram-config.js";
|
|
116
122
|
import { loadPolicy } from "../core/policy-load.js";
|
|
123
|
+
import { valueFindings } from "../core/instance.js";
|
|
124
|
+
import { defaultSourceRunner } from "../core/env-file.js";
|
|
125
|
+
import { claimBot, describeOwners, otherOwnersOf, ownedBot, } from "../core/channel-owner.js";
|
|
126
|
+
import { actorForSender, recordedSenderFor, senderKeyFrom, } from "../core/sender-identity.js";
|
|
127
|
+
import { recordRefusedGesture, } from "../core/gesture-refusal.js";
|
|
128
|
+
import { attestationRefusal, checkAttestation } from "../core/attest.js";
|
|
117
129
|
import { promptLayoutFor } from "../core/prompt-layout.js";
|
|
118
130
|
import { passphraseEnvFor } from "../core/vault.js";
|
|
119
131
|
import { isAttestationActionKey, proposalRecords, proposalState, } from "../core/policy-proposal.js";
|
|
@@ -136,6 +148,8 @@ const LISTEN_FLAGS = {
|
|
|
136
148
|
"--api-base": "string",
|
|
137
149
|
"--poll-timeout": "string",
|
|
138
150
|
"--once": "boolean",
|
|
151
|
+
/** APRV-390: start on a credential this instance did not configure. */
|
|
152
|
+
"--allow-cross-instance": "boolean",
|
|
139
153
|
"--gloss": "boolean",
|
|
140
154
|
"--no-gloss": "boolean",
|
|
141
155
|
"--gloss-provider": "string",
|
|
@@ -201,6 +215,77 @@ function env(name) {
|
|
|
201
215
|
const value = process.env[name];
|
|
202
216
|
return value === undefined || value.trim().length === 0 ? null : value.trim();
|
|
203
217
|
}
|
|
218
|
+
/**
|
|
219
|
+
* Claim this listener's bot before it polls (APRV-390).
|
|
220
|
+
*
|
|
221
|
+
* ONE `getMe`, once per process, before the first `getUpdates`. Two things
|
|
222
|
+
* come out of it: the bot's identity, which is the only thing that can tell
|
|
223
|
+
* two instances holding two differently-named copies of one token apart; and
|
|
224
|
+
* a claim in the per-machine registry, which is what makes the SECOND such
|
|
225
|
+
* listener refuse instead of joining a 409 loop.
|
|
226
|
+
*
|
|
227
|
+
* ## Why an unreachable Bot API is not a refusal
|
|
228
|
+
*
|
|
229
|
+
* A `getMe` that fails says nothing about ownership. The machine may be behind
|
|
230
|
+
* a captive portal, the API may be rate-limiting, the network may be down for
|
|
231
|
+
* four seconds. Refusing to start on that would mean a transient network fault
|
|
232
|
+
* took the phone channel down until somebody noticed, in exchange for no
|
|
233
|
+
* safety: an unreachable Bot API is also a `getUpdates` that cannot conflict
|
|
234
|
+
* with anything. So the preflight says what happened and lets the listener
|
|
235
|
+
* start, where the existing retry loop is already the right behaviour. The
|
|
236
|
+
* refusal is reserved for the case this task is about, which is a bot that is
|
|
237
|
+
* demonstrably somebody else's.
|
|
238
|
+
*/
|
|
239
|
+
export async function claimListenerBot(setup, report) {
|
|
240
|
+
let identity;
|
|
241
|
+
try {
|
|
242
|
+
identity = await setup.channel.identify();
|
|
243
|
+
}
|
|
244
|
+
catch (cause) {
|
|
245
|
+
report(`approval: telegram getMe could not be reached (${cause instanceof Error ? cause.message : String(cause)}), so which bot this token names is unknown and no ownership was recorded; starting anyway`);
|
|
246
|
+
return { ok: true };
|
|
247
|
+
}
|
|
248
|
+
if (identity.id.length === 0) {
|
|
249
|
+
report("approval: telegram getMe answered without a bot id, so no ownership was recorded; starting anyway");
|
|
250
|
+
return { ok: true };
|
|
251
|
+
}
|
|
252
|
+
const claim = claimBot(setup.logPath, {
|
|
253
|
+
channel: "telegram",
|
|
254
|
+
botId: identity.id,
|
|
255
|
+
username: identity.username,
|
|
256
|
+
apiBase: setup.apiBase,
|
|
257
|
+
});
|
|
258
|
+
if (!claim.ok) {
|
|
259
|
+
if (!setup.allowCrossInstance) {
|
|
260
|
+
return { ok: false, code: "bot-owned-elsewhere", message: claim.message };
|
|
261
|
+
}
|
|
262
|
+
// The deliberate case. It says what it is doing, and it does NOT record a
|
|
263
|
+
// second claim: the registry answers "who owns this bot", and two owners
|
|
264
|
+
// is the state it exists to report rather than a state to write down.
|
|
265
|
+
report(`approval: --allow-cross-instance: starting anyway — ${claim.message}`);
|
|
266
|
+
return { ok: true };
|
|
267
|
+
}
|
|
268
|
+
report(`approval: telegram ${identity.username} (bot id ${identity.id}) is this instance's own; no update was consumed by this check`);
|
|
269
|
+
return { ok: true };
|
|
270
|
+
}
|
|
271
|
+
/**
|
|
272
|
+
* What to append to a runtime 409, naming the other instance if one is known.
|
|
273
|
+
*
|
|
274
|
+
* Read at the moment of the conflict rather than captured at start-up, because
|
|
275
|
+
* the other gate may have claimed the bot in between: the registry is the only
|
|
276
|
+
* thing that knows, and it is cheap to re-read.
|
|
277
|
+
*/
|
|
278
|
+
export function conflictAdviceFor(setup) {
|
|
279
|
+
return () => {
|
|
280
|
+
const mine = ownedBot(setup.logPath, "telegram");
|
|
281
|
+
if (mine === null)
|
|
282
|
+
return null;
|
|
283
|
+
const others = otherOwnersOf(setup.logPath, mine.botId, mine.apiBase);
|
|
284
|
+
return others.length === 0
|
|
285
|
+
? `this instance is ${mine.instanceHome} (instance ${mine.instanceId}) on ${mine.username}, and nothing else on this machine has claimed it — the other poller is on another machine, or was started outside this runtime`
|
|
286
|
+
: `${mine.instanceHome} (instance ${mine.instanceId}) and ${describeOwners(others)} have both claimed ${mine.username}; stop one of them`;
|
|
287
|
+
};
|
|
288
|
+
}
|
|
204
289
|
function payloadSource(resolved) {
|
|
205
290
|
if (resolved === null)
|
|
206
291
|
return { ok: true, source: undefined };
|
|
@@ -243,6 +328,21 @@ export const LISTEN_REFUSAL_CODES = [
|
|
|
243
328
|
"log-unreadable",
|
|
244
329
|
/** `--payloads` did not hold a JSON object of action key -> payload. */
|
|
245
330
|
"payloads-unreadable",
|
|
331
|
+
/**
|
|
332
|
+
* A credential variable holds a value this instance did not configure
|
|
333
|
+
* (APRV-390): it was exported before the process started and is not this
|
|
334
|
+
* instance's own `approval env` export, or `.approval/env` names another
|
|
335
|
+
* instance's keystore item. Refused rather than warned since APRV-390 — the
|
|
336
|
+
* warning is what let a demo gate spend an evening on the production bot.
|
|
337
|
+
* `--allow-cross-instance` is the deliberate case.
|
|
338
|
+
*/
|
|
339
|
+
"cross-instance-credential",
|
|
340
|
+
/**
|
|
341
|
+
* `getMe` named a bot another instance on this machine has already claimed
|
|
342
|
+
* (APRV-390). Refused BEFORE the first `getUpdates`, so the operator reads
|
|
343
|
+
* which two gates are involved instead of an HTTP 409 loop.
|
|
344
|
+
*/
|
|
345
|
+
"bot-owned-elsewhere",
|
|
246
346
|
];
|
|
247
347
|
/**
|
|
248
348
|
* Everything that can fail without touching the network, in order.
|
|
@@ -280,6 +380,35 @@ export function prepareListen(request) {
|
|
|
280
380
|
message: `telegram is not configured: ${missing.join(" and ")} ${missing.length === 1 ? "is" : "are"} unset or empty (both ${tokenEnv} and ${chatEnv} are required; APPROVAL.md carries only their names)`,
|
|
281
381
|
};
|
|
282
382
|
}
|
|
383
|
+
// APRV-390. WHOSE credentials are these? `instanceFindings` answers from
|
|
384
|
+
// names alone — no value is read, compared or printed — and until this task
|
|
385
|
+
// its answer was a warning `approval up` printed on the way past. It is a
|
|
386
|
+
// refusal now. The incident it is named after is a demo gate that started on
|
|
387
|
+
// the primary's exported token and long-polled the primary's bot: the
|
|
388
|
+
// warning was on the operator's terminal the whole time, above a process
|
|
389
|
+
// that had already started. An override exists because feeding a daemon from
|
|
390
|
+
// a shell profile is a deliberate, supported thing to do; what was missing
|
|
391
|
+
// is that it now has to be said out loud.
|
|
392
|
+
//
|
|
393
|
+
// `valueFindings` and not `instanceFindings`: this is the one caller that is
|
|
394
|
+
// about to USE the values, so it is allowed to resolve the file and compare
|
|
395
|
+
// (APRV-390, second round). Comparing is what tells a stale export — the
|
|
396
|
+
// shell holding a token the keychain has since replaced, which `approval env`
|
|
397
|
+
// will never override — from an honest one, and what stops the correct `eval
|
|
398
|
+
// "$(approval env)"` ritual being reported as a finding. `approval doctor`
|
|
399
|
+
// keeps the name-only rule, because a diagnostic may not block on an unlock
|
|
400
|
+
// dialog. No value is printed here or anywhere below.
|
|
401
|
+
const crossInstance = valueFindings(request.logPath, policyLoad, defaultSourceRunner);
|
|
402
|
+
if (crossInstance.length > 0 && request.allowCrossInstance !== true) {
|
|
403
|
+
const stale = crossInstance.some((finding) => finding.kind === "stale-export");
|
|
404
|
+
return {
|
|
405
|
+
ok: false,
|
|
406
|
+
code: "cross-instance-credential",
|
|
407
|
+
message: `${crossInstance.map((finding) => finding.detail).join("; ")}. ${stale
|
|
408
|
+
? "A stale export is why a token re-stored in the keystore can still answer 401: `approval env` never overrides a variable this shell has already exported."
|
|
409
|
+
: "Two gates on one bot token both long-poll it, their getUpdates offsets acknowledge each other's updates, and an approval tap is answered by whichever listener asked first."} Fix it with one of: \`unset ${crossInstance.map((finding) => finding.variable).join(" ")}\` and then \`eval "$(approval env)"\` in this shell; a fresh shell that has never exported it; or \`approval setup channel telegram\` to give this instance its own bot. Pass --allow-cross-instance to start anyway`,
|
|
410
|
+
};
|
|
411
|
+
}
|
|
283
412
|
const actor = resolveHumanActor(request.as === null ? {} : { actor: request.as });
|
|
284
413
|
if (actor === null) {
|
|
285
414
|
return {
|
|
@@ -342,6 +471,10 @@ export function prepareListen(request) {
|
|
|
342
471
|
// asks of the policy file, and a policy that failed to load leaves the
|
|
343
472
|
// default (paced) in force rather than a mode nobody chose.
|
|
344
473
|
delivery: telegramDeliveryFor(policyLoad),
|
|
474
|
+
// APRV-390. Empty unless --allow-cross-instance let a finding through.
|
|
475
|
+
crossInstance,
|
|
476
|
+
allowCrossInstance: request.allowCrossInstance === true,
|
|
477
|
+
apiBase: request.apiBase ?? TELEGRAM_DEFAULT_API_BASE,
|
|
345
478
|
gateOptions: { policy: request.policy },
|
|
346
479
|
tagOptions: {
|
|
347
480
|
policy: request.policy,
|
|
@@ -458,6 +591,8 @@ function setUp(argv, streams, cwd) {
|
|
|
458
591
|
pollTimeout: stringFlag(flags, "--poll-timeout"),
|
|
459
592
|
once: boolFlag(flags, "--once"),
|
|
460
593
|
json,
|
|
594
|
+
// APRV-390.
|
|
595
|
+
allowCrossInstance: boolFlag(flags, "--allow-cross-instance"),
|
|
461
596
|
log: (message) => streams.err(`${message}\n`),
|
|
462
597
|
// APRV-144, on by default, `--no-gloss` to turn it off (APRV-197).
|
|
463
598
|
//
|
|
@@ -1569,7 +1704,10 @@ function handlerFor(setup, streams) {
|
|
|
1569
1704
|
})}\n`);
|
|
1570
1705
|
}
|
|
1571
1706
|
else if (result.outcome.ok) {
|
|
1572
|
-
|
|
1707
|
+
// APRV-324: the actor off the RECORD, not the one this process was
|
|
1708
|
+
// launched with. Under a sender mapping they differ, and the line a human
|
|
1709
|
+
// reads on the operator's terminal has to say who the log says decided.
|
|
1710
|
+
streams.out(`${decision.decision === "grant" ? "granted" : "rejected"} ${decision.action_key} (seq ${result.outcome.record.seq}) by ${result.outcome.record.actor} via telegram\n`);
|
|
1573
1711
|
}
|
|
1574
1712
|
else {
|
|
1575
1713
|
streams.err(`approval: telegram decision refused (${result.outcome.code}): ${result.outcome.message}\n`);
|
|
@@ -1606,8 +1744,101 @@ function handlerFor(setup, streams) {
|
|
|
1606
1744
|
* request here to reject, and a checkpoint that is owed is a warning at every
|
|
1607
1745
|
* layer and a refusal at none.
|
|
1608
1746
|
*/
|
|
1747
|
+
/**
|
|
1748
|
+
* Where this listener's policy lives, as `loadPolicy` wants it (APRV-324
|
|
1749
|
+
* follow-up).
|
|
1750
|
+
*
|
|
1751
|
+
* The same location every gate operation this process performs resolves,
|
|
1752
|
+
* because the handlers below ask the policy who a sender is and the gate asks
|
|
1753
|
+
* it what a class resolves to, and two different files answering those two
|
|
1754
|
+
* questions would be a listener enforcing one policy and attributing under
|
|
1755
|
+
* another.
|
|
1756
|
+
*/
|
|
1757
|
+
function policyLoadOptions(setup) {
|
|
1758
|
+
const policy = setup.gateOptions.policy;
|
|
1759
|
+
if (policy?.file !== undefined)
|
|
1760
|
+
return { file: policy.file };
|
|
1761
|
+
if (policy?.dir !== undefined)
|
|
1762
|
+
return { dir: policy.dir };
|
|
1763
|
+
return {};
|
|
1764
|
+
}
|
|
1765
|
+
function senderIdentityFor(setup, sender) {
|
|
1766
|
+
const load = loadPolicy(policyLoadOptions(setup));
|
|
1767
|
+
if (sender === undefined)
|
|
1768
|
+
return { ok: true, actor: setup.actor };
|
|
1769
|
+
// APRV-370. The operator's sender key, and with it the form the two refusals
|
|
1770
|
+
// below record the account in. Those two never reach the in-force mapping,
|
|
1771
|
+
// so they take `recordedSenderFor`'s rule: the form follows the FILE, the
|
|
1772
|
+
// mapping follows the ATTESTATION.
|
|
1773
|
+
const key = senderKeyFrom();
|
|
1774
|
+
if (load.ok) {
|
|
1775
|
+
const read = readVerifiedRecords(setup.logPath);
|
|
1776
|
+
if (!read.ok) {
|
|
1777
|
+
return {
|
|
1778
|
+
ok: false,
|
|
1779
|
+
code: "policy-not-attested",
|
|
1780
|
+
sender: recordedSenderFor(load, sender, key),
|
|
1781
|
+
message: `the log could not be read to check whether ${load.source.filename} is attested (${read.code}): ${read.message}. Nothing was recorded; a decision made at a terminal carries no sender and is unaffected.`,
|
|
1782
|
+
};
|
|
1783
|
+
}
|
|
1784
|
+
// `core/attest.ts`'s own check and its own wording, rather than a second
|
|
1785
|
+
// comparison written here that could come to a different conclusion about
|
|
1786
|
+
// the same file than the one the gate's refusal is built on.
|
|
1787
|
+
const refusal = attestationRefusal(checkAttestation(read.records, load.source.path));
|
|
1788
|
+
if (refusal !== null) {
|
|
1789
|
+
return {
|
|
1790
|
+
ok: false,
|
|
1791
|
+
code: refusal.code,
|
|
1792
|
+
sender: recordedSenderFor(load, sender, key),
|
|
1793
|
+
message: `${refusal.message}. The sender mapping it declares is therefore not in force, so the account this gesture came from cannot be resolved against it and nothing was recorded. Re-attest the policy, or do this from a terminal, which authenticates no sender and is unaffected by the mapping.`,
|
|
1794
|
+
};
|
|
1795
|
+
}
|
|
1796
|
+
}
|
|
1797
|
+
return actorForSender(load, setup.actor, sender, key);
|
|
1798
|
+
}
|
|
1799
|
+
/**
|
|
1800
|
+
* Record that a gesture this listener refused was made (APRV-355).
|
|
1801
|
+
*
|
|
1802
|
+
* Best-effort, and after the refusal is already decided: the card the person
|
|
1803
|
+
* sees does not depend on this write landing. A failure is reported on stderr
|
|
1804
|
+
* beside the refusal it was about, because a record that silently did not land
|
|
1805
|
+
* is exactly the gap this task exists to close, and one that fails quietly is
|
|
1806
|
+
* the same gap wearing a fix.
|
|
1807
|
+
*
|
|
1808
|
+
* The runtime cannot name a person on `sender-unmapped` or `sender-ambiguous`,
|
|
1809
|
+
* which is the whole point of those refusals, so `actor` is the listener's
|
|
1810
|
+
* configured identity ONLY where the resolution produced one. The observed
|
|
1811
|
+
* account goes in either way; it is the only thing known about who tapped.
|
|
1812
|
+
*/
|
|
1813
|
+
function recordGestureRefusal(setup, streams, gesture, refused) {
|
|
1814
|
+
const recorded = recordRefusedGesture(setup.logPath, {
|
|
1815
|
+
gesture,
|
|
1816
|
+
actor: null,
|
|
1817
|
+
channel: "telegram",
|
|
1818
|
+
sender: refused.sender,
|
|
1819
|
+
}, { code: refused.code, message: refused.message }, setup.gateOptions);
|
|
1820
|
+
if (!recorded.ok) {
|
|
1821
|
+
streams.err(`approval: telegram ${gesture} refusal could not be recorded (${recorded.code}): ${recorded.message}\n`);
|
|
1822
|
+
}
|
|
1823
|
+
}
|
|
1609
1824
|
export function checkpointHandlerFor(setup, streams) {
|
|
1610
1825
|
return (tap) => {
|
|
1826
|
+
// APRV-324 follow-up, and FIRST, before the decline branch is even
|
|
1827
|
+
// considered: a signature says this log's head is what this person saw, so
|
|
1828
|
+
// an account the ATTESTED policy does not name may not produce one. Under
|
|
1829
|
+
// no mapping this resolves to the configured identity and the whole branch
|
|
1830
|
+
// is today's behaviour.
|
|
1831
|
+
const resolved = senderIdentityFor(setup, tap.sender);
|
|
1832
|
+
if (!resolved.ok) {
|
|
1833
|
+
streams.err(`approval: telegram checkpoint refused (${resolved.code}): ${resolved.message}\n`);
|
|
1834
|
+
recordGestureRefusal(setup, streams, "checkpoint-signature", resolved);
|
|
1835
|
+
return {
|
|
1836
|
+
ok: false,
|
|
1837
|
+
headline: TELEGRAM_NOT_RECORDED,
|
|
1838
|
+
detail: [refusedDecisionLine(resolved.code)],
|
|
1839
|
+
toast: "Not signed.",
|
|
1840
|
+
};
|
|
1841
|
+
}
|
|
1611
1842
|
if (!tap.sign) {
|
|
1612
1843
|
return {
|
|
1613
1844
|
ok: true,
|
|
@@ -1619,7 +1850,7 @@ export function checkpointHandlerFor(setup, streams) {
|
|
|
1619
1850
|
toast: "Not now.",
|
|
1620
1851
|
};
|
|
1621
1852
|
}
|
|
1622
|
-
const result = signCheckpointOffer(setup.checkpoint, tap.head,
|
|
1853
|
+
const result = signCheckpointOffer(setup.checkpoint, tap.head, resolved.actor, "telegram", process.cwd());
|
|
1623
1854
|
const lines = checkpointSignedLines(result);
|
|
1624
1855
|
const [headline, ...detail] = lines;
|
|
1625
1856
|
if (setup.json) {
|
|
@@ -1632,7 +1863,7 @@ export function checkpointHandlerFor(setup, streams) {
|
|
|
1632
1863
|
})}\n`);
|
|
1633
1864
|
}
|
|
1634
1865
|
else if (result.ok) {
|
|
1635
|
-
streams.out(`checkpoint ${String(result.seq)}: signed head seq ${String(result.signed.seq)} ${result.signed.hash} by ${
|
|
1866
|
+
streams.out(`checkpoint ${String(result.seq)}: signed head seq ${String(result.signed.seq)} ${result.signed.hash} by ${resolved.actor} via telegram\n`);
|
|
1636
1867
|
}
|
|
1637
1868
|
else {
|
|
1638
1869
|
streams.err(`approval: telegram checkpoint refused (${result.code}): ${result.message}\n`);
|
|
@@ -1668,10 +1899,36 @@ export function checkpointHandlerFor(setup, streams) {
|
|
|
1668
1899
|
*/
|
|
1669
1900
|
export function reviewHandlerFor(setup, streams) {
|
|
1670
1901
|
return (tap) => {
|
|
1671
|
-
|
|
1902
|
+
// APRV-324 follow-up. A review confers no authority and is still a HUMAN's
|
|
1903
|
+
// observation: `approval feedback` hands it to agents as human-authored
|
|
1904
|
+
// guidance, so a review attributed to the wrong person is guidance in
|
|
1905
|
+
// somebody else's name. Resolved against the ATTESTED policy exactly as a
|
|
1906
|
+
// signature is, and under no mapping this is the configured identity and
|
|
1907
|
+
// today's behaviour.
|
|
1908
|
+
const resolved = senderIdentityFor(setup, tap.sender);
|
|
1909
|
+
if (!resolved.ok) {
|
|
1910
|
+
streams.err(`approval: telegram review refused (${resolved.code}): ${resolved.message}\n`);
|
|
1911
|
+
// `review-note` when the tap carried words: a note is attention spent
|
|
1912
|
+
// writing rather than attention spent tapping, and an operator reading
|
|
1913
|
+
// this record wants to know which they lost (APRV-355).
|
|
1914
|
+
recordGestureRefusal(setup, streams, tap.note === undefined || tap.note.trim().length === 0 ? "review" : "review-note", resolved);
|
|
1915
|
+
return {
|
|
1916
|
+
ok: false,
|
|
1917
|
+
headline: TELEGRAM_NOT_RECORDED,
|
|
1918
|
+
detail: [refusedDecisionLine(resolved.code)],
|
|
1919
|
+
toast: "Not recorded.",
|
|
1920
|
+
};
|
|
1921
|
+
}
|
|
1922
|
+
const result = reviewSample(setup.logPath, { kind: "seq", seq: tap.sampleSeq }, resolved.actor, tap.note ?? null, {
|
|
1672
1923
|
...(setup.gateOptions.policy === undefined ? {} : { policy: setup.gateOptions.policy }),
|
|
1673
1924
|
verdict: tap.verdict,
|
|
1674
1925
|
...(tap.reaction === undefined ? {} : { reaction: tap.reaction }),
|
|
1926
|
+
...(resolved.sender === undefined
|
|
1927
|
+
? {}
|
|
1928
|
+
: {
|
|
1929
|
+
sender: resolved.sender,
|
|
1930
|
+
...(resolved.source === undefined ? {} : { senderSource: resolved.source }),
|
|
1931
|
+
}),
|
|
1675
1932
|
});
|
|
1676
1933
|
if (!result.ok) {
|
|
1677
1934
|
// SPEC.md §11.1 invariant 6: the code is machine-readable and distinct,
|
|
@@ -1927,7 +2184,11 @@ export function startListener(setup, streams) {
|
|
|
1927
2184
|
const beforePoll = async () => {
|
|
1928
2185
|
reportCycle(await dispatchPending(setup, streams, state, new Date().toISOString()), streams);
|
|
1929
2186
|
};
|
|
1930
|
-
|
|
2187
|
+
// APRV-390. The 409's report reads the ownership registry at the moment
|
|
2188
|
+
// the conflict happens; the channel prints what this returns and knows
|
|
2189
|
+
// nothing about instances or files.
|
|
2190
|
+
const conflictAdvice = conflictAdviceFor(setup);
|
|
2191
|
+
await channel.listen(setup.once ? { once: true, beforePoll, conflictAdvice } : { beforePoll, conflictAdvice });
|
|
1931
2192
|
if (setup.json) {
|
|
1932
2193
|
streams.out(`${JSON.stringify({ event: "stopped", ...channel.stats() })}\n`);
|
|
1933
2194
|
}
|
|
@@ -1936,6 +2197,16 @@ export function startListener(setup, streams) {
|
|
|
1936
2197
|
return { done, stop };
|
|
1937
2198
|
}
|
|
1938
2199
|
async function runListener(setup, streams) {
|
|
2200
|
+
// APRV-390. Before anything is delivered and before the first poll: whose
|
|
2201
|
+
// bot is this? An override that got this far still owes the operator the
|
|
2202
|
+
// sentence, because the whole point of `--allow-cross-instance` is that the
|
|
2203
|
+
// deliberate case is deliberate out loud.
|
|
2204
|
+
for (const finding of setup.crossInstance) {
|
|
2205
|
+
streams.err(`approval: --allow-cross-instance: starting anyway — ${finding.detail}\n`);
|
|
2206
|
+
}
|
|
2207
|
+
const claimed = await claimListenerBot(setup, (message) => streams.err(`${message}\n`));
|
|
2208
|
+
if (!claimed.ok)
|
|
2209
|
+
return integrityError(streams, setup.json, claimed.message);
|
|
1939
2210
|
const running = startListener(setup, streams);
|
|
1940
2211
|
const stop = () => running.stop();
|
|
1941
2212
|
process.on("SIGINT", stop);
|
|
@@ -1989,6 +2260,10 @@ export function commandTelegramHealth(argv, streams, cwd) {
|
|
|
1989
2260
|
const parsed = parseFlags(argv, {
|
|
1990
2261
|
"--policy": "string",
|
|
1991
2262
|
"--dir": "string",
|
|
2263
|
+
// APRV-390. Which instance's ownership record to read. Accepted here for
|
|
2264
|
+
// the same reason `listen` accepts it: a gate may be configured with its
|
|
2265
|
+
// log somewhere other than beside the policy.
|
|
2266
|
+
"--log": "string",
|
|
1992
2267
|
"--json": "boolean",
|
|
1993
2268
|
"--help": "boolean",
|
|
1994
2269
|
"-h": "boolean",
|
|
@@ -2013,6 +2288,19 @@ export function commandTelegramHealth(argv, streams, cwd) {
|
|
|
2013
2288
|
const token = env(tokenEnv);
|
|
2014
2289
|
const chatId = env(chatEnv);
|
|
2015
2290
|
const ok = token !== null && chatId !== null;
|
|
2291
|
+
// APRV-390. Which bot, and whose. Read from the two ownership records and
|
|
2292
|
+
// never from the network: this verb makes no Bot API call on any path, and
|
|
2293
|
+
// the last `getMe` a listener or a setup run made is already written down.
|
|
2294
|
+
// An instance that has never started a listener has no record, which is a
|
|
2295
|
+
// state and not a fault — the row says so rather than guessing.
|
|
2296
|
+
const logPath = resolvePath(stringFlag(parsed.flags, "--log"), DEFAULT_LOG_PATH, dirFlag === null ? cwd : absolute(dirFlag, cwd));
|
|
2297
|
+
const mine = ownedBot(logPath, "telegram");
|
|
2298
|
+
const others = mine === null ? [] : otherOwnersOf(logPath, mine.botId, mine.apiBase);
|
|
2299
|
+
const ownership = mine === null
|
|
2300
|
+
? "no bot recorded for this instance yet: it is written by the first `approval up` or `approval channel telegram listen` that reaches getMe"
|
|
2301
|
+
: others.length === 0
|
|
2302
|
+
? `bot ${mine.username} (id ${mine.botId}) is owned by this instance, ${mine.instanceHome} (instance ${mine.instanceId}), recorded ${mine.claimedAt}`
|
|
2303
|
+
: `bot ${mine.username} (id ${mine.botId}) is claimed by this instance AND by ${describeOwners(others)}; one of them must be given its own bot`;
|
|
2016
2304
|
if (json) {
|
|
2017
2305
|
streams.out(`${JSON.stringify({
|
|
2018
2306
|
ok,
|
|
@@ -2022,10 +2310,20 @@ export function commandTelegramHealth(argv, streams, cwd) {
|
|
|
2022
2310
|
token_set: token !== null,
|
|
2023
2311
|
chat_env: chatEnv,
|
|
2024
2312
|
chat_id: chatId,
|
|
2313
|
+
// APRV-390. Names and ids, never a value.
|
|
2314
|
+
bot_username: mine?.username ?? null,
|
|
2315
|
+
bot_id: mine?.botId ?? null,
|
|
2316
|
+
owner_instance: mine?.instanceId ?? null,
|
|
2317
|
+
owner_home: mine?.instanceHome ?? null,
|
|
2318
|
+
other_owners: others.map((claim) => ({
|
|
2319
|
+
instance_id: claim.instanceId,
|
|
2320
|
+
instance_home: claim.instanceHome,
|
|
2321
|
+
})),
|
|
2025
2322
|
})}\n`);
|
|
2026
2323
|
}
|
|
2027
2324
|
else if (ok) {
|
|
2028
2325
|
streams.out(`telegram: configured (${tokenEnv} set, chat ${String(chatId)})\n`);
|
|
2326
|
+
streams.out(` ${ownership}\n`);
|
|
2029
2327
|
}
|
|
2030
2328
|
else {
|
|
2031
2329
|
streams.err(`approval: telegram is not configured: ${[
|