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.
- package/SPEC.md +8 -0
- package/dist/src/cli/attest.js +2 -1
- package/dist/src/cli/attest.js.map +1 -1
- package/dist/src/cli/audit.js +3 -2
- package/dist/src/cli/audit.js.map +1 -1
- package/dist/src/cli/channel-telegram.js +2 -1
- package/dist/src/cli/channel-telegram.js.map +1 -1
- package/dist/src/cli/channel-web.js +2 -1
- package/dist/src/cli/channel-web.js.map +1 -1
- package/dist/src/cli/channel.js +2 -1
- package/dist/src/cli/channel.js.map +1 -1
- package/dist/src/cli/doctor.js +1 -1
- package/dist/src/cli/doctor.js.map +1 -1
- package/dist/src/cli/execute.js +4 -3
- package/dist/src/cli/execute.js.map +1 -1
- package/dist/src/cli/gate.js +2 -1
- package/dist/src/cli/gate.js.map +1 -1
- package/dist/src/cli/hook.d.ts +32 -2
- package/dist/src/cli/hook.js +510 -36
- package/dist/src/cli/hook.js.map +1 -1
- package/dist/src/cli/log-checkpoint.js +2 -1
- package/dist/src/cli/log-checkpoint.js.map +1 -1
- package/dist/src/cli/log-unlock.js +2 -1
- package/dist/src/cli/log-unlock.js.map +1 -1
- package/dist/src/cli/setup-common.js +2 -1
- package/dist/src/cli/setup-common.js.map +1 -1
- package/dist/src/cli/vault.js +2 -1
- package/dist/src/cli/vault.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.js +2 -1
- package/dist/src/core/attest-bootstrap.js.map +1 -1
- package/dist/src/core/attest.js +5 -4
- package/dist/src/core/attest.js.map +1 -1
- package/dist/src/core/audit.d.ts +15 -0
- package/dist/src/core/audit.js +19 -3
- package/dist/src/core/audit.js.map +1 -1
- package/dist/src/core/budgets.d.ts +12 -0
- package/dist/src/core/budgets.js +54 -1
- package/dist/src/core/budgets.js.map +1 -1
- package/dist/src/core/checkpoint.js +3 -2
- package/dist/src/core/checkpoint.js.map +1 -1
- package/dist/src/core/command-class.d.ts +57 -0
- package/dist/src/core/command-class.js +188 -10
- package/dist/src/core/command-class.js.map +1 -1
- package/dist/src/core/decision-refusal.js +2 -1
- package/dist/src/core/decision-refusal.js.map +1 -1
- package/dist/src/core/delegation.d.ts +109 -0
- package/dist/src/core/delegation.js +244 -0
- package/dist/src/core/delegation.js.map +1 -0
- package/dist/src/core/execute.js +12 -3
- package/dist/src/core/execute.js.map +1 -1
- package/dist/src/core/gate-window.js +4 -3
- package/dist/src/core/gate-window.js.map +1 -1
- package/dist/src/core/gate.d.ts +26 -42
- package/dist/src/core/gate.js +84 -3
- package/dist/src/core/gate.js.map +1 -1
- package/dist/src/core/gesture-refusal.js +2 -1
- package/dist/src/core/gesture-refusal.js.map +1 -1
- package/dist/src/core/identity.d.ts +105 -0
- package/dist/src/core/identity.js +122 -0
- package/dist/src/core/identity.js.map +1 -0
- package/dist/src/core/live-draw.d.ts +12 -2
- package/dist/src/core/live-draw.js +12 -2
- package/dist/src/core/live-draw.js.map +1 -1
- package/dist/src/core/loop.d.ts +11 -0
- package/dist/src/core/loop.js +63 -4
- package/dist/src/core/loop.js.map +1 -1
- package/dist/src/core/policy-diff.js +20 -1
- package/dist/src/core/policy-diff.js.map +1 -1
- package/dist/src/core/policy-explain.js +18 -2
- package/dist/src/core/policy-explain.js.map +1 -1
- package/dist/src/core/policy-load.d.ts +38 -1
- package/dist/src/core/policy-load.js +34 -2
- package/dist/src/core/policy-load.js.map +1 -1
- package/dist/src/core/policy-match.js +30 -5
- package/dist/src/core/policy-match.js.map +1 -1
- package/dist/src/core/policy-proposal.js +3 -2
- package/dist/src/core/policy-proposal.js.map +1 -1
- package/dist/src/core/token.js +11 -0
- package/dist/src/core/token.js.map +1 -1
- package/dist/src/core/tool-map.d.ts +125 -0
- package/dist/src/core/tool-map.js +179 -0
- package/dist/src/core/tool-map.js.map +1 -0
- package/dist/src/daemon/git-evidence.d.ts +2 -2
- package/dist/src/daemon/git-evidence.js +1 -1
- package/docs/README-extended.md +13 -0
- package/docs/cli-reference.md +64 -3
- package/package.json +2 -2
- package/schema/event.schema.json +34 -2
- package/schema/fixtures/event/invalid/audit-reviewed-model-actor.json +20 -0
- package/schema/fixtures/event/invalid/audit-reviewed-verdict-source-model.json +20 -0
- package/schema/fixtures/event/invalid/execution-started-harness-tool-space.json +18 -0
- package/schema/fixtures/event/valid/execution-started-harness-tool.json +19 -0
- package/schema/fixtures/policy/invalid/delegation-advice-string.json +15 -0
- package/schema/fixtures/policy/invalid/delegation-class-duplicate.json +18 -0
- package/schema/fixtures/policy/invalid/delegation-class-wildcard.json +17 -0
- package/schema/fixtures/policy/invalid/delegation-daily-cap-fractional.json +15 -0
- package/schema/fixtures/policy/invalid/delegation-daily-cap-negative.json +15 -0
- package/schema/fixtures/policy/invalid/delegation-daily-cap-too-high.json +15 -0
- package/schema/fixtures/policy/invalid/delegation-escalate-duplicate.json +18 -0
- package/schema/fixtures/policy/invalid/delegation-escalate-unknown.json +17 -0
- package/schema/fixtures/policy/invalid/delegation-max-autonomy-alias.json +15 -0
- package/schema/fixtures/policy/invalid/delegation-max-autonomy-autonomous.json +15 -0
- package/schema/fixtures/policy/invalid/delegation-model-no-version.json +15 -0
- package/schema/fixtures/policy/invalid/delegation-model-provider-form.json +15 -0
- package/schema/fixtures/policy/invalid/delegation-null.json +7 -0
- package/schema/fixtures/policy/invalid/delegation-reviewer-agent.json +17 -0
- package/schema/fixtures/policy/invalid/delegation-reviewer-bare-id.json +17 -0
- package/schema/fixtures/policy/invalid/delegation-unknown-key.json +16 -0
- package/schema/fixtures/policy/invalid/tools-class-wildcard.json +9 -0
- package/schema/fixtures/policy/invalid/tools-match-double-star.json +9 -0
- package/schema/fixtures/policy/invalid/unmapped-tool-unknown.json +7 -0
- package/schema/fixtures/policy/valid/delegation-empty.json +7 -0
- package/schema/fixtures/policy/valid/delegation-grammar-full.json +41 -0
- package/schema/fixtures/policy/valid/delegation-off.json +15 -0
- package/schema/fixtures/policy/valid/tools-mapping.json +17 -0
- package/schema/policy.schema.json +126 -0
package/dist/src/cli/hook.js
CHANGED
|
@@ -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
|
|
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
|
|
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:
|
|
2121
|
-
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, {
|
|
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
|
-
|
|
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
|
-
|
|
5506
|
-
|
|
5507
|
-
|
|
5508
|
-
|
|
5509
|
-
|
|
5510
|
-
|
|
5511
|
-
|
|
5512
|
-
|
|
5513
|
-
|
|
5514
|
-
|
|
5515
|
-
|
|
5516
|
-
|
|
5517
|
-
|
|
5518
|
-
|
|
5519
|
-
|
|
5520
|
-
|
|
5521
|
-
|
|
5522
|
-
|
|
5523
|
-
|
|
5524
|
-
|
|
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:
|
|
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
|
-
|
|
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);
|