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.
- 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 +7 -0
- package/dist/src/cli/hook.js +252 -30
- 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/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/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
|
@@ -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, {
|
|
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
|
-
|
|
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
|
-
|
|
5506
|
-
|
|
5507
|
-
|
|
5508
|
-
|
|
5509
|
-
|
|
5510
|
-
|
|
5511
|
-
|
|
5512
|
-
|
|
5513
|
-
|
|
5514
|
-
|
|
5515
|
-
|
|
5516
|
-
|
|
5517
|
-
|
|
5518
|
-
|
|
5519
|
-
|
|
5520
|
-
|
|
5521
|
-
|
|
5522
|
-
|
|
5523
|
-
|
|
5524
|
-
|
|
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:
|
|
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
|
-
|
|
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);
|