approval-md 0.4.0 → 0.4.1
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/SPEC.md +3 -0
- package/cli.js +72 -4
- package/dist/src/channels/contract.d.ts +53 -0
- package/dist/src/channels/contract.js +56 -0
- package/dist/src/channels/contract.js.map +1 -1
- package/dist/src/channels/tagging.d.ts +25 -0
- package/dist/src/channels/tagging.js +57 -5
- package/dist/src/channels/tagging.js.map +1 -1
- package/dist/src/channels/telegram-minimal.d.ts +251 -0
- package/dist/src/channels/telegram-minimal.js +618 -0
- package/dist/src/channels/telegram-minimal.js.map +1 -0
- package/dist/src/channels/telegram.d.ts +454 -19
- package/dist/src/channels/telegram.js +931 -80
- package/dist/src/channels/telegram.js.map +1 -1
- package/dist/src/cli/amend.js +8 -1
- package/dist/src/cli/amend.js.map +1 -1
- package/dist/src/cli/audit-card.d.ts +1 -1
- package/dist/src/cli/audit-card.js +40 -1
- package/dist/src/cli/audit-card.js.map +1 -1
- package/dist/src/cli/audit.d.ts +7 -5
- package/dist/src/cli/audit.js +22 -10
- package/dist/src/cli/audit.js.map +1 -1
- package/dist/src/cli/channel-telegram-webhook.d.ts +2 -0
- package/dist/src/cli/channel-telegram-webhook.js +7 -0
- package/dist/src/cli/channel-telegram-webhook.js.map +1 -1
- package/dist/src/cli/channel-telegram.d.ts +75 -2
- package/dist/src/cli/channel-telegram.js +170 -15
- package/dist/src/cli/channel-telegram.js.map +1 -1
- package/dist/src/cli/execute.d.ts +12 -0
- package/dist/src/cli/execute.js +46 -1
- package/dist/src/cli/execute.js.map +1 -1
- package/dist/src/cli/help.d.ts +1 -0
- package/dist/src/cli/help.js +39 -18
- package/dist/src/cli/help.js.map +1 -1
- package/dist/src/cli/hook.d.ts +114 -3
- package/dist/src/cli/hook.js +468 -50
- package/dist/src/cli/hook.js.map +1 -1
- package/dist/src/cli/log-unlock.d.ts +23 -0
- package/dist/src/cli/log-unlock.js +109 -0
- package/dist/src/cli/log-unlock.js.map +1 -0
- package/dist/src/cli/main.js +23 -6
- package/dist/src/cli/main.js.map +1 -1
- package/dist/src/cli/up.js +5 -0
- package/dist/src/cli/up.js.map +1 -1
- package/dist/src/cli/verb-registry.js +1 -0
- 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/audit.d.ts +98 -9
- package/dist/src/core/audit.js +363 -15
- package/dist/src/core/audit.js.map +1 -1
- package/dist/src/core/command-class.js +6 -0
- package/dist/src/core/command-class.js.map +1 -1
- package/dist/src/core/gate.d.ts +58 -1
- package/dist/src/core/gate.js +30 -3
- package/dist/src/core/gate.js.map +1 -1
- package/dist/src/core/log-lock.d.ts +435 -0
- package/dist/src/core/log-lock.js +1093 -0
- package/dist/src/core/log-lock.js.map +1 -0
- package/dist/src/core/log.d.ts +82 -3
- package/dist/src/core/log.js +285 -40
- package/dist/src/core/log.js.map +1 -1
- package/dist/src/core/policy-proposal.d.ts +17 -0
- package/dist/src/core/policy-proposal.js +29 -14
- package/dist/src/core/policy-proposal.js.map +1 -1
- package/dist/src/core/prompt-layout.d.ts +186 -1
- package/dist/src/core/prompt-layout.js +345 -3
- package/dist/src/core/prompt-layout.js.map +1 -1
- package/dist/src/core/sender-identity.d.ts +24 -1
- package/dist/src/core/sender-identity.js +31 -0
- package/dist/src/core/sender-identity.js.map +1 -1
- package/dist/src/core/validate.d.ts +20 -6
- package/dist/src/core/validate.js +0 -0
- package/dist/src/core/validate.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/serve/archive.d.ts +8 -0
- package/dist/src/serve/archive.js +11 -2
- package/dist/src/serve/archive.js.map +1 -1
- package/docs/README-extended.md +2 -0
- package/docs/cli-reference.md +507 -25
- package/package.json +2 -2
- package/schema/event.schema.json +354 -28
- package/schema/fixtures/event/invalid/audit-lock-reclaimed-age-without-created.json +19 -0
- package/schema/fixtures/event/invalid/audit-lock-reclaimed-agent-actor.json +14 -0
- package/schema/fixtures/event/invalid/audit-lock-reclaimed-lockfile-path.json +14 -0
- package/schema/fixtures/event/invalid/audit-lock-reclaimed-operator-cleared-system-actor.json +14 -0
- package/schema/fixtures/event/invalid/audit-lock-reclaimed-unknown-reason.json +14 -0
- package/schema/fixtures/event/invalid/audit-lock-reclaimed-without-age.json +13 -0
- package/schema/fixtures/event/invalid/audit-reviewed-agent-actor.json +6 -1
- package/schema/fixtures/event/invalid/audit-reviewed-bad-payload-hash.json +21 -0
- package/schema/fixtures/event/invalid/audit-reviewed-loved-no-note.json +5 -0
- package/schema/fixtures/event/invalid/audit-reviewed-no-subject-hash.json +19 -0
- package/schema/fixtures/event/invalid/audit-reviewed-no-subject-seq.json +19 -0
- package/schema/fixtures/event/invalid/audit-reviewed-no-verdict-source.json +19 -0
- package/schema/fixtures/event/invalid/audit-reviewed-no-verdict.json +19 -0
- package/schema/fixtures/event/invalid/audit-reviewed-system-actor.json +6 -1
- package/schema/fixtures/event/invalid/audit-sampled-no-policy-hash.json +14 -0
- package/schema/fixtures/event/valid/audit-lock-reclaimed-implausible-created.json +18 -0
- package/schema/fixtures/event/valid/audit-lock-reclaimed-legacy.json +14 -0
- package/schema/fixtures/event/valid/audit-lock-reclaimed-unlock.json +19 -0
- package/schema/fixtures/event/valid/audit-lock-reclaimed.json +19 -0
- package/schema/fixtures/event/valid/audit-reviewed-reaction.json +5 -0
- package/schema/fixtures/event/valid/audit-reviewed.json +6 -1
- package/schema/fixtures/event/valid/audit-sampled.json +2 -1
- package/schema/fixtures/event/valid/daemon-identity.json +2 -1
- package/schema/policy.schema.json +52 -0
package/dist/src/cli/hook.js
CHANGED
|
@@ -85,6 +85,8 @@ import { harnessProvenance, isHarnessKind, } from "../core/harness-version.js";
|
|
|
85
85
|
import { abandonedAfterMs, HARNESS_CAP_MARGIN_MS, harnessCapFitsMargin, harnessCappedTtlMs, HOOK_DEFAULT_WAIT, HOOK_RETRY_GRACE_MS, } from "../core/harness-wait.js";
|
|
86
86
|
import { harnessLoopFloor, isLoopEscalated, isSideEffectingClass, loopClearance, UNKNOWN_SESSION, } from "../core/loop.js";
|
|
87
87
|
import { drawSocketPathFor, drawSocketUsable } from "../core/live-draw.js";
|
|
88
|
+
import { appendLockHeld, DEFAULT_LOCK_RETRY_MS, DEFAULT_LOCK_TIMEOUT_MS, } from "../core/log.js";
|
|
89
|
+
import { settleTerminationGuards } from "../core/log-lock.js";
|
|
88
90
|
import { payloadHash } from "../core/payload.js";
|
|
89
91
|
import { classifyApplyPatch, parseApplyPatch } from "../core/apply-patch.js";
|
|
90
92
|
import { loadPolicy, parseDuration } from "../core/policy-load.js";
|
|
@@ -988,6 +990,86 @@ export function harnessBlockDirective(code, detail, harness) {
|
|
|
988
990
|
const exitCode = deny({ out: (text) => out.push(text), err: () => undefined }, code, detail, harness);
|
|
989
991
|
return { stdout: out.join(""), exitCode };
|
|
990
992
|
}
|
|
993
|
+
/**
|
|
994
|
+
* Raised on `globalThis` while the runtime's own Hermes signal guards are
|
|
995
|
+
* registered (APRV-466). The `approval` bin reads the same registry symbol and
|
|
996
|
+
* steps aside while it is `true`, so the bin and the runtime never both print.
|
|
997
|
+
* An explicit flag rather than a listener count: an unrelated listener that
|
|
998
|
+
* does not exit must not switch the bin's guard off.
|
|
999
|
+
*/
|
|
1000
|
+
export const HERMES_SIGNAL_OWNER = Symbol.for("approval-md.hermes-signal-owner");
|
|
1001
|
+
/**
|
|
1002
|
+
* The block directive a Hermes hook prints when a signal arrives before it
|
|
1003
|
+
* reached a verdict (APRV-445, APRV-466), as bytes.
|
|
1004
|
+
*
|
|
1005
|
+
* Two places print it: `hermesFailClosed`'s guard below, and the `approval` bin
|
|
1006
|
+
* (`cli.js`), which has to answer a signal that lands while this module is
|
|
1007
|
+
* still loading and so cannot import it. The bin spells the same bytes by hand,
|
|
1008
|
+
* and `tests/hermes-bin-signal-guard.test.ts` pins the two equal, so the
|
|
1009
|
+
* early-signal answer is one code with one spelling (SPEC.md §11.1 invariant
|
|
1010
|
+
* 6). The wait's own handler words its message differently ("while waiting for
|
|
1011
|
+
* a decision") under the same code.
|
|
1012
|
+
*
|
|
1013
|
+
* On the CLI the runtime's guard prints it for a signal held through the
|
|
1014
|
+
* synchronous stretch before the first spend (the stdin read, the
|
|
1015
|
+
* classification), at the pause `BEFORE_SPEND` (APRV-473); the bin prints it
|
|
1016
|
+
* for a signal during the load (docs/hermes-hook.md, "Two layers against a
|
|
1017
|
+
* signal").
|
|
1018
|
+
*/
|
|
1019
|
+
export function hermesInterruptedDirective(signal) {
|
|
1020
|
+
return harnessBlockDirective("hook-interrupted", `the hook received ${signal} before it reached a verdict; nothing authorizes this call`, "hermes").stdout;
|
|
1021
|
+
}
|
|
1022
|
+
/**
|
|
1023
|
+
* The block directive a harness wait answers when a SIGTERM or SIGINT ends it
|
|
1024
|
+
* (APRV-473 for Hermes, APRV-475 for every harness), as bytes and exit code.
|
|
1025
|
+
*
|
|
1026
|
+
* It is the harness's ORDINARY deny, built by {@link harnessBlockDirective}
|
|
1027
|
+
* and so by {@link deny}, under the `hook-interrupted` code: the nested
|
|
1028
|
+
* `hookSpecificOutput` deny at exit 0 for Claude Code, Codex and Muse,
|
|
1029
|
+
* `{permission:"deny"}` at exit 0 for Cursor, `{decision:"deny"}` at exit 2
|
|
1030
|
+
* for Grok, `{action:"block"}` at exit 2 for Hermes. One construction site per
|
|
1031
|
+
* harness, so the answer to an interruption is spelled exactly as every other
|
|
1032
|
+
* refusal that harness receives from this hook, and a harness that blocks on
|
|
1033
|
+
* those blocks on this. `tests/harness-wait-interrupt.test.ts` pins the bytes
|
|
1034
|
+
* per harness.
|
|
1035
|
+
*/
|
|
1036
|
+
export function interruptedWaitDirective(signal, harness) {
|
|
1037
|
+
return harnessBlockDirective("hook-interrupted", `the hook received ${signal} while waiting for a decision; nothing authorizes this call`, harness);
|
|
1038
|
+
}
|
|
1039
|
+
/**
|
|
1040
|
+
* The exit code an interrupted wait leaves with when stdout would not take its
|
|
1041
|
+
* directive whole (APRV-475): a closed pipe, or a short write.
|
|
1042
|
+
*
|
|
1043
|
+
* 2, because with an empty or torn stdout the exit code is the whole verdict,
|
|
1044
|
+
* and 2 is the one code these harnesses read as a block on its own: Claude Code
|
|
1045
|
+
* documents it as a blocking error, Grok as its deny, Hermes blocks on it
|
|
1046
|
+
* unconditionally, Muse's live probe blocked on it with an empty stdout, and a
|
|
1047
|
+
* Cursor entry with `failClosed` blocks on any non-zero exit. Exiting 0 there
|
|
1048
|
+
* would be the one wrong answer on the harnesses whose deny is exit 0 with a
|
|
1049
|
+
* body: an exit 0 with no body is no verdict, and those harnesses run the call.
|
|
1050
|
+
*/
|
|
1051
|
+
const INTERRUPTED_BARE_EXIT = 2;
|
|
1052
|
+
/**
|
|
1053
|
+
* Answer an interrupted wait (APRV-475), and return the code to exit with.
|
|
1054
|
+
*
|
|
1055
|
+
* Writes {@link interruptedWaitDirective} synchronously to fd 1, because the
|
|
1056
|
+
* `process.exit` that follows does not wait for a stream to drain. Returns the
|
|
1057
|
+
* directive's own exit code, or {@link INTERRUPTED_BARE_EXIT} when the write
|
|
1058
|
+
* threw or came up short. Called by the wait's signal handler only, BEFORE its
|
|
1059
|
+
* withdrawal, so the block is on stdout however long the withdrawal waits for
|
|
1060
|
+
* the log's lock.
|
|
1061
|
+
*/
|
|
1062
|
+
function writeInterruptedWait(signal, harness) {
|
|
1063
|
+
const directive = interruptedWaitDirective(signal, harness);
|
|
1064
|
+
let whole = false;
|
|
1065
|
+
try {
|
|
1066
|
+
whole = writeSync(1, directive.stdout) === Buffer.byteLength(directive.stdout);
|
|
1067
|
+
}
|
|
1068
|
+
catch {
|
|
1069
|
+
// stdout is gone; the exit code is the whole verdict.
|
|
1070
|
+
}
|
|
1071
|
+
return whole ? directive.exitCode : INTERRUPTED_BARE_EXIT;
|
|
1072
|
+
}
|
|
991
1073
|
/** The machine-readable code a Hermes `execute_code` call is refused with. */
|
|
992
1074
|
export const HERMES_EXECUTE_CODE_REFUSAL = "hook-hermes-execute-code-unbound";
|
|
993
1075
|
/** The tool that carries a program and no path, no argv and no directory. */
|
|
@@ -2111,10 +2193,221 @@ function commandClassify(argv, streams, cwd) {
|
|
|
2111
2193
|
return EXIT_OK;
|
|
2112
2194
|
}
|
|
2113
2195
|
function sleepSync(ms) {
|
|
2196
|
+
// A synchronous wait never runs with termination signals held by the log
|
|
2197
|
+
// lock's guard (APRV-479 round 2, RS2: `approval codex bridge` waits for a
|
|
2198
|
+
// human here through `driveSync`).
|
|
2199
|
+
settleTerminationGuards();
|
|
2114
2200
|
if (ms <= 0)
|
|
2115
2201
|
return;
|
|
2116
2202
|
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
|
|
2117
2203
|
}
|
|
2204
|
+
/** The longest delay `setTimeout` honours (2^31 - 1 ms). */
|
|
2205
|
+
const MAX_TIMER_MS = 2_147_483_647;
|
|
2206
|
+
/** Run `steps` to its verdict, sleeping each pause synchronously. */
|
|
2207
|
+
function driveSync(steps) {
|
|
2208
|
+
for (;;) {
|
|
2209
|
+
const next = steps.next(false);
|
|
2210
|
+
if (next.done === true)
|
|
2211
|
+
return next.value;
|
|
2212
|
+
if (next.value > 0)
|
|
2213
|
+
sleepSync(next.value);
|
|
2214
|
+
}
|
|
2215
|
+
}
|
|
2216
|
+
/**
|
|
2217
|
+
* Run `steps` to its verdict, sleeping each pause on the event loop.
|
|
2218
|
+
*
|
|
2219
|
+
* A zero pause is TWO `setImmediate` hops, and the second is what guarantees a
|
|
2220
|
+
* poll phase in between. libuv hands a signal to its JS listeners only when the
|
|
2221
|
+
* poll phase reads the signal pipe. A single immediate scheduled from a
|
|
2222
|
+
* poll-phase callback runs in the same iteration's check phase, before any
|
|
2223
|
+
* further poll, so a signal that arrived during the synchronous stretch would
|
|
2224
|
+
* still be sitting in the pipe when the spend ran. That is the case whenever
|
|
2225
|
+
* the run is still inside the continuation of the bin's `import()` of `dist/`
|
|
2226
|
+
* on a Node whose module load turns the loop (24, and likely 22; APRV-473
|
|
2227
|
+
* refuter, reproduced on 24). A second immediate scheduled from the check phase
|
|
2228
|
+
* runs in the NEXT iteration's check phase, after that iteration's poll, from
|
|
2229
|
+
* whichever phase the first was scheduled.
|
|
2230
|
+
*/
|
|
2231
|
+
async function driveYielding(steps) {
|
|
2232
|
+
for (;;) {
|
|
2233
|
+
const next = steps.next(true);
|
|
2234
|
+
if (next.done === true)
|
|
2235
|
+
return next.value;
|
|
2236
|
+
// Clamped to the largest delay a timer holds: past it Node fires the timer
|
|
2237
|
+
// at once, and a pause would become a busy poll. The wait re-reads its
|
|
2238
|
+
// deadline after every pause, so a shorter pause changes nothing it judges.
|
|
2239
|
+
const ms = Math.min(next.value, MAX_TIMER_MS);
|
|
2240
|
+
await new Promise((settle) => {
|
|
2241
|
+
if (ms <= 0)
|
|
2242
|
+
setImmediate(() => setImmediate(settle));
|
|
2243
|
+
else
|
|
2244
|
+
setTimeout(settle, ms);
|
|
2245
|
+
});
|
|
2246
|
+
}
|
|
2247
|
+
}
|
|
2248
|
+
/**
|
|
2249
|
+
* The pause before anything appends `execution.started` or `gate.bypassed`
|
|
2250
|
+
* (APRV-473): a poll phase of the event loop under {@link driveYielding},
|
|
2251
|
+
* nothing under {@link driveSync}.
|
|
2252
|
+
*
|
|
2253
|
+
* Everything between two pauses runs synchronously, so a signal that lands
|
|
2254
|
+
* during the stdin read, the classification, or the poll read that found the
|
|
2255
|
+
* grant is held until the next pause. Without this one, the next point it
|
|
2256
|
+
* could be dispatched is after the spend, and a hook interrupted before its
|
|
2257
|
+
* verdict would record the execution of a call its harness had already given
|
|
2258
|
+
* up on. With it, the listener in force runs first: the wait's handler
|
|
2259
|
+
* withdraws the question and blocks, the early guard (`hermesFailClosed`)
|
|
2260
|
+
* blocks, and either one exits the process before the spend is reached.
|
|
2261
|
+
*
|
|
2262
|
+
* Shared by every harness (APRV-475): it is a yield inside the one gate chain,
|
|
2263
|
+
* so there is no per-harness copy to drift. A signal is HELD only while some
|
|
2264
|
+
* JS listener for it is registered. Outside the wait, the harnesses other than
|
|
2265
|
+
* Hermes register none, so there a signal takes its default disposition the
|
|
2266
|
+
* moment it arrives (the process dies with nothing printed and nothing spent),
|
|
2267
|
+
* and this pause matters for them at the post-wait spend, where the wait's
|
|
2268
|
+
* handler is registered.
|
|
2269
|
+
*
|
|
2270
|
+
* Taken inside {@link spendUnderLock} since APRV-478, immediately before the
|
|
2271
|
+
* spend's one attempt and after any wait for the log's lock, so nothing that can
|
|
2272
|
+
* wait sits between the last pause and the append.
|
|
2273
|
+
*/
|
|
2274
|
+
const BEFORE_SPEND = 0;
|
|
2275
|
+
/**
|
|
2276
|
+
* Did this attempt lose a race for the log's lock, writing nothing?
|
|
2277
|
+
*
|
|
2278
|
+
* Two shapes: the spend's own append refused `append-failed` with the writer's
|
|
2279
|
+
* `lock-timeout`, and a budget refusal whose `budget.exceeded` evidence lost the
|
|
2280
|
+
* race (`startHarnessExecution` carries the writer's error on that refusal for
|
|
2281
|
+
* this, APRV-478). In both nothing was appended, so re-running the whole
|
|
2282
|
+
* attempt is safe and gives the record the chance a synchronous lock wait gave
|
|
2283
|
+
* it.
|
|
2284
|
+
*/
|
|
2285
|
+
function lockTimedOut(result) {
|
|
2286
|
+
return (!result.ok &&
|
|
2287
|
+
(result.code === "append-failed" || result.code === "budget-exceeded") &&
|
|
2288
|
+
result.append?.code === "lock-timeout");
|
|
2289
|
+
}
|
|
2290
|
+
/**
|
|
2291
|
+
* Run one spend (an append that authorizes a harness call) without ever waiting
|
|
2292
|
+
* for the log's lock synchronously (APRV-478).
|
|
2293
|
+
*
|
|
2294
|
+
* ## The hole
|
|
2295
|
+
*
|
|
2296
|
+
* Every writer waits for `<log>.lock` inside `core/log.ts`, retrying with an
|
|
2297
|
+
* `Atomics.wait` sleep for up to {@link DEFAULT_LOCK_TIMEOUT_MS}. On the gate
|
|
2298
|
+
* path that wait came AFTER the last pause ({@link BEFORE_SPEND}), so a signal
|
|
2299
|
+
* that arrived while another writer (the daemon, routinely) held the lock was
|
|
2300
|
+
* held by the wait's listener through the whole stretch, the spend appended
|
|
2301
|
+
* `execution.started` and the allow went out, and the wait's `finally` then
|
|
2302
|
+
* removed the listener and the held signal was discarded with it: a start
|
|
2303
|
+
* recorded after the harness had abandoned the call, on every harness.
|
|
2304
|
+
*
|
|
2305
|
+
* ## What this does instead
|
|
2306
|
+
*
|
|
2307
|
+
* The wait for the lock becomes yields of the same generator chain, under ONE
|
|
2308
|
+
* deadline for the whole spend (the writer's own `lockTimeoutMs`, default
|
|
2309
|
+
* {@link DEFAULT_LOCK_TIMEOUT_MS}, taken once when the spend begins):
|
|
2310
|
+
*
|
|
2311
|
+
* 1. While the lockfile is present ({@link appendLockHeld}, advisory) and the
|
|
2312
|
+
* deadline has not passed, yield the retry interval (`lockRetryMs`, default
|
|
2313
|
+
* {@link DEFAULT_LOCK_RETRY_MS}).
|
|
2314
|
+
* 2. {@link BEFORE_SPEND}: the zero pause, taken when the spend begins and again
|
|
2315
|
+
* after any wait, so it always comes immediately before the attempt (with at
|
|
2316
|
+
* most the probe in between). This
|
|
2317
|
+
* is the pause the guarantee rests on: two `setImmediate` hops cross a poll
|
|
2318
|
+
* phase from any phase, so a signal that arrived during the wait or the
|
|
2319
|
+
* probe is dispatched here at the latest (a retry timer usually dispatches
|
|
2320
|
+
* it sooner, but a timer scheduled outside a timer callback can fire before
|
|
2321
|
+
* the next poll). The listener in force then blocks, withdraws what it can
|
|
2322
|
+
* and exits, and nothing below runs.
|
|
2323
|
+
* 3. One attempt with `lockTimeoutMs: 0`: the whole spend (a fresh verified read,
|
|
2324
|
+
* every check, compare-and-append against the head that read saw), whose
|
|
2325
|
+
* append tries the lock exactly ONCE and never sleeps. The lock protocol is
|
|
2326
|
+
* `core/log.ts`'s, untouched: atomic create-or-fail, never stolen, the head
|
|
2327
|
+
* compared under it, so this changes when a writer tries and never what a
|
|
2328
|
+
* try may do.
|
|
2329
|
+
*
|
|
2330
|
+
* An attempt that lost the race (another writer took the lock between the probe
|
|
2331
|
+
* and the try, a window as long as the attempt's own read) yields the retry
|
|
2332
|
+
* interval and goes back to 1, until the deadline: the same number of tries
|
|
2333
|
+
* inside the same bound as `acquireLock`'s synchronous retry, each one a whole
|
|
2334
|
+
* fresh attempt. An attempt that loses at or after the deadline is final,
|
|
2335
|
+
* exactly as a writer's own `lock-timeout` is: the caller denies, and nothing
|
|
2336
|
+
* was appended (fail closed, §11.1 invariant 8). See {@link lockTimedOut} for
|
|
2337
|
+
* the two refusals that count as a lost race.
|
|
2338
|
+
*
|
|
2339
|
+
* What is left between the last pause and the append is the spend's own read
|
|
2340
|
+
* of the log (milliseconds on a small log, longer on a large one) and its
|
|
2341
|
+
* single try at the lock, never a wait on another process. A signal there is
|
|
2342
|
+
* answered by the verdict the spend reaches.
|
|
2343
|
+
*
|
|
2344
|
+
* **The trade, on the yielding path only.** The writer's synchronous wait read
|
|
2345
|
+
* the log first and then spun on the lock itself, so it caught a gap of a
|
|
2346
|
+
* millisecond. Here the spend's read sits between the probe that saw the lock
|
|
2347
|
+
* free and the try, so a lock that is only ever free for gaps shorter than that
|
|
2348
|
+
* read (a writer holding it at a duty cycle near 100%) can run the spend out of
|
|
2349
|
+
* its bound where the synchronous wait got through. That ends in the deny
|
|
2350
|
+
* above, the grant left standing for a retry: fail closed. Holding the lock
|
|
2351
|
+
* across a yield to close it is not an option, because a handler that exits at
|
|
2352
|
+
* that yield would leave a lockfile that is never stolen.
|
|
2353
|
+
*
|
|
2354
|
+
* **Under {@link driveSync} nothing changes.** `commandHook` in process and in
|
|
2355
|
+
* the serve worker, and `decideHarnessCall` for the Codex bridge, have no event
|
|
2356
|
+
* loop that a signal could be dispatched on, so a yielded wait buys them
|
|
2357
|
+
* nothing. The driver says so at the first pause (see {@link GateSteps}), and
|
|
2358
|
+
* the spend is then exactly what it was: that zero pause (no pause under this
|
|
2359
|
+
* driver) and one call with the caller's own options, whose writer waits for
|
|
2360
|
+
* the lock synchronously, per head-moved attempt, as it always did.
|
|
2361
|
+
*
|
|
2362
|
+
* `attempt` receives the append options to use (the caller's own, with
|
|
2363
|
+
* `lockTimeoutMs: 0` on the yielding path), and the attempt number (1-based),
|
|
2364
|
+
* for a caller whose first attempt may reuse a read it already holds and whose
|
|
2365
|
+
* later ones may not.
|
|
2366
|
+
*/
|
|
2367
|
+
function* spendUnderLock(logPath, append, attempt) {
|
|
2368
|
+
// The shared pause, and the driver's answer to whether it turns the loop.
|
|
2369
|
+
const yielding = (yield BEFORE_SPEND) === true;
|
|
2370
|
+
if (!yielding)
|
|
2371
|
+
return attempt(append ?? {}, 1);
|
|
2372
|
+
const askedBudget = append?.lockTimeoutMs ?? DEFAULT_LOCK_TIMEOUT_MS;
|
|
2373
|
+
const budgetMs = Number.isFinite(askedBudget) ? Math.max(0, askedBudget) : DEFAULT_LOCK_TIMEOUT_MS;
|
|
2374
|
+
// Never a zero (or non-finite) retry: under `driveSync` a zero pause is no
|
|
2375
|
+
// pause, and the probe loop would spin.
|
|
2376
|
+
const askedRetry = append?.lockRetryMs ?? DEFAULT_LOCK_RETRY_MS;
|
|
2377
|
+
const retryMs = Number.isFinite(askedRetry) ? Math.max(1, askedRetry) : DEFAULT_LOCK_RETRY_MS;
|
|
2378
|
+
const once = { ...append, lockTimeoutMs: 0 };
|
|
2379
|
+
const deadline = Date.now() + budgetMs;
|
|
2380
|
+
for (let tries = 1;; tries += 1) {
|
|
2381
|
+
let waited = tries > 1;
|
|
2382
|
+
for (;;) {
|
|
2383
|
+
if (!appendLockHeld(logPath))
|
|
2384
|
+
break;
|
|
2385
|
+
const left = deadline - Date.now();
|
|
2386
|
+
if (left <= 0)
|
|
2387
|
+
break;
|
|
2388
|
+
waited = true;
|
|
2389
|
+
yield Math.min(retryMs, left);
|
|
2390
|
+
}
|
|
2391
|
+
// Uncontended first try: the pause above was taken a probe ago, and the
|
|
2392
|
+
// probe is the only thing that ran since. Otherwise the pause again, now
|
|
2393
|
+
// immediately before the attempt.
|
|
2394
|
+
if (waited)
|
|
2395
|
+
yield BEFORE_SPEND;
|
|
2396
|
+
const result = attempt(once, tries);
|
|
2397
|
+
if (!lockTimedOut(result))
|
|
2398
|
+
return result;
|
|
2399
|
+
const left = deadline - Date.now();
|
|
2400
|
+
if (left > 0) {
|
|
2401
|
+
yield Math.min(retryMs, left);
|
|
2402
|
+
continue;
|
|
2403
|
+
}
|
|
2404
|
+
// A copy, as `core/head-retry.ts` makes its own: only the message changes.
|
|
2405
|
+
return {
|
|
2406
|
+
...result,
|
|
2407
|
+
message: `${result.message ?? "lock-timeout"}; the spend waited for the log's lock on the event loop for its whole ${String(budgetMs)} ms bound, trying every ${String(retryMs)} ms, and another writer held it at each of its ${String(tries)} attempt${tries === 1 ? "" : "s"} (APRV-478). Nothing was appended.`,
|
|
2408
|
+
};
|
|
2409
|
+
}
|
|
2410
|
+
}
|
|
2118
2411
|
function truncate(text, limit) {
|
|
2119
2412
|
const collapsed = text.replace(/\s+/gu, " ").trim();
|
|
2120
2413
|
return collapsed.length <= limit ? collapsed : `${collapsed.slice(0, limit - 1)}…`;
|
|
@@ -2742,8 +3035,25 @@ function withdrawAbandoned(run, streams, records, now, keepHash, only = null) {
|
|
|
2742
3035
|
* is about (APRV-146). The gate requires it and compares it against what the
|
|
2743
3036
|
* human answered: the same value keyed the carryover that found these grants, so
|
|
2744
3037
|
* presenting it states, at the spend, the fact the match was made on.
|
|
3038
|
+
*
|
|
3039
|
+
* Steps since APRV-478: each key is spent through {@link spendUnderLock}, so a
|
|
3040
|
+
* wait for the log's lock is a yield and the shared pause before the spend
|
|
3041
|
+
* ({@link BEFORE_SPEND}) is taken there, immediately before each append.
|
|
3042
|
+
*
|
|
3043
|
+
* **A multi-key spend pauses before every key, deliberately.** A signal that
|
|
3044
|
+
* lands after the first key is spent and before the second is dispatched at the
|
|
3045
|
+
* second key's pause: the call blocks with the first key's `execution.started`
|
|
3046
|
+
* already recorded (before the signal) and the rest unspent. That is the same
|
|
3047
|
+
* outcome as a spend that fails on a later key, above: a recorded start for a
|
|
3048
|
+
* call the harness does not run, a grant that costs one more prompt, and
|
|
3049
|
+
* nothing authorized. The alternative, spending the later keys with no pause,
|
|
3050
|
+
* would make each later key's lock wait synchronous again, and a signal there
|
|
3051
|
+
* would be held through starts recorded AFTER it and answered with an allow.
|
|
3052
|
+
* Recording nothing after the signal and blocking is the stricter of the two.
|
|
3053
|
+
* An atomic spend of every key at once needs a gate operation that does not
|
|
3054
|
+
* exist; this is the residue the SPEC hunk names for a multi-class call.
|
|
2745
3055
|
*/
|
|
2746
|
-
function consumeGrants(run, keys, hash,
|
|
3056
|
+
function* consumeGrants(run, keys, hash,
|
|
2747
3057
|
/**
|
|
2748
3058
|
* This invocation's own task id (APRV-200). The gate compares it against the
|
|
2749
3059
|
* task the request record carries and records `grant_origin: "direct"` only
|
|
@@ -2751,11 +3061,12 @@ function consumeGrants(run, keys, hash,
|
|
|
2751
3061
|
*/
|
|
2752
3062
|
task) {
|
|
2753
3063
|
for (const key of keys) {
|
|
2754
|
-
const spent = consumeHarnessGrant(run.logPath, key, run.actor, {
|
|
3064
|
+
const spent = yield* spendUnderLock(run.logPath, run.options.append, (append) => consumeHarnessGrant(run.logPath, key, run.actor, {
|
|
2755
3065
|
...run.options,
|
|
3066
|
+
append,
|
|
2756
3067
|
presentedPayloadHash: hash,
|
|
2757
3068
|
spendingTask: task,
|
|
2758
|
-
});
|
|
3069
|
+
}));
|
|
2759
3070
|
if (!spent.ok)
|
|
2760
3071
|
return { code: spent.code, message: `${key}: ${spent.message}` };
|
|
2761
3072
|
}
|
|
@@ -2914,10 +3225,13 @@ known = null) {
|
|
|
2914
3225
|
* declaration would buy no oversight and would double the volume of exactly the
|
|
2915
3226
|
* traffic this is trying not to drown the log in. The supervised classes are
|
|
2916
3227
|
* registered already, by the caller, which is what makes them sampleable.
|
|
3228
|
+
*
|
|
3229
|
+
* Steps since APRV-478, for the reason {@link consumeGrants} gives: each class
|
|
3230
|
+
* is recorded through {@link spendUnderLock}, which takes the shared pause.
|
|
2917
3231
|
*/
|
|
2918
|
-
function recordUnattended(run, task, classes, hash) {
|
|
3232
|
+
function* recordUnattended(run, task, classes, hash) {
|
|
2919
3233
|
for (const cls of classes) {
|
|
2920
|
-
const started = startHarnessExecution(run.logPath, { task, actionKey: `${task}:${cls}`, cls, payload_hash: hash }, run.actor, run.options);
|
|
3234
|
+
const started = yield* spendUnderLock(run.logPath, run.options.append, (append) => startHarnessExecution(run.logPath, { task, actionKey: `${task}:${cls}`, cls, payload_hash: hash }, run.actor, { ...run.options, append }));
|
|
2921
3235
|
if (!started.ok)
|
|
2922
3236
|
return { code: started.code, message: `${cls}: ${started.message}` };
|
|
2923
3237
|
}
|
|
@@ -3045,7 +3359,11 @@ function renderVerdict(streams, adapter, codexCommand, verdict) {
|
|
|
3045
3359
|
? allow(streams, verdict.reason, adapter.kind, codexCommand)
|
|
3046
3360
|
: deny(streams, verdict.code, verdict.detail, adapter.kind);
|
|
3047
3361
|
}
|
|
3048
|
-
export function gateHarnessCall(
|
|
3362
|
+
export function gateHarnessCall(...args) {
|
|
3363
|
+
return driveSync(gateHarnessSteps(...args));
|
|
3364
|
+
}
|
|
3365
|
+
/** {@link gateHarnessCall} as steps, for a driver that yields (APRV-473). */
|
|
3366
|
+
function* gateHarnessSteps(streams, run, classes,
|
|
3049
3367
|
/**
|
|
3050
3368
|
* The bytes the grant binds to: `{command, cwd}` for a Bash call, the change
|
|
3051
3369
|
* itself for a file tool (APRV-124). Whatever this is, it is what reaches the
|
|
@@ -3306,8 +3624,9 @@ floor = null) {
|
|
|
3306
3624
|
// SPEC.md §6.3), so there is nothing to wait for and nothing to spend.
|
|
3307
3625
|
// What there is, since APRV-141, is something to charge: the start event
|
|
3308
3626
|
// is this execution's authorization, and the registration `fresh` just
|
|
3309
|
-
// wrote is what makes it a sampleable one.
|
|
3310
|
-
|
|
3627
|
+
// wrote is what makes it a sampleable one. The shared pause before the
|
|
3628
|
+
// spend is taken inside (APRV-473, APRV-478).
|
|
3629
|
+
const charged = yield* recordUnattended(run, task, classes, hash);
|
|
3311
3630
|
if (charged !== null) {
|
|
3312
3631
|
return sayDeny(`hook-gate-refused:${charged.code}`, charged.message);
|
|
3313
3632
|
}
|
|
@@ -3315,7 +3634,7 @@ floor = null) {
|
|
|
3315
3634
|
}
|
|
3316
3635
|
// Every gated class carried an unspent grant: a human already answered this
|
|
3317
3636
|
// exact question about these exact bytes, and nobody is asked again.
|
|
3318
|
-
const failed = consumeGrants(run, spendKeys, hash, task);
|
|
3637
|
+
const failed = yield* consumeGrants(run, spendKeys, hash, task);
|
|
3319
3638
|
if (failed !== null) {
|
|
3320
3639
|
return sayDeny(`hook-gate-refused:${failed.code}`, failed.message);
|
|
3321
3640
|
}
|
|
@@ -3349,24 +3668,42 @@ floor = null) {
|
|
|
3349
3668
|
// `process.exit` is deliberate and immediate: the default disposition for
|
|
3350
3669
|
// these signals is to die, and a handler that only withdrew would leave the
|
|
3351
3670
|
// hook wedged in its poll loop with the harness waiting on it.
|
|
3671
|
+
//
|
|
3672
|
+
// APRV-473, APRV-475. On every `approval hook <harness>` CLI route this
|
|
3673
|
+
// handler gets its turn: the wait yields to the event loop between polls
|
|
3674
|
+
// (`driveYielding`), so a signal is dispatched within one pause of its
|
|
3675
|
+
// arrival instead of being held until the wait returns. It exits, so nothing
|
|
3676
|
+
// after it in this function runs: no poll reads the grant a human gives
|
|
3677
|
+
// afterwards, and nothing spends it. The synchronous callers (`commandHook`
|
|
3678
|
+
// in process, the Codex bridge's `decideHarnessCall`, a serve worker thread)
|
|
3679
|
+
// drive these steps with `driveSync`, under which this listener is
|
|
3680
|
+
// registered and removed without the loop ever turning, so it never runs
|
|
3681
|
+
// there and never writes into a stdout that is not a hook's (the bridge's is
|
|
3682
|
+
// a JSON-RPC channel). A serve thread's caller departing is `callerGone`.
|
|
3352
3683
|
const onSignal = (signal) => {
|
|
3353
3684
|
leaveWait();
|
|
3354
|
-
|
|
3355
|
-
//
|
|
3356
|
-
//
|
|
3357
|
-
//
|
|
3358
|
-
//
|
|
3359
|
-
|
|
3360
|
-
|
|
3361
|
-
|
|
3362
|
-
|
|
3363
|
-
|
|
3364
|
-
|
|
3365
|
-
|
|
3366
|
-
|
|
3367
|
-
process
|
|
3685
|
+
// APRV-445, APRV-475. A harness tearing down may still read the verdict,
|
|
3686
|
+
// and on several harnesses an exit with nothing on stdout is not a block
|
|
3687
|
+
// (Hermes reads it as an ALLOW unless the exit is 2; Claude Code, Codex
|
|
3688
|
+
// and Muse read most such exits as a failed hook and run the call). So the
|
|
3689
|
+
// harness's own block directive goes out FIRST, in its own dialect,
|
|
3690
|
+
// written synchronously, before the withdrawal: the withdrawal is a
|
|
3691
|
+
// compare-and-append that can wait up to the lock timeout behind another
|
|
3692
|
+
// writer, and a harness that escalates SIGTERM to SIGKILL inside that wait
|
|
3693
|
+
// must already be holding the block (APRV-475 refuter). The directive does
|
|
3694
|
+
// not depend on the withdrawal's outcome, and a withdrawal that never lands
|
|
3695
|
+
// leaves the question open as a timed-out one is.
|
|
3696
|
+
const exitCode = writeInterruptedWait(signal, run.harness);
|
|
3697
|
+
try {
|
|
3698
|
+
withdrawPending(run, streams, ownKeys, `the requesting hook process received ${signal} while waiting; the session is ending, so no retry will adopt this request`);
|
|
3699
|
+
}
|
|
3700
|
+
catch (cause) {
|
|
3701
|
+
// The withdrawal failed outright (an I/O fault inside the append). The
|
|
3702
|
+
// block is already on stdout: a block with the question left standing
|
|
3703
|
+
// is the stricter of the two outcomes this handler can still reach.
|
|
3704
|
+
streams.err(`approval: the hook could not withdraw its request after ${signal}: ${cause instanceof Error ? cause.message : String(cause)}\n`);
|
|
3368
3705
|
}
|
|
3369
|
-
process.exit(
|
|
3706
|
+
process.exit(exitCode);
|
|
3370
3707
|
};
|
|
3371
3708
|
const onTerm = () => onSignal("SIGTERM");
|
|
3372
3709
|
const onInt = () => onSignal("SIGINT");
|
|
@@ -3431,7 +3768,7 @@ floor = null) {
|
|
|
3431
3768
|
streams.err(`approval: the log's last line was incomplete when this hook read it (another writer mid-append); reading again rather than giving up on ${task}\n`);
|
|
3432
3769
|
}
|
|
3433
3770
|
tornReads += 1;
|
|
3434
|
-
|
|
3771
|
+
yield Math.min(run.intervalMs, Math.max(0, deadline - Date.now()));
|
|
3435
3772
|
continue;
|
|
3436
3773
|
}
|
|
3437
3774
|
// AT the deadline (APRV-427 review). A clean read at this tick would
|
|
@@ -3536,13 +3873,22 @@ floor = null) {
|
|
|
3536
3873
|
if (states.every((state) => state === "granted")) {
|
|
3537
3874
|
// The grants are spent before the allow is printed, so this exact
|
|
3538
3875
|
// command cannot ride the same authorization twice.
|
|
3876
|
+
//
|
|
3877
|
+
// APRV-473, APRV-478. `consumeGrants` turns the event loop before it
|
|
3878
|
+
// spends: once per retry while another writer holds the log's lock,
|
|
3879
|
+
// and once more (`BEFORE_SPEND`) immediately before each append. A
|
|
3880
|
+
// signal that arrived while this read ran, or while the spend waited
|
|
3881
|
+
// for the lock, is dispatched there, to the handler above, which
|
|
3882
|
+
// blocks, withdraws (a no-op now the request is decided, refused
|
|
3883
|
+
// `already-decided`) and exits. The grant is then left unspent rather
|
|
3884
|
+
// than recorded as the start of a call the harness gave up on.
|
|
3539
3885
|
leaveWait();
|
|
3540
3886
|
// Looked at again now the lock is held: re-taking it may have waited,
|
|
3541
3887
|
// and a caller that left meanwhile must not have its grant spent on a
|
|
3542
3888
|
// verdict nobody will receive. The retry is who spends it.
|
|
3543
3889
|
if (callerGone())
|
|
3544
3890
|
return sayGone();
|
|
3545
|
-
const failed = consumeGrants(run, spendKeys, hash, task);
|
|
3891
|
+
const failed = yield* consumeGrants(run, spendKeys, hash, task);
|
|
3546
3892
|
if (failed !== null) {
|
|
3547
3893
|
return sayDeny(`hook-gate-refused:${failed.code}`, failed.message);
|
|
3548
3894
|
}
|
|
@@ -3586,7 +3932,7 @@ floor = null) {
|
|
|
3586
3932
|
}
|
|
3587
3933
|
return keptOpen(`no decision on ${waitKeys.join(", ")} within the hook's ${waitText(run)}.`, stillLagging);
|
|
3588
3934
|
}
|
|
3589
|
-
|
|
3935
|
+
yield Math.min(run.intervalMs, Math.max(0, deadline - Date.now()));
|
|
3590
3936
|
}
|
|
3591
3937
|
}
|
|
3592
3938
|
catch (cause) {
|
|
@@ -4809,7 +5155,7 @@ function bypassBanner(window, classes, seq) {
|
|
|
4809
5155
|
* command that ran and left no record is the one state this feature must not be
|
|
4810
5156
|
* able to reach, so an append failure is a deny.
|
|
4811
5157
|
*/
|
|
4812
|
-
function runBypass(streams, input, adapter, cwd, logPath, flags, actor, window,
|
|
5158
|
+
function* runBypass(streams, input, adapter, cwd, logPath, flags, actor, window,
|
|
4813
5159
|
/**
|
|
4814
5160
|
* The verified read `window` was derived from (APRV-294), handed on to the
|
|
4815
5161
|
* append so the same records answer "is a window open" and "which head does
|
|
@@ -4869,7 +5215,12 @@ decidedOn) {
|
|
|
4869
5215
|
// window path is the one a human comes back to read, so the binary that
|
|
4870
5216
|
// printed the allow is named on it.
|
|
4871
5217
|
const provenance = harnessProvenance(adapter.kind, input.harnessVersion);
|
|
4872
|
-
|
|
5218
|
+
// APRV-473 refuter: the bypass record is this path's authorization, exactly
|
|
5219
|
+
// as `execution.started` is the gated path's, so a signal held through the
|
|
5220
|
+
// stdin read is dispatched before it is written (`BEFORE_SPEND`, taken
|
|
5221
|
+
// inside `spendUnderLock`, after any wait for the log's lock, which yields:
|
|
5222
|
+
// APRV-478).
|
|
5223
|
+
const recorded = yield* spendUnderLock(logPath, undefined, (append, round) => recordGateBypass(logPath, {
|
|
4873
5224
|
tool: input.toolName,
|
|
4874
5225
|
summary: truncate(described.headline, SUMMARY_LIMIT),
|
|
4875
5226
|
classes,
|
|
@@ -4878,14 +5229,17 @@ decidedOn) {
|
|
|
4878
5229
|
...(input.toolUseId === null ? {} : { toolUseId: input.toolUseId }),
|
|
4879
5230
|
...(input.cwd.length === 0 ? {} : { cwd: input.cwd }),
|
|
4880
5231
|
...(provenance === null ? {} : { harness: provenance }),
|
|
4881
|
-
}, actor, {},
|
|
4882
|
-
// APRV-294: the window this verdict was decided under, and the read it
|
|
4883
|
-
// decided on. The append uses both, so a window that ended in
|
|
4884
|
-
// reported as the thing that happened rather than as "no
|
|
5232
|
+
}, actor, { append },
|
|
5233
|
+
// APRV-294: the window this verdict was decided under, and the read it
|
|
5234
|
+
// was decided on. The append uses both, so a window that ended in
|
|
5235
|
+
// between is reported as the thing that happened rather than as "no
|
|
5236
|
+
// window is open". The read seeds the FIRST round only: a later round
|
|
5237
|
+
// exists because another writer took the lock, so that read's head is
|
|
5238
|
+
// already known to be stale, and `recordGateBypass` re-reads.
|
|
4885
5239
|
{
|
|
4886
5240
|
openedSeq: window.seq,
|
|
4887
|
-
...(decidedOn === null ? {} : { read: decidedOn }),
|
|
4888
|
-
});
|
|
5241
|
+
...(decidedOn === null || round > 1 ? {} : { read: decidedOn }),
|
|
5242
|
+
}));
|
|
4889
5243
|
if (!recorded.ok) {
|
|
4890
5244
|
// Invariant 8: the record lands before the allow, so a refusal here is a
|
|
4891
5245
|
// deny even though a window is open. `append-failed` reaches the caller
|
|
@@ -4898,7 +5252,7 @@ decidedOn) {
|
|
|
4898
5252
|
const notes = [...described.notes, ...(policyNote === null ? [] : [policyNote])];
|
|
4899
5253
|
return allow(streams, `gate-open: ${classes.join(", ")} bypassed by the window opened at seq ${String(window.seq)} by ${window.openedBy} (expires ${window.expiresAt}); recorded as gate.bypassed seq ${String(recorded.record.seq)}${notes.length === 0 ? "" : ` (${notes.join("; ")})`}`, adapter.kind, codexCommand);
|
|
4900
5254
|
}
|
|
4901
|
-
function runHarnessHook(argv, streams, cwd, readStdin, adapter, waitSeam) {
|
|
5255
|
+
function* runHarnessHook(argv, streams, cwd, readStdin, adapter, waitSeam) {
|
|
4902
5256
|
// Codex and (APRV-445) Hermes answer a misconfigured entry with a verdict:
|
|
4903
5257
|
// on both a usage error's empty stdout is a harness that runs the call, and
|
|
4904
5258
|
// on Hermes a non-zero exit with an empty stdout is an allow outright.
|
|
@@ -5199,13 +5553,13 @@ function runHarnessHook(argv, streams, cwd, readStdin, adapter, waitSeam) {
|
|
|
5199
5553
|
// words. The window suspends the POLICY; it never suspends the log.
|
|
5200
5554
|
const looked = lookupWindow(logPath);
|
|
5201
5555
|
if (looked.window !== null) {
|
|
5202
|
-
return runBypass(streams, input, adapter, cwd, logPath, parsed.flags, actor, looked.window,
|
|
5556
|
+
return yield* runBypass(streams, input, adapter, cwd, logPath, parsed.flags, actor, looked.window,
|
|
5203
5557
|
// APRV-294. The records this window was derived from travel with it: the
|
|
5204
5558
|
// bypass record is appended against the head they ended at, so the
|
|
5205
5559
|
// verdict and the record are one read of the log.
|
|
5206
5560
|
looked.records === null ? null : { records: looked.records, head: looked.head });
|
|
5207
5561
|
}
|
|
5208
|
-
return renderVerdict(streams, adapter, codexCommand,
|
|
5562
|
+
return renderVerdict(streams, adapter, codexCommand, yield* decideHarnessSteps({
|
|
5209
5563
|
streams,
|
|
5210
5564
|
input,
|
|
5211
5565
|
adapter,
|
|
@@ -5244,6 +5598,10 @@ function runHarnessHook(argv, streams, cwd, readStdin, adapter, waitSeam) {
|
|
|
5244
5598
|
* progress and withdrawal lines, which are a report rather than a decision.
|
|
5245
5599
|
*/
|
|
5246
5600
|
export function decideHarnessCall(decide) {
|
|
5601
|
+
return driveSync(decideHarnessSteps(decide));
|
|
5602
|
+
}
|
|
5603
|
+
/** {@link decideHarnessCall} as steps, for a driver that yields (APRV-473). */
|
|
5604
|
+
function* decideHarnessSteps(decide) {
|
|
5247
5605
|
const { streams, input, adapter, cwd, logPath, root, options, actor, timeoutMs, intervalMs, graceMs, codexCommand, windowRecords, } = decide;
|
|
5248
5606
|
const harnessCapMs = decide.harnessCapMs ?? null;
|
|
5249
5607
|
const harnessCapStated = decide.harnessCapStated ?? false;
|
|
@@ -5486,8 +5844,10 @@ export function decideHarnessCall(decide) {
|
|
|
5486
5844
|
// §6.3), so nothing is requested, decided or granted here. What IS appended
|
|
5487
5845
|
// since APRV-141 is the execution record itself — the moment the policy
|
|
5488
5846
|
// authorized this command — because a budget the busiest path does not
|
|
5489
|
-
// charge is not a budget. See `recordUnattended`.
|
|
5490
|
-
|
|
5847
|
+
// charge is not a budget. See `recordUnattended`. A held signal is
|
|
5848
|
+
// dispatched first, at the pause it takes before the append (APRV-473,
|
|
5849
|
+
// `BEFORE_SPEND`), and a wait for the log's lock yields (APRV-478).
|
|
5850
|
+
const charged = yield* recordUnattended(run, task, classes, payloadHash(payload));
|
|
5491
5851
|
if (charged !== null) {
|
|
5492
5852
|
return {
|
|
5493
5853
|
permission: "deny",
|
|
@@ -5503,14 +5863,14 @@ export function decideHarnessCall(decide) {
|
|
|
5503
5863
|
// do not survive a merge. An initialized-but-empty `.approval/log/` counts as
|
|
5504
5864
|
// reachable — an audit trail that has recorded nothing is an empty log, not a
|
|
5505
5865
|
// missing one (see `preflightLog`) — and `register` appends the first line.
|
|
5506
|
-
return
|
|
5866
|
+
return yield* gateHarnessSteps(streams, run, classes, payload, headline, task, note, floor);
|
|
5507
5867
|
}
|
|
5508
|
-
function commandHarnessHook(argv, streams, cwd, readStdin, adapter, waitSeam) {
|
|
5868
|
+
function* commandHarnessHook(argv, streams, cwd, readStdin, adapter, waitSeam) {
|
|
5509
5869
|
if (adapter.kind === "hermes") {
|
|
5510
|
-
return hermesFailClosed(argv, streams, cwd, readStdin, adapter, waitSeam);
|
|
5870
|
+
return yield* hermesFailClosed(argv, streams, cwd, readStdin, adapter, waitSeam);
|
|
5511
5871
|
}
|
|
5512
5872
|
try {
|
|
5513
|
-
return runHarnessHook(argv, streams, cwd, readStdin, adapter, waitSeam);
|
|
5873
|
+
return yield* runHarnessHook(argv, streams, cwd, readStdin, adapter, waitSeam);
|
|
5514
5874
|
}
|
|
5515
5875
|
catch (cause) {
|
|
5516
5876
|
// A hook that throws is a hook the harness treats as a non-blocking error,
|
|
@@ -5541,7 +5901,7 @@ function commandHarnessHook(argv, streams, cwd, readStdin, adapter, waitSeam) {
|
|
|
5541
5901
|
* nothing on stdout by design. A throw becomes the same block the other
|
|
5542
5902
|
* adapters' catch produces.
|
|
5543
5903
|
*/
|
|
5544
|
-
function hermesFailClosed(argv, streams, cwd, readStdin, adapter,
|
|
5904
|
+
function* hermesFailClosed(argv, streams, cwd, readStdin, adapter,
|
|
5545
5905
|
/** APRV-427: serve's wait seam, passed through untouched (null on the CLI). */
|
|
5546
5906
|
waitSeam) {
|
|
5547
5907
|
let stdout = "";
|
|
@@ -5567,12 +5927,24 @@ waitSeam) {
|
|
|
5567
5927
|
// handler is prepended, so inside the wait it answers first and exits. CLI
|
|
5568
5928
|
// only: under `approval serve` (a wait seam) the process's signals belong to
|
|
5569
5929
|
// the server.
|
|
5930
|
+
//
|
|
5931
|
+
// A JS listener runs only when the event loop turns (APRV-466). On the CLI
|
|
5932
|
+
// this run is driven by `driveYielding` (APRV-473), so the loop turns at
|
|
5933
|
+
// every pause: between the wait's polls, where the wait's handler answers,
|
|
5934
|
+
// and once before anything appends `execution.started` (`BEFORE_SPEND`),
|
|
5935
|
+
// where whichever of the two is registered answers a signal held through
|
|
5936
|
+
// the synchronous stretch before it (the stdin read, the classification, the
|
|
5937
|
+
// registration). A signal that lands after the last pause is dispatched
|
|
5938
|
+
// once the run has returned, to the `approval` bin's own guard, which exits
|
|
5939
|
+
// with the answer this run gave. While these guards are registered,
|
|
5940
|
+
// HERMES_SIGNAL_OWNER tells the bin's guard to step aside, so the two never
|
|
5941
|
+
// both print.
|
|
5570
5942
|
const onEarlySignal = (signal) => {
|
|
5571
5943
|
if (raw !== null && isHermesPostEvent(raw))
|
|
5572
5944
|
process.exit(EXIT_OK);
|
|
5573
5945
|
if (stdout.length === 0) {
|
|
5574
5946
|
try {
|
|
5575
|
-
writeSync(1,
|
|
5947
|
+
writeSync(1, hermesInterruptedDirective(signal));
|
|
5576
5948
|
}
|
|
5577
5949
|
catch {
|
|
5578
5950
|
// stdout is gone; the exit code below is the whole verdict.
|
|
@@ -5582,23 +5954,27 @@ waitSeam) {
|
|
|
5582
5954
|
};
|
|
5583
5955
|
const onEarlyTerm = () => onEarlySignal("SIGTERM");
|
|
5584
5956
|
const onEarlyInt = () => onEarlySignal("SIGINT");
|
|
5957
|
+
const owner = globalThis;
|
|
5958
|
+
const ownerBefore = owner[HERMES_SIGNAL_OWNER];
|
|
5585
5959
|
if (waitSeam === null) {
|
|
5586
5960
|
process.on("SIGTERM", onEarlyTerm);
|
|
5587
5961
|
process.on("SIGINT", onEarlyInt);
|
|
5962
|
+
owner[HERMES_SIGNAL_OWNER] = true;
|
|
5588
5963
|
}
|
|
5589
5964
|
try {
|
|
5590
|
-
return hermesVerdict();
|
|
5965
|
+
return yield* hermesVerdict();
|
|
5591
5966
|
}
|
|
5592
5967
|
finally {
|
|
5593
5968
|
if (waitSeam === null) {
|
|
5969
|
+
owner[HERMES_SIGNAL_OWNER] = ownerBefore;
|
|
5594
5970
|
process.off("SIGTERM", onEarlyTerm);
|
|
5595
5971
|
process.off("SIGINT", onEarlyInt);
|
|
5596
5972
|
}
|
|
5597
5973
|
}
|
|
5598
|
-
function hermesVerdict() {
|
|
5974
|
+
function* hermesVerdict() {
|
|
5599
5975
|
let code;
|
|
5600
5976
|
try {
|
|
5601
|
-
code = runHarnessHook(argv, tracked, cwd, reading, adapter, waitSeam);
|
|
5977
|
+
code = yield* runHarnessHook(argv, tracked, cwd, reading, adapter, waitSeam);
|
|
5602
5978
|
}
|
|
5603
5979
|
catch (cause) {
|
|
5604
5980
|
// A post-event never blocks: the call already ran (see the post path).
|
|
@@ -5657,6 +6033,48 @@ export function commandHook(argv, streams, cwd, readStdin = defaultStdin,
|
|
|
5657
6033
|
* the call polls. Every CLI route omits it. See {@link HookWaitSeam}.
|
|
5658
6034
|
*/
|
|
5659
6035
|
waitSeam = null) {
|
|
6036
|
+
return driveSync(hookSteps(argv, streams, cwd, readStdin, waitSeam));
|
|
6037
|
+
}
|
|
6038
|
+
/**
|
|
6039
|
+
* {@link commandHook}, with its pauses on the event loop (APRV-473).
|
|
6040
|
+
*
|
|
6041
|
+
* Every `approval hook` CLI route, from `main`: Hermes since APRV-473, the
|
|
6042
|
+
* other five harnesses (and `classify`, which never pauses) since APRV-475.
|
|
6043
|
+
* The same steps as the synchronous form; the difference is that a SIGTERM or
|
|
6044
|
+
* SIGINT reaches the listener in force within one pause of arriving, instead
|
|
6045
|
+
* of after the run returns. In the wait that listener withdraws the question
|
|
6046
|
+
* this invocation opened, prints the harness's own `hook-interrupted` block
|
|
6047
|
+
* directive ({@link interruptedWaitDirective}) and exits with that directive's
|
|
6048
|
+
* exit code, so a grant that lands after the harness abandoned the call is
|
|
6049
|
+
* never spent on it. See {@link GateSteps} and {@link BEFORE_SPEND}.
|
|
6050
|
+
*/
|
|
6051
|
+
export function commandHookYielding(argv, streams, cwd, readStdin = defaultStdin) {
|
|
6052
|
+
// The loop now turns mid-wait, so an asynchronous stderr failure (EPIPE on a
|
|
6053
|
+
// closed pipe after `announceWait`, where pipes are asynchronous) would
|
|
6054
|
+
// surface as an uncaught exception in the middle of the wait and end it
|
|
6055
|
+
// without withdrawing. Stderr carries progress lines and never the verdict,
|
|
6056
|
+
// so its failure is swallowed (APRV-473 refuter).
|
|
6057
|
+
process.stderr.on("error", () => undefined);
|
|
6058
|
+
return driveYielding(hookSteps(argv, streams, cwd, readStdin, null));
|
|
6059
|
+
}
|
|
6060
|
+
/**
|
|
6061
|
+
* The steps {@link commandHook} and {@link commandHookYielding} drive, for a
|
|
6062
|
+
* test that drives them itself (APRV-475 refuter).
|
|
6063
|
+
*
|
|
6064
|
+
* A test seam and nothing more: it exists so a test can stop at each pause and
|
|
6065
|
+
* look at the log, which is the only deterministic way to pin that the pause
|
|
6066
|
+
* before a spend ({@link BEFORE_SPEND}) sits between the read that found a
|
|
6067
|
+
* grant and the append that spends it, on every harness. No CLI route and no
|
|
6068
|
+
* embedding caller uses it; the one entry for those is the two drivers above.
|
|
6069
|
+
*
|
|
6070
|
+
* Pass `true` to each `next()` to step as {@link commandHookYielding} does
|
|
6071
|
+
* (the spend then waits for a held log lock in yields, APRV-478); `false` or
|
|
6072
|
+
* nothing steps as {@link commandHook} does.
|
|
6073
|
+
*/
|
|
6074
|
+
export function commandHookSteps(argv, streams, cwd, readStdin) {
|
|
6075
|
+
return hookSteps(argv, streams, cwd, readStdin, null);
|
|
6076
|
+
}
|
|
6077
|
+
function* hookSteps(argv, streams, cwd, readStdin, waitSeam) {
|
|
5660
6078
|
const sub = argv[0];
|
|
5661
6079
|
const rest = argv.slice(1);
|
|
5662
6080
|
if (sub === undefined) {
|
|
@@ -5670,7 +6088,7 @@ waitSeam = null) {
|
|
|
5670
6088
|
// boundary and doctor use, so a kind that can be recorded is a kind that can
|
|
5671
6089
|
// be invoked, and neither can be added without the other.
|
|
5672
6090
|
if (isHarnessKind(sub)) {
|
|
5673
|
-
return commandHarnessHook(rest, streams, cwd, readStdin, HARNESS_ADAPTERS[sub], waitSeam);
|
|
6091
|
+
return yield* commandHarnessHook(rest, streams, cwd, readStdin, HARNESS_ADAPTERS[sub], waitSeam);
|
|
5674
6092
|
}
|
|
5675
6093
|
if (sub === "classify")
|
|
5676
6094
|
return commandClassify(rest, streams, cwd);
|