approval-md 0.3.0 → 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +83 -914
- package/SPEC.md +98 -13
- package/cli.js +66 -7
- package/dist/src/adapters/smtp.d.ts +18 -0
- package/dist/src/adapters/smtp.js +24 -3
- package/dist/src/adapters/smtp.js.map +1 -1
- package/dist/src/channels/contract.d.ts +19 -0
- package/dist/src/channels/contract.js +105 -1
- package/dist/src/channels/contract.js.map +1 -1
- package/dist/src/channels/relay-server.d.ts +90 -0
- package/dist/src/channels/relay-server.js +295 -0
- package/dist/src/channels/relay-server.js.map +1 -0
- package/dist/src/channels/relay.d.ts +273 -0
- package/dist/src/channels/relay.js +564 -0
- package/dist/src/channels/relay.js.map +1 -0
- package/dist/src/channels/tagging.js +6 -1
- package/dist/src/channels/tagging.js.map +1 -1
- package/dist/src/channels/telegram-webhook.d.ts +273 -0
- package/dist/src/channels/telegram-webhook.js +536 -0
- package/dist/src/channels/telegram-webhook.js.map +1 -0
- package/dist/src/channels/telegram.d.ts +271 -1
- package/dist/src/channels/telegram.js +403 -45
- package/dist/src/channels/telegram.js.map +1 -1
- package/dist/src/cli/amend.js +350 -4
- package/dist/src/cli/amend.js.map +1 -1
- package/dist/src/cli/attest.js +43 -6
- package/dist/src/cli/attest.js.map +1 -1
- package/dist/src/cli/channel-relay.d.ts +108 -0
- package/dist/src/cli/channel-relay.js +334 -0
- package/dist/src/cli/channel-relay.js.map +1 -0
- package/dist/src/cli/channel-telegram-webhook.d.ts +273 -0
- package/dist/src/cli/channel-telegram-webhook.js +709 -0
- package/dist/src/cli/channel-telegram-webhook.js.map +1 -0
- package/dist/src/cli/channel-telegram.d.ts +272 -26
- package/dist/src/cli/channel-telegram.js +478 -41
- package/dist/src/cli/channel-telegram.js.map +1 -1
- package/dist/src/cli/channel.d.ts +1 -1
- package/dist/src/cli/channel.js +5 -1
- package/dist/src/cli/channel.js.map +1 -1
- package/dist/src/cli/daemon.js +37 -5
- package/dist/src/cli/daemon.js.map +1 -1
- package/dist/src/cli/doctor.d.ts +63 -0
- package/dist/src/cli/doctor.js +856 -111
- package/dist/src/cli/doctor.js.map +1 -1
- package/dist/src/cli/execute.d.ts +17 -1
- package/dist/src/cli/execute.js +341 -20
- package/dist/src/cli/execute.js.map +1 -1
- package/dist/src/cli/exit-codes.d.ts +12 -0
- package/dist/src/cli/exit-codes.js +13 -0
- package/dist/src/cli/exit-codes.js.map +1 -1
- package/dist/src/cli/gate.d.ts +2 -0
- package/dist/src/cli/gate.js +233 -2
- package/dist/src/cli/gate.js.map +1 -1
- package/dist/src/cli/help.d.ts +9 -1
- package/dist/src/cli/help.js +246 -38
- package/dist/src/cli/help.js.map +1 -1
- package/dist/src/cli/hook.d.ts +371 -3
- package/dist/src/cli/hook.js +1926 -44
- package/dist/src/cli/hook.js.map +1 -1
- package/dist/src/cli/init.js +16 -7
- package/dist/src/cli/init.js.map +1 -1
- package/dist/src/cli/instructions.js +24 -3
- package/dist/src/cli/instructions.js.map +1 -1
- package/dist/src/cli/log-anchor.d.ts +55 -0
- package/dist/src/cli/log-anchor.js +199 -60
- package/dist/src/cli/log-anchor.js.map +1 -1
- package/dist/src/cli/main.d.ts +0 -12
- package/dist/src/cli/main.js +51 -0
- package/dist/src/cli/main.js.map +1 -1
- package/dist/src/cli/muse.d.ts +2 -0
- package/dist/src/cli/muse.js +62 -0
- package/dist/src/cli/muse.js.map +1 -0
- package/dist/src/cli/payload.js +79 -1
- package/dist/src/cli/payload.js.map +1 -1
- package/dist/src/cli/scaffold.js +6 -0
- package/dist/src/cli/scaffold.js.map +1 -1
- package/dist/src/cli/serve.d.ts +53 -0
- package/dist/src/cli/serve.js +270 -0
- package/dist/src/cli/serve.js.map +1 -0
- package/dist/src/cli/up.js +21 -2
- package/dist/src/cli/up.js.map +1 -1
- package/dist/src/cli/verb-registry.d.ts +2 -0
- package/dist/src/cli/verb-registry.js +337 -39
- package/dist/src/cli/verb-registry.js.map +1 -1
- package/dist/src/cli/wordmark.d.ts +2 -2
- package/dist/src/cli/wordmark.js +2 -2
- package/dist/src/core/attest-bootstrap.d.ts +95 -0
- package/dist/src/core/attest-bootstrap.js +135 -0
- package/dist/src/core/attest-bootstrap.js.map +1 -0
- package/dist/src/core/attest.d.ts +20 -3
- package/dist/src/core/attest.js +25 -0
- package/dist/src/core/attest.js.map +1 -1
- package/dist/src/core/audit.d.ts +55 -3
- package/dist/src/core/audit.js +52 -5
- package/dist/src/core/audit.js.map +1 -1
- package/dist/src/core/channel-lease.d.ts +322 -0
- package/dist/src/core/channel-lease.js +663 -0
- package/dist/src/core/channel-lease.js.map +1 -0
- package/dist/src/core/command-class.d.ts +51 -0
- package/dist/src/core/command-class.js +760 -8
- package/dist/src/core/command-class.js.map +1 -1
- package/dist/src/core/daemon-host.d.ts +178 -0
- package/dist/src/core/daemon-host.js +228 -0
- package/dist/src/core/daemon-host.js.map +1 -0
- package/dist/src/core/daemon-identity.d.ts +238 -0
- package/dist/src/core/daemon-identity.js +252 -0
- package/dist/src/core/daemon-identity.js.map +1 -0
- package/dist/src/core/gate.d.ts +306 -5
- package/dist/src/core/gate.js +772 -24
- package/dist/src/core/gate.js.map +1 -1
- package/dist/src/core/harness-version.d.ts +128 -4
- package/dist/src/core/harness-version.js +179 -12
- package/dist/src/core/harness-version.js.map +1 -1
- package/dist/src/core/harness-wait.d.ts +82 -3
- package/dist/src/core/harness-wait.js +89 -3
- package/dist/src/core/harness-wait.js.map +1 -1
- package/dist/src/core/log-subscribe.d.ts +21 -0
- package/dist/src/core/log-subscribe.js +16 -6
- package/dist/src/core/log-subscribe.js.map +1 -1
- package/dist/src/core/log-write-layer.d.ts +55 -0
- package/dist/src/core/log-write-layer.js +91 -0
- package/dist/src/core/log-write-layer.js.map +1 -0
- package/dist/src/core/log.d.ts +50 -21
- package/dist/src/core/log.js +197 -19
- package/dist/src/core/log.js.map +1 -1
- package/dist/src/core/payload-census.d.ts +51 -0
- package/dist/src/core/payload-census.js +125 -2
- package/dist/src/core/payload-census.js.map +1 -1
- package/dist/src/core/payload-store.d.ts +31 -0
- package/dist/src/core/payload-store.js +113 -8
- package/dist/src/core/payload-store.js.map +1 -1
- package/dist/src/core/payload.d.ts +18 -3
- package/dist/src/core/payload.js +20 -4
- package/dist/src/core/payload.js.map +1 -1
- package/dist/src/core/policy-diff.d.ts +10 -0
- package/dist/src/core/policy-diff.js +10 -2
- package/dist/src/core/policy-diff.js.map +1 -1
- package/dist/src/core/policy-explain.d.ts +7 -1
- package/dist/src/core/policy-explain.js +16 -1
- package/dist/src/core/policy-explain.js.map +1 -1
- package/dist/src/core/policy-load.d.ts +24 -0
- package/dist/src/core/policy-load.js.map +1 -1
- package/dist/src/core/policy-match.d.ts +38 -0
- package/dist/src/core/policy-match.js +35 -0
- package/dist/src/core/policy-match.js.map +1 -1
- package/dist/src/core/policy-proposal.d.ts +23 -0
- package/dist/src/core/policy-proposal.js +8 -2
- package/dist/src/core/policy-proposal.js.map +1 -1
- package/dist/src/core/read-scope.d.ts +10 -0
- package/dist/src/core/read-scope.js +4 -0
- package/dist/src/core/read-scope.js.map +1 -1
- package/dist/src/core/run-payload.d.ts +160 -0
- package/dist/src/core/run-payload.js +304 -0
- package/dist/src/core/run-payload.js.map +1 -0
- package/dist/src/core/sender-identity.d.ts +35 -10
- package/dist/src/core/sender-identity.js +48 -11
- package/dist/src/core/sender-identity.js.map +1 -1
- package/dist/src/core/state.d.ts +52 -1
- package/dist/src/core/state.js +52 -6
- package/dist/src/core/state.js.map +1 -1
- package/dist/src/core/telegram-config.d.ts +79 -0
- package/dist/src/core/telegram-config.js +108 -0
- package/dist/src/core/telegram-config.js.map +1 -1
- package/dist/src/core/verify.d.ts +23 -1
- package/dist/src/core/verify.js +20 -2
- package/dist/src/core/verify.js.map +1 -1
- package/dist/src/daemon/audit.d.ts +80 -5
- package/dist/src/daemon/audit.js +126 -6
- package/dist/src/daemon/audit.js.map +1 -1
- package/dist/src/daemon/daemon.d.ts +174 -2
- package/dist/src/daemon/daemon.js +298 -7
- package/dist/src/daemon/daemon.js.map +1 -1
- package/dist/src/daemon/git-evidence.d.ts +2 -2
- package/dist/src/daemon/git-evidence.js +1 -1
- package/dist/src/daemon/projection.d.ts +13 -2
- package/dist/src/daemon/projection.js +39 -6
- package/dist/src/daemon/projection.js.map +1 -1
- package/dist/src/mcp/server.d.ts +39 -3
- package/dist/src/mcp/server.js +22 -3
- package/dist/src/mcp/server.js.map +1 -1
- package/dist/src/muse/server.d.ts +16 -0
- package/dist/src/muse/server.js +238 -0
- package/dist/src/muse/server.js.map +1 -0
- package/dist/src/serve/archive.d.ts +158 -0
- package/dist/src/serve/archive.js +354 -0
- package/dist/src/serve/archive.js.map +1 -0
- package/dist/src/serve/arguments.d.ts +93 -0
- package/dist/src/serve/arguments.js +228 -0
- package/dist/src/serve/arguments.js.map +1 -0
- package/dist/src/serve/credentials.d.ts +91 -0
- package/dist/src/serve/credentials.js +143 -0
- package/dist/src/serve/credentials.js.map +1 -0
- package/dist/src/serve/follow.d.ts +86 -0
- package/dist/src/serve/follow.js +101 -0
- package/dist/src/serve/follow.js.map +1 -0
- package/dist/src/serve/hook-thread.d.ts +134 -0
- package/dist/src/serve/hook-thread.js +333 -0
- package/dist/src/serve/hook-thread.js.map +1 -0
- package/dist/src/serve/hook-worker.d.ts +134 -0
- package/dist/src/serve/hook-worker.js +173 -0
- package/dist/src/serve/hook-worker.js.map +1 -0
- package/dist/src/serve/server.d.ts +412 -0
- package/dist/src/serve/server.js +1251 -0
- package/dist/src/serve/server.js.map +1 -0
- package/docs/README-extended.md +766 -0
- package/docs/cli-reference.md +1633 -43
- package/package.json +4 -2
- package/schema/event.schema.json +59 -2
- package/schema/fixtures/event/invalid/daemon-id-malformed.json +15 -0
- package/schema/fixtures/event/invalid/execution-started-truncated-policy-sha256.json +18 -0
- package/schema/fixtures/event/invalid/harness-cap-at-margin.json +16 -0
- package/schema/fixtures/event/invalid/harness-cap-fractional.json +16 -0
- package/schema/fixtures/event/invalid/harness-cap-negative.json +16 -0
- package/schema/fixtures/event/invalid/harness-cap-string.json +16 -0
- package/schema/fixtures/event/invalid/harness-cap-without-harness-execution.json +16 -0
- package/schema/fixtures/event/invalid/harness-cap-zero.json +16 -0
- package/schema/fixtures/event/valid/approval-requested-harness-cap.json +18 -0
- package/schema/fixtures/event/valid/daemon-identity-declared.json +17 -0
- package/schema/fixtures/event/valid/daemon-identity.json +15 -0
- package/schema/fixtures/event/valid/execution-started-policy-sha256.json +18 -0
- package/schema/fixtures/event/valid/harness-kind-hermes.json +23 -0
- package/schema/fixtures/policy/invalid/daemons-malformed-id.json +10 -0
- package/schema/fixtures/policy/valid/daemons-allowlist.json +19 -0
- package/schema/fixtures/policy-md/invalid/schema-invalid-agent-may-request.md +21 -0
- package/schema/fixtures/policy-md/valid/agent-may-request.md +24 -0
- package/schema/policy.schema.json +50 -6
|
@@ -0,0 +1,228 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What a caller on this transport may say: nothing about the filesystem
|
|
3
|
+
* outside the store, and nothing shaped like a flag where a value belongs
|
|
4
|
+
* (APRV-421, review findings 3 and 1).
|
|
5
|
+
*
|
|
6
|
+
* ## The two holes this closes
|
|
7
|
+
*
|
|
8
|
+
* **A published path flag is a filesystem oracle.** `approval mcp serve`
|
|
9
|
+
* publishes `--log`, `--dir` and `--policy` on the verbs that accept them and
|
|
10
|
+
* injects the operator's pins only where the operator passed any. On a pipe
|
|
11
|
+
* the operator handed the server that is harmless: the party at the other end
|
|
12
|
+
* is the party who started it. Over HTTP the party at the other end is a
|
|
13
|
+
* sandboxed harness on another machine, and the same flag answers questions
|
|
14
|
+
* about files it cannot see.
|
|
15
|
+
*
|
|
16
|
+
* **A positional is a flag if it starts with a dash.** `buildArgv` emits
|
|
17
|
+
* positionals FIRST and verbatim, and `parseFlags` reads any token beginning
|
|
18
|
+
* with `-` as a flag. So a caller refused `{"flags":{"--payload":"…"}}` could
|
|
19
|
+
* write `{"positionals":["--payload","/tmp/secret","t1"]}` and reach the same
|
|
20
|
+
* flag through the front door — including the `--payload=/tmp/secret`
|
|
21
|
+
* spelling, which `parseFlags` splits on `=`. Positionals are VALUES. One that
|
|
22
|
+
* looks like a flag is refused rather than escaped, because escaping is a
|
|
23
|
+
* thing to get subtly wrong once and lose.
|
|
24
|
+
*
|
|
25
|
+
* `trailing` needs no such rule and deliberately does not get one. `buildArgv`
|
|
26
|
+
* always emits `--` before it, and `parseFlags` STOPS at `--`, pushing the
|
|
27
|
+
* remainder into positionals without reading a single token as a flag. A
|
|
28
|
+
* blanket refusal of dash-leading trailing elements would instead break the
|
|
29
|
+
* one agent verb that needs them: `hook classify -- git push -f` classifies a
|
|
30
|
+
* command line, and command lines have flags. The invariant that makes this
|
|
31
|
+
* safe is pinned by a test rather than assumed here.
|
|
32
|
+
*
|
|
33
|
+
* ## The rule, by scope
|
|
34
|
+
*
|
|
35
|
+
* **Both scopes.** No positional may begin with `-`. `--dir`, `--log` and
|
|
36
|
+
* `--policy` are appended to every call from the launch configuration and are
|
|
37
|
+
* refused from a caller even when the value is correct: one process serves one
|
|
38
|
+
* store, and a caller restating it is a caller that believes it can choose.
|
|
39
|
+
*
|
|
40
|
+
* **Agent scope.** Every path-typed flag is refused OUTRIGHT rather than
|
|
41
|
+
* confined: a sandboxed harness has no files on the daemon's machine, so a
|
|
42
|
+
* path it names is either useless or somebody else's. Payload bytes reach the
|
|
43
|
+
* gate for such a harness through the hook route, which builds the envelope
|
|
44
|
+
* from the tool call itself. On top of that, only the flags named in
|
|
45
|
+
* {@link AGENT_FLAGS} are accepted at all, per verb, so a flag added to one of
|
|
46
|
+
* the seven agent verbs tomorrow is refused until somebody decides otherwise.
|
|
47
|
+
*
|
|
48
|
+
* **Tenant scope.** Every path-typed flag is CONFINED to the store, and which
|
|
49
|
+
* flags those are comes from the registry ({@link pathFlagsOf}) rather than
|
|
50
|
+
* from a list kept here, so a flag declared `"path"` tomorrow is confined on
|
|
51
|
+
* the day it appears. Positionals that name files are confined the same way.
|
|
52
|
+
*/
|
|
53
|
+
import { realpathSync } from "node:fs";
|
|
54
|
+
import { dirname, isAbsolute, resolve, sep } from "node:path";
|
|
55
|
+
import { pathFlagsOf, verbLabel } from "../cli/verb-registry.js";
|
|
56
|
+
/**
|
|
57
|
+
* The three the server pins itself, refused from any caller in any scope.
|
|
58
|
+
*
|
|
59
|
+
* They ARE the store. Everything else this module decides is about values
|
|
60
|
+
* inside it.
|
|
61
|
+
*/
|
|
62
|
+
export const PINNED_FLAGS = ["--dir", "--log", "--policy"];
|
|
63
|
+
/**
|
|
64
|
+
* What each AGENT verb may be given, flag by flag.
|
|
65
|
+
*
|
|
66
|
+
* A positive list per verb, and short on purpose. The agent surface is seven
|
|
67
|
+
* verbs; between them they need an action key, a decision deadline, a note,
|
|
68
|
+
* a proposal's class, key, summary and inline payload, and `--json`. Everything else on those verbs is either the store (pinned), the
|
|
69
|
+
* identity (`--as`, refused by the argv builder both transports share), a path
|
|
70
|
+
* (refused below), or something nobody has decided a sandboxed harness should
|
|
71
|
+
* have.
|
|
72
|
+
*
|
|
73
|
+
* `--help` is absent deliberately: a help page over a transport whose client
|
|
74
|
+
* is a program is an odd thing to serve, and `instructions` is the verb for
|
|
75
|
+
* that.
|
|
76
|
+
*/
|
|
77
|
+
export const AGENT_FLAGS = new Map([
|
|
78
|
+
["instructions", new Set(["--schemas", "--json"])],
|
|
79
|
+
["hook classify", new Set(["--json"])],
|
|
80
|
+
["request", new Set(["--action", "--json"])],
|
|
81
|
+
["wait", new Set(["--timeout", "--interval", "--withdraw-on-timeout", "--json"])],
|
|
82
|
+
["withdraw", new Set(["--action", "--reason", "--note", "--json"])],
|
|
83
|
+
// APRV-445. The payload travels as a string VALUE, which is the whole reason
|
|
84
|
+
// these two verbs exist on this surface: a path would name the host's file.
|
|
85
|
+
["propose", new Set(["--class", "--key", "--summary", "--payload-json", "--json"])],
|
|
86
|
+
["start", new Set(["--action", "--payload-json", "--json"])],
|
|
87
|
+
]);
|
|
88
|
+
/**
|
|
89
|
+
* Verbs whose POSITIONAL argument names a file, by registry label.
|
|
90
|
+
*
|
|
91
|
+
* Read off the registry's own positional titles: each declares a `file` or a
|
|
92
|
+
* `task-file`. None is on the agent surface any more, and the confinement
|
|
93
|
+
* still runs for the tenant, because one process serves one store whoever is
|
|
94
|
+
* asking.
|
|
95
|
+
*/
|
|
96
|
+
const PATH_POSITIONAL_VERBS = new Map([
|
|
97
|
+
["payload hash", 0],
|
|
98
|
+
["register", 0],
|
|
99
|
+
["import agents-md", 0],
|
|
100
|
+
]);
|
|
101
|
+
const OUTSIDE = "serve-path-outside-store";
|
|
102
|
+
const PINNED = "serve-path-pinned";
|
|
103
|
+
const DASH = "serve-positional-flag";
|
|
104
|
+
const UNSUPPORTED = "serve-flag-not-permitted";
|
|
105
|
+
/**
|
|
106
|
+
* Is `candidate` inside `root`?
|
|
107
|
+
*
|
|
108
|
+
* Resolved through `realpath` on the deepest existing ancestor, so a symlink
|
|
109
|
+
* in the middle of the path cannot walk out of the store and back in on paper.
|
|
110
|
+
* A path that does not exist is judged by where it WOULD be, which is the
|
|
111
|
+
* right answer for a verb that is about to create it.
|
|
112
|
+
*/
|
|
113
|
+
function insideStore(root, candidate) {
|
|
114
|
+
const real = (path) => {
|
|
115
|
+
let current = path;
|
|
116
|
+
const tail = [];
|
|
117
|
+
for (;;) {
|
|
118
|
+
try {
|
|
119
|
+
return resolve(realpathSync(current), ...tail.reverse());
|
|
120
|
+
}
|
|
121
|
+
catch {
|
|
122
|
+
const parent = dirname(current);
|
|
123
|
+
if (parent === current)
|
|
124
|
+
return resolve(path);
|
|
125
|
+
tail.push(current.slice(parent.length + 1));
|
|
126
|
+
current = parent;
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
};
|
|
130
|
+
const realRoot = real(root);
|
|
131
|
+
const realCandidate = real(candidate);
|
|
132
|
+
return realCandidate === realRoot || realCandidate.startsWith(`${realRoot}${sep}`);
|
|
133
|
+
}
|
|
134
|
+
function confine(root, cwd, label, value) {
|
|
135
|
+
// `-` is stdin and is refused by the argv builder both transports share.
|
|
136
|
+
if (value === "-")
|
|
137
|
+
return { ok: true };
|
|
138
|
+
const absolute = isAbsolute(value) ? value : resolve(cwd, value);
|
|
139
|
+
if (insideStore(root, absolute))
|
|
140
|
+
return { ok: true };
|
|
141
|
+
return {
|
|
142
|
+
ok: false,
|
|
143
|
+
code: OUTSIDE,
|
|
144
|
+
message: `${label} names ${JSON.stringify(value)}, which is outside the store this server was started for. This transport serves ONE store and reads no path beyond it: a path argument that reached the host filesystem would answer questions about files the caller cannot see and did not put there`,
|
|
145
|
+
};
|
|
146
|
+
}
|
|
147
|
+
/**
|
|
148
|
+
* Check one verb call's arguments before an argv is built from them.
|
|
149
|
+
*
|
|
150
|
+
* Runs for EVERY scope. The tenant credential is the operator's and is trusted
|
|
151
|
+
* with far more than the agent's, and that is still not a reason to let one
|
|
152
|
+
* process serve two stores: the pins are the launch configuration's, and a
|
|
153
|
+
* second store reached through an argument would be a second gate with no
|
|
154
|
+
* attestation, no daemon id, and no record of which one answered.
|
|
155
|
+
*/
|
|
156
|
+
export function checkVerbArguments(spec, rawArgs, storeRoot, cwd, scope) {
|
|
157
|
+
const args = (rawArgs ?? {});
|
|
158
|
+
if (typeof args !== "object" || Array.isArray(args))
|
|
159
|
+
return { ok: true };
|
|
160
|
+
const label = verbLabel(spec);
|
|
161
|
+
// A POSITIONAL THAT LOOKS LIKE A FLAG IS A FLAG. Checked first and in both
|
|
162
|
+
// scopes, because it is the route around every other rule in this file.
|
|
163
|
+
const positionals = args["positionals"];
|
|
164
|
+
if (Array.isArray(positionals)) {
|
|
165
|
+
for (const value of positionals) {
|
|
166
|
+
if (typeof value !== "string" || !value.startsWith("-"))
|
|
167
|
+
continue;
|
|
168
|
+
return {
|
|
169
|
+
ok: false,
|
|
170
|
+
code: DASH,
|
|
171
|
+
message: `positional ${JSON.stringify(value)} begins with a dash. Positionals on this surface are VALUES, passed to the verb verbatim, and the verb reads anything starting with \`-\` as a flag: a value spelled this way reaches the flag parser through the front door. Send flags in \`flags\``,
|
|
172
|
+
};
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
const pathFlags = new Set(pathFlagsOf(spec));
|
|
176
|
+
const permitted = AGENT_FLAGS.get(label);
|
|
177
|
+
const flags = args["flags"];
|
|
178
|
+
if (typeof flags === "object" && flags !== null && !Array.isArray(flags)) {
|
|
179
|
+
for (const [flag, value] of Object.entries(flags)) {
|
|
180
|
+
if (PINNED_FLAGS.includes(flag)) {
|
|
181
|
+
return {
|
|
182
|
+
ok: false,
|
|
183
|
+
code: PINNED,
|
|
184
|
+
message: `${flag} is not accepted from a caller. This server serves the one store it was started for, and ${PINNED_FLAGS.join(", ")} are appended to every verb call from its launch configuration; a call that could restate them could point this process at another tenant's log, at another tenant's policy, or at the host's filesystem`,
|
|
185
|
+
};
|
|
186
|
+
}
|
|
187
|
+
if (scope === "agent") {
|
|
188
|
+
// `--as` is not this module's to refuse. The argv builder both
|
|
189
|
+
// transports share owns identity and refuses it with
|
|
190
|
+
// `mcp-identity-fixed`, whose message is the whole reason this surface
|
|
191
|
+
// is safe to run; catching it here first would replace that with a
|
|
192
|
+
// vaguer code and leave the rule stated in two places.
|
|
193
|
+
if (flag === "--as")
|
|
194
|
+
continue;
|
|
195
|
+
if (pathFlags.has(flag)) {
|
|
196
|
+
return {
|
|
197
|
+
ok: false,
|
|
198
|
+
code: UNSUPPORTED,
|
|
199
|
+
message: `${flag} names a file on the machine this server runs on, and the agent credential never names one. A harness reached through this transport has no files there; payload bytes reach the gate through \`POST /hook/<harness>\`, which builds the envelope from the tool call itself`,
|
|
200
|
+
};
|
|
201
|
+
}
|
|
202
|
+
if (permitted !== undefined && !permitted.has(flag)) {
|
|
203
|
+
return {
|
|
204
|
+
ok: false,
|
|
205
|
+
code: UNSUPPORTED,
|
|
206
|
+
message: `${flag} is not accepted on \`approval ${label}\` from the agent credential, which takes ${[...permitted].sort().join(", ")} on this verb and nothing else. A flag added to it later is refused until somebody decides a harness under oversight should have it`,
|
|
207
|
+
};
|
|
208
|
+
}
|
|
209
|
+
continue;
|
|
210
|
+
}
|
|
211
|
+
if (!pathFlags.has(flag) || typeof value !== "string")
|
|
212
|
+
continue;
|
|
213
|
+
const checked = confine(storeRoot, cwd, flag, value);
|
|
214
|
+
if (!checked.ok)
|
|
215
|
+
return checked;
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
const index = PATH_POSITIONAL_VERBS.get(label);
|
|
219
|
+
if (index === undefined)
|
|
220
|
+
return { ok: true };
|
|
221
|
+
if (!Array.isArray(positionals))
|
|
222
|
+
return { ok: true };
|
|
223
|
+
const value = positionals[index];
|
|
224
|
+
if (typeof value !== "string")
|
|
225
|
+
return { ok: true };
|
|
226
|
+
return confine(storeRoot, cwd, `\`approval ${label}\`'s file argument`, value);
|
|
227
|
+
}
|
|
228
|
+
//# sourceMappingURL=arguments.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"arguments.js","sourceRoot":"","sources":["../../../src/serve/arguments.ts"],"names":[],"mappings":"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"}
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The two bearer credentials `approval serve` reads from its launch
|
|
3
|
+
* environment, and the scope each one buys (APRV-421).
|
|
4
|
+
*
|
|
5
|
+
* ## Two credentials, because there are two parties
|
|
6
|
+
*
|
|
7
|
+
* SPEC.md §11 names the agent the untrusted policy and the human the trusted
|
|
8
|
+
* overseer, and a hosted deployment adds a third fact: the log and the policy
|
|
9
|
+
* belong to the TENANT, while the harness that is judged by them runs
|
|
10
|
+
* somewhere else entirely (design/hosted-daemon-identity.md, §1). So this
|
|
11
|
+
* server has two doors and never one.
|
|
12
|
+
*
|
|
13
|
+
* - The **agent credential** ({@link AGENT_TOKEN_ENV}) reaches the
|
|
14
|
+
* agent-facing verb surface and the harness hook. It is the credential a
|
|
15
|
+
* sandbox holds, and the sandbox is the party under oversight.
|
|
16
|
+
* - The **tenant credential** ({@link TENANT_TOKEN_ENV}) reaches the log, the
|
|
17
|
+
* export and the status report. It is the credential the tenant's own tools
|
|
18
|
+
* hold.
|
|
19
|
+
*
|
|
20
|
+
* **The agent credential must never read the log it is judged by.** That is
|
|
21
|
+
* the sentence the split exists for. An agent that can stream the verified log
|
|
22
|
+
* can read every decision made about it, every other action in the tenant's
|
|
23
|
+
* store, and the shape of the oversight it is under; an agent that can export
|
|
24
|
+
* the store can carry all of it away. Neither is a thing the gate would ever
|
|
25
|
+
* grant, so neither is reachable with the credential the gate hands the
|
|
26
|
+
* harness. The refusal is a scope refusal and not a not-found, because a
|
|
27
|
+
* caller that guessed a path deserves to learn that the door exists and is not
|
|
28
|
+
* theirs, rather than to probe for one that answers differently.
|
|
29
|
+
*
|
|
30
|
+
* ## Why the environment
|
|
31
|
+
*
|
|
32
|
+
* SPEC.md §11.1 invariant 7: configuration is never loaded implicitly from the
|
|
33
|
+
* working tree. A credential in a file this process discovered would be a
|
|
34
|
+
* credential anything able to write that file could rotate. The host that
|
|
35
|
+
* launches one process per tenant is the party that sets these, exactly as it
|
|
36
|
+
* sets the bot token, the sampling secret and the vault passphrase
|
|
37
|
+
* (design/hosted-daemon-identity.md §1.2).
|
|
38
|
+
*
|
|
39
|
+
* ## What comparison costs
|
|
40
|
+
*
|
|
41
|
+
* Both values are compared as SHA-256 digests under `timingSafeEqual`, so the
|
|
42
|
+
* comparison is constant-time AND length-independent: `timingSafeEqual` throws
|
|
43
|
+
* on a length mismatch, which would itself be an oracle for the length of the
|
|
44
|
+
* real credential. Nothing here logs, prints, or returns either value, and the
|
|
45
|
+
* server never puts one in a record, a message or a header.
|
|
46
|
+
*/
|
|
47
|
+
/** The environment variable naming the credential a sandboxed harness holds. */
|
|
48
|
+
export declare const AGENT_TOKEN_ENV = "APPROVAL_SERVE_AGENT_TOKEN";
|
|
49
|
+
/** The environment variable naming the credential the tenant's own tools hold. */
|
|
50
|
+
export declare const TENANT_TOKEN_ENV = "APPROVAL_SERVE_TENANT_TOKEN";
|
|
51
|
+
/**
|
|
52
|
+
* The shortest credential this server will start with.
|
|
53
|
+
*
|
|
54
|
+
* Not a password policy: a floor under the one mistake that makes the split
|
|
55
|
+
* meaningless, which is a memorable value somebody typed. The credential is
|
|
56
|
+
* generated by the host, never by a person, so the floor costs nothing.
|
|
57
|
+
*/
|
|
58
|
+
export declare const MIN_TOKEN_LENGTH = 24;
|
|
59
|
+
/** Which of the two parties a request is acting as. */
|
|
60
|
+
export type ServeScope = "agent" | "tenant";
|
|
61
|
+
/**
|
|
62
|
+
* The frozen refusal vocabulary of the credential layer (SPEC.md §11.1
|
|
63
|
+
* invariant 6). Distinct codes, because the repairs are distinct: one is a
|
|
64
|
+
* missing or wrong credential, and the other two are the right credential at
|
|
65
|
+
* the wrong door, in each direction.
|
|
66
|
+
*/
|
|
67
|
+
export declare const SERVE_CREDENTIAL_REFUSAL_CODES: readonly ["serve-unauthorized", "serve-agent-forbidden", "serve-tenant-forbidden"];
|
|
68
|
+
export type ServeCredentialRefusalCode = (typeof SERVE_CREDENTIAL_REFUSAL_CODES)[number];
|
|
69
|
+
/** Hold the digests, never the values. */
|
|
70
|
+
export interface ServeCredentials {
|
|
71
|
+
/** Which scope this bearer value belongs to, or `null` for neither. */
|
|
72
|
+
identify(bearer: string): ServeScope | null;
|
|
73
|
+
}
|
|
74
|
+
export type CredentialResolution = {
|
|
75
|
+
ok: true;
|
|
76
|
+
credentials: ServeCredentials;
|
|
77
|
+
} | {
|
|
78
|
+
ok: false;
|
|
79
|
+
message: string;
|
|
80
|
+
};
|
|
81
|
+
/**
|
|
82
|
+
* Resolve both credentials, or say why the server may not start.
|
|
83
|
+
*
|
|
84
|
+
* Every failure is a STARTUP failure. A server that began listening and then
|
|
85
|
+
* discovered it had no tenant credential would be a server something had
|
|
86
|
+
* already spoken to, which is the rule `cli/mcp.ts` states for identity and
|
|
87
|
+
* this file inherits for authority.
|
|
88
|
+
*/
|
|
89
|
+
export declare function resolveServeCredentials(env?: NodeJS.ProcessEnv): CredentialResolution;
|
|
90
|
+
/** The bearer value of an `Authorization` header, or null. */
|
|
91
|
+
export declare function bearerOf(header: string | string[] | undefined): string | null;
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The two bearer credentials `approval serve` reads from its launch
|
|
3
|
+
* environment, and the scope each one buys (APRV-421).
|
|
4
|
+
*
|
|
5
|
+
* ## Two credentials, because there are two parties
|
|
6
|
+
*
|
|
7
|
+
* SPEC.md §11 names the agent the untrusted policy and the human the trusted
|
|
8
|
+
* overseer, and a hosted deployment adds a third fact: the log and the policy
|
|
9
|
+
* belong to the TENANT, while the harness that is judged by them runs
|
|
10
|
+
* somewhere else entirely (design/hosted-daemon-identity.md, §1). So this
|
|
11
|
+
* server has two doors and never one.
|
|
12
|
+
*
|
|
13
|
+
* - The **agent credential** ({@link AGENT_TOKEN_ENV}) reaches the
|
|
14
|
+
* agent-facing verb surface and the harness hook. It is the credential a
|
|
15
|
+
* sandbox holds, and the sandbox is the party under oversight.
|
|
16
|
+
* - The **tenant credential** ({@link TENANT_TOKEN_ENV}) reaches the log, the
|
|
17
|
+
* export and the status report. It is the credential the tenant's own tools
|
|
18
|
+
* hold.
|
|
19
|
+
*
|
|
20
|
+
* **The agent credential must never read the log it is judged by.** That is
|
|
21
|
+
* the sentence the split exists for. An agent that can stream the verified log
|
|
22
|
+
* can read every decision made about it, every other action in the tenant's
|
|
23
|
+
* store, and the shape of the oversight it is under; an agent that can export
|
|
24
|
+
* the store can carry all of it away. Neither is a thing the gate would ever
|
|
25
|
+
* grant, so neither is reachable with the credential the gate hands the
|
|
26
|
+
* harness. The refusal is a scope refusal and not a not-found, because a
|
|
27
|
+
* caller that guessed a path deserves to learn that the door exists and is not
|
|
28
|
+
* theirs, rather than to probe for one that answers differently.
|
|
29
|
+
*
|
|
30
|
+
* ## Why the environment
|
|
31
|
+
*
|
|
32
|
+
* SPEC.md §11.1 invariant 7: configuration is never loaded implicitly from the
|
|
33
|
+
* working tree. A credential in a file this process discovered would be a
|
|
34
|
+
* credential anything able to write that file could rotate. The host that
|
|
35
|
+
* launches one process per tenant is the party that sets these, exactly as it
|
|
36
|
+
* sets the bot token, the sampling secret and the vault passphrase
|
|
37
|
+
* (design/hosted-daemon-identity.md §1.2).
|
|
38
|
+
*
|
|
39
|
+
* ## What comparison costs
|
|
40
|
+
*
|
|
41
|
+
* Both values are compared as SHA-256 digests under `timingSafeEqual`, so the
|
|
42
|
+
* comparison is constant-time AND length-independent: `timingSafeEqual` throws
|
|
43
|
+
* on a length mismatch, which would itself be an oracle for the length of the
|
|
44
|
+
* real credential. Nothing here logs, prints, or returns either value, and the
|
|
45
|
+
* server never puts one in a record, a message or a header.
|
|
46
|
+
*/
|
|
47
|
+
import { createHash, timingSafeEqual } from "node:crypto";
|
|
48
|
+
/** The environment variable naming the credential a sandboxed harness holds. */
|
|
49
|
+
export const AGENT_TOKEN_ENV = "APPROVAL_SERVE_AGENT_TOKEN";
|
|
50
|
+
/** The environment variable naming the credential the tenant's own tools hold. */
|
|
51
|
+
export const TENANT_TOKEN_ENV = "APPROVAL_SERVE_TENANT_TOKEN";
|
|
52
|
+
/**
|
|
53
|
+
* The shortest credential this server will start with.
|
|
54
|
+
*
|
|
55
|
+
* Not a password policy: a floor under the one mistake that makes the split
|
|
56
|
+
* meaningless, which is a memorable value somebody typed. The credential is
|
|
57
|
+
* generated by the host, never by a person, so the floor costs nothing.
|
|
58
|
+
*/
|
|
59
|
+
export const MIN_TOKEN_LENGTH = 24;
|
|
60
|
+
/**
|
|
61
|
+
* The frozen refusal vocabulary of the credential layer (SPEC.md §11.1
|
|
62
|
+
* invariant 6). Distinct codes, because the repairs are distinct: one is a
|
|
63
|
+
* missing or wrong credential, and the other two are the right credential at
|
|
64
|
+
* the wrong door, in each direction.
|
|
65
|
+
*/
|
|
66
|
+
export const SERVE_CREDENTIAL_REFUSAL_CODES = [
|
|
67
|
+
"serve-unauthorized",
|
|
68
|
+
"serve-agent-forbidden",
|
|
69
|
+
"serve-tenant-forbidden",
|
|
70
|
+
];
|
|
71
|
+
function digest(value) {
|
|
72
|
+
return createHash("sha256").update(value, "utf8").digest();
|
|
73
|
+
}
|
|
74
|
+
function sameDigest(left, right) {
|
|
75
|
+
// Equal by construction (two SHA-256 digests), so `timingSafeEqual` can never
|
|
76
|
+
// throw here and the comparison leaks nothing about the length of either
|
|
77
|
+
// input.
|
|
78
|
+
return timingSafeEqual(left, right);
|
|
79
|
+
}
|
|
80
|
+
/**
|
|
81
|
+
* Resolve both credentials, or say why the server may not start.
|
|
82
|
+
*
|
|
83
|
+
* Every failure is a STARTUP failure. A server that began listening and then
|
|
84
|
+
* discovered it had no tenant credential would be a server something had
|
|
85
|
+
* already spoken to, which is the rule `cli/mcp.ts` states for identity and
|
|
86
|
+
* this file inherits for authority.
|
|
87
|
+
*/
|
|
88
|
+
export function resolveServeCredentials(env = process.env) {
|
|
89
|
+
const agent = env[AGENT_TOKEN_ENV] ?? "";
|
|
90
|
+
const tenant = env[TENANT_TOKEN_ENV] ?? "";
|
|
91
|
+
for (const [name, value] of [
|
|
92
|
+
[AGENT_TOKEN_ENV, agent],
|
|
93
|
+
[TENANT_TOKEN_ENV, tenant],
|
|
94
|
+
]) {
|
|
95
|
+
if (value.length === 0) {
|
|
96
|
+
return {
|
|
97
|
+
ok: false,
|
|
98
|
+
message: `no ${name} in the environment: this server has two doors and starts only when the host has named both. The agent credential reaches the verbs and the hook; the tenant credential reaches the log, the export and status`,
|
|
99
|
+
};
|
|
100
|
+
}
|
|
101
|
+
if (value.length < MIN_TOKEN_LENGTH) {
|
|
102
|
+
return {
|
|
103
|
+
ok: false,
|
|
104
|
+
message: `${name} is shorter than ${String(MIN_TOKEN_LENGTH)} characters. Generate it (\`openssl rand -hex 32\`) rather than choosing it: this value is the whole of what separates the two parties`,
|
|
105
|
+
};
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
if (sameDigest(digest(agent), digest(tenant))) {
|
|
109
|
+
return {
|
|
110
|
+
ok: false,
|
|
111
|
+
message: `${AGENT_TOKEN_ENV} and ${TENANT_TOKEN_ENV} are the same value, which is the same as having neither: the agent credential would reach the log it is judged by`,
|
|
112
|
+
};
|
|
113
|
+
}
|
|
114
|
+
const agentDigest = digest(agent);
|
|
115
|
+
const tenantDigest = digest(tenant);
|
|
116
|
+
return {
|
|
117
|
+
ok: true,
|
|
118
|
+
credentials: {
|
|
119
|
+
identify(bearer) {
|
|
120
|
+
const offered = digest(bearer);
|
|
121
|
+
// Both compared, always, in the same order: returning as soon as the
|
|
122
|
+
// agent digest matched would make an agent credential measurably
|
|
123
|
+
// faster to present than a tenant one.
|
|
124
|
+
const isAgent = sameDigest(offered, agentDigest);
|
|
125
|
+
const isTenant = sameDigest(offered, tenantDigest);
|
|
126
|
+
if (isAgent)
|
|
127
|
+
return "agent";
|
|
128
|
+
if (isTenant)
|
|
129
|
+
return "tenant";
|
|
130
|
+
return null;
|
|
131
|
+
},
|
|
132
|
+
},
|
|
133
|
+
};
|
|
134
|
+
}
|
|
135
|
+
/** The bearer value of an `Authorization` header, or null. */
|
|
136
|
+
export function bearerOf(header) {
|
|
137
|
+
const raw = Array.isArray(header) ? header[0] : header;
|
|
138
|
+
if (raw === undefined)
|
|
139
|
+
return null;
|
|
140
|
+
const match = /^Bearer[ \t]+(?<token>\S+)$/iu.exec(raw.trim());
|
|
141
|
+
return match?.groups?.["token"] ?? null;
|
|
142
|
+
}
|
|
143
|
+
//# sourceMappingURL=credentials.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"credentials.js","sourceRoot":"","sources":["../../../src/serve/credentials.ts"],"names":[],"mappings":"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"}
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `log/follow`, paged (APRV-421).
|
|
3
|
+
*
|
|
4
|
+
* `approval log follow` is a foreground stream: it prints verified records as
|
|
5
|
+
* JSON Lines and stays. Over HTTP that shape is wrong in both directions — a
|
|
6
|
+
* request has to end, and a tenant behind a sleeping Firecracker host has to
|
|
7
|
+
* be able to come back tomorrow and resume — so this answers ONE page and
|
|
8
|
+
* hands back the cursor to ask with next time.
|
|
9
|
+
*
|
|
10
|
+
* Everything else is the streaming verb's, unchanged, because it is literally
|
|
11
|
+
* the same generator: `core/log-subscribe.ts` verifies the chain from genesis
|
|
12
|
+
* before it emits anything, binds the exclusive `(seq, hash)` cursor to the
|
|
13
|
+
* prefix the caller says it consumed, and refuses a mismatch as an integrity
|
|
14
|
+
* failure with `cursor-mismatch` as its reason. Nothing in this file verifies,
|
|
15
|
+
* parses or re-derives a record; it counts to a limit and stops.
|
|
16
|
+
*
|
|
17
|
+
* **At-least-once, and the cursor is the caller's.** This server keeps no
|
|
18
|
+
* subscription state: a page is a function of the cursor in the request and
|
|
19
|
+
* the bytes on disk. A caller that performs an external effect persists its
|
|
20
|
+
* cursor after the effect, exactly as SPEC.md §8 requires of any consumer, and
|
|
21
|
+
* a restart of this process on either side changes nothing about what the next
|
|
22
|
+
* page contains. That is what "no in-memory state a restart loses" means here.
|
|
23
|
+
*/
|
|
24
|
+
import type { EventRecord } from "../core/log.js";
|
|
25
|
+
/** Records one page carries when the caller names no limit. */
|
|
26
|
+
export declare const DEFAULT_FOLLOW_LIMIT = 200;
|
|
27
|
+
/** The most any caller may ask for in one page. */
|
|
28
|
+
export declare const MAX_FOLLOW_LIMIT = 1000;
|
|
29
|
+
export interface FollowCursor {
|
|
30
|
+
/** Exclusive: the caller has consumed through this sequence number. */
|
|
31
|
+
seq: number;
|
|
32
|
+
/** The hash of record `seq`, or null when the caller never held one. */
|
|
33
|
+
hash: string | null;
|
|
34
|
+
}
|
|
35
|
+
export interface FollowPage {
|
|
36
|
+
records: EventRecord[];
|
|
37
|
+
/** Where to resume. The caller sends this back verbatim. */
|
|
38
|
+
cursor: FollowCursor;
|
|
39
|
+
/**
|
|
40
|
+
* True when this page exhausted the verified log rather than the limit.
|
|
41
|
+
*
|
|
42
|
+
* A caller that gets `false` asks again immediately; one that gets `true`
|
|
43
|
+
* waits before it does. It is a statement about the snapshot this page was
|
|
44
|
+
* cut from and never a promise about the next instant.
|
|
45
|
+
*/
|
|
46
|
+
caught_up: boolean;
|
|
47
|
+
}
|
|
48
|
+
export type FollowResult = {
|
|
49
|
+
ok: true;
|
|
50
|
+
page: FollowPage;
|
|
51
|
+
} | {
|
|
52
|
+
ok: false;
|
|
53
|
+
/** The CLI's own code for this failure: `integrity`, `torn-tail` or `io`. */
|
|
54
|
+
code: string;
|
|
55
|
+
message: string;
|
|
56
|
+
/** `cursor-mismatch` and the rest of the subscription's own reasons. */
|
|
57
|
+
reason: string | null;
|
|
58
|
+
};
|
|
59
|
+
/**
|
|
60
|
+
* The exit code `approval log follow` would have exited with for this failure.
|
|
61
|
+
*
|
|
62
|
+
* Carried on the response so a caller reproducing the CLI locally reports the
|
|
63
|
+
* same thing. The numbers are `cli/exit-codes.ts`'s and are frozen public API;
|
|
64
|
+
* they are mapped here exactly as `main.ts`'s `commandFollow` maps them.
|
|
65
|
+
*/
|
|
66
|
+
export declare function followFailureExit(code: string): number;
|
|
67
|
+
/**
|
|
68
|
+
* There is deliberately NO clamp here any more (APRV-421, review).
|
|
69
|
+
*
|
|
70
|
+
* A limit outside the range above used to be pulled quietly into it, so
|
|
71
|
+
* `limit=0` returned two hundred records and a caller paging by a number it
|
|
72
|
+
* had computed could not tell that the server had answered a different
|
|
73
|
+
* question than the one it asked. The transport refuses an out-of-range limit
|
|
74
|
+
* with `serve-invalid-cursor` instead, and `docs/cli-reference.md` states the
|
|
75
|
+
* range. A silent correction is the failure mode this project exists to
|
|
76
|
+
* dislike.
|
|
77
|
+
*/
|
|
78
|
+
/**
|
|
79
|
+
* Read one page of verified records after `cursor`.
|
|
80
|
+
*
|
|
81
|
+
* A failure returns NO records at all, including the ones a partial drain had
|
|
82
|
+
* already produced: SPEC.md §8 says a subscription "MUST emit no record from a
|
|
83
|
+
* batch that fails verification or its retained cursor binding", and a page
|
|
84
|
+
* that carried half a batch beside an integrity refusal would be exactly that.
|
|
85
|
+
*/
|
|
86
|
+
export declare function followPage(logPath: string, cursor: FollowCursor, limit: number): Promise<FollowResult>;
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `log/follow`, paged (APRV-421).
|
|
3
|
+
*
|
|
4
|
+
* `approval log follow` is a foreground stream: it prints verified records as
|
|
5
|
+
* JSON Lines and stays. Over HTTP that shape is wrong in both directions — a
|
|
6
|
+
* request has to end, and a tenant behind a sleeping Firecracker host has to
|
|
7
|
+
* be able to come back tomorrow and resume — so this answers ONE page and
|
|
8
|
+
* hands back the cursor to ask with next time.
|
|
9
|
+
*
|
|
10
|
+
* Everything else is the streaming verb's, unchanged, because it is literally
|
|
11
|
+
* the same generator: `core/log-subscribe.ts` verifies the chain from genesis
|
|
12
|
+
* before it emits anything, binds the exclusive `(seq, hash)` cursor to the
|
|
13
|
+
* prefix the caller says it consumed, and refuses a mismatch as an integrity
|
|
14
|
+
* failure with `cursor-mismatch` as its reason. Nothing in this file verifies,
|
|
15
|
+
* parses or re-derives a record; it counts to a limit and stops.
|
|
16
|
+
*
|
|
17
|
+
* **At-least-once, and the cursor is the caller's.** This server keeps no
|
|
18
|
+
* subscription state: a page is a function of the cursor in the request and
|
|
19
|
+
* the bytes on disk. A caller that performs an external effect persists its
|
|
20
|
+
* cursor after the effect, exactly as SPEC.md §8 requires of any consumer, and
|
|
21
|
+
* a restart of this process on either side changes nothing about what the next
|
|
22
|
+
* page contains. That is what "no in-memory state a restart loses" means here.
|
|
23
|
+
*/
|
|
24
|
+
import { LogSubscriptionError, subscribeVerifiedLog } from "../core/log-subscribe.js";
|
|
25
|
+
/** Records one page carries when the caller names no limit. */
|
|
26
|
+
export const DEFAULT_FOLLOW_LIMIT = 200;
|
|
27
|
+
/** The most any caller may ask for in one page. */
|
|
28
|
+
export const MAX_FOLLOW_LIMIT = 1000;
|
|
29
|
+
/**
|
|
30
|
+
* The exit code `approval log follow` would have exited with for this failure.
|
|
31
|
+
*
|
|
32
|
+
* Carried on the response so a caller reproducing the CLI locally reports the
|
|
33
|
+
* same thing. The numbers are `cli/exit-codes.ts`'s and are frozen public API;
|
|
34
|
+
* they are mapped here exactly as `main.ts`'s `commandFollow` maps them.
|
|
35
|
+
*/
|
|
36
|
+
export function followFailureExit(code) {
|
|
37
|
+
if (code === "integrity")
|
|
38
|
+
return 1;
|
|
39
|
+
if (code === "torn-tail")
|
|
40
|
+
return 3;
|
|
41
|
+
return 4;
|
|
42
|
+
}
|
|
43
|
+
/**
|
|
44
|
+
* There is deliberately NO clamp here any more (APRV-421, review).
|
|
45
|
+
*
|
|
46
|
+
* A limit outside the range above used to be pulled quietly into it, so
|
|
47
|
+
* `limit=0` returned two hundred records and a caller paging by a number it
|
|
48
|
+
* had computed could not tell that the server had answered a different
|
|
49
|
+
* question than the one it asked. The transport refuses an out-of-range limit
|
|
50
|
+
* with `serve-invalid-cursor` instead, and `docs/cli-reference.md` states the
|
|
51
|
+
* range. A silent correction is the failure mode this project exists to
|
|
52
|
+
* dislike.
|
|
53
|
+
*/
|
|
54
|
+
/**
|
|
55
|
+
* Read one page of verified records after `cursor`.
|
|
56
|
+
*
|
|
57
|
+
* A failure returns NO records at all, including the ones a partial drain had
|
|
58
|
+
* already produced: SPEC.md §8 says a subscription "MUST emit no record from a
|
|
59
|
+
* batch that fails verification or its retained cursor binding", and a page
|
|
60
|
+
* that carried half a batch beside an integrity refusal would be exactly that.
|
|
61
|
+
*/
|
|
62
|
+
export async function followPage(logPath, cursor, limit) {
|
|
63
|
+
const records = [];
|
|
64
|
+
let caughtUp = true;
|
|
65
|
+
const options = {
|
|
66
|
+
from: cursor.seq,
|
|
67
|
+
once: true,
|
|
68
|
+
...(cursor.hash === null ? {} : { expectedHash: cursor.hash }),
|
|
69
|
+
};
|
|
70
|
+
try {
|
|
71
|
+
for await (const record of subscribeVerifiedLog(logPath, options)) {
|
|
72
|
+
records.push(record);
|
|
73
|
+
if (records.length >= limit) {
|
|
74
|
+
// The generator's `finally` runs on this break: the iterator is closed
|
|
75
|
+
// and nothing is left watching or waiting.
|
|
76
|
+
caughtUp = false;
|
|
77
|
+
break;
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
catch (cause) {
|
|
82
|
+
if (!(cause instanceof LogSubscriptionError))
|
|
83
|
+
throw cause;
|
|
84
|
+
return {
|
|
85
|
+
ok: false,
|
|
86
|
+
code: cause.kind === "torn-tail" ? "torn-tail" : cause.kind === "io" ? "io" : "integrity",
|
|
87
|
+
message: cause.message,
|
|
88
|
+
reason: cause.reason,
|
|
89
|
+
};
|
|
90
|
+
}
|
|
91
|
+
const last = records.at(-1);
|
|
92
|
+
return {
|
|
93
|
+
ok: true,
|
|
94
|
+
page: {
|
|
95
|
+
records,
|
|
96
|
+
cursor: last === undefined ? cursor : { seq: last.seq, hash: last.hash },
|
|
97
|
+
caught_up: caughtUp,
|
|
98
|
+
},
|
|
99
|
+
};
|
|
100
|
+
}
|
|
101
|
+
//# sourceMappingURL=follow.js.map
|