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
package/dist/src/cli/doctor.js
CHANGED
|
@@ -54,20 +54,23 @@
|
|
|
54
54
|
* precisely what this command reports, so they are check failures (exit 1).
|
|
55
55
|
*/
|
|
56
56
|
import { createServer } from "node:net";
|
|
57
|
-
import { closeSync, existsSync, openSync, readFileSync, readdirSync, statSync, unlinkSync, } from "node:fs";
|
|
57
|
+
import { closeSync, existsSync, openSync, readFileSync, readdirSync, realpathSync, statSync, unlinkSync, } from "node:fs";
|
|
58
|
+
import { homedir } from "node:os";
|
|
58
59
|
import { basename, dirname, isAbsolute, join, resolve as resolvePathSegments } from "node:path";
|
|
59
60
|
import { WEB_DEFAULT_PORT } from "../channels/web.js";
|
|
60
61
|
import { TELEGRAM_DEFAULT_API_BASE, telegramChatEnvFor, telegramTokenEnvFor, } from "../channels/telegram.js";
|
|
61
|
-
import { HUMAN_ACTOR_ENV, checkAttestation, findOrganAttestation, findPathSignOff, latestOrganAttestation, latestPathSignOff, policyBytesHash, resolveHumanActor, } from "../core/attest.js";
|
|
62
|
+
import { HUMAN_ACTOR_ENV, attesterAt, checkAttestation, findOrganAttestation, findPathSignOff, latestOrganAttestation, latestPathSignOff, policyBytesHash, resolveHumanActor, } from "../core/attest.js";
|
|
62
63
|
import { isGateOrganPath, POLICY_EDIT_SUBCLASS, protectedPathClass, } from "../core/command-class.js";
|
|
63
64
|
import { VALUES_INFO_STRING, loadValues } from "../core/values.js";
|
|
64
65
|
import { KEYSTORE_DEFERRED, NON_RESOLVING_RUNNER, envFilePathFor, resolveEnvironment, } from "../core/env-file.js";
|
|
65
66
|
import { readTaskFile } from "../core/frontmatter.js";
|
|
67
|
+
import { derivedDaemonId, resolveDaemonAllowlist, resolveDaemonId, } from "../core/daemon-host.js";
|
|
68
|
+
import { DAEMON_ID_ENV } from "../core/daemon-identity.js";
|
|
66
69
|
import { instanceFindings, instanceHomeFor, instanceIdFor } from "../core/instance.js";
|
|
67
70
|
import { describeOwners, otherOwnersOf, ownedBot } from "../core/channel-owner.js";
|
|
68
71
|
import { checkLogAnchor } from "./log-anchor.js";
|
|
69
72
|
import { checkLogCheckpoints, checkpointPolicyOf } from "../core/checkpoint.js";
|
|
70
|
-
import { payloadStoreCensus } from "../core/payload-census.js";
|
|
73
|
+
import { payloadIntegrity, payloadStoreCensus } from "../core/payload-census.js";
|
|
71
74
|
import { payloadStoreDirFor } from "../core/payload-store.js";
|
|
72
75
|
import { DEFAULT_TASKS_DIR, latestRegistration } from "../core/registration.js";
|
|
73
76
|
import { POLICY_FILENAMES, loadPolicy } from "../core/policy-load.js";
|
|
@@ -84,8 +87,12 @@ import { policyWebPort } from "./channel-web.js";
|
|
|
84
87
|
import { EXIT_INTEGRITY, EXIT_IO, EXIT_OK, EXIT_USAGE } from "./exit-codes.js";
|
|
85
88
|
import { RESOLVE_DANGLING_COMMAND, lastAdvance, proveDanglingAdvances, } from "../core/advance-cycle.js";
|
|
86
89
|
import { DEFAULT_DARK_WINDOW_MS, reportDarkSessions, } from "../core/dark-session.js";
|
|
87
|
-
import { HARNESS_BINARY, HARNESS_KINDS, installedHarnessVersion, isHarnessKind, readHarnessProvenance, } from "../core/harness-version.js";
|
|
88
|
-
import { git, repoPath, repoRoot } from "./git-scope.js";
|
|
90
|
+
import { HARNESS_BINARY, HARNESS_KINDS, HERMES_FAIL_CLOSED_FLOOR, hermesFailClosedSupport, installedHarnessVersion, installedHarnessVersionRaw, isHarnessKind, readHarnessProvenance, } from "../core/harness-version.js";
|
|
91
|
+
import { git, primaryRoot, repoPath, repoRoot } from "./git-scope.js";
|
|
92
|
+
// APRV-408: the gated tool roster of the `harness-hook-wiring` row is READ from
|
|
93
|
+
// the adapter that answers the calls, never restated here. No new export; the
|
|
94
|
+
// map has been exported since APRV-398.
|
|
95
|
+
import { HARNESS_ADAPTERS } from "./hook.js";
|
|
89
96
|
import { ScanError, checkAttestedPolicyOnMain, checkBuildFreshness, checkMainBehindOrigin, installationRoot, } from "./preflight.js";
|
|
90
97
|
import { publishedState } from "./log-advance.js";
|
|
91
98
|
import { DOCTOR_HELP } from "./help.js";
|
|
@@ -248,12 +255,17 @@ function resolvePolicyPath(policyFlag, dir, cwd) {
|
|
|
248
255
|
function checkAttestationHealth(records, policyPath) {
|
|
249
256
|
const status = checkAttestation(records, policyPath);
|
|
250
257
|
switch (status.status) {
|
|
251
|
-
case "attested":
|
|
258
|
+
case "attested": {
|
|
259
|
+
// APRV-449: WHO set the policy in force, from the verified record at that
|
|
260
|
+
// seq and never from the file. A hosted tenant reads this row to learn
|
|
261
|
+
// whether their starting policy is the operator's or their own.
|
|
262
|
+
const by = attesterAt(records, status.seq);
|
|
252
263
|
return {
|
|
253
264
|
check: "attestation",
|
|
254
265
|
status: "pass",
|
|
255
|
-
detail: `${policyPath} is attested at seq ${status.seq} (sha256 ${status.sha256.slice(0, 12)}…)`,
|
|
266
|
+
detail: `${policyPath} is attested at seq ${status.seq}${by === null ? "" : ` by ${by}`} (sha256 ${status.sha256.slice(0, 12)}…)`,
|
|
256
267
|
};
|
|
268
|
+
}
|
|
257
269
|
case "not-attested":
|
|
258
270
|
return {
|
|
259
271
|
check: "attestation",
|
|
@@ -280,6 +292,14 @@ function checkAttestationHealth(records, policyPath) {
|
|
|
280
292
|
// ---------------------------------------------------------------------------
|
|
281
293
|
// 4. log
|
|
282
294
|
// ---------------------------------------------------------------------------
|
|
295
|
+
/**
|
|
296
|
+
* A path as one POSIX shell word: bare when it holds only characters no shell
|
|
297
|
+
* interprets, single-quoted otherwise (`'` closed, escaped and reopened), so a
|
|
298
|
+
* fix command pasted from a row names the file the row is about.
|
|
299
|
+
*/
|
|
300
|
+
function shellWord(path) {
|
|
301
|
+
return /^[A-Za-z0-9_./@:+-]+$/.test(path) ? path : `'${path.split("'").join(`'\\''`)}'`;
|
|
302
|
+
}
|
|
283
303
|
/** The chain verdict, in doctor's vocabulary. Reads; never writes. */
|
|
284
304
|
function checkLog(logPath, result) {
|
|
285
305
|
switch (result.status) {
|
|
@@ -291,13 +311,31 @@ function checkLog(logPath, result) {
|
|
|
291
311
|
? `${logPath} is empty (an audit trail that has recorded nothing is clean, not missing)`
|
|
292
312
|
: `${logPath} verifies: ${result.records} record(s), head seq ${result.head.seq} ${result.head.hash.slice(0, 12)}…`,
|
|
293
313
|
};
|
|
294
|
-
case "torn-tail":
|
|
314
|
+
case "torn-tail": {
|
|
315
|
+
const file = shellWord(logPath);
|
|
316
|
+
const keep = String(result.intactBytes);
|
|
317
|
+
// What the truncation removes, from the file's own size: verify counts
|
|
318
|
+
// the tail's DECODED bytes, which overstates a tail holding invalid UTF-8.
|
|
319
|
+
let drop = result.tornBytes;
|
|
320
|
+
try {
|
|
321
|
+
drop = statSync(logPath).size - result.intactBytes;
|
|
322
|
+
}
|
|
323
|
+
catch {
|
|
324
|
+
// Unreadable now: the verify count stands.
|
|
325
|
+
}
|
|
295
326
|
return {
|
|
296
327
|
check: "log",
|
|
297
328
|
status: "fail",
|
|
298
|
-
detail:
|
|
299
|
-
|
|
329
|
+
detail: result.tear === "nul-filled"
|
|
330
|
+
? `${logPath} ends with ${String(result.tornBytes)} NUL byte(s) after record ${String(result.intactThroughSeq)}, the crash-before-writeback signature: the file grew but its data never reached the disk before the machine stopped, so nothing was tampered and whatever was appended there is lost; records 1..${String(result.intactThroughSeq)} verify clean`
|
|
331
|
+
: `${logPath} ends with an unterminated line of ${String(result.tornBytes)} byte(s) after record ${String(result.intactThroughSeq)}, the signature of a crashed write (a writer that died mid-line), not of tampering; records 1..${String(result.intactThroughSeq)} verify clean`,
|
|
332
|
+
// APRV-440: which bytes, exactly. The line still opens with the read,
|
|
333
|
+
// because truncating is a human decision taken after it, and the
|
|
334
|
+
// truncation is spelled in `node` because `truncate(1)` is not on
|
|
335
|
+
// macOS and node is wherever this CLI runs. Doctor truncates nothing.
|
|
336
|
+
fix: `approval log verify — read the full report; then, if you decide to repair, keep the first ${keep} byte(s) of ${logPath} and drop the final ${String(drop)}: \`node -e "require('fs').truncateSync(process.argv[1], ${keep})" ${file}\`. Nothing here truncates, because that is a human decision`,
|
|
300
337
|
};
|
|
338
|
+
}
|
|
301
339
|
case "corrupt":
|
|
302
340
|
return {
|
|
303
341
|
check: "log",
|
|
@@ -516,8 +554,12 @@ const PAYLOAD_STORE_WARNING = "the store holds the bytes approvals bind to, keye
|
|
|
516
554
|
* The probe is a real create-and-remove in the store directory, not a `statSync`
|
|
517
555
|
* mode test: mode bits do not answer the question on a read-only mount, under an
|
|
518
556
|
* ACL, or in a container whose uid mapping differs from the one that made the
|
|
519
|
-
* directory. Nothing is left behind, and no payload file is
|
|
520
|
-
*
|
|
557
|
+
* directory. Nothing is left behind, and no payload file is written here.
|
|
558
|
+
*
|
|
559
|
+
* Since APRV-457 the bound payloads are read back and verified (see
|
|
560
|
+
* {@link payloadDamageCheck}): a store that is writable but holds a torn,
|
|
561
|
+
* missing or tampered payload a verified record binds fails, each reading with
|
|
562
|
+
* its own text.
|
|
521
563
|
*/
|
|
522
564
|
function checkPayloadStore(logPath, records) {
|
|
523
565
|
const storeDir = payloadStoreDirFor(logPath);
|
|
@@ -527,6 +569,14 @@ function checkPayloadStore(logPath, records) {
|
|
|
527
569
|
}
|
|
528
570
|
catch (cause) {
|
|
529
571
|
if (cause.code === "ENOENT") {
|
|
572
|
+
// APRV-457: an absent store is only "not created yet" when no verified
|
|
573
|
+
// record proves the store already held bytes. When one does, the whole
|
|
574
|
+
// directory is missing under it: the crash signature of a first store
|
|
575
|
+
// whose directory entry never reached the disk, or a store not carried
|
|
576
|
+
// with the log. Every such binding reads as lost.
|
|
577
|
+
const damage = payloadIntegrity(records, storeDir);
|
|
578
|
+
if (damage.length > 0)
|
|
579
|
+
return payloadDamageCheck(damage);
|
|
530
580
|
return {
|
|
531
581
|
check: "payload-store",
|
|
532
582
|
status: "pass",
|
|
@@ -589,12 +639,94 @@ function checkPayloadStore(logPath, records) {
|
|
|
589
639
|
const residue = census.awaitingRemoval === 0
|
|
590
640
|
? ""
|
|
591
641
|
: `, ${census.awaitingRemoval} already recorded as pruned and awaiting removal by the daemon`;
|
|
642
|
+
// APRV-457: the bytes verified records bind, read back. Only a damaged store
|
|
643
|
+
// changes the row; an intact one reads exactly as it did before.
|
|
644
|
+
const damage = payloadIntegrity(records, storeDir);
|
|
645
|
+
if (damage.length > 0)
|
|
646
|
+
return payloadDamageCheck(damage);
|
|
592
647
|
return {
|
|
593
648
|
check: "payload-store",
|
|
594
649
|
status: "pass",
|
|
595
650
|
detail: `${storeDir} is writable and holds ${files} payload file(s), ${census.pruned} pruned by the log, ${census.orphans} bound to no record${residue}; ${PAYLOAD_STORE_WARNING}`,
|
|
596
651
|
};
|
|
597
652
|
}
|
|
653
|
+
/** At most this many payloads are named per kind; the count covers the rest. */
|
|
654
|
+
const DAMAGE_NAMED = 5;
|
|
655
|
+
function namedDamage(items, describe) {
|
|
656
|
+
const named = items.slice(0, DAMAGE_NAMED).map(describe).join("; ");
|
|
657
|
+
const more = items.length > DAMAGE_NAMED ? `; and ${String(items.length - DAMAGE_NAMED)} more` : "";
|
|
658
|
+
return `${named}${more}`;
|
|
659
|
+
}
|
|
660
|
+
/**
|
|
661
|
+
* The payload-store row when bound payloads are not intact (APRV-457).
|
|
662
|
+
*
|
|
663
|
+
* Two readings, kept apart because they call for opposite reactions:
|
|
664
|
+
*
|
|
665
|
+
* - **The crash-before-writeback signature.** A file that is empty or all NUL
|
|
666
|
+
* (`torn`), or a file that is absent although its record proves the store
|
|
667
|
+
* held it (`lost`). A crash leaves exactly these and nothing else: it can drop
|
|
668
|
+
* bytes and names, never write bytes nobody wrote. Before APRV-457 the store
|
|
669
|
+
* did not fsync, so a platform kill could keep the (fsynced) record and drop
|
|
670
|
+
* its payload. Nothing was tampered; the bytes are gone from this store.
|
|
671
|
+
* - **Tampering or corruption** (`mismatch`). The file holds at least one
|
|
672
|
+
* non-NUL byte and does not verify. That is not the pure crash shape, so it is never
|
|
673
|
+
* read as one (a partial writeback, valid prefix and NUL tail, lands here too: the stricter reading), and its fix moves the file aside for investigation.
|
|
674
|
+
*
|
|
675
|
+
* The torn reading cannot swallow a tampered file that still carries content:
|
|
676
|
+
* it requires every byte to be NUL. A file someone zeroed by hand reads as torn,
|
|
677
|
+
* which is honest, because its bytes say nothing either way and the material is
|
|
678
|
+
* lost either way. A partially written payload (valid prefix, NUL tail) is
|
|
679
|
+
* reported as a mismatch, the stricter of the two readings.
|
|
680
|
+
*
|
|
681
|
+
* Doctor moves, restores and deletes nothing; every fix is the operator's.
|
|
682
|
+
*/
|
|
683
|
+
function payloadDamageCheck(damage) {
|
|
684
|
+
const torn = damage.filter((item) => item.kind === "torn");
|
|
685
|
+
const lost = damage.filter((item) => item.kind === "lost");
|
|
686
|
+
const mismatch = damage.filter((item) => item.kind === "mismatch");
|
|
687
|
+
const unreadable = damage.filter((item) => item.kind === "unreadable");
|
|
688
|
+
const short = (hash) => `${hash.slice(0, 12)}…`;
|
|
689
|
+
const parts = [];
|
|
690
|
+
if (mismatch.length > 0) {
|
|
691
|
+
parts.push(`${String(mismatch.length)} payload(s) a verified record binds hold bytes that do not hash to their name and are NOT the crash signature (that is an empty or all-NUL file; a partial writeback can also leave a valid prefix with a NUL tail, so the stricter reading applies until shown otherwise): treat them as tampered or corrupted (${namedDamage(mismatch, (item) => `${short(item.hash)} bound at seq ${String(item.seq)}`)}); every read re-verifies, so no channel renders them`);
|
|
692
|
+
}
|
|
693
|
+
if (torn.length > 0) {
|
|
694
|
+
parts.push(`${String(torn.length)} payload(s) a verified record binds are empty or NUL-filled, the crash-before-writeback signature: the name reached the disk and the bytes did not, so nothing was tampered and the material is lost from this store (${namedDamage(torn, (item) => `${short(item.hash)} bound at seq ${String(item.seq)}, ${String(item.bytes)} NUL byte(s)`)})`);
|
|
695
|
+
}
|
|
696
|
+
if (lost.length > 0) {
|
|
697
|
+
parts.push(`${String(lost.length)} payload(s) are missing although the record that bound them proves the store held them (${namedDamage(lost, (item) => `${short(item.hash)}, ${item.event} at seq ${String(item.seq)}`)}): the crash-before-writeback signature when this store has always lived beside this log (the name never reached the disk), or a store that was not carried with the log`);
|
|
698
|
+
}
|
|
699
|
+
if (unreadable.length > 0) {
|
|
700
|
+
parts.push(`${String(unreadable.length)} payload(s) a verified record binds could not be read (${namedDamage(unreadable, (item) => `${short(item.hash)} bound at seq ${String(item.seq)}: ${item.detail}`)})`);
|
|
701
|
+
}
|
|
702
|
+
// One command leads the fix, for the reading that matters most: suspected
|
|
703
|
+
// tampering first, then a torn file (which must be moved before a copy can go
|
|
704
|
+
// back), then a lost one, then an unreadable one.
|
|
705
|
+
const restore = (hash, path) => `restore ${hash}.json from wherever else it lives (another checkout, a backup, the git history of the store) once \`approval payload hash\` prints ${hash} for the copy, and put it at ${path}; the log records the binding and never the material, so nothing rebuilds it`;
|
|
706
|
+
let fix;
|
|
707
|
+
const firstMismatch = mismatch[0];
|
|
708
|
+
const firstTorn = torn[0];
|
|
709
|
+
const firstLost = lost[0];
|
|
710
|
+
if (firstMismatch !== undefined) {
|
|
711
|
+
fix = `mv ${shellWord(firstMismatch.path)} ${shellWord(`${firstMismatch.path}.suspect`)} — move each payload that does not verify aside (do not delete it), find out what wrote it, and ${restore(firstMismatch.hash, firstMismatch.path)}`;
|
|
712
|
+
}
|
|
713
|
+
else if (firstTorn !== undefined) {
|
|
714
|
+
fix = `mv ${shellWord(firstTorn.path)} ${shellWord(`${firstTorn.path}.torn`)} — move each torn payload aside (the nonmanual intake refuses to replace a file that does not verify), then ${restore(firstTorn.hash, firstTorn.path)}`;
|
|
715
|
+
}
|
|
716
|
+
else if (firstLost !== undefined) {
|
|
717
|
+
fix = `approval payload hash <copy> — ${restore(firstLost.hash, firstLost.path)}`;
|
|
718
|
+
}
|
|
719
|
+
else {
|
|
720
|
+
const first = unreadable[0];
|
|
721
|
+
fix = `chmod u+r ${shellWord(first.path)} — make each payload readable by the user running approval, check its ownership and the disk, then run approval doctor again`;
|
|
722
|
+
}
|
|
723
|
+
return {
|
|
724
|
+
check: "payload-store",
|
|
725
|
+
status: "fail",
|
|
726
|
+
detail: `${parts.join("; ")}; ${PAYLOAD_STORE_WARNING}`,
|
|
727
|
+
fix: `${fix}. Doctor moves, restores and deletes nothing`,
|
|
728
|
+
};
|
|
729
|
+
}
|
|
598
730
|
// ---------------------------------------------------------------------------
|
|
599
731
|
// 7b. the verified-head snapshot (APRV-188)
|
|
600
732
|
// ---------------------------------------------------------------------------
|
|
@@ -1698,6 +1830,80 @@ function checkKeychainScope(logPath, load) {
|
|
|
1698
1830
|
};
|
|
1699
1831
|
}
|
|
1700
1832
|
// ---------------------------------------------------------------------------
|
|
1833
|
+
// 32. daemon-identity (APRV-383)
|
|
1834
|
+
// ---------------------------------------------------------------------------
|
|
1835
|
+
/**
|
|
1836
|
+
* Which daemon would write here, and whether the attested policy admits it
|
|
1837
|
+
* (APRV-383).
|
|
1838
|
+
*
|
|
1839
|
+
* The sibling of `keychain-scope` above, one layer along: that row answers
|
|
1840
|
+
* "whose CREDENTIALS is this instance using", this one answers "whose NAME would
|
|
1841
|
+
* this instance's daemon write onto every record it appends". Both derive from the
|
|
1842
|
+
* same instance id, which is the point — an operator comparing `daemon-3f2a9c11`
|
|
1843
|
+
* in a record against `approval-tg-token-3f2a9c11` in `.approval/env` is looking
|
|
1844
|
+
* at one gate.
|
|
1845
|
+
*
|
|
1846
|
+
* ## The verdict rule
|
|
1847
|
+
*
|
|
1848
|
+
* - **FAIL** for an `APPROVAL_DAEMON_ID` in this shell that is not a usable id.
|
|
1849
|
+
* That is a broken launch environment wherever it is read from: a daemon started
|
|
1850
|
+
* from this shell would refuse every append, and the repair is one variable.
|
|
1851
|
+
* - **SKIP**, named and loud, for an id the policy's `daemons` list does not
|
|
1852
|
+
* admit. Deliberately not a fail, and the reason is the one `keychain-scope`
|
|
1853
|
+
* states for the legacy item: under the deployment this key exists FOR, the
|
|
1854
|
+
* tenant's policy names a daemon id belonging to somebody else's machine, so a
|
|
1855
|
+
* red row here would be red on every tenant's laptop forever, and a red row on
|
|
1856
|
+
* every installation is a red row people learn to skip past. The line says
|
|
1857
|
+
* plainly that a daemon started HERE would write nothing.
|
|
1858
|
+
* - **PASS** when the list names this id, and when there is no list at all, which
|
|
1859
|
+
* is every installation that never adopts the key.
|
|
1860
|
+
*
|
|
1861
|
+
* It reads a policy and an environment variable's SHAPE. No value is printed on
|
|
1862
|
+
* any path, because the only value involved is an id, which the policy and the log
|
|
1863
|
+
* both carry in the open.
|
|
1864
|
+
*/
|
|
1865
|
+
function checkDaemonIdentity(logPath, records, load) {
|
|
1866
|
+
const resolved = resolveDaemonId(logPath);
|
|
1867
|
+
if (!resolved.ok) {
|
|
1868
|
+
return {
|
|
1869
|
+
check: "daemon-identity",
|
|
1870
|
+
status: "fail",
|
|
1871
|
+
detail: `${resolved.message}. A daemon started from this shell would be refused \`daemon-id-invalid\` on every append, so it would read, render and report while writing nothing`,
|
|
1872
|
+
fix: `unset ${DAEMON_ID_ENV} to use the derived id ${derivedDaemonId(logPath)}, or set it to a usable one: lowercase letters, digits, \`.\`, \`-\` and \`_\`, starting with a letter or digit`,
|
|
1873
|
+
};
|
|
1874
|
+
}
|
|
1875
|
+
const head = `a daemon run against ${instanceHomeFor(logPath)} writes \`daemon: ${resolved.id}\` onto every record it appends (${resolved.source === "environment" ? `declared in ${DAEMON_ID_ENV}` : "derived from this instance, the same id the keychain-scope row names"})`;
|
|
1876
|
+
const allowlist = resolveDaemonAllowlist(records, load);
|
|
1877
|
+
if (!allowlist.ok) {
|
|
1878
|
+
return {
|
|
1879
|
+
check: "daemon-identity",
|
|
1880
|
+
status: "pass",
|
|
1881
|
+
detail: `${head}. No \`daemons\` allowlist is in force (${allowlist.detail}), so any daemon may write, exactly as before the key existed`,
|
|
1882
|
+
};
|
|
1883
|
+
}
|
|
1884
|
+
const allowed = allowlist.allowed;
|
|
1885
|
+
if (allowed === null) {
|
|
1886
|
+
return {
|
|
1887
|
+
check: "daemon-identity",
|
|
1888
|
+
status: "pass",
|
|
1889
|
+
detail: `${head}. The attested policy lists no allowed daemon ids, so any daemon may write — which is what every policy written before this key looked like`,
|
|
1890
|
+
};
|
|
1891
|
+
}
|
|
1892
|
+
if (!allowed.includes(resolved.id)) {
|
|
1893
|
+
return {
|
|
1894
|
+
check: "daemon-identity",
|
|
1895
|
+
status: "skip",
|
|
1896
|
+
detail: `${head}. The attested policy's \`daemons\` list admits ${allowed.length === 0 ? "no daemon at all" : allowed.join(", ")}, so a daemon started HERE would be refused \`daemon-not-allowed\` on every append and would write nothing. Expected where the daemon for this log runs on another machine; wrong if that daemon is meant to be this one`,
|
|
1897
|
+
fix: `add ${resolved.id} to \`daemons\` and re-attest the policy, or run the daemon whose id the list already names`,
|
|
1898
|
+
};
|
|
1899
|
+
}
|
|
1900
|
+
return {
|
|
1901
|
+
check: "daemon-identity",
|
|
1902
|
+
status: "pass",
|
|
1903
|
+
detail: `${head}, and the attested policy's \`daemons\` list admits it (${allowed.join(", ")}). Being listed grants nothing beyond the ability to write: no verdict, budget, floor, draw or token reads this id`,
|
|
1904
|
+
};
|
|
1905
|
+
}
|
|
1906
|
+
// ---------------------------------------------------------------------------
|
|
1701
1907
|
// 12. log-drift (APRV-125)
|
|
1702
1908
|
// ---------------------------------------------------------------------------
|
|
1703
1909
|
/**
|
|
@@ -1935,28 +2141,180 @@ function checkAdvanceCadence(logPath, records) {
|
|
|
1935
2141
|
// ---------------------------------------------------------------------------
|
|
1936
2142
|
/** Where Claude Code keeps the hook registration a human commits. */
|
|
1937
2143
|
const CLAUDE_SETTINGS = join(".claude", "settings.json");
|
|
1938
|
-
/**
|
|
1939
|
-
|
|
2144
|
+
/** This runtime's own Claude Code hook adapter, the roster both rows read. */
|
|
2145
|
+
const CLAUDE_HOOK_ADAPTER = HARNESS_ADAPTERS["claude-code"];
|
|
2146
|
+
/**
|
|
2147
|
+
* The tools a protected-path write can arrive as, DERIVED (APRV-408).
|
|
2148
|
+
*
|
|
2149
|
+
* Until APRV-408 this was a hand list, `["Edit", "Write", "Bash"]`, and it had
|
|
2150
|
+
* drifted: the adapter gates `Bash` plus `Edit`, `Write`, `MultiEdit` and
|
|
2151
|
+
* `NotebookEdit`, so a matcher of `Bash|Edit|Write` passed the wiring row while
|
|
2152
|
+
* a `MultiEdit` to a protected path reached the file system unclassified. Two
|
|
2153
|
+
* lists of the same fact is one list too many, so there is now one: the
|
|
2154
|
+
* adapter's, because the adapter is what actually answers the call.
|
|
2155
|
+
*/
|
|
2156
|
+
function gatedClaudeTools() {
|
|
2157
|
+
return [CLAUDE_HOOK_ADAPTER.shellTool, ...CLAUDE_HOOK_ADAPTER.fileTools];
|
|
2158
|
+
}
|
|
2159
|
+
/**
|
|
2160
|
+
* Anything that makes a command line mean something other than its own words.
|
|
2161
|
+
*
|
|
2162
|
+
* Deliberately generous. A command carrying any of these is reported as
|
|
2163
|
+
* unresolved rather than parsed on a best guess, because the question this
|
|
2164
|
+
* parse answers — which checkout does the gate resolve its policy and log from
|
|
2165
|
+
* — has no safe approximation. Fail closed: SPEC §11.
|
|
2166
|
+
*/
|
|
2167
|
+
const SHELL_OPAQUE = /[$`\\;&|<>(){}\r\n*?[\]]/u;
|
|
2168
|
+
/**
|
|
2169
|
+
* The `hook claude-code` verb named anywhere in a command, however wrapped.
|
|
2170
|
+
*
|
|
2171
|
+
* The VERB and not the executable, because the executable is exactly what a
|
|
2172
|
+
* wrapper hides: `$(which approval) hook claude-code` and `bash -lc "approval
|
|
2173
|
+
* hook claude-code …"` both name this handler and neither carries the word
|
|
2174
|
+
* `approval` in a position any parse could trust. Naming the verb is what makes
|
|
2175
|
+
* such an entry `unresolved` (reported, never a pass) instead of `foreign`
|
|
2176
|
+
* (invisible, which is how a wrapped handler would read as "no hook
|
|
2177
|
+
* registered"). Another harness's entry parked in this file — `approval hook
|
|
2178
|
+
* cursor` — does not name it, and stays foreign.
|
|
2179
|
+
*/
|
|
2180
|
+
const CLAUDE_HOOK_MENTION = /\bhook\s+claude-code\b/u;
|
|
2181
|
+
/**
|
|
2182
|
+
* Split a command line into words, honouring simple quotes.
|
|
2183
|
+
*
|
|
2184
|
+
* Only ever reached for a command {@link SHELL_OPAQUE} has already cleared, so
|
|
2185
|
+
* there is no expansion, no escape and no operator left to get wrong; `null` is
|
|
2186
|
+
* an unclosed quote.
|
|
2187
|
+
*/
|
|
2188
|
+
function shellWords(command) {
|
|
2189
|
+
const tokens = [];
|
|
2190
|
+
let current = "";
|
|
2191
|
+
let quote = null;
|
|
2192
|
+
let started = false;
|
|
2193
|
+
for (const character of command) {
|
|
2194
|
+
if (quote !== null) {
|
|
2195
|
+
if (character === quote)
|
|
2196
|
+
quote = null;
|
|
2197
|
+
else
|
|
2198
|
+
current += character;
|
|
2199
|
+
continue;
|
|
2200
|
+
}
|
|
2201
|
+
if (character === '"' || character === "'") {
|
|
2202
|
+
quote = character;
|
|
2203
|
+
started = true;
|
|
2204
|
+
continue;
|
|
2205
|
+
}
|
|
2206
|
+
if (/\s/u.test(character)) {
|
|
2207
|
+
if (started)
|
|
2208
|
+
tokens.push(current);
|
|
2209
|
+
current = "";
|
|
2210
|
+
started = false;
|
|
2211
|
+
continue;
|
|
2212
|
+
}
|
|
2213
|
+
current += character;
|
|
2214
|
+
started = true;
|
|
2215
|
+
}
|
|
2216
|
+
if (quote !== null)
|
|
2217
|
+
return null;
|
|
2218
|
+
if (started)
|
|
2219
|
+
tokens.push(current);
|
|
2220
|
+
return tokens;
|
|
2221
|
+
}
|
|
2222
|
+
/**
|
|
2223
|
+
* Read a registered command as an `approval hook claude-code` invocation
|
|
2224
|
+
* (APRV-408).
|
|
2225
|
+
*
|
|
2226
|
+
* Modelled on {@link isDirectCodexHookCommand}, which the Codex rows have had
|
|
2227
|
+
* since APRV-313, and which the Claude Code rows had no counterpart of: they
|
|
2228
|
+
* matched the substring `approval hook`, so a wrapper script, a different
|
|
2229
|
+
* harness's entry and a handler bound to a checkout that does not exist on this
|
|
2230
|
+
* machine all read the same. Tolerant of flag order and of `--as`/`--timeout`
|
|
2231
|
+
* being absent, because those do not decide anything this row reports; strict
|
|
2232
|
+
* about the three facts that do — the executable is `approval`, the verb is
|
|
2233
|
+
* `hook claude-code`, and the `--dir` is whatever it is.
|
|
2234
|
+
*/
|
|
2235
|
+
function parseClaudeHookCommand(command) {
|
|
2236
|
+
const mentioned = CLAUDE_HOOK_MENTION.test(command);
|
|
2237
|
+
const unreadable = (reason) => mentioned ? { kind: "unresolved", reason } : { kind: "foreign" };
|
|
2238
|
+
if (SHELL_OPAQUE.test(command)) {
|
|
2239
|
+
return unreadable("it carries shell metacharacters, so what it runs depends on a shell rather than on these words");
|
|
2240
|
+
}
|
|
2241
|
+
const tokens = shellWords(command);
|
|
2242
|
+
if (tokens === null)
|
|
2243
|
+
return unreadable("its quoting does not close");
|
|
2244
|
+
const executable = tokens[0] ?? "";
|
|
2245
|
+
if (tokens[1] !== "hook" || tokens[2] !== "claude-code") {
|
|
2246
|
+
// `approval hook cursor` in this file is another harness's entry, and a
|
|
2247
|
+
// wrapper name with the verb buried inside it is ours and unreadable.
|
|
2248
|
+
return unreadable("its first words are not `approval hook claude-code`");
|
|
2249
|
+
}
|
|
2250
|
+
if (basename(executable) !== "approval") {
|
|
2251
|
+
return unreadable(`its executable ${JSON.stringify(executable)} is not \`approval\``);
|
|
2252
|
+
}
|
|
2253
|
+
if (executable.includes("/") && !isAbsolute(executable)) {
|
|
2254
|
+
return unreadable(`its executable ${JSON.stringify(executable)} is a relative path, so which binary runs depends on the working directory`);
|
|
2255
|
+
}
|
|
2256
|
+
let dir = null;
|
|
2257
|
+
for (let index = 3; index < tokens.length; index += 1) {
|
|
2258
|
+
const token = tokens[index] ?? "";
|
|
2259
|
+
if (token.startsWith("--dir=")) {
|
|
2260
|
+
dir = token.slice("--dir=".length);
|
|
2261
|
+
continue;
|
|
2262
|
+
}
|
|
2263
|
+
if (token !== "--dir")
|
|
2264
|
+
continue;
|
|
2265
|
+
const value = tokens[index + 1];
|
|
2266
|
+
if (value === undefined || value.startsWith("--")) {
|
|
2267
|
+
return unreadable("its `--dir` flag names no value");
|
|
2268
|
+
}
|
|
2269
|
+
dir = value;
|
|
2270
|
+
index += 1;
|
|
2271
|
+
}
|
|
2272
|
+
return { kind: "handler", dir };
|
|
2273
|
+
}
|
|
2274
|
+
/**
|
|
2275
|
+
* Every `hooks.<event>` entry of this file that is OURS (APRV-408).
|
|
2276
|
+
*
|
|
2277
|
+
* One reading for both harness rows, so `harness-hook-outcomes` and
|
|
2278
|
+
* `harness-hook-wiring` cannot disagree about which entries they are talking
|
|
2279
|
+
* about. An unresolved entry counts as ours here — it names the verb, and a
|
|
2280
|
+
* command this file cannot read is a reason to report less confidently rather
|
|
2281
|
+
* than to report that no hook is registered.
|
|
2282
|
+
*/
|
|
2283
|
+
function claudeHookEntries(hooks, event) {
|
|
1940
2284
|
if (typeof hooks !== "object" || hooks === null || Array.isArray(hooks))
|
|
1941
|
-
return
|
|
1942
|
-
const
|
|
1943
|
-
if (!Array.isArray(
|
|
1944
|
-
return
|
|
1945
|
-
|
|
1946
|
-
|
|
2285
|
+
return [];
|
|
2286
|
+
const groups = hooks[event];
|
|
2287
|
+
if (!Array.isArray(groups))
|
|
2288
|
+
return [];
|
|
2289
|
+
const found = [];
|
|
2290
|
+
for (const group of groups) {
|
|
2291
|
+
if (typeof group !== "object" || group === null)
|
|
1947
2292
|
continue;
|
|
1948
|
-
const entries =
|
|
2293
|
+
const entries = group["hooks"];
|
|
1949
2294
|
if (!Array.isArray(entries))
|
|
1950
2295
|
continue;
|
|
2296
|
+
const pattern = group["matcher"];
|
|
1951
2297
|
for (const entry of entries) {
|
|
1952
2298
|
if (typeof entry !== "object" || entry === null)
|
|
1953
2299
|
continue;
|
|
1954
2300
|
const command = entry["command"];
|
|
1955
|
-
if (typeof command
|
|
1956
|
-
|
|
2301
|
+
if (typeof command !== "string")
|
|
2302
|
+
continue;
|
|
2303
|
+
const parse = parseClaudeHookCommand(command);
|
|
2304
|
+
if (parse.kind === "foreign")
|
|
2305
|
+
continue;
|
|
2306
|
+
found.push({
|
|
2307
|
+
matcher: typeof pattern === "string" ? pattern : "",
|
|
2308
|
+
command,
|
|
2309
|
+
parse,
|
|
2310
|
+
});
|
|
1957
2311
|
}
|
|
1958
2312
|
}
|
|
1959
|
-
return
|
|
2313
|
+
return found;
|
|
2314
|
+
}
|
|
2315
|
+
/** Does any `hooks.<event>` entry run this CLI's Claude Code hook? */
|
|
2316
|
+
function registersApprovalHook(hooks, event) {
|
|
2317
|
+
return claudeHookEntries(hooks, event).length > 0;
|
|
1960
2318
|
}
|
|
1961
2319
|
/**
|
|
1962
2320
|
* Is the harness registered for the event that reports outcomes (APRV-145)?
|
|
@@ -1972,6 +2330,12 @@ function registersApprovalHook(hooks, event) {
|
|
|
1972
2330
|
* `policy.core` in this taxonomy — a file that configures the gate is part of
|
|
1973
2331
|
* the gate — so the repair is a line for a human to commit, printed by
|
|
1974
2332
|
* `approval instructions hook`.
|
|
2333
|
+
*
|
|
2334
|
+
* Which entries are OURS is read by {@link claudeHookEntries} since APRV-408,
|
|
2335
|
+
* the same reading `harness-hook-wiring` uses, so the two rows cannot come
|
|
2336
|
+
* apart on the question. Before that this row matched the substring `approval
|
|
2337
|
+
* hook`, which counted another harness's entry parked in Claude Code's own
|
|
2338
|
+
* settings file as a post-execution reporter for Claude Code.
|
|
1975
2339
|
*/
|
|
1976
2340
|
function checkHarnessOutcomes(dir) {
|
|
1977
2341
|
const check = "harness-hook-outcomes";
|
|
@@ -2004,53 +2368,138 @@ function checkHarnessOutcomes(dir) {
|
|
|
2004
2368
|
return {
|
|
2005
2369
|
check,
|
|
2006
2370
|
status: "skip",
|
|
2007
|
-
detail: `${path} registers no \`approval hook\` entry, so this checkout is not gated by the harness hook at all`,
|
|
2371
|
+
detail: `${path} registers no \`approval hook claude-code\` entry, so this checkout is not gated by the harness hook at all`,
|
|
2008
2372
|
};
|
|
2009
2373
|
}
|
|
2010
2374
|
if (!post) {
|
|
2011
2375
|
return {
|
|
2012
2376
|
check,
|
|
2013
2377
|
status: "fail",
|
|
2014
|
-
detail: `${path} registers \`approval hook\` for PreToolUse and not for PostToolUse, so no tool call ever reports an outcome: every harness execution.started stays delegated, and the loop escalation of SPEC.md §10.2 cannot accrue on this path`,
|
|
2378
|
+
detail: `${path} registers \`approval hook claude-code\` for PreToolUse and not for PostToolUse, so no tool call ever reports an outcome: every harness execution.started stays delegated, and the loop escalation of SPEC.md §10.2 cannot accrue on this path`,
|
|
2015
2379
|
fix: "approval hook claude-code --help — prints the PostToolUse entry to add, which a human commits (.claude/settings.json is policy.core)",
|
|
2016
2380
|
};
|
|
2017
2381
|
}
|
|
2018
2382
|
return {
|
|
2019
2383
|
check,
|
|
2020
2384
|
status: "pass",
|
|
2021
|
-
detail: `${path} registers \`approval hook\` for the ${pre ? "pre-execution and " : ""}post-execution event, so tool call outcomes reach the log and loop escalation can accrue`,
|
|
2385
|
+
detail: `${path} registers \`approval hook claude-code\` for the ${pre ? "pre-execution and " : ""}post-execution event, so tool call outcomes reach the log and loop escalation can accrue`,
|
|
2022
2386
|
};
|
|
2023
2387
|
}
|
|
2024
2388
|
// ---------------------------------------------------------------------------
|
|
2025
2389
|
// harness hook wiring in THIS worktree (APRV-151)
|
|
2026
2390
|
// ---------------------------------------------------------------------------
|
|
2027
|
-
/** The
|
|
2028
|
-
const
|
|
2029
|
-
/**
|
|
2030
|
-
|
|
2031
|
-
|
|
2032
|
-
|
|
2033
|
-
|
|
2034
|
-
|
|
2035
|
-
|
|
2036
|
-
|
|
2037
|
-
|
|
2038
|
-
|
|
2391
|
+
/** The repair every wiring verdict but the two absences points at. */
|
|
2392
|
+
const HOOK_ENTRY_FIX = "approval instructions hook — prints the PreToolUse entry a human commits";
|
|
2393
|
+
/** A tool name as a `matcher` may spell it: no regex syntax anywhere in it. */
|
|
2394
|
+
const MATCHER_TOOL_NAME = /^[A-Za-z0-9_-]+$/u;
|
|
2395
|
+
/**
|
|
2396
|
+
* The tools a `matcher` names, or `"pattern"` when it is not a plain list
|
|
2397
|
+
* (APRV-408).
|
|
2398
|
+
*
|
|
2399
|
+
* Claude Code treats `matcher` as a regular expression, and this file will not
|
|
2400
|
+
* evaluate one: a regex is answered by matching it against candidate strings,
|
|
2401
|
+
* and the candidates here are "every tool name that exists", which is not a set
|
|
2402
|
+
* this runtime holds. So the only shape read is the documented one — tool names
|
|
2403
|
+
* joined by `|` — and everything else is reported as unresolved coverage rather
|
|
2404
|
+
* than guessed at. An empty matcher is `"pattern"` too: it selects every tool
|
|
2405
|
+
* in Claude Code, which is a coverage claim this row would rather see written
|
|
2406
|
+
* down than infer.
|
|
2407
|
+
*
|
|
2408
|
+
* Nothing here compiles the pattern, so a hostile one (`(a+)+$` and its kin)
|
|
2409
|
+
* costs one anchored character-class test per alternative and cannot make
|
|
2410
|
+
* `doctor` hang.
|
|
2411
|
+
*/
|
|
2412
|
+
function matcherTools(pattern) {
|
|
2413
|
+
if (pattern.trim().length === 0)
|
|
2414
|
+
return "pattern";
|
|
2415
|
+
const parts = pattern.split("|");
|
|
2416
|
+
if (parts.some((part) => !MATCHER_TOOL_NAME.test(part)))
|
|
2417
|
+
return "pattern";
|
|
2418
|
+
return parts;
|
|
2419
|
+
}
|
|
2420
|
+
/** `realpathSync` that answers the path itself when it cannot be resolved. */
|
|
2421
|
+
function realOrSelf(path) {
|
|
2422
|
+
try {
|
|
2423
|
+
return realpathSync(path);
|
|
2424
|
+
}
|
|
2425
|
+
catch {
|
|
2426
|
+
return path;
|
|
2427
|
+
}
|
|
2428
|
+
}
|
|
2429
|
+
/**
|
|
2430
|
+
* Which checkout the registered handler resolves policy and log from
|
|
2431
|
+
* (APRV-408).
|
|
2432
|
+
*
|
|
2433
|
+
* The committed entry in this repository hardcodes one absolute `--dir`, which
|
|
2434
|
+
* is correct on the machine it was written on and names a directory that does
|
|
2435
|
+
* not exist in a clone anywhere else; nothing checked. A handler bound to
|
|
2436
|
+
* another checkout is not a weaker gate, it is a DIFFERENT gate: another
|
|
2437
|
+
* policy, another log, another open window. That is the one thing this row
|
|
2438
|
+
* fails on.
|
|
2439
|
+
*
|
|
2440
|
+
* `primaryRoot` first and `repoRoot` as the fallback, because the documented
|
|
2441
|
+
* `--dir` names the PRIMARY checkout: in a linked worktree the two differ, and
|
|
2442
|
+
* the primary is the one the gate must answer from.
|
|
2443
|
+
*/
|
|
2444
|
+
function describeBinding(entries, dir, fallbackRoot) {
|
|
2445
|
+
const primary = primaryRoot(dir);
|
|
2446
|
+
const expected = primary ?? fallbackRoot;
|
|
2447
|
+
const expectedName = primary === null ? "repository root" : "primary root";
|
|
2448
|
+
const rank = { pass: 0, skip: 1, fail: 2 };
|
|
2449
|
+
const reports = [];
|
|
2450
|
+
for (const entry of entries) {
|
|
2451
|
+
const parse = entry.parse;
|
|
2452
|
+
// `claudeHookEntries` keeps no foreign entry, and the narrowing is written
|
|
2453
|
+
// out anyway: a reader of this loop should not have to hold that invariant.
|
|
2454
|
+
if (parse.kind === "foreign")
|
|
2039
2455
|
continue;
|
|
2040
|
-
|
|
2041
|
-
|
|
2456
|
+
if (parse.kind === "unresolved") {
|
|
2457
|
+
reports.push({
|
|
2458
|
+
status: "skip",
|
|
2459
|
+
clause: `BINDING UNRESOLVED: the PreToolUse command ${JSON.stringify(entry.command)} cannot be read as an \`approval hook claude-code\` invocation (${parse.reason}), so which checkout it would gate is not established here — and unresolved is never a pass.`,
|
|
2460
|
+
});
|
|
2461
|
+
continue;
|
|
2462
|
+
}
|
|
2463
|
+
const declared = parse.dir;
|
|
2464
|
+
if (declared === null) {
|
|
2465
|
+
reports.push({
|
|
2466
|
+
status: "skip",
|
|
2467
|
+
clause: `BINDING UNSTATED: the PreToolUse command names no \`--dir\`, so the hook resolves its policy and log from wherever the harness happens to run it (APRV-101) rather than from a directory this row can read. The ${expectedName} here is ${expected ?? "unknown to git"}.`,
|
|
2468
|
+
});
|
|
2042
2469
|
continue;
|
|
2043
|
-
for (const entry of entries) {
|
|
2044
|
-
if (typeof entry !== "object" || entry === null)
|
|
2045
|
-
continue;
|
|
2046
|
-
const command = entry["command"];
|
|
2047
|
-
if (typeof command !== "string" || !/\bapproval hook\b/u.test(command))
|
|
2048
|
-
continue;
|
|
2049
|
-
const pattern = matcher["matcher"];
|
|
2050
|
-
found.push(typeof pattern === "string" ? pattern : "");
|
|
2051
2470
|
}
|
|
2471
|
+
if (!isAbsolute(declared)) {
|
|
2472
|
+
reports.push({
|
|
2473
|
+
status: "skip",
|
|
2474
|
+
clause: `BINDING RELATIVE: the PreToolUse command binds \`--dir ${declared}\`, which names a different directory from every working directory it could be launched in. The ${expectedName} here is ${expected ?? "unknown to git"}.`,
|
|
2475
|
+
});
|
|
2476
|
+
continue;
|
|
2477
|
+
}
|
|
2478
|
+
if (expected === null) {
|
|
2479
|
+
reports.push({
|
|
2480
|
+
status: "skip",
|
|
2481
|
+
clause: `BINDING UNCHECKED: the PreToolUse command binds \`--dir ${declared}\`, and git could not say what checkout ${dir} belongs to, so the two cannot be compared here.`,
|
|
2482
|
+
});
|
|
2483
|
+
continue;
|
|
2484
|
+
}
|
|
2485
|
+
if (realOrSelf(declared) !== realOrSelf(expected)) {
|
|
2486
|
+
reports.push({
|
|
2487
|
+
status: "fail",
|
|
2488
|
+
clause: `MISBOUND: the PreToolUse command binds \`--dir ${declared}\`, and this checkout's ${expectedName} is ${expected}. A hook answering from another checkout answers another checkout's policy, log and open window, so a call gated here is not gated by the gate a reader of this repository is looking at.`,
|
|
2489
|
+
});
|
|
2490
|
+
continue;
|
|
2491
|
+
}
|
|
2492
|
+
reports.push({
|
|
2493
|
+
status: "pass",
|
|
2494
|
+
clause: `BOUND to ${expected}, this checkout's ${expectedName}, by the \`--dir\` the PreToolUse command carries.`,
|
|
2495
|
+
});
|
|
2052
2496
|
}
|
|
2053
|
-
|
|
2497
|
+
if (reports.length === 0) {
|
|
2498
|
+
return { status: "skip", clause: "BINDING UNRESOLVED: no PreToolUse command to read." };
|
|
2499
|
+
}
|
|
2500
|
+
const status = reports.reduce((worst, report) => (rank[report.status] > rank[worst] ? report.status : worst), "pass");
|
|
2501
|
+
const clauses = [...new Set(reports.map((report) => report.clause))];
|
|
2502
|
+
return { status, clause: clauses.join(" ") };
|
|
2054
2503
|
}
|
|
2055
2504
|
/**
|
|
2056
2505
|
* Does the settings file THIS worktree carries register the pre-execution hook
|
|
@@ -2074,11 +2523,25 @@ function approvalHookMatchers(hooks, event) {
|
|
|
2074
2523
|
* CI-side, over the committed log, in `core/protected-path-guard.ts`: it does
|
|
2075
2524
|
* not trust session wiring, and this row does not claim to establish it.
|
|
2076
2525
|
*
|
|
2077
|
-
* Advisory, so it never fails the run
|
|
2078
|
-
*
|
|
2526
|
+
* Advisory, so it never fails the run — with the single exception APRV-408
|
|
2527
|
+
* added: a handler POSITIVELY bound to a different checkout. That is not a
|
|
2528
|
+
* weaker report of the same gate, it is a statement that the entry on disk
|
|
2529
|
+
* answers from somewhere else, which this row can establish from the file
|
|
2530
|
+
* alone. Everything else it learned in APRV-408 only ADDS lines from disk
|
|
2531
|
+
* facts: the gated roster comes from the adapter rather than a hand list, the
|
|
2532
|
+
* read tools the adapter declares and the matcher leaves out are named as the
|
|
2533
|
+
* documented default they are, and a matcher tool the adapter does not handle
|
|
2534
|
+
* is named as the `allow` it will actually receive. None of those feeds a
|
|
2535
|
+
* verdict, and none of them asks the session anything about itself (SPEC §11:
|
|
2536
|
+
* a self-reported field never reduces scrutiny, and this row reads no
|
|
2537
|
+
* self-report at all).
|
|
2538
|
+
*
|
|
2539
|
+
* Doctor reads and never writes; the file is `policy.core` and its repair is a
|
|
2540
|
+
* line for a human to commit.
|
|
2079
2541
|
*/
|
|
2080
2542
|
function checkHarnessWiring(dir) {
|
|
2081
2543
|
const check = "harness-hook-wiring";
|
|
2544
|
+
const gated = gatedClaudeTools();
|
|
2082
2545
|
const root = repoRoot(dir);
|
|
2083
2546
|
const where = root === null ? dir : root;
|
|
2084
2547
|
const path = join(where, CLAUDE_SETTINGS);
|
|
@@ -2112,29 +2575,72 @@ function checkHarnessWiring(dir) {
|
|
|
2112
2575
|
const hooks = typeof parsed === "object" && parsed !== null && !Array.isArray(parsed)
|
|
2113
2576
|
? parsed["hooks"]
|
|
2114
2577
|
: null;
|
|
2115
|
-
const
|
|
2116
|
-
if (
|
|
2578
|
+
const entries = claudeHookEntries(hooks, "PreToolUse");
|
|
2579
|
+
if (entries.length === 0) {
|
|
2117
2580
|
return {
|
|
2118
2581
|
check,
|
|
2119
2582
|
status: "skip",
|
|
2120
|
-
detail: `NOT WIRED: ${path} registers no \`approval hook\` entry for PreToolUse, so a protected-path
|
|
2121
|
-
fix:
|
|
2583
|
+
detail: `NOT WIRED: ${path} registers no \`approval hook claude-code\` entry for PreToolUse, so a protected-path write arriving through ${gated.join(", ")} in this checkout reaches the file system unclassified.`,
|
|
2584
|
+
fix: HOOK_ENTRY_FIX,
|
|
2122
2585
|
};
|
|
2123
2586
|
}
|
|
2124
|
-
const
|
|
2125
|
-
const
|
|
2587
|
+
const matchers = entries.map((entry) => entry.matcher);
|
|
2588
|
+
const named = new Set();
|
|
2589
|
+
const patterns = [];
|
|
2590
|
+
for (const matcher of matchers) {
|
|
2591
|
+
const tools = matcherTools(matcher);
|
|
2592
|
+
if (tools === "pattern") {
|
|
2593
|
+
patterns.push(matcher);
|
|
2594
|
+
continue;
|
|
2595
|
+
}
|
|
2596
|
+
for (const tool of tools)
|
|
2597
|
+
named.add(tool);
|
|
2598
|
+
}
|
|
2599
|
+
const binding = describeBinding(entries, dir, root);
|
|
2600
|
+
const wired = `${path} registers \`approval hook claude-code\` for PreToolUse with matcher ${JSON.stringify(matchers.join(", "))}`;
|
|
2601
|
+
const notProof = `This is the file being present, NOT proof this session loaded it — the APRV-151 bypasses happened in worktrees carrying exactly this entry. The check that does not trust session wiring is the CI-side grant cross-check over the committed log, which asks whether the CHANGE was granted rather than whether the path ever was (APRV-202).`;
|
|
2602
|
+
// A matcher this file will not read leaves every coverage question open, so
|
|
2603
|
+
// the roster, read-tool and passthrough lines are withheld rather than
|
|
2604
|
+
// computed against a set that may be incomplete. The binding is independent
|
|
2605
|
+
// of the matcher and is still reported (and can still fail).
|
|
2606
|
+
if (patterns.length > 0) {
|
|
2607
|
+
return {
|
|
2608
|
+
check,
|
|
2609
|
+
status: binding.status === "fail" ? "fail" : "skip",
|
|
2610
|
+
detail: `MATCHED BY PATTERN: ${wired}. ${patterns
|
|
2611
|
+
.map((pattern) => JSON.stringify(pattern))
|
|
2612
|
+
.join(" and ")} is not a plain \`|\`-joined list of tool names, and this row does not evaluate a regular expression, so which of ${gated.join(", ")} it covers is not resolved here. ${binding.clause} ${notProof}`,
|
|
2613
|
+
fix: HOOK_ENTRY_FIX,
|
|
2614
|
+
};
|
|
2615
|
+
}
|
|
2616
|
+
const missing = gated.filter((tool) => !named.has(tool));
|
|
2126
2617
|
if (missing.length > 0) {
|
|
2127
2618
|
return {
|
|
2128
2619
|
check,
|
|
2129
|
-
status: "skip",
|
|
2130
|
-
detail: `NOT WIRED for every tool: ${
|
|
2131
|
-
fix:
|
|
2620
|
+
status: binding.status === "fail" ? "fail" : "skip",
|
|
2621
|
+
detail: `NOT WIRED for every tool: ${wired}, which does not cover ${missing.join(", ")}. This runtime's Claude Code adapter gates ${gated.join(", ")}, so a protected-path write arriving through ${missing[0]} is never classified. ${binding.clause}`,
|
|
2622
|
+
fix: HOOK_ENTRY_FIX,
|
|
2132
2623
|
};
|
|
2133
2624
|
}
|
|
2625
|
+
// Informational, both of them, and neither is ever a verdict.
|
|
2626
|
+
const held = CLAUDE_HOOK_ADAPTER.readTools.filter((tool) => !named.has(tool));
|
|
2627
|
+
const readLine = held.length === 0
|
|
2628
|
+
? ""
|
|
2629
|
+
: ` READ TOOLS HELD BY DESIGN: the adapter also knows ${held.join(", ")}, and the matcher leaves ${held.length === 1 ? "it" : "them"} out, so a read is answered without reaching the gate. That is the documented default rather than a gap — see "The read scope" in docs/claude-code-hook.md, where the \`Bash|Edit|Write|MultiEdit|NotebookEdit|Read|Glob|Grep\` matcher is printed and deliberately left out of the installed one, because a read is the most frequent tool call a session makes and every matched call is a Node process start. This line never fails this row.`;
|
|
2630
|
+
const handled = new Set([
|
|
2631
|
+
...gated,
|
|
2632
|
+
...CLAUDE_HOOK_ADAPTER.readTools,
|
|
2633
|
+
...(CLAUDE_HOOK_ADAPTER.passThroughTools ?? []),
|
|
2634
|
+
]);
|
|
2635
|
+
const unhandled = [...named].filter((tool) => !handled.has(tool));
|
|
2636
|
+
const passthroughLine = unhandled.length === 0
|
|
2637
|
+
? ""
|
|
2638
|
+
: ` MATCHED AND NOT CLASSIFIED: ${unhandled.join(", ")} ${unhandled.length === 1 ? "is" : "are"} in the matcher and this runtime's Claude Code adapter handles ${unhandled.length === 1 ? "it" : "them"} as neither a shell, file nor read tool, so every such call spawns the hook and is answered \`allow\` with "is not a gated tool". The cost is real and the coverage is not.`;
|
|
2134
2639
|
return {
|
|
2135
2640
|
check,
|
|
2136
|
-
status:
|
|
2137
|
-
detail: `WIRED on disk: ${path} registers \`approval hook\` for PreToolUse over ${
|
|
2641
|
+
status: binding.status,
|
|
2642
|
+
detail: `WIRED on disk: ${path} registers \`approval hook claude-code\` for PreToolUse over ${gated.join(", ")}, the tools this runtime's own adapter gates. ${binding.clause}${readLine}${passthroughLine} ${notProof}`,
|
|
2643
|
+
...(binding.status === "pass" ? {} : { fix: HOOK_ENTRY_FIX }),
|
|
2138
2644
|
};
|
|
2139
2645
|
}
|
|
2140
2646
|
/** The project-local Codex hook files doctor can observe without asking Codex. */
|
|
@@ -2250,10 +2756,138 @@ export function checkCodexHookWiring(dir) {
|
|
|
2250
2756
|
// ---------------------------------------------------------------------------
|
|
2251
2757
|
/** The Cursor counterpart of {@link CLAUDE_SETTINGS}. */
|
|
2252
2758
|
const CURSOR_HOOKS = join(".cursor", "hooks.json");
|
|
2253
|
-
/**
|
|
2254
|
-
|
|
2255
|
-
|
|
2256
|
-
|
|
2759
|
+
/**
|
|
2760
|
+
* The environment variable Hermes Agent resolves its own configuration root
|
|
2761
|
+
* from, and the default it falls back to (APRV-398).
|
|
2762
|
+
*
|
|
2763
|
+
* Read here for ONE purpose: to know where to look for a registration this
|
|
2764
|
+
* checkout cannot hold. Hermes documents no project-local configuration
|
|
2765
|
+
* directory, so a repository-relative search for a Hermes hook would be empty on
|
|
2766
|
+
* every machine and the row would never be able to say anything true.
|
|
2767
|
+
*
|
|
2768
|
+
* Reading an environment variable is acceptable here in the narrow way it is
|
|
2769
|
+
* acceptable in `sandboxRequirement`: it moves where a DIAGNOSTIC looks, and the
|
|
2770
|
+
* row it feeds can only SKIP when it finds nothing and only ADD a line when it
|
|
2771
|
+
* finds something. Nothing an agent can set makes a red line go away, which is
|
|
2772
|
+
* the only property §11.1 invariant 4 asks of a self-reported input.
|
|
2773
|
+
*/
|
|
2774
|
+
const HERMES_HOME_ENV = "HERMES_HOME";
|
|
2775
|
+
const HERMES_HOME_DEFAULT = ".hermes";
|
|
2776
|
+
/**
|
|
2777
|
+
* Where each harness's `approval hook` registration can be written.
|
|
2778
|
+
*
|
|
2779
|
+
* A `Record<HarnessKind, …>` since APRV-398, and the type is the point, exactly
|
|
2780
|
+
* as it is for `HARNESS_ADAPTERS`: a kind added to {@link HARNESS_KINDS} with no
|
|
2781
|
+
* entry here fails to compile, and `tests/harness-enum.test.ts` pins the keys
|
|
2782
|
+
* set-equal to that list. Before this it was a flat array of three paths and a
|
|
2783
|
+
* regex naming three kinds, so `grok` and `muse` shipped adapters that this row
|
|
2784
|
+
* could not see — the same class of miss APRV-358 was filed for, in the one
|
|
2785
|
+
* place APRV-358 did not reach.
|
|
2786
|
+
*
|
|
2787
|
+
* An empty list is a statement rather than a gap, and `codex` has one on
|
|
2788
|
+
* purpose: `.codex/hooks.json` is already read by the Codex-specific rows above
|
|
2789
|
+
* ({@link CODEX_HOOKS}), and this list is consulted by `registeredHarnesses`,
|
|
2790
|
+
* whose behaviour for the three harnesses that were already in it must not
|
|
2791
|
+
* change by a byte in this task. Codex is therefore listed with the file it
|
|
2792
|
+
* always had and nothing new.
|
|
2793
|
+
*/
|
|
2794
|
+
export const HARNESS_SETTINGS = {
|
|
2795
|
+
"claude-code": [{ file: CLAUDE_SETTINGS }],
|
|
2796
|
+
cursor: [{ file: CURSOR_HOOKS }],
|
|
2797
|
+
codex: [{ file: CODEX_HOOKS }],
|
|
2798
|
+
// APRV-243: Grok Build installs a PreToolUse entry per file under
|
|
2799
|
+
// `.grok/hooks/`, so the directory is scanned rather than one name guessed.
|
|
2800
|
+
// It also READS `.claude/settings.json` and `.cursor/hooks.json` for
|
|
2801
|
+
// compatibility, and both of those are already scanned above; a Grok session
|
|
2802
|
+
// hosted by this repository's claude-code entry is the hazard
|
|
2803
|
+
// `docs/grok-hook.md` opens with, not a registration this row should claim.
|
|
2804
|
+
grok: [{ dir: join(".grok", "hooks") }],
|
|
2805
|
+
// APRV-350, observed: the installed build reads `.muse/hooks.json`.
|
|
2806
|
+
// `.muse/settings.json` is listed for the reason the classifier lists it —
|
|
2807
|
+
// Meta documents a user-level `settings.json` `hooks` block, so a
|
|
2808
|
+
// project-level copy would be the same organ under a second name — and an
|
|
2809
|
+
// entry for a file Muse does not read costs a miss rather than a false row.
|
|
2810
|
+
muse: [{ file: join(".muse", "hooks.json") }, { file: join(".muse", "settings.json") }],
|
|
2811
|
+
// APRV-398: not in the checkout at all. See {@link HERMES_HOME_ENV}.
|
|
2812
|
+
hermes: [
|
|
2813
|
+
{ home: "hermes", file: "config.yaml", text: true },
|
|
2814
|
+
{ home: "hermes", file: "config.yml", text: true },
|
|
2815
|
+
],
|
|
2816
|
+
};
|
|
2817
|
+
/**
|
|
2818
|
+
* `approval hook <kind>` inside a command string, whichever file shape holds it.
|
|
2819
|
+
*
|
|
2820
|
+
* DERIVED from {@link HARNESS_KINDS} since APRV-398 rather than spelled, so a
|
|
2821
|
+
* new adapter cannot ship with the pattern left behind — which is what happened
|
|
2822
|
+
* to `grok` and `muse`. Longest alternative first: the alternation is tried left
|
|
2823
|
+
* to right, and a kind that is a prefix of another kind would otherwise match
|
|
2824
|
+
* the shorter spelling and leave the rest of the name outside the capture.
|
|
2825
|
+
*/
|
|
2826
|
+
const HOOK_COMMAND = new RegExp(`\\bapproval["']?\\s+hook\\s+(${[...HARNESS_KINDS]
|
|
2827
|
+
.sort((left, right) => right.length - left.length)
|
|
2828
|
+
.join("|")})\\b`, "u");
|
|
2829
|
+
/**
|
|
2830
|
+
* The places {@link registeredHarnesses} looked, for the row that found nothing.
|
|
2831
|
+
*
|
|
2832
|
+
* Every location of every kind, in kind order, so the line a reader gets names
|
|
2833
|
+
* the file they have to write rather than three of the six. A home-resolved
|
|
2834
|
+
* location is printed with its `$HERMES_HOME/` prefix rather than expanded: the
|
|
2835
|
+
* expansion is this machine's answer, and the instruction is the general one.
|
|
2836
|
+
*/
|
|
2837
|
+
function harnessSettingNames() {
|
|
2838
|
+
return HARNESS_KINDS.flatMap((kind) => HARNESS_SETTINGS[kind].map((location) => {
|
|
2839
|
+
const name = location.file ?? `${location.dir ?? "?"}/`;
|
|
2840
|
+
return location.home === "hermes" ? `$HERMES_HOME/${name}` : name;
|
|
2841
|
+
})).join(" or ");
|
|
2842
|
+
}
|
|
2843
|
+
/**
|
|
2844
|
+
* A fresh copy of {@link HOOK_COMMAND}, for the suite that pins it (APRV-398).
|
|
2845
|
+
*
|
|
2846
|
+
* A function rather than the constant, so a caller cannot carry state into this
|
|
2847
|
+
* module's own matching: the pattern is not global today, and a test that shared
|
|
2848
|
+
* the object would silently break on the day somebody adds a flag to it.
|
|
2849
|
+
*/
|
|
2850
|
+
export function hookCommandPattern() {
|
|
2851
|
+
return new RegExp(HOOK_COMMAND.source, HOOK_COMMAND.flags);
|
|
2852
|
+
}
|
|
2853
|
+
/** Hermes's configuration root, from the environment or its documented default. */
|
|
2854
|
+
function hermesHome(env = process.env) {
|
|
2855
|
+
const declared = env[HERMES_HOME_ENV];
|
|
2856
|
+
if (typeof declared === "string" && declared.trim() !== "")
|
|
2857
|
+
return declared;
|
|
2858
|
+
return join(homedir(), HERMES_HOME_DEFAULT);
|
|
2859
|
+
}
|
|
2860
|
+
/** Every file a location names that exists on disk, absolute. */
|
|
2861
|
+
function settingFiles(dir, location) {
|
|
2862
|
+
const base = location.home === "hermes" ? hermesHome() : dir;
|
|
2863
|
+
if (location.file !== undefined) {
|
|
2864
|
+
const path = join(base, location.file);
|
|
2865
|
+
return existsSync(path) ? [path] : [];
|
|
2866
|
+
}
|
|
2867
|
+
if (location.dir === undefined)
|
|
2868
|
+
return [];
|
|
2869
|
+
let names;
|
|
2870
|
+
try {
|
|
2871
|
+
names = readdirSync(join(base, location.dir));
|
|
2872
|
+
}
|
|
2873
|
+
catch {
|
|
2874
|
+
return [];
|
|
2875
|
+
}
|
|
2876
|
+
const found = [];
|
|
2877
|
+
for (const name of names.sort()) {
|
|
2878
|
+
if (location.prefix !== undefined && !name.startsWith(location.prefix))
|
|
2879
|
+
continue;
|
|
2880
|
+
const path = join(base, location.dir, name);
|
|
2881
|
+
try {
|
|
2882
|
+
if (statSync(path).isFile())
|
|
2883
|
+
found.push(path);
|
|
2884
|
+
}
|
|
2885
|
+
catch {
|
|
2886
|
+
continue;
|
|
2887
|
+
}
|
|
2888
|
+
}
|
|
2889
|
+
return found;
|
|
2890
|
+
}
|
|
2257
2891
|
/**
|
|
2258
2892
|
* Every harness this checkout registers an `approval hook` command for.
|
|
2259
2893
|
*
|
|
@@ -2268,35 +2902,52 @@ const HOOK_COMMAND = /\bapproval["']?\s+hook\s+(claude-code|cursor|codex)\b/u;
|
|
|
2268
2902
|
*/
|
|
2269
2903
|
export function registeredHarnesses(dir) {
|
|
2270
2904
|
const found = new Set();
|
|
2271
|
-
|
|
2272
|
-
|
|
2273
|
-
|
|
2274
|
-
|
|
2275
|
-
|
|
2276
|
-
|
|
2277
|
-
|
|
2278
|
-
|
|
2279
|
-
|
|
2280
|
-
|
|
2281
|
-
|
|
2282
|
-
|
|
2283
|
-
|
|
2284
|
-
|
|
2285
|
-
|
|
2286
|
-
const match = HOOK_COMMAND.exec(node);
|
|
2287
|
-
if (match !== null &&
|
|
2288
|
-
isHarnessKind(match[1]) &&
|
|
2289
|
-
(match[1] !== "codex" || isDirectCodexHookCommand(node))) {
|
|
2290
|
-
found.add(match[1]);
|
|
2905
|
+
const locations = HARNESS_KINDS.flatMap((kind) => HARNESS_SETTINGS[kind]);
|
|
2906
|
+
for (const location of locations) {
|
|
2907
|
+
for (const path of settingFiles(dir, location)) {
|
|
2908
|
+
let parsed;
|
|
2909
|
+
if (location.text === true) {
|
|
2910
|
+
// A YAML document has no JSON leaves to walk, so the whole text is the
|
|
2911
|
+
// haystack. Only the hermes location asks for this; the three that were
|
|
2912
|
+
// here before this task keep the JSON walk they have always had, which
|
|
2913
|
+
// is what keeps their behaviour byte-identical.
|
|
2914
|
+
let text;
|
|
2915
|
+
try {
|
|
2916
|
+
text = readFileSync(path, "utf8");
|
|
2917
|
+
}
|
|
2918
|
+
catch {
|
|
2919
|
+
continue;
|
|
2291
2920
|
}
|
|
2921
|
+
const match = HOOK_COMMAND.exec(text);
|
|
2922
|
+
if (match !== null && isHarnessKind(match[1]))
|
|
2923
|
+
found.add(match[1]);
|
|
2292
2924
|
continue;
|
|
2293
2925
|
}
|
|
2294
|
-
|
|
2295
|
-
|
|
2926
|
+
try {
|
|
2927
|
+
parsed = JSON.parse(readFileSync(path, "utf8"));
|
|
2928
|
+
}
|
|
2929
|
+
catch {
|
|
2296
2930
|
continue;
|
|
2297
2931
|
}
|
|
2298
|
-
|
|
2299
|
-
|
|
2932
|
+
const stack = [parsed];
|
|
2933
|
+
while (stack.length > 0) {
|
|
2934
|
+
const node = stack.pop();
|
|
2935
|
+
if (typeof node === "string") {
|
|
2936
|
+
const match = HOOK_COMMAND.exec(node);
|
|
2937
|
+
if (match !== null &&
|
|
2938
|
+
isHarnessKind(match[1]) &&
|
|
2939
|
+
(match[1] !== "codex" || isDirectCodexHookCommand(node))) {
|
|
2940
|
+
found.add(match[1]);
|
|
2941
|
+
}
|
|
2942
|
+
continue;
|
|
2943
|
+
}
|
|
2944
|
+
if (Array.isArray(node)) {
|
|
2945
|
+
stack.push(...node);
|
|
2946
|
+
continue;
|
|
2947
|
+
}
|
|
2948
|
+
if (typeof node === "object" && node !== null) {
|
|
2949
|
+
stack.push(...Object.values(node));
|
|
2950
|
+
}
|
|
2300
2951
|
}
|
|
2301
2952
|
}
|
|
2302
2953
|
}
|
|
@@ -2368,6 +3019,20 @@ function recordedHarnessVersions(records) {
|
|
|
2368
3019
|
* has nothing to compare against, and inventing a baseline would be inventing
|
|
2369
3020
|
* the fact); and no such binary on PATH, since doctor may be running somewhere
|
|
2370
3021
|
* the harness is not installed, which is a state and not a fault.
|
|
3022
|
+
*
|
|
3023
|
+
* ## The Hermes fail-closed floor rides in this row (APRV-415)
|
|
3024
|
+
*
|
|
3025
|
+
* One harness has a second version question, and it is graver than the first. A
|
|
3026
|
+
* Hermes below `HERMES_FAIL_CLOSED_FLOOR` does not know `fail_closed` and ignores
|
|
3027
|
+
* it silently, so every broken hook PROCEEDS and the session looks gated while it
|
|
3028
|
+
* is not. That belongs here rather than in a row of its own because both findings
|
|
3029
|
+
* answer one question — is the binary hosting this hook in a state where the gate
|
|
3030
|
+
* is known to fire — and a reader who sees this row green has been told both. It
|
|
3031
|
+
* is read from `<binary> --version` rather than from a record, because Hermes's
|
|
3032
|
+
* build stamp sits behind a non-ASCII separator that the write boundary refuses,
|
|
3033
|
+
* so no record can ever carry it. The comparison is on the BUILD DATE and the
|
|
3034
|
+
* upstream commit: the two builds that differ on this behaviour report the same
|
|
3035
|
+
* semver.
|
|
2371
3036
|
*/
|
|
2372
3037
|
function checkHarnessVersion(dir, records) {
|
|
2373
3038
|
const check = "harness-version-unverified";
|
|
@@ -2378,13 +3043,41 @@ function checkHarnessVersion(dir, records) {
|
|
|
2378
3043
|
return {
|
|
2379
3044
|
check,
|
|
2380
3045
|
status: "skip",
|
|
2381
|
-
detail: `${where} registers no \`approval hook\` command in ${
|
|
3046
|
+
detail: `${where} registers no \`approval hook\` command in ${harnessSettingNames()}, so no harness hosts the hook here and there is no installed version for the log to be behind`,
|
|
2382
3047
|
};
|
|
2383
3048
|
}
|
|
2384
3049
|
const recorded = recordedHarnessVersions(records);
|
|
2385
3050
|
const mismatched = [];
|
|
2386
3051
|
const matched = [];
|
|
2387
3052
|
const unknown = [];
|
|
3053
|
+
// APRV-415. The VERSION FLOOR, which is a different question from the rest of
|
|
3054
|
+
// this row and belongs to it for one reason: both ask whether the binary
|
|
3055
|
+
// hosting this hook is in a state where the gate is known to fire. The row
|
|
3056
|
+
// above asks whether the binary CHANGED without the gate being exercised
|
|
3057
|
+
// since; this asks whether the binary is old enough that a broken hook would
|
|
3058
|
+
// not have stopped anything in the first place.
|
|
3059
|
+
//
|
|
3060
|
+
// It is read off the INSTALLED build rather than off a record, and that is not
|
|
3061
|
+
// a shortcut: Hermes prints its build stamp behind a non-ASCII separator, so
|
|
3062
|
+
// the write boundary's printable-ASCII rule (SPEC.md §11.1 invariant 3) refuses
|
|
3063
|
+
// the whole line and no record ever carries a Hermes version. The floor would be
|
|
3064
|
+
// permanently unknown if it waited for one.
|
|
3065
|
+
const floor = [];
|
|
3066
|
+
const belowFloor = [];
|
|
3067
|
+
if (kinds.includes("hermes")) {
|
|
3068
|
+
const raw = installedHarnessVersionRaw("hermes");
|
|
3069
|
+
const support = hermesFailClosedSupport(raw);
|
|
3070
|
+
const quoted = raw === null ? "(no answer)" : JSON.stringify(raw);
|
|
3071
|
+
if (support === "ignores") {
|
|
3072
|
+
belowFloor.push(`the installed Hermes is BELOW the fail_closed floor: \`${HARNESS_BINARY["hermes"]} --version\` says ${quoted}, and the floor is ${HERMES_FAIL_CLOSED_FLOOR.statement}. On a build that does not know the key, a hook crash, a hook timeout and unparseable hook output all PROCEED, silently, so every gated tool call in this checkout is a backstop rather than a gate and nothing in the session says so`);
|
|
3073
|
+
}
|
|
3074
|
+
else if (support === "honours") {
|
|
3075
|
+
floor.push(`hermes ${quoted} is at or above the fail_closed floor (${HERMES_FAIL_CLOSED_FLOOR.statement}), so a broken hook blocks rather than proceeding`);
|
|
3076
|
+
}
|
|
3077
|
+
else {
|
|
3078
|
+
floor.push(`hermes: the fail_closed floor could not be established from ${quoted} (no build date and not the floor's own commit; two commits cannot be ordered without a repository), so check it by hand — the floor is ${HERMES_FAIL_CLOSED_FLOOR.statement}`);
|
|
3079
|
+
}
|
|
3080
|
+
}
|
|
2388
3081
|
for (const kind of kinds) {
|
|
2389
3082
|
const last = recorded.get(kind);
|
|
2390
3083
|
if (last === undefined) {
|
|
@@ -2402,12 +3095,26 @@ function checkHarnessVersion(dir, records) {
|
|
|
2402
3095
|
}
|
|
2403
3096
|
mismatched.push(`${kind} is installed at ${JSON.stringify(installed)} and the last hook-written record (seq ${String(last.seq)}) was issued by ${JSON.stringify(last.version)}`);
|
|
2404
3097
|
}
|
|
3098
|
+
// APRV-415: the floor is reported FIRST when it is breached, because it is the
|
|
3099
|
+
// graver of the two findings. An unverified change means nobody has checked
|
|
3100
|
+
// whether the gate still fires; a build below the floor means a broken hook does
|
|
3101
|
+
// not stop anything, which is established rather than unchecked.
|
|
3102
|
+
if (belowFloor.length > 0) {
|
|
3103
|
+
return {
|
|
3104
|
+
check,
|
|
3105
|
+
status: "fail",
|
|
3106
|
+
detail: `${belowFloor.join("; ")}${mismatched.length === 0
|
|
3107
|
+
? ""
|
|
3108
|
+
: `. And the harness binary changed with no gate exercised since: ${mismatched.join("; ")}`}`,
|
|
3109
|
+
fix: `hermes update (or reinstall from main), then \`approval doctor\` again. The installed version is self-reported, so this row can only ask for a look: it never widens a verdict, and the adapter's own refusals are the same on every build.`,
|
|
3110
|
+
};
|
|
3111
|
+
}
|
|
2405
3112
|
if (mismatched.length > 0) {
|
|
2406
3113
|
const first = kinds[0];
|
|
2407
3114
|
return {
|
|
2408
3115
|
check,
|
|
2409
3116
|
status: "fail",
|
|
2410
|
-
detail: `the harness binary changed and the gate has not been exercised since: ${mismatched.join("; ")}. A release can change the hook envelope semantics, so until one record is written under the new binary nothing here shows the hook still fires. The recorded version is self-reported and reduces nothing: a match would not have proved the hook fired either, and what a mismatch says is that nobody has looked
|
|
3117
|
+
detail: `the harness binary changed and the gate has not been exercised since: ${mismatched.join("; ")}. A release can change the hook envelope semantics, so until one record is written under the new binary nothing here shows the hook still fires. The recorded version is self-reported and reduces nothing: a match would not have proved the hook fired either, and what a mismatch says is that nobody has looked.${floor.length === 0 ? "" : ` Separately: ${floor.join("; ")}.`}`,
|
|
2411
3118
|
fix: `approval hook ${first} --dir ${where} < one PreToolUse event for a supervised-class command — the self-test in docs/${first === "cursor" ? "cursor" : "claude-code"}-hook.md. It prompts nobody and writes one task.registered carrying the installed version.`,
|
|
2412
3119
|
};
|
|
2413
3120
|
}
|
|
@@ -2415,13 +3122,13 @@ function checkHarnessVersion(dir, records) {
|
|
|
2415
3122
|
return {
|
|
2416
3123
|
check,
|
|
2417
3124
|
status: "pass",
|
|
2418
|
-
detail: `${matched.join("; ")}${unknown.length === 0 ? "" : `; ${unknown.join("; ")}`}. A match is not proof the hook fired; it is the absence of the one thing this row can see, an unverified change of the binary hosting it
|
|
3125
|
+
detail: `${matched.join("; ")}${unknown.length === 0 ? "" : `; ${unknown.join("; ")}`}. A match is not proof the hook fired; it is the absence of the one thing this row can see, an unverified change of the binary hosting it.${floor.length === 0 ? "" : ` ${floor.join("; ")}.`}`,
|
|
2419
3126
|
};
|
|
2420
3127
|
}
|
|
2421
3128
|
return {
|
|
2422
3129
|
check,
|
|
2423
3130
|
status: "skip",
|
|
2424
|
-
detail: `${where} registers ${kinds.join(", ")} and no comparison could be made: ${unknown.join("; ")}`,
|
|
3131
|
+
detail: `${where} registers ${kinds.join(", ")} and no comparison could be made: ${unknown.join("; ")}${floor.length === 0 ? "" : `. ${floor.join("; ")}`}`,
|
|
2425
3132
|
};
|
|
2426
3133
|
}
|
|
2427
3134
|
// ---------------------------------------------------------------------------
|
|
@@ -2515,16 +3222,46 @@ function checkDarkSessions(logPath, dir, policyPath, records, verified) {
|
|
|
2515
3222
|
* is reported); this list only says where to look, so a directory nobody uses
|
|
2516
3223
|
* costs nothing and a file nobody named is not invented.
|
|
2517
3224
|
*/
|
|
2518
|
-
const ORGAN_SEARCH =
|
|
2519
|
-
{ dir: ".claude", prefix: "settings" },
|
|
2520
|
-
|
|
2521
|
-
|
|
2522
|
-
|
|
2523
|
-
|
|
3225
|
+
export const ORGAN_SEARCH = {
|
|
3226
|
+
"claude-code": [{ dir: ".claude", prefix: "settings" }],
|
|
3227
|
+
cursor: [
|
|
3228
|
+
{ dir: ".cursor", prefix: "hooks.json" },
|
|
3229
|
+
{ dir: join(".cursor", "hooks") },
|
|
3230
|
+
{ dir: join(".cursor", "agents") },
|
|
3231
|
+
],
|
|
3232
|
+
// EMPTY ON PURPOSE, and this is the one entry in the record that is a decision
|
|
3233
|
+
// rather than a list. `.codex/config.toml` and `.codex/hooks.json` are organs
|
|
3234
|
+
// by `core/command-class.ts`'s own word, and adding them here would change
|
|
3235
|
+
// what the `gate-organs` row reports in every Codex checkout — a change
|
|
3236
|
+
// APRV-398 has no business making while adding a different harness. The row
|
|
3237
|
+
// they belong in is their own task. What this record buys meanwhile is that
|
|
3238
|
+
// the omission is now VISIBLE and typed rather than an absence nobody could
|
|
3239
|
+
// see: a reader of this table can tell that codex was considered.
|
|
3240
|
+
codex: [],
|
|
3241
|
+
// APRV-243 and APRV-350: the organs the classifier has recognised since those
|
|
3242
|
+
// tasks, which this list never learned about. `.grok/hooks/` and
|
|
3243
|
+
// `.muse/hooks.json` are where each harness's hook is installed, so a
|
|
3244
|
+
// hand-edited one nobody attested is exactly what this row exists to report.
|
|
3245
|
+
grok: [
|
|
3246
|
+
{ dir: ".grok", prefix: "hooks.json" },
|
|
3247
|
+
{ dir: join(".grok", "hooks") },
|
|
3248
|
+
],
|
|
3249
|
+
muse: [{ dir: ".muse", prefix: "hooks" }, { dir: ".muse", prefix: "settings" }],
|
|
3250
|
+
// APRV-398. Hermes's real organ is `$HERMES_HOME/config.yaml` in the user
|
|
3251
|
+
// home, and this row is about THIS CHECKOUT, so what is listed is the
|
|
3252
|
+
// repository-relative directory the classifier would call an organ if a
|
|
3253
|
+
// checkout carried one. A directory nobody uses costs nothing (see the header),
|
|
3254
|
+
// and every candidate found here is put to the classifier before it is
|
|
3255
|
+
// reported, so nothing is invented. The home file is reached by the
|
|
3256
|
+
// harness-version row above, through {@link HARNESS_SETTINGS}.
|
|
3257
|
+
hermes: [{ dir: ".hermes", prefix: "config" }, { dir: join(".hermes", "agent-hooks") }],
|
|
3258
|
+
};
|
|
3259
|
+
/** Every organ directory to look in, across harnesses, in kind order. */
|
|
3260
|
+
const ORGAN_SEARCH_ENTRIES = HARNESS_KINDS.flatMap((kind) => ORGAN_SEARCH[kind]);
|
|
2524
3261
|
/** The organ files this checkout actually carries, repository-relative, sorted. */
|
|
2525
3262
|
function listGateOrgans(root) {
|
|
2526
3263
|
const found = [];
|
|
2527
|
-
for (const entry of
|
|
3264
|
+
for (const entry of ORGAN_SEARCH_ENTRIES) {
|
|
2528
3265
|
let names;
|
|
2529
3266
|
try {
|
|
2530
3267
|
names = readdirSync(join(root, entry.dir));
|
|
@@ -2649,7 +3386,7 @@ function checkGateOrgans(dir, records) {
|
|
|
2649
3386
|
return {
|
|
2650
3387
|
check,
|
|
2651
3388
|
status: "skip",
|
|
2652
|
-
detail: `${root} carries no gate organ files (${
|
|
3389
|
+
detail: `${root} carries no gate organ files (${ORGAN_SEARCH_ENTRIES.map((entry) => entry.dir).join(", ")}), so there is nothing here for a human to have attested`,
|
|
2653
3390
|
};
|
|
2654
3391
|
}
|
|
2655
3392
|
const unattested = [];
|
|
@@ -2901,10 +3638,11 @@ function trackedPrivateKeys(root, keyDir) {
|
|
|
2901
3638
|
* `token_sealed` for as long as the token is unspent and inside its TTL, so the
|
|
2902
3639
|
* whole design of sealed delivery rests on the key never being committed.
|
|
2903
3640
|
*
|
|
2904
|
-
* Nothing enforced that. `.approval/payloads/`
|
|
2905
|
-
*
|
|
2906
|
-
*
|
|
2907
|
-
*
|
|
3641
|
+
* Nothing enforced that. `.approval/payloads/` was TRACKED by default until
|
|
3642
|
+
* APRV-445 (and still is wherever an operator keeps it in history, as this
|
|
3643
|
+
* repository does), so `.approval/` is a directory an operator adds from during
|
|
3644
|
+
* a records or ceremony commit, and a key store with no ignore line is swept in
|
|
3645
|
+
* by the same `git add` that carries the payloads.
|
|
2908
3646
|
*
|
|
2909
3647
|
* Two questions, in the order of what stays wrong the longest, the same reading
|
|
2910
3648
|
* the vault and environment rows use:
|
|
@@ -2953,7 +3691,7 @@ function checkSealedKeys(logPath, dir) {
|
|
|
2953
3691
|
return {
|
|
2954
3692
|
check,
|
|
2955
3693
|
status: "fail",
|
|
2956
|
-
detail: `${String(present.length)} sealed-delivery private key(s) in ${keyDir} are NOT gitignored in ${dir}: one \`git add .approval/\` — the command a records or ceremony commit uses,
|
|
3694
|
+
detail: `${String(present.length)} sealed-delivery private key(s) in ${keyDir} are NOT gitignored in ${dir}: one \`git add .approval/\` — the command a records or ceremony commit uses, wherever \`.approval/payloads/\` is kept in history — publishes a live key beside the ciphertext it opens`,
|
|
2957
3695
|
fix: `echo '${KEYS_IGNORE_LINE}' >> ${join(dir, ".gitignore")} — the line \`approval init\` writes; and treat every action whose token is still unspent inside its TTL as disclosed`,
|
|
2958
3696
|
};
|
|
2959
3697
|
}
|
|
@@ -3170,9 +3908,10 @@ export function commandDoctor(argv, streams, cwd) {
|
|
|
3170
3908
|
checkGateOrgans(dir, verified.records),
|
|
3171
3909
|
// APRV-285: appended, eighteenth time, same reason. The sibling of the
|
|
3172
3910
|
// vault and environment rows for the one file under `.approval/` that is
|
|
3173
|
-
// a raw private key: `.approval/payloads/` is tracked
|
|
3174
|
-
//
|
|
3175
|
-
//
|
|
3911
|
+
// a raw private key: wherever `.approval/payloads/` is tracked (the
|
|
3912
|
+
// default before APRV-445, and this repository's choice), `.approval/` is
|
|
3913
|
+
// a directory people `git add` from, and the key store had nothing
|
|
3914
|
+
// telling them it must not come along.
|
|
3176
3915
|
checkSealedKeys(logPath, dir),
|
|
3177
3916
|
// APRV-313: appended, nineteenth time, same reason. Configuration on
|
|
3178
3917
|
// disk is distinct from Codex trust, loading and observed execution.
|
|
@@ -3200,6 +3939,12 @@ export function commandDoctor(argv, streams, cwd) {
|
|
|
3200
3939
|
// one, and this is where an operator finds out it happened without
|
|
3201
3940
|
// reading the log by hand.
|
|
3202
3941
|
checkCodexAutoReviewer(verified.records, verified.result.status === "clean"),
|
|
3942
|
+
// APRV-383: appended, twenty-fourth time, same reason. The sibling of
|
|
3943
|
+
// `keychain-scope` one layer along — that row says whose credentials this
|
|
3944
|
+
// instance uses, this one says whose name its daemon writes onto every
|
|
3945
|
+
// record — and the row an operator wiring up a hosted daemon needs BEFORE
|
|
3946
|
+
// anything is running.
|
|
3947
|
+
checkDaemonIdentity(logPath, verified.records, policyLoad),
|
|
3203
3948
|
];
|
|
3204
3949
|
const ok = checks.every((entry) => entry.status !== "fail");
|
|
3205
3950
|
if (json)
|