approval-md 0.4.1 → 0.4.2

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 (112) 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 +7 -0
  19. package/dist/src/cli/hook.js +252 -30
  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/decision-refusal.js +2 -1
  44. package/dist/src/core/decision-refusal.js.map +1 -1
  45. package/dist/src/core/delegation.d.ts +109 -0
  46. package/dist/src/core/delegation.js +244 -0
  47. package/dist/src/core/delegation.js.map +1 -0
  48. package/dist/src/core/execute.js +12 -3
  49. package/dist/src/core/execute.js.map +1 -1
  50. package/dist/src/core/gate-window.js +4 -3
  51. package/dist/src/core/gate-window.js.map +1 -1
  52. package/dist/src/core/gate.d.ts +26 -42
  53. package/dist/src/core/gate.js +84 -3
  54. package/dist/src/core/gate.js.map +1 -1
  55. package/dist/src/core/gesture-refusal.js +2 -1
  56. package/dist/src/core/gesture-refusal.js.map +1 -1
  57. package/dist/src/core/identity.d.ts +105 -0
  58. package/dist/src/core/identity.js +122 -0
  59. package/dist/src/core/identity.js.map +1 -0
  60. package/dist/src/core/loop.d.ts +11 -0
  61. package/dist/src/core/loop.js +63 -4
  62. package/dist/src/core/loop.js.map +1 -1
  63. package/dist/src/core/policy-diff.js +20 -1
  64. package/dist/src/core/policy-diff.js.map +1 -1
  65. package/dist/src/core/policy-explain.js +18 -2
  66. package/dist/src/core/policy-explain.js.map +1 -1
  67. package/dist/src/core/policy-load.d.ts +38 -1
  68. package/dist/src/core/policy-load.js +34 -2
  69. package/dist/src/core/policy-load.js.map +1 -1
  70. package/dist/src/core/policy-match.js +30 -5
  71. package/dist/src/core/policy-match.js.map +1 -1
  72. package/dist/src/core/policy-proposal.js +3 -2
  73. package/dist/src/core/policy-proposal.js.map +1 -1
  74. package/dist/src/core/token.js +11 -0
  75. package/dist/src/core/token.js.map +1 -1
  76. package/dist/src/core/tool-map.d.ts +125 -0
  77. package/dist/src/core/tool-map.js +179 -0
  78. package/dist/src/core/tool-map.js.map +1 -0
  79. package/dist/src/daemon/git-evidence.d.ts +2 -2
  80. package/dist/src/daemon/git-evidence.js +1 -1
  81. package/docs/README-extended.md +13 -0
  82. package/docs/cli-reference.md +64 -3
  83. package/package.json +2 -2
  84. package/schema/event.schema.json +34 -2
  85. package/schema/fixtures/event/invalid/audit-reviewed-model-actor.json +20 -0
  86. package/schema/fixtures/event/invalid/audit-reviewed-verdict-source-model.json +20 -0
  87. package/schema/fixtures/event/invalid/execution-started-harness-tool-space.json +18 -0
  88. package/schema/fixtures/event/valid/execution-started-harness-tool.json +19 -0
  89. package/schema/fixtures/policy/invalid/delegation-advice-string.json +15 -0
  90. package/schema/fixtures/policy/invalid/delegation-class-duplicate.json +18 -0
  91. package/schema/fixtures/policy/invalid/delegation-class-wildcard.json +17 -0
  92. package/schema/fixtures/policy/invalid/delegation-daily-cap-fractional.json +15 -0
  93. package/schema/fixtures/policy/invalid/delegation-daily-cap-negative.json +15 -0
  94. package/schema/fixtures/policy/invalid/delegation-daily-cap-too-high.json +15 -0
  95. package/schema/fixtures/policy/invalid/delegation-escalate-duplicate.json +18 -0
  96. package/schema/fixtures/policy/invalid/delegation-escalate-unknown.json +17 -0
  97. package/schema/fixtures/policy/invalid/delegation-max-autonomy-alias.json +15 -0
  98. package/schema/fixtures/policy/invalid/delegation-max-autonomy-autonomous.json +15 -0
  99. package/schema/fixtures/policy/invalid/delegation-model-no-version.json +15 -0
  100. package/schema/fixtures/policy/invalid/delegation-model-provider-form.json +15 -0
  101. package/schema/fixtures/policy/invalid/delegation-null.json +7 -0
  102. package/schema/fixtures/policy/invalid/delegation-reviewer-agent.json +17 -0
  103. package/schema/fixtures/policy/invalid/delegation-reviewer-bare-id.json +17 -0
  104. package/schema/fixtures/policy/invalid/delegation-unknown-key.json +16 -0
  105. package/schema/fixtures/policy/invalid/tools-class-wildcard.json +9 -0
  106. package/schema/fixtures/policy/invalid/tools-match-double-star.json +9 -0
  107. package/schema/fixtures/policy/invalid/unmapped-tool-unknown.json +7 -0
  108. package/schema/fixtures/policy/valid/delegation-empty.json +7 -0
  109. package/schema/fixtures/policy/valid/delegation-grammar-full.json +41 -0
  110. package/schema/fixtures/policy/valid/delegation-off.json +15 -0
  111. package/schema/fixtures/policy/valid/tools-mapping.json +17 -0
  112. package/schema/policy.schema.json +126 -0
@@ -76,7 +76,7 @@ import { existsSync, readdirSync, readFileSync, realpathSync, statSync, writeSyn
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
81
  import { classifyCommand, commandSegmentShapes, commandSegmentWords, CODE_EXECUTING_RULES, CONTRIBUTOR_SUFFIX, GATE_SELF_CLASS, isCredentialPath, protectedPathClass, } from "../core/command-class.js";
82
82
  import { consumeHarnessGrant, findHarnessCarry, finishHarnessExecution, isProposalTask, register, request, startHarnessExecution, withdraw, } from "../core/gate.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
  *
@@ -3066,6 +3161,8 @@ task) {
3066
3161
  append,
3067
3162
  presentedPayloadHash: hash,
3068
3163
  spendingTask: task,
3164
+ // APRV-499 (refutation S3): a mapped or unmapped spend names its tool.
3165
+ ...(run.harnessTool === undefined ? {} : { harnessTool: run.harnessTool }),
3069
3166
  }));
3070
3167
  if (!spent.ok)
3071
3168
  return { code: spent.code, message: `${key}: ${spent.message}` };
@@ -3230,8 +3327,15 @@ known = null) {
3230
3327
  * is recorded through {@link spendUnderLock}, which takes the shared pause.
3231
3328
  */
3232
3329
  function* recordUnattended(run, task, classes, hash) {
3330
+ const harnessTool = run.harnessTool;
3233
3331
  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 }));
3332
+ const started = yield* spendUnderLock(run.logPath, run.options.append, (append) => startHarnessExecution(run.logPath, {
3333
+ task,
3334
+ actionKey: `${task}:${cls}`,
3335
+ cls,
3336
+ payload_hash: hash,
3337
+ ...(harnessTool === undefined ? {} : { harness_tool: harnessTool }),
3338
+ }, run.actor, { ...run.options, append }));
3235
3339
  if (!started.ok)
3236
3340
  return { code: started.code, message: `${cls}: ${started.message}` };
3237
3341
  }
@@ -3543,6 +3647,9 @@ floor = null) {
3543
3647
  const registered = register(run.logPath, { task, envelope }, run.actor, {
3544
3648
  ...run.options,
3545
3649
  ...(provenance === null ? {} : { harness: provenance }),
3650
+ // APRV-499: the hook is the one caller that may declare the reserved
3651
+ // harness.tool.unmapped class.
3652
+ toolMapHook: true,
3546
3653
  });
3547
3654
  if (!registered.ok) {
3548
3655
  return sayDeny(`hook-gate-refused:${registered.code}`, registered.message);
@@ -4276,7 +4383,14 @@ function runPostToolUse(flags, streams, cwd, input, actor, adapter) {
4276
4383
  // nothing, and the close below finds no start and says so in its own words.
4277
4384
  !adapter.readTools.includes(input.toolName) &&
4278
4385
  // APRV-445: a tool the rule table gated wrote a start like any other.
4279
- toolRuleOf(adapter, input)?.kind !== "gated") {
4386
+ toolRuleOf(adapter, input)?.kind !== "gated" &&
4387
+ // APRV-499: an unclaimed tool the policy maps, or one it records or asks
4388
+ // about as unmapped, wrote a start too. So did any unclaimed tool whose
4389
+ // policy no longer loads or is not the attested one, as far as this half
4390
+ // can tell, so that one goes on to the close, which finds the start or says
4391
+ // there is none. Only an ATTESTED policy that loads and leaves the tool
4392
+ // outside the gate keeps this answer (fix round 1, refutation B1).
4393
+ (!unclaimedTool(adapter, input) || unclaimedUngated(flags, cwd, input.toolName))) {
4280
4394
  return report(streams, "post-tool-not-gated", `${input.toolName} is not a gated tool, so no execution.started was ever written for it`);
4281
4395
  }
4282
4396
  if (input.toolUseId === null) {
@@ -4317,7 +4431,23 @@ function runPostToolUse(flags, streams, cwd, input, actor, adapter) {
4317
4431
  }
4318
4432
  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
4433
  }
4320
- function describeToolCall(input, adapter, protectedPaths, cwd, readRoots = []) {
4434
+ function describeToolCall(input, adapter, protectedPaths, cwd, readRoots = [],
4435
+ /**
4436
+ * The loaded policy whose `tools` mapping and `defaults.unmapped_tool` an
4437
+ * unclaimed call is answered by (APRV-499), or `null` when there is none to
4438
+ * read (the open window over a policy that did not load).
4439
+ */
4440
+ toolPolicy = null,
4441
+ /**
4442
+ * May an unclaimed call the policy leaves outside the gate be described as
4443
+ * "not a gated tool"? (APRV-499 fix round 1, refutations B1 and S1.) The
4444
+ * closed path passes `true` and checks the attestation on the allow it gets
4445
+ * back. The open window passes `true` only for an attested policy that
4446
+ * loads; otherwise an unclaimed call is described under
4447
+ * `harness.tool.unmapped`, so the bypass RECORDS it rather than letting it
4448
+ * run unrecorded on the word of a file nobody vouched for.
4449
+ */
4450
+ ungatedAllowed = true) {
4321
4451
  if (adapter.kind === "codex" && input.toolName === "apply_patch") {
4322
4452
  // APRV-363. The app-server's LEGACY `applyPatchApproval` carries its change
4323
4453
  // as a MAP of path to change, inline on the request, and never as an
@@ -4536,6 +4666,42 @@ function describeToolCall(input, adapter, protectedPaths, cwd, readRoots = []) {
4536
4666
  notes: [],
4537
4667
  };
4538
4668
  }
4669
+ // APRV-499: a call the adapter's own tables leave unclaimed (every MCP tool
4670
+ // among them) is answered by the policy's `tools` mapping, first match wins,
4671
+ // then by `defaults.unmapped_tool`. Below the rule table and the shell, and
4672
+ // above the read and file branches only because those two never see an
4673
+ // unclaimed call: the predicate excludes them. The payload is the whole call,
4674
+ // as the rule table's is, so a grant binds every argument; only the NAME is
4675
+ // written to the log, on the start record.
4676
+ if (unclaimedTool(adapter, input)) {
4677
+ const verdict = toolPolicy === null ? { kind: "not-gated" } : toolMapVerdict(toolPolicy, input.toolName);
4678
+ if (verdict.kind === "not-gated" && ungatedAllowed) {
4679
+ return { kind: "allow", reason: `${input.toolName} is not a gated tool`, unclaimed: true };
4680
+ }
4681
+ // Refutation S4: a name the record could not carry is refused here, by
4682
+ // name, before anything is appended, rather than at the write boundary.
4683
+ if (!isRecordableToolName(input.toolName)) {
4684
+ return {
4685
+ kind: "deny",
4686
+ code: "hook-io",
4687
+ 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`,
4688
+ };
4689
+ }
4690
+ const cls = verdict.kind === "not-gated" ? UNMAPPED_TOOL_CLASS : verdict.cls;
4691
+ const note = verdict.kind === "mapped"
4692
+ ? `tools entry ${String(verdict.index)} (${JSON.stringify(verdict.match)}) maps ${input.toolName} to ${verdict.cls} (APRV-499)`
4693
+ : verdict.kind === "unmapped"
4694
+ ? `${input.toolName} matches no tools entry; defaults.unmapped_tool: ${verdict.mode} (APRV-499)`
4695
+ : `${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)`;
4696
+ return {
4697
+ kind: "gated",
4698
+ classes: [cls],
4699
+ payload: { tool: input.toolName, input: input.toolInput },
4700
+ headline: `${input.toolName} (${cls})`,
4701
+ notes: [note],
4702
+ harnessTool: input.toolName,
4703
+ };
4704
+ }
4539
4705
  // APRV-347, above the file-tool branch because the two lists are disjoint and
4540
4706
  // a read is the cheaper question to answer: a read inside the scope, or one
4541
4707
  // naming no path at all, returns `null` here and takes the allow below.
@@ -5177,7 +5343,18 @@ decidedOn) {
5177
5343
  // APRV-347. The window suspends the POLICY, not the classification: a read
5178
5344
  // outside the scope is described as one here too, so the bypass RECORD says
5179
5345
  // 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));
5346
+ resolveReadRoots(cwd, policyRootOf(load, scope.root), load.ok ? load.policy.read_scope?.roots : undefined),
5347
+ // APRV-499. The window suspends the policy's ANSWER, not its mapping: a
5348
+ // mapped or unmapped tool is described under its class here too, so the
5349
+ // bypass record names it.
5350
+ load.ok ? load.policy : null,
5351
+ // Fix round 1 (refutations S1 and B1): "not a gated tool" is believed only
5352
+ // from an attested policy that loads. A policy that did not load, or one
5353
+ // nobody attested, cannot say a tool is outside the gate, so the call is
5354
+ // described under harness.tool.unmapped and the bypass records it: a
5355
+ // window being open does not let a call run unrecorded.
5356
+ unclaimedStanding(scope.options, cwd, input.toolName, decidedOn?.records ?? null).kind ===
5357
+ "ungated");
5181
5358
  if (described.kind === "deny") {
5182
5359
  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
5360
  }
@@ -5500,28 +5677,41 @@ function* runHarnessHook(argv, streams, cwd, readStdin, adapter, waitSeam) {
5500
5677
  input.toolName !== adapter.shellTool &&
5501
5678
  !adapter.fileTools.includes(input.toolName)) {
5502
5679
  if (!adapter.readTools.includes(input.toolName)) {
5503
- return allow(streams, `${input.toolName} is not a gated tool`, adapter.kind, codexCommand);
5680
+ // APRV-499: an unclaimed tool is answered here, with one policy load,
5681
+ // only when the policy loads and neither maps it nor declares
5682
+ // `defaults.unmapped_tool`. Otherwise it goes on to the path below, where
5683
+ // a mapped or unmapped call is classified and a policy that did not load
5684
+ // is refused.
5685
+ // Fix round 1 (refutation B1): and only when those bytes are the ATTESTED
5686
+ // policy, which costs one verified read of the log. Anything else goes on
5687
+ // to the path below, which refuses it in the gate's own words or, under
5688
+ // an open window, records the bypass.
5689
+ if (unclaimedUngated(parsed.flags, cwd, input.toolName)) {
5690
+ return allow(streams, `${input.toolName} is not a gated tool`, adapter.kind, codexCommand);
5691
+ }
5504
5692
  }
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);
5693
+ else {
5694
+ // APRV-347, and the placement is the whole of its cost. A read tool call is
5695
+ // the most frequent event a session produces, and before this task it was
5696
+ // answered here with no policy load, no verified read of the log and no
5697
+ // window lookup. Everything below this line is expensive, so the reads that
5698
+ // do not need it must not pay for it.
5699
+ //
5700
+ // The short-circuit is sound because `read_scope` is ADDITIVE: it can only
5701
+ // widen the roots, so a target already inside the BUILT-IN roots is inside
5702
+ // the effective ones whatever the policy says, and the policy need not be
5703
+ // read to know it. A target outside them falls through, the policy is
5704
+ // loaded below, and `describeToolCall` asks again with the widened set —
5705
+ // which is where a policy-declared root actually takes effect.
5706
+ //
5707
+ // The cost of the fast path is a handful of `realpath` calls and no process
5708
+ // spawn, provided the hook is configured with `--dir` as the docs show
5709
+ // (otherwise `hookScope` runs `git rev-parse` to find the primary).
5710
+ const early = hookScope(parsed.flags, cwd);
5711
+ if (credentialReadGate(input.toolName, input.toolInput, cwd) === null &&
5712
+ readToolGate(input.toolName, input.toolInput, resolveReadRoots(cwd, early.root), cwd) === null) {
5713
+ return allow(streams, `${input.toolName} is not a gated tool`, adapter.kind, codexCommand);
5714
+ }
5525
5715
  }
5526
5716
  }
5527
5717
  // APRV-188. From here on this process may resume a verified read behind the
@@ -5621,7 +5811,7 @@ function* decideHarnessSteps(decide) {
5621
5811
  return {
5622
5812
  permission: "deny",
5623
5813
  code: "hook-policy-unavailable",
5624
- detail: `${load.code}: ${load.message}; every class resolves to manual and the hook cannot verify a decision`,
5814
+ detail: policyUnavailableDetail(load),
5625
5815
  };
5626
5816
  }
5627
5817
  const protectedPaths = load.policy.protected_paths ?? [];
@@ -5633,12 +5823,43 @@ function* decideHarnessSteps(decide) {
5633
5823
  // both paths since APRV-214 (see `describeToolCall`): the open window
5634
5824
  // classifies exactly as the closed one does, and a second copy of this would
5635
5825
  // be a second answer to "what is this command".
5636
- const described = describeToolCall(input, adapter, protectedPaths, cwd, readRoots);
5826
+ const described = describeToolCall(input, adapter, protectedPaths, cwd, readRoots, load.policy);
5637
5827
  if (described.kind === "deny") {
5638
5828
  return { permission: "deny", code: described.code, detail: described.detail };
5639
5829
  }
5640
5830
  if (described.kind === "allow") {
5641
- return { permission: "allow", reason: described.reason };
5831
+ if (described.unclaimed !== true)
5832
+ return { permission: "allow", reason: described.reason };
5833
+ // APRV-499 fix round 1 (refutation B1): the on-disk policy leaves this call
5834
+ // outside the gate, and that is believed only of the ATTESTED policy. The
5835
+ // log is required for that, exactly as it is for an unattended Bash call.
5836
+ if (!existsSync(logPath) && !existsSync(dirname(logPath))) {
5837
+ return {
5838
+ permission: "deny",
5839
+ code: "hook-log-unreachable",
5840
+ 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`,
5841
+ };
5842
+ }
5843
+ let records = windowRecords;
5844
+ if (records === null) {
5845
+ const read = readVerifiedRecords(logPath);
5846
+ if (!read.ok)
5847
+ return { permission: "deny", code: "hook-io", detail: read.message };
5848
+ records = read.records;
5849
+ }
5850
+ const standing = unclaimedStanding(options, cwd, input.toolName, records);
5851
+ if (standing.kind === "ungated")
5852
+ return { permission: "allow", reason: described.reason };
5853
+ if (standing.kind === "unattested") {
5854
+ return { permission: "deny", code: standing.code, detail: standing.detail };
5855
+ }
5856
+ // The file changed between the two reads of this one decision: refuse,
5857
+ // and the next call decides against whatever is there then.
5858
+ return {
5859
+ permission: "deny",
5860
+ code: "hook-policy-unavailable",
5861
+ 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`,
5862
+ };
5642
5863
  }
5643
5864
  const { classes, payload, headline } = described;
5644
5865
  /** What the history-rewrite refinement did, for the decision reason. */
@@ -5693,6 +5914,7 @@ function* decideHarnessSteps(decide) {
5693
5914
  // changed when an operator reordered their policy would read as a change of
5694
5915
  // state.
5695
5916
  channels: Object.keys(load.policy.channels ?? {}).sort(),
5917
+ ...(described.harnessTool === undefined ? {} : { harnessTool: described.harnessTool }),
5696
5918
  };
5697
5919
  const resolutions = classes.map((cls) => resolvePolicy(load, cls));
5698
5920
  const autonomies = resolutions.map((resolution) => resolution.autonomy);