approval-md 0.4.1 → 0.4.3

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.
Files changed (118) hide show
  1. package/SPEC.md +8 -0
  2. package/dist/src/cli/attest.js +2 -1
  3. package/dist/src/cli/attest.js.map +1 -1
  4. package/dist/src/cli/audit.js +3 -2
  5. package/dist/src/cli/audit.js.map +1 -1
  6. package/dist/src/cli/channel-telegram.js +2 -1
  7. package/dist/src/cli/channel-telegram.js.map +1 -1
  8. package/dist/src/cli/channel-web.js +2 -1
  9. package/dist/src/cli/channel-web.js.map +1 -1
  10. package/dist/src/cli/channel.js +2 -1
  11. package/dist/src/cli/channel.js.map +1 -1
  12. package/dist/src/cli/doctor.js +1 -1
  13. package/dist/src/cli/doctor.js.map +1 -1
  14. package/dist/src/cli/execute.js +4 -3
  15. package/dist/src/cli/execute.js.map +1 -1
  16. package/dist/src/cli/gate.js +2 -1
  17. package/dist/src/cli/gate.js.map +1 -1
  18. package/dist/src/cli/hook.d.ts +32 -2
  19. package/dist/src/cli/hook.js +510 -36
  20. package/dist/src/cli/hook.js.map +1 -1
  21. package/dist/src/cli/log-checkpoint.js +2 -1
  22. package/dist/src/cli/log-checkpoint.js.map +1 -1
  23. package/dist/src/cli/log-unlock.js +2 -1
  24. package/dist/src/cli/log-unlock.js.map +1 -1
  25. package/dist/src/cli/setup-common.js +2 -1
  26. package/dist/src/cli/setup-common.js.map +1 -1
  27. package/dist/src/cli/vault.js +2 -1
  28. package/dist/src/cli/vault.js.map +1 -1
  29. package/dist/src/cli/wordmark.d.ts +2 -2
  30. package/dist/src/cli/wordmark.js +2 -2
  31. package/dist/src/core/attest-bootstrap.js +2 -1
  32. package/dist/src/core/attest-bootstrap.js.map +1 -1
  33. package/dist/src/core/attest.js +5 -4
  34. package/dist/src/core/attest.js.map +1 -1
  35. package/dist/src/core/audit.d.ts +15 -0
  36. package/dist/src/core/audit.js +19 -3
  37. package/dist/src/core/audit.js.map +1 -1
  38. package/dist/src/core/budgets.d.ts +12 -0
  39. package/dist/src/core/budgets.js +54 -1
  40. package/dist/src/core/budgets.js.map +1 -1
  41. package/dist/src/core/checkpoint.js +3 -2
  42. package/dist/src/core/checkpoint.js.map +1 -1
  43. package/dist/src/core/command-class.d.ts +57 -0
  44. package/dist/src/core/command-class.js +188 -10
  45. package/dist/src/core/command-class.js.map +1 -1
  46. package/dist/src/core/decision-refusal.js +2 -1
  47. package/dist/src/core/decision-refusal.js.map +1 -1
  48. package/dist/src/core/delegation.d.ts +109 -0
  49. package/dist/src/core/delegation.js +244 -0
  50. package/dist/src/core/delegation.js.map +1 -0
  51. package/dist/src/core/execute.js +12 -3
  52. package/dist/src/core/execute.js.map +1 -1
  53. package/dist/src/core/gate-window.js +4 -3
  54. package/dist/src/core/gate-window.js.map +1 -1
  55. package/dist/src/core/gate.d.ts +26 -42
  56. package/dist/src/core/gate.js +84 -3
  57. package/dist/src/core/gate.js.map +1 -1
  58. package/dist/src/core/gesture-refusal.js +2 -1
  59. package/dist/src/core/gesture-refusal.js.map +1 -1
  60. package/dist/src/core/identity.d.ts +105 -0
  61. package/dist/src/core/identity.js +122 -0
  62. package/dist/src/core/identity.js.map +1 -0
  63. package/dist/src/core/live-draw.d.ts +12 -2
  64. package/dist/src/core/live-draw.js +12 -2
  65. package/dist/src/core/live-draw.js.map +1 -1
  66. package/dist/src/core/loop.d.ts +11 -0
  67. package/dist/src/core/loop.js +63 -4
  68. package/dist/src/core/loop.js.map +1 -1
  69. package/dist/src/core/policy-diff.js +20 -1
  70. package/dist/src/core/policy-diff.js.map +1 -1
  71. package/dist/src/core/policy-explain.js +18 -2
  72. package/dist/src/core/policy-explain.js.map +1 -1
  73. package/dist/src/core/policy-load.d.ts +38 -1
  74. package/dist/src/core/policy-load.js +34 -2
  75. package/dist/src/core/policy-load.js.map +1 -1
  76. package/dist/src/core/policy-match.js +30 -5
  77. package/dist/src/core/policy-match.js.map +1 -1
  78. package/dist/src/core/policy-proposal.js +3 -2
  79. package/dist/src/core/policy-proposal.js.map +1 -1
  80. package/dist/src/core/token.js +11 -0
  81. package/dist/src/core/token.js.map +1 -1
  82. package/dist/src/core/tool-map.d.ts +125 -0
  83. package/dist/src/core/tool-map.js +179 -0
  84. package/dist/src/core/tool-map.js.map +1 -0
  85. package/dist/src/daemon/git-evidence.d.ts +2 -2
  86. package/dist/src/daemon/git-evidence.js +1 -1
  87. package/docs/README-extended.md +13 -0
  88. package/docs/cli-reference.md +64 -3
  89. package/package.json +2 -2
  90. package/schema/event.schema.json +34 -2
  91. package/schema/fixtures/event/invalid/audit-reviewed-model-actor.json +20 -0
  92. package/schema/fixtures/event/invalid/audit-reviewed-verdict-source-model.json +20 -0
  93. package/schema/fixtures/event/invalid/execution-started-harness-tool-space.json +18 -0
  94. package/schema/fixtures/event/valid/execution-started-harness-tool.json +19 -0
  95. package/schema/fixtures/policy/invalid/delegation-advice-string.json +15 -0
  96. package/schema/fixtures/policy/invalid/delegation-class-duplicate.json +18 -0
  97. package/schema/fixtures/policy/invalid/delegation-class-wildcard.json +17 -0
  98. package/schema/fixtures/policy/invalid/delegation-daily-cap-fractional.json +15 -0
  99. package/schema/fixtures/policy/invalid/delegation-daily-cap-negative.json +15 -0
  100. package/schema/fixtures/policy/invalid/delegation-daily-cap-too-high.json +15 -0
  101. package/schema/fixtures/policy/invalid/delegation-escalate-duplicate.json +18 -0
  102. package/schema/fixtures/policy/invalid/delegation-escalate-unknown.json +17 -0
  103. package/schema/fixtures/policy/invalid/delegation-max-autonomy-alias.json +15 -0
  104. package/schema/fixtures/policy/invalid/delegation-max-autonomy-autonomous.json +15 -0
  105. package/schema/fixtures/policy/invalid/delegation-model-no-version.json +15 -0
  106. package/schema/fixtures/policy/invalid/delegation-model-provider-form.json +15 -0
  107. package/schema/fixtures/policy/invalid/delegation-null.json +7 -0
  108. package/schema/fixtures/policy/invalid/delegation-reviewer-agent.json +17 -0
  109. package/schema/fixtures/policy/invalid/delegation-reviewer-bare-id.json +17 -0
  110. package/schema/fixtures/policy/invalid/delegation-unknown-key.json +16 -0
  111. package/schema/fixtures/policy/invalid/tools-class-wildcard.json +9 -0
  112. package/schema/fixtures/policy/invalid/tools-match-double-star.json +9 -0
  113. package/schema/fixtures/policy/invalid/unmapped-tool-unknown.json +7 -0
  114. package/schema/fixtures/policy/valid/delegation-empty.json +7 -0
  115. package/schema/fixtures/policy/valid/delegation-grammar-full.json +41 -0
  116. package/schema/fixtures/policy/valid/delegation-off.json +15 -0
  117. package/schema/fixtures/policy/valid/tools-mapping.json +17 -0
  118. package/schema/policy.schema.json +126 -0
@@ -72,13 +72,13 @@
72
72
  * auditor holding the log alone. See `docs/claude-code-hook.md`.
73
73
  */
74
74
  import { spawnSync } from "node:child_process";
75
- import { existsSync, readdirSync, readFileSync, realpathSync, statSync, writeSync } from "node:fs";
75
+ import { existsSync, lstatSync, readdirSync, readFileSync, readlinkSync, realpathSync, statSync, writeSync, } from "node:fs";
76
76
  import { randomBytes } from "node:crypto";
77
77
  import { tmpdir } from "node:os";
78
78
  import { basename, dirname, isAbsolute, join, resolve as resolvePathSegments, sep, } from "node:path";
79
- import { attestationRefusal, checkAttestation } from "../core/attest.js";
79
+ import { attestationRefusal, checkAttestation, checkAttestationOfBytes } from "../core/attest.js";
80
80
  import { childEnvironment } from "../core/child-env.js";
81
- import { classifyCommand, commandSegmentShapes, commandSegmentWords, CODE_EXECUTING_RULES, CONTRIBUTOR_SUFFIX, GATE_SELF_CLASS, isCredentialPath, protectedPathClass, } from "../core/command-class.js";
81
+ import { classifyCommand, commandSegmentShapes, commandSegmentWords, CODE_EXECUTING_RULES, CONTRIBUTOR_SUFFIX, GATE_SELF_CLASS, isCredentialPath, NODE_SKILL_SCRIPT_RULE, protectedPathClass, SKILL_SCRIPT_RULE, } from "../core/command-class.js";
82
82
  import { consumeHarnessGrant, findHarnessCarry, finishHarnessExecution, isProposalTask, register, request, startHarnessExecution, withdraw, } from "../core/gate.js";
83
83
  import { openGateWindow, recordGateBypass, } from "../core/gate-window.js";
84
84
  import { harnessProvenance, isHarnessKind, } from "../core/harness-version.js";
@@ -89,7 +89,8 @@ import { appendLockHeld, DEFAULT_LOCK_RETRY_MS, DEFAULT_LOCK_TIMEOUT_MS, } from
89
89
  import { settleTerminationGuards } from "../core/log-lock.js";
90
90
  import { payloadHash } from "../core/payload.js";
91
91
  import { classifyApplyPatch, parseApplyPatch } from "../core/apply-patch.js";
92
- import { loadPolicy, parseDuration } from "../core/policy-load.js";
92
+ import { loadPolicy, loadPolicyText, parseDuration, POLICY_FILENAMES, } from "../core/policy-load.js";
93
+ import { isRecordableToolName, toolMapVerdict, UNMAPPED_TOOL_CLASS, } from "../core/tool-map.js";
93
94
  import { READ_OUT_OF_SCOPE_CLASS, effectiveReadRoots, isInReadScope, readTargetsOf, renderReadRoots, } from "../core/read-scope.js";
94
95
  import { harnessLaunchNeedsRule, harnessLaunchUnruledRefusal, humanOnlyRefusal, resolve as resolvePolicy, } from "../core/policy-match.js";
95
96
  import { payloadOf, readVerifiedRecords, requestExpiresAt, requestState, useVerifiedSnapshots, } from "../core/state.js";
@@ -812,6 +813,100 @@ function toolRuleOf(adapter, input) {
812
813
  }
813
814
  return adapter.toolRules(input.toolName, input.toolInput);
814
815
  }
816
+ /**
817
+ * Is this call one the adapter's own tables leave unclaimed? (APRV-499.)
818
+ *
819
+ * Unclaimed means: not the shell tool, not a file or read tool, not a
820
+ * pass-through tool, and not a tool the hard-coded rule table answers (gated OR
821
+ * read). Exactly these calls reach the policy's `tools` mapping and its
822
+ * unmapped-tool default, and before APRV-499 exactly these were answered "not a
823
+ * gated tool". The adapter's tables keep precedence because they read the
824
+ * call's arguments, and a `tools` entry reads only its name.
825
+ */
826
+ function unclaimedTool(adapter, input) {
827
+ if (input.toolName === adapter.shellTool)
828
+ return false;
829
+ if (adapter.fileTools.includes(input.toolName))
830
+ return false;
831
+ if (adapter.readTools.includes(input.toolName))
832
+ return false;
833
+ if (adapter.passThroughTools?.includes(input.toolName) === true)
834
+ return false;
835
+ return toolRuleOf(adapter, input) === null;
836
+ }
837
+ /**
838
+ * The policy file's bytes, found the way `loadPolicy` finds them: `--policy`
839
+ * when given, else the first of {@link POLICY_FILENAMES} in the policy
840
+ * directory, else in `cwd`. `null` when there is none to read.
841
+ *
842
+ * Read ONCE and handed to both the parse and the attestation hash (APRV-142's
843
+ * rule, applied to this hook's unclaimed-tool answer), so no file swap between
844
+ * the two can attest one policy and decide by another.
845
+ */
846
+ function readHookPolicyBytes(options, cwd) {
847
+ const candidates = options.policy?.file === undefined
848
+ ? POLICY_FILENAMES.map((filename) => join(options.policy?.dir ?? cwd, filename))
849
+ : [options.policy.file];
850
+ for (const candidate of candidates) {
851
+ try {
852
+ return { path: candidate, bytes: readFileSync(candidate) };
853
+ }
854
+ catch {
855
+ continue;
856
+ }
857
+ }
858
+ return null;
859
+ }
860
+ function unclaimedStanding(options, cwd, toolName, records) {
861
+ const read = readHookPolicyBytes(options, cwd);
862
+ if (read === null)
863
+ return { kind: "decide" };
864
+ const load = loadPolicyText(read.path, read.bytes.toString("utf8"));
865
+ if (!load.ok)
866
+ return { kind: "decide" };
867
+ if (toolMapVerdict(load.policy, toolName).kind !== "not-gated")
868
+ return { kind: "decide" };
869
+ if (records === null) {
870
+ return {
871
+ kind: "unattested",
872
+ code: "hook-io",
873
+ detail: `${toolName} is outside the gate only under an attested policy, and the log that would show the attestation could not be read and verified; nothing was appended`,
874
+ };
875
+ }
876
+ const refusal = attestationRefusal(checkAttestationOfBytes(records, read.bytes));
877
+ if (refusal === null)
878
+ return { kind: "ungated" };
879
+ return {
880
+ kind: "unattested",
881
+ code: `hook-gate-refused:${refusal.code}`,
882
+ detail: `${refusal.message}. ${toolName} is a tool this adapter does not classify, and whether the policy gates it (a \`tools\` entry, \`defaults.unmapped_tool\`) is a question only the attested policy answers, so the on-disk file is not read as "not a gated tool" until a human attests it. Nothing was appended.`,
883
+ };
884
+ }
885
+ /**
886
+ * {@link unclaimedStanding} for a caller that has not read the log: the fast
887
+ * path and the post-execution half. One verified read, resumed behind the
888
+ * daemon's snapshot (APRV-188) where there is one.
889
+ */
890
+ function unclaimedUngated(flags, cwd, toolName) {
891
+ const { logPath, options } = hookScope(flags, cwd);
892
+ useVerifiedSnapshots(true);
893
+ const read = readVerifiedRecords(logPath);
894
+ return unclaimedStanding(options, cwd, toolName, read.ok ? read.records : null).kind === "ungated";
895
+ }
896
+ /**
897
+ * The `hook-policy-unavailable` detail (APRV-499, refutation S2): the load's
898
+ * own code and message, the first per-entry errors it computed, and the
899
+ * repair, so the human reading the refusal is told what to change.
900
+ */
901
+ function policyUnavailableDetail(load) {
902
+ const errors = load.errors ?? [];
903
+ const shown = errors
904
+ .slice(0, 5)
905
+ .map((error) => `${error.path.length === 0 ? "(root)" : error.path}: ${error.message}`);
906
+ const more = errors.length > shown.length ? `; and ${String(errors.length - shown.length)} more` : "";
907
+ const listed = shown.length === 0 ? "" : ` (${shown.join("; ")}${more})`;
908
+ return `${load.code}: ${load.message}${listed}; every class resolves to manual and the hook cannot verify a decision. Repair: correct ${shown.length === 0 ? "the policy file" : "each entry named above"}, then have a human attest the result (\`approval policy attest\`); an edited policy is inoperative until then`;
909
+ }
815
910
  /**
816
911
  * Every harness this runtime speaks a hook protocol for, by kind (APRV-358).
817
912
  *
@@ -2087,13 +2182,256 @@ export function refineWriteScope(result, roots, cwd) {
2087
2182
  }
2088
2183
  return { result: { ok: true, segments, classes }, notes };
2089
2184
  }
2185
+ // ===========================================================================
2186
+ // Skill scripts, the disk half (APRV-502)
2187
+ // ===========================================================================
2090
2188
  /**
2091
- * The classifier, its context, and all four impure refinements, in the one
2189
+ * The rules whose segments this pass judges: an interpreter running
2190
+ * `skills/<skill>/scripts/<file>`, which the pure classifier answers
2191
+ * `exec.local` from the TEXT alone.
2192
+ */
2193
+ const SKILL_SCRIPT_RULES = [SKILL_SCRIPT_RULE, NODE_SKILL_SCRIPT_RULE];
2194
+ /** The `code` of a filesystem error, or `null`. */
2195
+ function fsErrorCode(error) {
2196
+ if (typeof error !== "object" || error === null || !("code" in error))
2197
+ return null;
2198
+ const code = error.code;
2199
+ return typeof code === "string" ? code : null;
2200
+ }
2201
+ /**
2202
+ * Judge one skill script from one working directory (APRV-502).
2203
+ *
2204
+ * `script` is the path the classifier bound, `skills/<skill>/scripts/<file>`
2205
+ * or an absolute `.hermes` spelling of it, so its parent is the spelled root
2206
+ * `<base>/skills/<skill>/scripts` and nothing needs re-parsing. `<base>` is
2207
+ * the directory the shape was spelled from: the working directory for the
2208
+ * relative shape, the `.hermes` or `.hermes/profiles/<p>` directory for the
2209
+ * absolute one. Both shapes put exactly three segments between base and root.
2210
+ *
2211
+ * ## Why an unreadable or absent script lets the text answer stand
2212
+ *
2213
+ * This pass can only tighten, like {@link resolvedPathClasses}, and where the
2214
+ * disk cannot be read it has nothing to tighten WITH. That case is not
2215
+ * hypothetical; it is the production shape. Under co-location the daemon that
2216
+ * answers `approval hook hermes` runs as the `approvald` user and the resident's
2217
+ * Hermes home (`/home/hermes/.hermes`) is mode 0700 owned by `hermes`, so
2218
+ * `realpath` of the script fails with EACCES on every live box (checked
2219
+ * read-only on one 2026-10-07). Refusing there would deny every skill script
2220
+ * fleet-wide, which is the outage APRV-502 exists to end, so the answer is the
2221
+ * text's and a note says the disk pass could not look. `resolvedPathClasses`
2222
+ * has exactly the same property for the same reason. An absent file
2223
+ * (ENOENT, ENOTDIR) is treated the same way: there is nothing on disk to run,
2224
+ * and the interpreter will fail on its own. What the text already guarantees
2225
+ * in both cases is the shape: no `..`, no variable, no glob, and every
2226
+ * protected and credential tier ahead of the rule.
2227
+ *
2228
+ * ## Where the disk CAN be read, everything off the plain shape refuses
2229
+ *
2230
+ * - A real path landing on a credential or protected path refuses, naming the
2231
+ * landing. It does not take the landing's class: for `bun` and `python3` the
2232
+ * direct spelling of a protected path is refused by the classifier (the
2233
+ * protected scan runs only after a table row matches), so taking the class
2234
+ * would answer a symlinked spelling MORE loosely than the direct one under a
2235
+ * policy where that class is looser than a refusal (the A502 refutation's
2236
+ * S2: `cron.manage` autonomous in the village template).
2237
+ * - No component of `skills/<skill>/scripts` may be a symlink: the real root
2238
+ * must equal `realpath(base)/skills/<skill>/scripts`. A symlinked base or
2239
+ * working directory is fine, because both sides are resolved.
2240
+ * - The real path of the script must sit strictly under that real root, so a
2241
+ * file symlink out of `skills/<skill>/scripts/` refuses. A dangling symlink
2242
+ * is judged by where it points, resolved through the deepest existing
2243
+ * ancestor of its target, so a link whose target does not exist YET cannot
2244
+ * be placed now and filled later.
2245
+ */
2246
+ function judgeSkillScript(script, cwd, protectedPaths) {
2247
+ const spelled = resolvePathSegments(cwd, script);
2248
+ const root = dirname(spelled);
2249
+ let real;
2250
+ try {
2251
+ real = realpathSync(spelled);
2252
+ }
2253
+ catch (error) {
2254
+ const code = fsErrorCode(error);
2255
+ if (code === "EACCES" || code === "EPERM") {
2256
+ return { kind: "unseen", why: `${spelled} cannot be read by this process (${code})` };
2257
+ }
2258
+ if (code === "ELOOP") {
2259
+ return { kind: "refused", detail: `${spelled} is a symlink loop (ELOOP)` };
2260
+ }
2261
+ if (code !== "ENOENT" && code !== "ENOTDIR") {
2262
+ return { kind: "refused", detail: `${spelled} could not be resolved (${code ?? "unknown error"})` };
2263
+ }
2264
+ // Absent, or a dangling symlink. Only the second has anything to judge.
2265
+ let target = null;
2266
+ try {
2267
+ if (lstatSync(spelled).isSymbolicLink())
2268
+ target = readlinkSync(spelled);
2269
+ }
2270
+ catch (inner) {
2271
+ const innerCode = fsErrorCode(inner);
2272
+ if (innerCode === "EACCES" || innerCode === "EPERM") {
2273
+ return { kind: "unseen", why: `${spelled} cannot be read by this process (${innerCode})` };
2274
+ }
2275
+ target = null;
2276
+ }
2277
+ if (target === null) {
2278
+ return { kind: "unseen", why: `${spelled} does not exist (${code})` };
2279
+ }
2280
+ real = canonicalPath(resolvePathSegments(dirname(spelled), target));
2281
+ }
2282
+ if (isCredentialPath(real)) {
2283
+ return {
2284
+ kind: "refused",
2285
+ detail: `${script} resolves to ${real}, a credential path (account.credential)`,
2286
+ };
2287
+ }
2288
+ const protectedClass = protectedPathClass(real, protectedPaths);
2289
+ if (protectedClass !== null) {
2290
+ return {
2291
+ kind: "refused",
2292
+ detail: `${script} resolves to ${real}, a protected path (${protectedClass})`,
2293
+ };
2294
+ }
2295
+ // `<base>/skills/<skill>/scripts`: three segments up from the root is base.
2296
+ const skill = basename(dirname(root));
2297
+ const base = dirname(dirname(dirname(root)));
2298
+ let realRoot;
2299
+ let realBase;
2300
+ try {
2301
+ realRoot = realpathSync(root);
2302
+ realBase = realpathSync(base);
2303
+ }
2304
+ catch (error) {
2305
+ // The script resolved and a directory above it did not: two reads of one
2306
+ // disk disagreeing. Nothing here can vouch for that.
2307
+ return {
2308
+ kind: "refused",
2309
+ detail: `${root} could not be resolved (${fsErrorCode(error) ?? "unknown error"}) although the script inside it did`,
2310
+ };
2311
+ }
2312
+ const expectedRoot = join(realBase, "skills", skill, "scripts");
2313
+ if (realRoot !== expectedRoot) {
2314
+ return {
2315
+ kind: "refused",
2316
+ detail: `${root} resolves to ${realRoot}, not ${expectedRoot}: a component of skills/${skill}/scripts is a symlink`,
2317
+ };
2318
+ }
2319
+ if (!isBelow(real, realRoot)) {
2320
+ return {
2321
+ kind: "refused",
2322
+ detail: `${script} resolves to ${real}, outside the directory it was spelled in (${realRoot})`,
2323
+ };
2324
+ }
2325
+ return { kind: "inside" };
2326
+ }
2327
+ /**
2328
+ * The directories a segment of a command line may run in: the hook's `cwd`,
2329
+ * plus every directory an earlier literal `cd` or `pushd` could have moved to
2330
+ * (a `cd` may or may not have run, `||` and a failing `cd` included, so both
2331
+ * sides are kept, as {@link resolvedPathClasses} keeps them). `null` when an
2332
+ * earlier segment moves to a directory the text cannot name: `cd $X`, `cd ~`,
2333
+ * a bare `cd`, `cd -`, `popd`, or a `cd` carrying flags.
2334
+ */
2335
+ function skillScriptCwds(segments, index, cwd) {
2336
+ let cwds = [cwd];
2337
+ for (const earlier of segments.slice(0, index)) {
2338
+ const parsed = commandSegmentWords(earlier.text)?.[0];
2339
+ if (parsed === undefined)
2340
+ continue;
2341
+ const bin = basename(parsed.bin);
2342
+ if (bin === "popd")
2343
+ return null;
2344
+ if (bin !== "cd" && bin !== "pushd")
2345
+ continue;
2346
+ const target = parsed.args[0];
2347
+ if (parsed.args.length !== 1 || target === undefined || target.startsWith("-"))
2348
+ return null;
2349
+ if (/[$`*?[{~]/u.test(target))
2350
+ return null;
2351
+ cwds = [...new Set([...cwds, ...cwds.map((from) => resolvePathSegments(from, target))])];
2352
+ if (cwds.length > 64)
2353
+ return null;
2354
+ }
2355
+ return cwds;
2356
+ }
2357
+ /**
2358
+ * Check every skill-script segment against the disk (APRV-502).
2359
+ *
2360
+ * IMPURE by design and by contract, like the other passes: it resolves paths.
2361
+ * It only ever TIGHTENS. A segment whose script resolves anywhere but a file
2362
+ * under the real `skills/<skill>/scripts/` directory it was spelled in (a
2363
+ * credential or protected landing, a path outside the real root, a symlinked
2364
+ * component of the root) turns the whole classification into an
2365
+ * `unclassified` failure naming the landing, which the hook denies as
2366
+ * `hook-unclassified`; and one the disk cannot show (unreadable, absent, or
2367
+ * after a `cd` the text cannot name) keeps the text's `exec.local` with a note
2368
+ * saying the pass could not look. See {@link judgeSkillScript} for why that
2369
+ * last case is deliberate, and why a protected landing refuses rather than
2370
+ * taking the landing's class.
2371
+ *
2372
+ * `cwd` is the directory the command runs in: the per-call Hermes `workdir`
2373
+ * when the harness states one (absolute only), the hook's own directory
2374
+ * elsewhere. It is used only to tighten, so a working directory the party under
2375
+ * oversight reported cannot loosen anything here (SPEC.md §11.1 invariant 4).
2376
+ * Nothing here reads `HERMES_HOME`: the daemon under `approval serve` does not
2377
+ * carry it.
2378
+ */
2379
+ export function refineSkillScripts(result, cwd, protectedPaths) {
2380
+ if (!result.ok)
2381
+ return { result, notes: [] };
2382
+ if (!result.segments.some((segment) => SKILL_SCRIPT_RULES.includes(segment.rule))) {
2383
+ return { result, notes: [] };
2384
+ }
2385
+ const notes = [];
2386
+ for (const [index, segment] of result.segments.entries()) {
2387
+ if (!SKILL_SCRIPT_RULES.includes(segment.rule) || segment.class !== "exec.local")
2388
+ continue;
2389
+ const script = segment.path;
2390
+ if (script === undefined) {
2391
+ // The classifier binds the script on every segment of these rules, so
2392
+ // a segment without one is two reads of the same bytes disagreeing.
2393
+ return {
2394
+ result: {
2395
+ ok: false,
2396
+ code: "unclassified",
2397
+ segment: segment.text,
2398
+ detail: `skill-script segment carries no script path, so it cannot be checked against the disk`,
2399
+ },
2400
+ notes,
2401
+ };
2402
+ }
2403
+ const cwds = skillScriptCwds(result.segments, index, cwd);
2404
+ if (cwds === null) {
2405
+ notes.push(`skill-script-unverified: an earlier cd moves to a directory the text cannot name, so ${script} was not checked against the disk and stays exec.local`);
2406
+ continue;
2407
+ }
2408
+ for (const from of cwds) {
2409
+ const judged = judgeSkillScript(script, from, protectedPaths);
2410
+ if (judged.kind === "refused") {
2411
+ return {
2412
+ result: {
2413
+ ok: false,
2414
+ code: "unclassified",
2415
+ segment: segment.text,
2416
+ detail: `skill script escapes its directory: ${judged.detail}. A skill script must be a file under the real skills/<skill>/scripts/ directory it is spelled in, with no symlink in that directory's own path`,
2417
+ },
2418
+ notes,
2419
+ };
2420
+ }
2421
+ if (judged.kind === "unseen") {
2422
+ notes.push(`skill-script-unverified: ${judged.why}, so the disk pass could not look and ${script} stays exec.local`);
2423
+ }
2424
+ }
2425
+ }
2426
+ return { result, notes };
2427
+ }
2428
+ /**
2429
+ * The classifier, its context, and all five impure refinements, in the one
2092
2430
  * order every caller must use.
2093
2431
  *
2094
2432
  * `hook classify` printing a different class from the one `hook claude-code`
2095
2433
  * decides would make the explainer a different program (APRV-108's note), and
2096
- * that stays true now there are four refinements in the chain.
2434
+ * that stays true now there are five refinements in the chain.
2097
2435
  *
2098
2436
  * `readRoots` is the one argument whose ABSENCE is the loose answer rather than
2099
2437
  * the strict one (APRV-347), so it is passed explicitly at every call site: an
@@ -2116,9 +2454,18 @@ export function classifyForHook(command, protectedPaths, cwd, readRoots = []) {
2116
2454
  // it is the narrowest, and because nothing after it would re-read a segment
2117
2455
  // it has already tightened.
2118
2456
  const written = refineWriteScope(scoped.result, resolveWriteRoots(cwd), cwd);
2457
+ // APRV-502. Touches only the two skill-script rules, which no pass above
2458
+ // reads, so its place in the chain changes nothing for any other segment.
2459
+ const scripted = refineSkillScripts(written.result, cwd, protectedPaths);
2119
2460
  return {
2120
- result: written.result,
2121
- notes: [...rewritten.notes, ...scratched.notes, ...scoped.notes, ...written.notes],
2461
+ result: scripted.result,
2462
+ notes: [
2463
+ ...rewritten.notes,
2464
+ ...scratched.notes,
2465
+ ...scoped.notes,
2466
+ ...written.notes,
2467
+ ...scripted.notes,
2468
+ ],
2122
2469
  };
2123
2470
  }
2124
2471
  // ===========================================================================
@@ -3066,6 +3413,8 @@ task) {
3066
3413
  append,
3067
3414
  presentedPayloadHash: hash,
3068
3415
  spendingTask: task,
3416
+ // APRV-499 (refutation S3): a mapped or unmapped spend names its tool.
3417
+ ...(run.harnessTool === undefined ? {} : { harnessTool: run.harnessTool }),
3069
3418
  }));
3070
3419
  if (!spent.ok)
3071
3420
  return { code: spent.code, message: `${key}: ${spent.message}` };
@@ -3230,8 +3579,15 @@ known = null) {
3230
3579
  * is recorded through {@link spendUnderLock}, which takes the shared pause.
3231
3580
  */
3232
3581
  function* recordUnattended(run, task, classes, hash) {
3582
+ const harnessTool = run.harnessTool;
3233
3583
  for (const cls of classes) {
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 }));
3584
+ const started = yield* spendUnderLock(run.logPath, run.options.append, (append) => startHarnessExecution(run.logPath, {
3585
+ task,
3586
+ actionKey: `${task}:${cls}`,
3587
+ cls,
3588
+ payload_hash: hash,
3589
+ ...(harnessTool === undefined ? {} : { harness_tool: harnessTool }),
3590
+ }, run.actor, { ...run.options, append }));
3235
3591
  if (!started.ok)
3236
3592
  return { code: started.code, message: `${cls}: ${started.message}` };
3237
3593
  }
@@ -3543,6 +3899,9 @@ floor = null) {
3543
3899
  const registered = register(run.logPath, { task, envelope }, run.actor, {
3544
3900
  ...run.options,
3545
3901
  ...(provenance === null ? {} : { harness: provenance }),
3902
+ // APRV-499: the hook is the one caller that may declare the reserved
3903
+ // harness.tool.unmapped class.
3904
+ toolMapHook: true,
3546
3905
  });
3547
3906
  if (!registered.ok) {
3548
3907
  return sayDeny(`hook-gate-refused:${registered.code}`, registered.message);
@@ -4276,7 +4635,14 @@ function runPostToolUse(flags, streams, cwd, input, actor, adapter) {
4276
4635
  // nothing, and the close below finds no start and says so in its own words.
4277
4636
  !adapter.readTools.includes(input.toolName) &&
4278
4637
  // APRV-445: a tool the rule table gated wrote a start like any other.
4279
- toolRuleOf(adapter, input)?.kind !== "gated") {
4638
+ toolRuleOf(adapter, input)?.kind !== "gated" &&
4639
+ // APRV-499: an unclaimed tool the policy maps, or one it records or asks
4640
+ // about as unmapped, wrote a start too. So did any unclaimed tool whose
4641
+ // policy no longer loads or is not the attested one, as far as this half
4642
+ // can tell, so that one goes on to the close, which finds the start or says
4643
+ // there is none. Only an ATTESTED policy that loads and leaves the tool
4644
+ // outside the gate keeps this answer (fix round 1, refutation B1).
4645
+ (!unclaimedTool(adapter, input) || unclaimedUngated(flags, cwd, input.toolName))) {
4280
4646
  return report(streams, "post-tool-not-gated", `${input.toolName} is not a gated tool, so no execution.started was ever written for it`);
4281
4647
  }
4282
4648
  if (input.toolUseId === null) {
@@ -4317,7 +4683,23 @@ function runPostToolUse(flags, streams, cwd, input, actor, adapter) {
4317
4683
  }
4318
4684
  return report(streams, POST_TOOL_REPORTED, `recorded ${reading.outcome} for ${String(finished.records.length)} delegated execution(s) of ${finished.task}`, { task: finished.task, outcome: reading.outcome, appended: finished.records.length });
4319
4685
  }
4320
- function describeToolCall(input, adapter, protectedPaths, cwd, readRoots = []) {
4686
+ function describeToolCall(input, adapter, protectedPaths, cwd, readRoots = [],
4687
+ /**
4688
+ * The loaded policy whose `tools` mapping and `defaults.unmapped_tool` an
4689
+ * unclaimed call is answered by (APRV-499), or `null` when there is none to
4690
+ * read (the open window over a policy that did not load).
4691
+ */
4692
+ toolPolicy = null,
4693
+ /**
4694
+ * May an unclaimed call the policy leaves outside the gate be described as
4695
+ * "not a gated tool"? (APRV-499 fix round 1, refutations B1 and S1.) The
4696
+ * closed path passes `true` and checks the attestation on the allow it gets
4697
+ * back. The open window passes `true` only for an attested policy that
4698
+ * loads; otherwise an unclaimed call is described under
4699
+ * `harness.tool.unmapped`, so the bypass RECORDS it rather than letting it
4700
+ * run unrecorded on the word of a file nobody vouched for.
4701
+ */
4702
+ ungatedAllowed = true) {
4321
4703
  if (adapter.kind === "codex" && input.toolName === "apply_patch") {
4322
4704
  // APRV-363. The app-server's LEGACY `applyPatchApproval` carries its change
4323
4705
  // as a MAP of path to change, inline on the request, and never as an
@@ -4536,6 +4918,42 @@ function describeToolCall(input, adapter, protectedPaths, cwd, readRoots = []) {
4536
4918
  notes: [],
4537
4919
  };
4538
4920
  }
4921
+ // APRV-499: a call the adapter's own tables leave unclaimed (every MCP tool
4922
+ // among them) is answered by the policy's `tools` mapping, first match wins,
4923
+ // then by `defaults.unmapped_tool`. Below the rule table and the shell, and
4924
+ // above the read and file branches only because those two never see an
4925
+ // unclaimed call: the predicate excludes them. The payload is the whole call,
4926
+ // as the rule table's is, so a grant binds every argument; only the NAME is
4927
+ // written to the log, on the start record.
4928
+ if (unclaimedTool(adapter, input)) {
4929
+ const verdict = toolPolicy === null ? { kind: "not-gated" } : toolMapVerdict(toolPolicy, input.toolName);
4930
+ if (verdict.kind === "not-gated" && ungatedAllowed) {
4931
+ return { kind: "allow", reason: `${input.toolName} is not a gated tool`, unclaimed: true };
4932
+ }
4933
+ // Refutation S4: a name the record could not carry is refused here, by
4934
+ // name, before anything is appended, rather than at the write boundary.
4935
+ if (!isRecordableToolName(input.toolName)) {
4936
+ return {
4937
+ kind: "deny",
4938
+ code: "hook-io",
4939
+ detail: `tool-name-invalid: ${JSON.stringify(input.toolName.slice(0, 80))}${input.toolName.length > 80 ? "…" : ""} is classified by the policy's tool mapping, and a tool so classified is recorded by name, which must be 1 to 256 of letters, digits, _, ., : and - (the characters a tools entry can match). Nothing was appended`,
4940
+ };
4941
+ }
4942
+ const cls = verdict.kind === "not-gated" ? UNMAPPED_TOOL_CLASS : verdict.cls;
4943
+ const note = verdict.kind === "mapped"
4944
+ ? `tools entry ${String(verdict.index)} (${JSON.stringify(verdict.match)}) maps ${input.toolName} to ${verdict.cls} (APRV-499)`
4945
+ : verdict.kind === "unmapped"
4946
+ ? `${input.toolName} matches no tools entry; defaults.unmapped_tool: ${verdict.mode} (APRV-499)`
4947
+ : `${input.toolName} is outside the gate only under an attested policy that loads, and this one ${toolPolicy === null ? "did not load" : "is not attested"}, so it is described as ${UNMAPPED_TOOL_CLASS} (APRV-499)`;
4948
+ return {
4949
+ kind: "gated",
4950
+ classes: [cls],
4951
+ payload: { tool: input.toolName, input: input.toolInput },
4952
+ headline: `${input.toolName} (${cls})`,
4953
+ notes: [note],
4954
+ harnessTool: input.toolName,
4955
+ };
4956
+ }
4539
4957
  // APRV-347, above the file-tool branch because the two lists are disjoint and
4540
4958
  // a read is the cheaper question to answer: a read inside the scope, or one
4541
4959
  // naming no path at all, returns `null` here and takes the allow below.
@@ -5177,7 +5595,18 @@ decidedOn) {
5177
5595
  // APRV-347. The window suspends the POLICY, not the classification: a read
5178
5596
  // outside the scope is described as one here too, so the bypass RECORD says
5179
5597
  // what was actually authorized rather than calling it an ordinary read.
5180
- resolveReadRoots(cwd, policyRootOf(load, scope.root), load.ok ? load.policy.read_scope?.roots : undefined));
5598
+ resolveReadRoots(cwd, policyRootOf(load, scope.root), load.ok ? load.policy.read_scope?.roots : undefined),
5599
+ // APRV-499. The window suspends the policy's ANSWER, not its mapping: a
5600
+ // mapped or unmapped tool is described under its class here too, so the
5601
+ // bypass record names it.
5602
+ load.ok ? load.policy : null,
5603
+ // Fix round 1 (refutations S1 and B1): "not a gated tool" is believed only
5604
+ // from an attested policy that loads. A policy that did not load, or one
5605
+ // nobody attested, cannot say a tool is outside the gate, so the call is
5606
+ // described under harness.tool.unmapped and the bypass records it: a
5607
+ // window being open does not let a call run unrecorded.
5608
+ unclaimedStanding(scope.options, cwd, input.toolName, decidedOn?.records ?? null).kind ===
5609
+ "ungated");
5181
5610
  if (described.kind === "deny") {
5182
5611
  return deny(streams, described.code, `${described.detail} The open window does not reach this: a command the classifier cannot read is a command nothing here can establish is safe to run unapproved.`, adapter.kind);
5183
5612
  }
@@ -5500,28 +5929,41 @@ function* runHarnessHook(argv, streams, cwd, readStdin, adapter, waitSeam) {
5500
5929
  input.toolName !== adapter.shellTool &&
5501
5930
  !adapter.fileTools.includes(input.toolName)) {
5502
5931
  if (!adapter.readTools.includes(input.toolName)) {
5503
- return allow(streams, `${input.toolName} is not a gated tool`, adapter.kind, codexCommand);
5932
+ // APRV-499: an unclaimed tool is answered here, with one policy load,
5933
+ // only when the policy loads and neither maps it nor declares
5934
+ // `defaults.unmapped_tool`. Otherwise it goes on to the path below, where
5935
+ // a mapped or unmapped call is classified and a policy that did not load
5936
+ // is refused.
5937
+ // Fix round 1 (refutation B1): and only when those bytes are the ATTESTED
5938
+ // policy, which costs one verified read of the log. Anything else goes on
5939
+ // to the path below, which refuses it in the gate's own words or, under
5940
+ // an open window, records the bypass.
5941
+ if (unclaimedUngated(parsed.flags, cwd, input.toolName)) {
5942
+ return allow(streams, `${input.toolName} is not a gated tool`, adapter.kind, codexCommand);
5943
+ }
5504
5944
  }
5505
- // APRV-347, and the placement is the whole of its cost. A read tool call is
5506
- // the most frequent event a session produces, and before this task it was
5507
- // answered here with no policy load, no verified read of the log and no
5508
- // window lookup. Everything below this line is expensive, so the reads that
5509
- // do not need it must not pay for it.
5510
- //
5511
- // The short-circuit is sound because `read_scope` is ADDITIVE: it can only
5512
- // widen the roots, so a target already inside the BUILT-IN roots is inside
5513
- // the effective ones whatever the policy says, and the policy need not be
5514
- // read to know it. A target outside them falls through, the policy is
5515
- // loaded below, and `describeToolCall` asks again with the widened set —
5516
- // which is where a policy-declared root actually takes effect.
5517
- //
5518
- // The cost of the fast path is a handful of `realpath` calls and no process
5519
- // spawn, provided the hook is configured with `--dir` as the docs show
5520
- // (otherwise `hookScope` runs `git rev-parse` to find the primary).
5521
- const early = hookScope(parsed.flags, cwd);
5522
- if (credentialReadGate(input.toolName, input.toolInput, cwd) === null &&
5523
- readToolGate(input.toolName, input.toolInput, resolveReadRoots(cwd, early.root), cwd) === null) {
5524
- return allow(streams, `${input.toolName} is not a gated tool`, adapter.kind, codexCommand);
5945
+ else {
5946
+ // APRV-347, and the placement is the whole of its cost. A read tool call is
5947
+ // the most frequent event a session produces, and before this task it was
5948
+ // answered here with no policy load, no verified read of the log and no
5949
+ // window lookup. Everything below this line is expensive, so the reads that
5950
+ // do not need it must not pay for it.
5951
+ //
5952
+ // The short-circuit is sound because `read_scope` is ADDITIVE: it can only
5953
+ // widen the roots, so a target already inside the BUILT-IN roots is inside
5954
+ // the effective ones whatever the policy says, and the policy need not be
5955
+ // read to know it. A target outside them falls through, the policy is
5956
+ // loaded below, and `describeToolCall` asks again with the widened set —
5957
+ // which is where a policy-declared root actually takes effect.
5958
+ //
5959
+ // The cost of the fast path is a handful of `realpath` calls and no process
5960
+ // spawn, provided the hook is configured with `--dir` as the docs show
5961
+ // (otherwise `hookScope` runs `git rev-parse` to find the primary).
5962
+ const early = hookScope(parsed.flags, cwd);
5963
+ if (credentialReadGate(input.toolName, input.toolInput, cwd) === null &&
5964
+ readToolGate(input.toolName, input.toolInput, resolveReadRoots(cwd, early.root), cwd) === null) {
5965
+ return allow(streams, `${input.toolName} is not a gated tool`, adapter.kind, codexCommand);
5966
+ }
5525
5967
  }
5526
5968
  }
5527
5969
  // APRV-188. From here on this process may resume a verified read behind the
@@ -5621,7 +6063,7 @@ function* decideHarnessSteps(decide) {
5621
6063
  return {
5622
6064
  permission: "deny",
5623
6065
  code: "hook-policy-unavailable",
5624
- detail: `${load.code}: ${load.message}; every class resolves to manual and the hook cannot verify a decision`,
6066
+ detail: policyUnavailableDetail(load),
5625
6067
  };
5626
6068
  }
5627
6069
  const protectedPaths = load.policy.protected_paths ?? [];
@@ -5633,12 +6075,43 @@ function* decideHarnessSteps(decide) {
5633
6075
  // both paths since APRV-214 (see `describeToolCall`): the open window
5634
6076
  // classifies exactly as the closed one does, and a second copy of this would
5635
6077
  // be a second answer to "what is this command".
5636
- const described = describeToolCall(input, adapter, protectedPaths, cwd, readRoots);
6078
+ const described = describeToolCall(input, adapter, protectedPaths, cwd, readRoots, load.policy);
5637
6079
  if (described.kind === "deny") {
5638
6080
  return { permission: "deny", code: described.code, detail: described.detail };
5639
6081
  }
5640
6082
  if (described.kind === "allow") {
5641
- return { permission: "allow", reason: described.reason };
6083
+ if (described.unclaimed !== true)
6084
+ return { permission: "allow", reason: described.reason };
6085
+ // APRV-499 fix round 1 (refutation B1): the on-disk policy leaves this call
6086
+ // outside the gate, and that is believed only of the ATTESTED policy. The
6087
+ // log is required for that, exactly as it is for an unattended Bash call.
6088
+ if (!existsSync(logPath) && !existsSync(dirname(logPath))) {
6089
+ return {
6090
+ permission: "deny",
6091
+ code: "hook-log-unreachable",
6092
+ detail: `no log at ${logPath}, so whether the policy that leaves ${input.toolName} ungated is the attested one cannot be established; the hook writes to an existing log and never creates one. Run \`approval init\` (then \`approval policy attest\`) in ${root}, or pass --log <path> to point the hook at the log that already exists`,
6093
+ };
6094
+ }
6095
+ let records = windowRecords;
6096
+ if (records === null) {
6097
+ const read = readVerifiedRecords(logPath);
6098
+ if (!read.ok)
6099
+ return { permission: "deny", code: "hook-io", detail: read.message };
6100
+ records = read.records;
6101
+ }
6102
+ const standing = unclaimedStanding(options, cwd, input.toolName, records);
6103
+ if (standing.kind === "ungated")
6104
+ return { permission: "allow", reason: described.reason };
6105
+ if (standing.kind === "unattested") {
6106
+ return { permission: "deny", code: standing.code, detail: standing.detail };
6107
+ }
6108
+ // The file changed between the two reads of this one decision: refuse,
6109
+ // and the next call decides against whatever is there then.
6110
+ return {
6111
+ permission: "deny",
6112
+ code: "hook-policy-unavailable",
6113
+ detail: `the policy file changed while ${input.toolName} was being decided; nothing was appended, and a retry is decided against the file as it then stands`,
6114
+ };
5642
6115
  }
5643
6116
  const { classes, payload, headline } = described;
5644
6117
  /** What the history-rewrite refinement did, for the decision reason. */
@@ -5693,6 +6166,7 @@ function* decideHarnessSteps(decide) {
5693
6166
  // changed when an operator reordered their policy would read as a change of
5694
6167
  // state.
5695
6168
  channels: Object.keys(load.policy.channels ?? {}).sort(),
6169
+ ...(described.harnessTool === undefined ? {} : { harnessTool: described.harnessTool }),
5696
6170
  };
5697
6171
  const resolutions = classes.map((cls) => resolvePolicy(load, cls));
5698
6172
  const autonomies = resolutions.map((resolution) => resolution.autonomy);