approval-md 0.4.0 → 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 +11 -0
- package/cli.js +72 -4
- package/dist/src/channels/contract.d.ts +53 -0
- package/dist/src/channels/contract.js +56 -0
- package/dist/src/channels/contract.js.map +1 -1
- package/dist/src/channels/tagging.d.ts +25 -0
- package/dist/src/channels/tagging.js +57 -5
- package/dist/src/channels/tagging.js.map +1 -1
- package/dist/src/channels/telegram-minimal.d.ts +251 -0
- package/dist/src/channels/telegram-minimal.js +618 -0
- package/dist/src/channels/telegram-minimal.js.map +1 -0
- package/dist/src/channels/telegram.d.ts +454 -19
- package/dist/src/channels/telegram.js +931 -80
- package/dist/src/channels/telegram.js.map +1 -1
- package/dist/src/cli/amend.js +8 -1
- package/dist/src/cli/amend.js.map +1 -1
- package/dist/src/cli/attest.js +2 -1
- package/dist/src/cli/attest.js.map +1 -1
- package/dist/src/cli/audit-card.d.ts +1 -1
- package/dist/src/cli/audit-card.js +40 -1
- package/dist/src/cli/audit-card.js.map +1 -1
- package/dist/src/cli/audit.d.ts +7 -5
- package/dist/src/cli/audit.js +25 -12
- package/dist/src/cli/audit.js.map +1 -1
- package/dist/src/cli/channel-telegram-webhook.d.ts +2 -0
- package/dist/src/cli/channel-telegram-webhook.js +7 -0
- package/dist/src/cli/channel-telegram-webhook.js.map +1 -1
- package/dist/src/cli/channel-telegram.d.ts +75 -2
- package/dist/src/cli/channel-telegram.js +172 -16
- 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.d.ts +12 -0
- package/dist/src/cli/execute.js +50 -4
- 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/help.d.ts +1 -0
- package/dist/src/cli/help.js +39 -18
- package/dist/src/cli/help.js.map +1 -1
- package/dist/src/cli/hook.d.ts +121 -3
- package/dist/src/cli/hook.js +719 -79
- 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.d.ts +23 -0
- package/dist/src/cli/log-unlock.js +110 -0
- package/dist/src/cli/log-unlock.js.map +1 -0
- package/dist/src/cli/main.js +23 -6
- package/dist/src/cli/main.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/up.js +5 -0
- package/dist/src/cli/up.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/verb-registry.js +1 -0
- package/dist/src/cli/verb-registry.js.map +1 -1
- package/dist/src/cli/wordmark.d.ts +2 -2
- package/dist/src/cli/wordmark.js +2 -2
- package/dist/src/core/attest-bootstrap.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 +113 -9
- package/dist/src/core/audit.js +382 -18
- 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.js +6 -0
- 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 +84 -43
- package/dist/src/core/gate.js +114 -6
- 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/log-lock.d.ts +435 -0
- package/dist/src/core/log-lock.js +1093 -0
- package/dist/src/core/log-lock.js.map +1 -0
- package/dist/src/core/log.d.ts +82 -3
- package/dist/src/core/log.js +285 -40
- package/dist/src/core/log.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.d.ts +17 -0
- package/dist/src/core/policy-proposal.js +32 -16
- package/dist/src/core/policy-proposal.js.map +1 -1
- package/dist/src/core/prompt-layout.d.ts +186 -1
- package/dist/src/core/prompt-layout.js +345 -3
- package/dist/src/core/prompt-layout.js.map +1 -1
- package/dist/src/core/sender-identity.d.ts +24 -1
- package/dist/src/core/sender-identity.js +31 -0
- package/dist/src/core/sender-identity.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/core/validate.d.ts +20 -6
- package/dist/src/core/validate.js +0 -0
- package/dist/src/core/validate.js.map +1 -1
- package/dist/src/daemon/git-evidence.d.ts +2 -2
- package/dist/src/daemon/git-evidence.js +1 -1
- package/dist/src/serve/archive.d.ts +8 -0
- package/dist/src/serve/archive.js +11 -2
- package/dist/src/serve/archive.js.map +1 -1
- package/docs/README-extended.md +15 -0
- package/docs/cli-reference.md +571 -28
- package/package.json +2 -2
- package/schema/event.schema.json +387 -29
- package/schema/fixtures/event/invalid/audit-lock-reclaimed-age-without-created.json +19 -0
- package/schema/fixtures/event/invalid/audit-lock-reclaimed-agent-actor.json +14 -0
- package/schema/fixtures/event/invalid/audit-lock-reclaimed-lockfile-path.json +14 -0
- package/schema/fixtures/event/invalid/audit-lock-reclaimed-operator-cleared-system-actor.json +14 -0
- package/schema/fixtures/event/invalid/audit-lock-reclaimed-unknown-reason.json +14 -0
- package/schema/fixtures/event/invalid/audit-lock-reclaimed-without-age.json +13 -0
- package/schema/fixtures/event/invalid/audit-reviewed-agent-actor.json +6 -1
- package/schema/fixtures/event/invalid/audit-reviewed-bad-payload-hash.json +21 -0
- package/schema/fixtures/event/invalid/audit-reviewed-loved-no-note.json +5 -0
- package/schema/fixtures/event/invalid/audit-reviewed-model-actor.json +20 -0
- package/schema/fixtures/event/invalid/audit-reviewed-no-subject-hash.json +19 -0
- package/schema/fixtures/event/invalid/audit-reviewed-no-subject-seq.json +19 -0
- package/schema/fixtures/event/invalid/audit-reviewed-no-verdict-source.json +19 -0
- package/schema/fixtures/event/invalid/audit-reviewed-no-verdict.json +19 -0
- package/schema/fixtures/event/invalid/audit-reviewed-system-actor.json +6 -1
- package/schema/fixtures/event/invalid/audit-reviewed-verdict-source-model.json +20 -0
- package/schema/fixtures/event/invalid/audit-sampled-no-policy-hash.json +14 -0
- package/schema/fixtures/event/invalid/execution-started-harness-tool-space.json +18 -0
- package/schema/fixtures/event/valid/audit-lock-reclaimed-implausible-created.json +18 -0
- package/schema/fixtures/event/valid/audit-lock-reclaimed-legacy.json +14 -0
- package/schema/fixtures/event/valid/audit-lock-reclaimed-unlock.json +19 -0
- package/schema/fixtures/event/valid/audit-lock-reclaimed.json +19 -0
- package/schema/fixtures/event/valid/audit-reviewed-reaction.json +5 -0
- package/schema/fixtures/event/valid/audit-reviewed.json +6 -1
- package/schema/fixtures/event/valid/audit-sampled.json +2 -1
- package/schema/fixtures/event/valid/daemon-identity.json +2 -1
- 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 +178 -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";
|
|
@@ -85,9 +85,12 @@ import { harnessProvenance, isHarnessKind, } from "../core/harness-version.js";
|
|
|
85
85
|
import { abandonedAfterMs, HARNESS_CAP_MARGIN_MS, harnessCapFitsMargin, harnessCappedTtlMs, HOOK_DEFAULT_WAIT, HOOK_RETRY_GRACE_MS, } from "../core/harness-wait.js";
|
|
86
86
|
import { harnessLoopFloor, isLoopEscalated, isSideEffectingClass, loopClearance, UNKNOWN_SESSION, } from "../core/loop.js";
|
|
87
87
|
import { drawSocketPathFor, drawSocketUsable } from "../core/live-draw.js";
|
|
88
|
+
import { appendLockHeld, DEFAULT_LOCK_RETRY_MS, DEFAULT_LOCK_TIMEOUT_MS, } from "../core/log.js";
|
|
89
|
+
import { settleTerminationGuards } from "../core/log-lock.js";
|
|
88
90
|
import { payloadHash } from "../core/payload.js";
|
|
89
91
|
import { classifyApplyPatch, parseApplyPatch } from "../core/apply-patch.js";
|
|
90
|
-
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";
|
|
91
94
|
import { READ_OUT_OF_SCOPE_CLASS, effectiveReadRoots, isInReadScope, readTargetsOf, renderReadRoots, } from "../core/read-scope.js";
|
|
92
95
|
import { harnessLaunchNeedsRule, harnessLaunchUnruledRefusal, humanOnlyRefusal, resolve as resolvePolicy, } from "../core/policy-match.js";
|
|
93
96
|
import { payloadOf, readVerifiedRecords, requestExpiresAt, requestState, useVerifiedSnapshots, } from "../core/state.js";
|
|
@@ -810,6 +813,100 @@ function toolRuleOf(adapter, input) {
|
|
|
810
813
|
}
|
|
811
814
|
return adapter.toolRules(input.toolName, input.toolInput);
|
|
812
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
|
+
}
|
|
813
910
|
/**
|
|
814
911
|
* Every harness this runtime speaks a hook protocol for, by kind (APRV-358).
|
|
815
912
|
*
|
|
@@ -988,6 +1085,86 @@ export function harnessBlockDirective(code, detail, harness) {
|
|
|
988
1085
|
const exitCode = deny({ out: (text) => out.push(text), err: () => undefined }, code, detail, harness);
|
|
989
1086
|
return { stdout: out.join(""), exitCode };
|
|
990
1087
|
}
|
|
1088
|
+
/**
|
|
1089
|
+
* Raised on `globalThis` while the runtime's own Hermes signal guards are
|
|
1090
|
+
* registered (APRV-466). The `approval` bin reads the same registry symbol and
|
|
1091
|
+
* steps aside while it is `true`, so the bin and the runtime never both print.
|
|
1092
|
+
* An explicit flag rather than a listener count: an unrelated listener that
|
|
1093
|
+
* does not exit must not switch the bin's guard off.
|
|
1094
|
+
*/
|
|
1095
|
+
export const HERMES_SIGNAL_OWNER = Symbol.for("approval-md.hermes-signal-owner");
|
|
1096
|
+
/**
|
|
1097
|
+
* The block directive a Hermes hook prints when a signal arrives before it
|
|
1098
|
+
* reached a verdict (APRV-445, APRV-466), as bytes.
|
|
1099
|
+
*
|
|
1100
|
+
* Two places print it: `hermesFailClosed`'s guard below, and the `approval` bin
|
|
1101
|
+
* (`cli.js`), which has to answer a signal that lands while this module is
|
|
1102
|
+
* still loading and so cannot import it. The bin spells the same bytes by hand,
|
|
1103
|
+
* and `tests/hermes-bin-signal-guard.test.ts` pins the two equal, so the
|
|
1104
|
+
* early-signal answer is one code with one spelling (SPEC.md §11.1 invariant
|
|
1105
|
+
* 6). The wait's own handler words its message differently ("while waiting for
|
|
1106
|
+
* a decision") under the same code.
|
|
1107
|
+
*
|
|
1108
|
+
* On the CLI the runtime's guard prints it for a signal held through the
|
|
1109
|
+
* synchronous stretch before the first spend (the stdin read, the
|
|
1110
|
+
* classification), at the pause `BEFORE_SPEND` (APRV-473); the bin prints it
|
|
1111
|
+
* for a signal during the load (docs/hermes-hook.md, "Two layers against a
|
|
1112
|
+
* signal").
|
|
1113
|
+
*/
|
|
1114
|
+
export function hermesInterruptedDirective(signal) {
|
|
1115
|
+
return harnessBlockDirective("hook-interrupted", `the hook received ${signal} before it reached a verdict; nothing authorizes this call`, "hermes").stdout;
|
|
1116
|
+
}
|
|
1117
|
+
/**
|
|
1118
|
+
* The block directive a harness wait answers when a SIGTERM or SIGINT ends it
|
|
1119
|
+
* (APRV-473 for Hermes, APRV-475 for every harness), as bytes and exit code.
|
|
1120
|
+
*
|
|
1121
|
+
* It is the harness's ORDINARY deny, built by {@link harnessBlockDirective}
|
|
1122
|
+
* and so by {@link deny}, under the `hook-interrupted` code: the nested
|
|
1123
|
+
* `hookSpecificOutput` deny at exit 0 for Claude Code, Codex and Muse,
|
|
1124
|
+
* `{permission:"deny"}` at exit 0 for Cursor, `{decision:"deny"}` at exit 2
|
|
1125
|
+
* for Grok, `{action:"block"}` at exit 2 for Hermes. One construction site per
|
|
1126
|
+
* harness, so the answer to an interruption is spelled exactly as every other
|
|
1127
|
+
* refusal that harness receives from this hook, and a harness that blocks on
|
|
1128
|
+
* those blocks on this. `tests/harness-wait-interrupt.test.ts` pins the bytes
|
|
1129
|
+
* per harness.
|
|
1130
|
+
*/
|
|
1131
|
+
export function interruptedWaitDirective(signal, harness) {
|
|
1132
|
+
return harnessBlockDirective("hook-interrupted", `the hook received ${signal} while waiting for a decision; nothing authorizes this call`, harness);
|
|
1133
|
+
}
|
|
1134
|
+
/**
|
|
1135
|
+
* The exit code an interrupted wait leaves with when stdout would not take its
|
|
1136
|
+
* directive whole (APRV-475): a closed pipe, or a short write.
|
|
1137
|
+
*
|
|
1138
|
+
* 2, because with an empty or torn stdout the exit code is the whole verdict,
|
|
1139
|
+
* and 2 is the one code these harnesses read as a block on its own: Claude Code
|
|
1140
|
+
* documents it as a blocking error, Grok as its deny, Hermes blocks on it
|
|
1141
|
+
* unconditionally, Muse's live probe blocked on it with an empty stdout, and a
|
|
1142
|
+
* Cursor entry with `failClosed` blocks on any non-zero exit. Exiting 0 there
|
|
1143
|
+
* would be the one wrong answer on the harnesses whose deny is exit 0 with a
|
|
1144
|
+
* body: an exit 0 with no body is no verdict, and those harnesses run the call.
|
|
1145
|
+
*/
|
|
1146
|
+
const INTERRUPTED_BARE_EXIT = 2;
|
|
1147
|
+
/**
|
|
1148
|
+
* Answer an interrupted wait (APRV-475), and return the code to exit with.
|
|
1149
|
+
*
|
|
1150
|
+
* Writes {@link interruptedWaitDirective} synchronously to fd 1, because the
|
|
1151
|
+
* `process.exit` that follows does not wait for a stream to drain. Returns the
|
|
1152
|
+
* directive's own exit code, or {@link INTERRUPTED_BARE_EXIT} when the write
|
|
1153
|
+
* threw or came up short. Called by the wait's signal handler only, BEFORE its
|
|
1154
|
+
* withdrawal, so the block is on stdout however long the withdrawal waits for
|
|
1155
|
+
* the log's lock.
|
|
1156
|
+
*/
|
|
1157
|
+
function writeInterruptedWait(signal, harness) {
|
|
1158
|
+
const directive = interruptedWaitDirective(signal, harness);
|
|
1159
|
+
let whole = false;
|
|
1160
|
+
try {
|
|
1161
|
+
whole = writeSync(1, directive.stdout) === Buffer.byteLength(directive.stdout);
|
|
1162
|
+
}
|
|
1163
|
+
catch {
|
|
1164
|
+
// stdout is gone; the exit code is the whole verdict.
|
|
1165
|
+
}
|
|
1166
|
+
return whole ? directive.exitCode : INTERRUPTED_BARE_EXIT;
|
|
1167
|
+
}
|
|
991
1168
|
/** The machine-readable code a Hermes `execute_code` call is refused with. */
|
|
992
1169
|
export const HERMES_EXECUTE_CODE_REFUSAL = "hook-hermes-execute-code-unbound";
|
|
993
1170
|
/** The tool that carries a program and no path, no argv and no directory. */
|
|
@@ -2111,10 +2288,221 @@ function commandClassify(argv, streams, cwd) {
|
|
|
2111
2288
|
return EXIT_OK;
|
|
2112
2289
|
}
|
|
2113
2290
|
function sleepSync(ms) {
|
|
2291
|
+
// A synchronous wait never runs with termination signals held by the log
|
|
2292
|
+
// lock's guard (APRV-479 round 2, RS2: `approval codex bridge` waits for a
|
|
2293
|
+
// human here through `driveSync`).
|
|
2294
|
+
settleTerminationGuards();
|
|
2114
2295
|
if (ms <= 0)
|
|
2115
2296
|
return;
|
|
2116
2297
|
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
|
|
2117
2298
|
}
|
|
2299
|
+
/** The longest delay `setTimeout` honours (2^31 - 1 ms). */
|
|
2300
|
+
const MAX_TIMER_MS = 2_147_483_647;
|
|
2301
|
+
/** Run `steps` to its verdict, sleeping each pause synchronously. */
|
|
2302
|
+
function driveSync(steps) {
|
|
2303
|
+
for (;;) {
|
|
2304
|
+
const next = steps.next(false);
|
|
2305
|
+
if (next.done === true)
|
|
2306
|
+
return next.value;
|
|
2307
|
+
if (next.value > 0)
|
|
2308
|
+
sleepSync(next.value);
|
|
2309
|
+
}
|
|
2310
|
+
}
|
|
2311
|
+
/**
|
|
2312
|
+
* Run `steps` to its verdict, sleeping each pause on the event loop.
|
|
2313
|
+
*
|
|
2314
|
+
* A zero pause is TWO `setImmediate` hops, and the second is what guarantees a
|
|
2315
|
+
* poll phase in between. libuv hands a signal to its JS listeners only when the
|
|
2316
|
+
* poll phase reads the signal pipe. A single immediate scheduled from a
|
|
2317
|
+
* poll-phase callback runs in the same iteration's check phase, before any
|
|
2318
|
+
* further poll, so a signal that arrived during the synchronous stretch would
|
|
2319
|
+
* still be sitting in the pipe when the spend ran. That is the case whenever
|
|
2320
|
+
* the run is still inside the continuation of the bin's `import()` of `dist/`
|
|
2321
|
+
* on a Node whose module load turns the loop (24, and likely 22; APRV-473
|
|
2322
|
+
* refuter, reproduced on 24). A second immediate scheduled from the check phase
|
|
2323
|
+
* runs in the NEXT iteration's check phase, after that iteration's poll, from
|
|
2324
|
+
* whichever phase the first was scheduled.
|
|
2325
|
+
*/
|
|
2326
|
+
async function driveYielding(steps) {
|
|
2327
|
+
for (;;) {
|
|
2328
|
+
const next = steps.next(true);
|
|
2329
|
+
if (next.done === true)
|
|
2330
|
+
return next.value;
|
|
2331
|
+
// Clamped to the largest delay a timer holds: past it Node fires the timer
|
|
2332
|
+
// at once, and a pause would become a busy poll. The wait re-reads its
|
|
2333
|
+
// deadline after every pause, so a shorter pause changes nothing it judges.
|
|
2334
|
+
const ms = Math.min(next.value, MAX_TIMER_MS);
|
|
2335
|
+
await new Promise((settle) => {
|
|
2336
|
+
if (ms <= 0)
|
|
2337
|
+
setImmediate(() => setImmediate(settle));
|
|
2338
|
+
else
|
|
2339
|
+
setTimeout(settle, ms);
|
|
2340
|
+
});
|
|
2341
|
+
}
|
|
2342
|
+
}
|
|
2343
|
+
/**
|
|
2344
|
+
* The pause before anything appends `execution.started` or `gate.bypassed`
|
|
2345
|
+
* (APRV-473): a poll phase of the event loop under {@link driveYielding},
|
|
2346
|
+
* nothing under {@link driveSync}.
|
|
2347
|
+
*
|
|
2348
|
+
* Everything between two pauses runs synchronously, so a signal that lands
|
|
2349
|
+
* during the stdin read, the classification, or the poll read that found the
|
|
2350
|
+
* grant is held until the next pause. Without this one, the next point it
|
|
2351
|
+
* could be dispatched is after the spend, and a hook interrupted before its
|
|
2352
|
+
* verdict would record the execution of a call its harness had already given
|
|
2353
|
+
* up on. With it, the listener in force runs first: the wait's handler
|
|
2354
|
+
* withdraws the question and blocks, the early guard (`hermesFailClosed`)
|
|
2355
|
+
* blocks, and either one exits the process before the spend is reached.
|
|
2356
|
+
*
|
|
2357
|
+
* Shared by every harness (APRV-475): it is a yield inside the one gate chain,
|
|
2358
|
+
* so there is no per-harness copy to drift. A signal is HELD only while some
|
|
2359
|
+
* JS listener for it is registered. Outside the wait, the harnesses other than
|
|
2360
|
+
* Hermes register none, so there a signal takes its default disposition the
|
|
2361
|
+
* moment it arrives (the process dies with nothing printed and nothing spent),
|
|
2362
|
+
* and this pause matters for them at the post-wait spend, where the wait's
|
|
2363
|
+
* handler is registered.
|
|
2364
|
+
*
|
|
2365
|
+
* Taken inside {@link spendUnderLock} since APRV-478, immediately before the
|
|
2366
|
+
* spend's one attempt and after any wait for the log's lock, so nothing that can
|
|
2367
|
+
* wait sits between the last pause and the append.
|
|
2368
|
+
*/
|
|
2369
|
+
const BEFORE_SPEND = 0;
|
|
2370
|
+
/**
|
|
2371
|
+
* Did this attempt lose a race for the log's lock, writing nothing?
|
|
2372
|
+
*
|
|
2373
|
+
* Two shapes: the spend's own append refused `append-failed` with the writer's
|
|
2374
|
+
* `lock-timeout`, and a budget refusal whose `budget.exceeded` evidence lost the
|
|
2375
|
+
* race (`startHarnessExecution` carries the writer's error on that refusal for
|
|
2376
|
+
* this, APRV-478). In both nothing was appended, so re-running the whole
|
|
2377
|
+
* attempt is safe and gives the record the chance a synchronous lock wait gave
|
|
2378
|
+
* it.
|
|
2379
|
+
*/
|
|
2380
|
+
function lockTimedOut(result) {
|
|
2381
|
+
return (!result.ok &&
|
|
2382
|
+
(result.code === "append-failed" || result.code === "budget-exceeded") &&
|
|
2383
|
+
result.append?.code === "lock-timeout");
|
|
2384
|
+
}
|
|
2385
|
+
/**
|
|
2386
|
+
* Run one spend (an append that authorizes a harness call) without ever waiting
|
|
2387
|
+
* for the log's lock synchronously (APRV-478).
|
|
2388
|
+
*
|
|
2389
|
+
* ## The hole
|
|
2390
|
+
*
|
|
2391
|
+
* Every writer waits for `<log>.lock` inside `core/log.ts`, retrying with an
|
|
2392
|
+
* `Atomics.wait` sleep for up to {@link DEFAULT_LOCK_TIMEOUT_MS}. On the gate
|
|
2393
|
+
* path that wait came AFTER the last pause ({@link BEFORE_SPEND}), so a signal
|
|
2394
|
+
* that arrived while another writer (the daemon, routinely) held the lock was
|
|
2395
|
+
* held by the wait's listener through the whole stretch, the spend appended
|
|
2396
|
+
* `execution.started` and the allow went out, and the wait's `finally` then
|
|
2397
|
+
* removed the listener and the held signal was discarded with it: a start
|
|
2398
|
+
* recorded after the harness had abandoned the call, on every harness.
|
|
2399
|
+
*
|
|
2400
|
+
* ## What this does instead
|
|
2401
|
+
*
|
|
2402
|
+
* The wait for the lock becomes yields of the same generator chain, under ONE
|
|
2403
|
+
* deadline for the whole spend (the writer's own `lockTimeoutMs`, default
|
|
2404
|
+
* {@link DEFAULT_LOCK_TIMEOUT_MS}, taken once when the spend begins):
|
|
2405
|
+
*
|
|
2406
|
+
* 1. While the lockfile is present ({@link appendLockHeld}, advisory) and the
|
|
2407
|
+
* deadline has not passed, yield the retry interval (`lockRetryMs`, default
|
|
2408
|
+
* {@link DEFAULT_LOCK_RETRY_MS}).
|
|
2409
|
+
* 2. {@link BEFORE_SPEND}: the zero pause, taken when the spend begins and again
|
|
2410
|
+
* after any wait, so it always comes immediately before the attempt (with at
|
|
2411
|
+
* most the probe in between). This
|
|
2412
|
+
* is the pause the guarantee rests on: two `setImmediate` hops cross a poll
|
|
2413
|
+
* phase from any phase, so a signal that arrived during the wait or the
|
|
2414
|
+
* probe is dispatched here at the latest (a retry timer usually dispatches
|
|
2415
|
+
* it sooner, but a timer scheduled outside a timer callback can fire before
|
|
2416
|
+
* the next poll). The listener in force then blocks, withdraws what it can
|
|
2417
|
+
* and exits, and nothing below runs.
|
|
2418
|
+
* 3. One attempt with `lockTimeoutMs: 0`: the whole spend (a fresh verified read,
|
|
2419
|
+
* every check, compare-and-append against the head that read saw), whose
|
|
2420
|
+
* append tries the lock exactly ONCE and never sleeps. The lock protocol is
|
|
2421
|
+
* `core/log.ts`'s, untouched: atomic create-or-fail, never stolen, the head
|
|
2422
|
+
* compared under it, so this changes when a writer tries and never what a
|
|
2423
|
+
* try may do.
|
|
2424
|
+
*
|
|
2425
|
+
* An attempt that lost the race (another writer took the lock between the probe
|
|
2426
|
+
* and the try, a window as long as the attempt's own read) yields the retry
|
|
2427
|
+
* interval and goes back to 1, until the deadline: the same number of tries
|
|
2428
|
+
* inside the same bound as `acquireLock`'s synchronous retry, each one a whole
|
|
2429
|
+
* fresh attempt. An attempt that loses at or after the deadline is final,
|
|
2430
|
+
* exactly as a writer's own `lock-timeout` is: the caller denies, and nothing
|
|
2431
|
+
* was appended (fail closed, §11.1 invariant 8). See {@link lockTimedOut} for
|
|
2432
|
+
* the two refusals that count as a lost race.
|
|
2433
|
+
*
|
|
2434
|
+
* What is left between the last pause and the append is the spend's own read
|
|
2435
|
+
* of the log (milliseconds on a small log, longer on a large one) and its
|
|
2436
|
+
* single try at the lock, never a wait on another process. A signal there is
|
|
2437
|
+
* answered by the verdict the spend reaches.
|
|
2438
|
+
*
|
|
2439
|
+
* **The trade, on the yielding path only.** The writer's synchronous wait read
|
|
2440
|
+
* the log first and then spun on the lock itself, so it caught a gap of a
|
|
2441
|
+
* millisecond. Here the spend's read sits between the probe that saw the lock
|
|
2442
|
+
* free and the try, so a lock that is only ever free for gaps shorter than that
|
|
2443
|
+
* read (a writer holding it at a duty cycle near 100%) can run the spend out of
|
|
2444
|
+
* its bound where the synchronous wait got through. That ends in the deny
|
|
2445
|
+
* above, the grant left standing for a retry: fail closed. Holding the lock
|
|
2446
|
+
* across a yield to close it is not an option, because a handler that exits at
|
|
2447
|
+
* that yield would leave a lockfile that is never stolen.
|
|
2448
|
+
*
|
|
2449
|
+
* **Under {@link driveSync} nothing changes.** `commandHook` in process and in
|
|
2450
|
+
* the serve worker, and `decideHarnessCall` for the Codex bridge, have no event
|
|
2451
|
+
* loop that a signal could be dispatched on, so a yielded wait buys them
|
|
2452
|
+
* nothing. The driver says so at the first pause (see {@link GateSteps}), and
|
|
2453
|
+
* the spend is then exactly what it was: that zero pause (no pause under this
|
|
2454
|
+
* driver) and one call with the caller's own options, whose writer waits for
|
|
2455
|
+
* the lock synchronously, per head-moved attempt, as it always did.
|
|
2456
|
+
*
|
|
2457
|
+
* `attempt` receives the append options to use (the caller's own, with
|
|
2458
|
+
* `lockTimeoutMs: 0` on the yielding path), and the attempt number (1-based),
|
|
2459
|
+
* for a caller whose first attempt may reuse a read it already holds and whose
|
|
2460
|
+
* later ones may not.
|
|
2461
|
+
*/
|
|
2462
|
+
function* spendUnderLock(logPath, append, attempt) {
|
|
2463
|
+
// The shared pause, and the driver's answer to whether it turns the loop.
|
|
2464
|
+
const yielding = (yield BEFORE_SPEND) === true;
|
|
2465
|
+
if (!yielding)
|
|
2466
|
+
return attempt(append ?? {}, 1);
|
|
2467
|
+
const askedBudget = append?.lockTimeoutMs ?? DEFAULT_LOCK_TIMEOUT_MS;
|
|
2468
|
+
const budgetMs = Number.isFinite(askedBudget) ? Math.max(0, askedBudget) : DEFAULT_LOCK_TIMEOUT_MS;
|
|
2469
|
+
// Never a zero (or non-finite) retry: under `driveSync` a zero pause is no
|
|
2470
|
+
// pause, and the probe loop would spin.
|
|
2471
|
+
const askedRetry = append?.lockRetryMs ?? DEFAULT_LOCK_RETRY_MS;
|
|
2472
|
+
const retryMs = Number.isFinite(askedRetry) ? Math.max(1, askedRetry) : DEFAULT_LOCK_RETRY_MS;
|
|
2473
|
+
const once = { ...append, lockTimeoutMs: 0 };
|
|
2474
|
+
const deadline = Date.now() + budgetMs;
|
|
2475
|
+
for (let tries = 1;; tries += 1) {
|
|
2476
|
+
let waited = tries > 1;
|
|
2477
|
+
for (;;) {
|
|
2478
|
+
if (!appendLockHeld(logPath))
|
|
2479
|
+
break;
|
|
2480
|
+
const left = deadline - Date.now();
|
|
2481
|
+
if (left <= 0)
|
|
2482
|
+
break;
|
|
2483
|
+
waited = true;
|
|
2484
|
+
yield Math.min(retryMs, left);
|
|
2485
|
+
}
|
|
2486
|
+
// Uncontended first try: the pause above was taken a probe ago, and the
|
|
2487
|
+
// probe is the only thing that ran since. Otherwise the pause again, now
|
|
2488
|
+
// immediately before the attempt.
|
|
2489
|
+
if (waited)
|
|
2490
|
+
yield BEFORE_SPEND;
|
|
2491
|
+
const result = attempt(once, tries);
|
|
2492
|
+
if (!lockTimedOut(result))
|
|
2493
|
+
return result;
|
|
2494
|
+
const left = deadline - Date.now();
|
|
2495
|
+
if (left > 0) {
|
|
2496
|
+
yield Math.min(retryMs, left);
|
|
2497
|
+
continue;
|
|
2498
|
+
}
|
|
2499
|
+
// A copy, as `core/head-retry.ts` makes its own: only the message changes.
|
|
2500
|
+
return {
|
|
2501
|
+
...result,
|
|
2502
|
+
message: `${result.message ?? "lock-timeout"}; the spend waited for the log's lock on the event loop for its whole ${String(budgetMs)} ms bound, trying every ${String(retryMs)} ms, and another writer held it at each of its ${String(tries)} attempt${tries === 1 ? "" : "s"} (APRV-478). Nothing was appended.`,
|
|
2503
|
+
};
|
|
2504
|
+
}
|
|
2505
|
+
}
|
|
2118
2506
|
function truncate(text, limit) {
|
|
2119
2507
|
const collapsed = text.replace(/\s+/gu, " ").trim();
|
|
2120
2508
|
return collapsed.length <= limit ? collapsed : `${collapsed.slice(0, limit - 1)}…`;
|
|
@@ -2742,8 +3130,25 @@ function withdrawAbandoned(run, streams, records, now, keepHash, only = null) {
|
|
|
2742
3130
|
* is about (APRV-146). The gate requires it and compares it against what the
|
|
2743
3131
|
* human answered: the same value keyed the carryover that found these grants, so
|
|
2744
3132
|
* presenting it states, at the spend, the fact the match was made on.
|
|
3133
|
+
*
|
|
3134
|
+
* Steps since APRV-478: each key is spent through {@link spendUnderLock}, so a
|
|
3135
|
+
* wait for the log's lock is a yield and the shared pause before the spend
|
|
3136
|
+
* ({@link BEFORE_SPEND}) is taken there, immediately before each append.
|
|
3137
|
+
*
|
|
3138
|
+
* **A multi-key spend pauses before every key, deliberately.** A signal that
|
|
3139
|
+
* lands after the first key is spent and before the second is dispatched at the
|
|
3140
|
+
* second key's pause: the call blocks with the first key's `execution.started`
|
|
3141
|
+
* already recorded (before the signal) and the rest unspent. That is the same
|
|
3142
|
+
* outcome as a spend that fails on a later key, above: a recorded start for a
|
|
3143
|
+
* call the harness does not run, a grant that costs one more prompt, and
|
|
3144
|
+
* nothing authorized. The alternative, spending the later keys with no pause,
|
|
3145
|
+
* would make each later key's lock wait synchronous again, and a signal there
|
|
3146
|
+
* would be held through starts recorded AFTER it and answered with an allow.
|
|
3147
|
+
* Recording nothing after the signal and blocking is the stricter of the two.
|
|
3148
|
+
* An atomic spend of every key at once needs a gate operation that does not
|
|
3149
|
+
* exist; this is the residue the SPEC hunk names for a multi-class call.
|
|
2745
3150
|
*/
|
|
2746
|
-
function consumeGrants(run, keys, hash,
|
|
3151
|
+
function* consumeGrants(run, keys, hash,
|
|
2747
3152
|
/**
|
|
2748
3153
|
* This invocation's own task id (APRV-200). The gate compares it against the
|
|
2749
3154
|
* task the request record carries and records `grant_origin: "direct"` only
|
|
@@ -2751,11 +3156,14 @@ function consumeGrants(run, keys, hash,
|
|
|
2751
3156
|
*/
|
|
2752
3157
|
task) {
|
|
2753
3158
|
for (const key of keys) {
|
|
2754
|
-
const spent = consumeHarnessGrant(run.logPath, key, run.actor, {
|
|
3159
|
+
const spent = yield* spendUnderLock(run.logPath, run.options.append, (append) => consumeHarnessGrant(run.logPath, key, run.actor, {
|
|
2755
3160
|
...run.options,
|
|
3161
|
+
append,
|
|
2756
3162
|
presentedPayloadHash: hash,
|
|
2757
3163
|
spendingTask: task,
|
|
2758
|
-
|
|
3164
|
+
// APRV-499 (refutation S3): a mapped or unmapped spend names its tool.
|
|
3165
|
+
...(run.harnessTool === undefined ? {} : { harnessTool: run.harnessTool }),
|
|
3166
|
+
}));
|
|
2759
3167
|
if (!spent.ok)
|
|
2760
3168
|
return { code: spent.code, message: `${key}: ${spent.message}` };
|
|
2761
3169
|
}
|
|
@@ -2914,10 +3322,20 @@ known = null) {
|
|
|
2914
3322
|
* declaration would buy no oversight and would double the volume of exactly the
|
|
2915
3323
|
* traffic this is trying not to drown the log in. The supervised classes are
|
|
2916
3324
|
* registered already, by the caller, which is what makes them sampleable.
|
|
3325
|
+
*
|
|
3326
|
+
* Steps since APRV-478, for the reason {@link consumeGrants} gives: each class
|
|
3327
|
+
* is recorded through {@link spendUnderLock}, which takes the shared pause.
|
|
2917
3328
|
*/
|
|
2918
|
-
function recordUnattended(run, task, classes, hash) {
|
|
3329
|
+
function* recordUnattended(run, task, classes, hash) {
|
|
3330
|
+
const harnessTool = run.harnessTool;
|
|
2919
3331
|
for (const cls of classes) {
|
|
2920
|
-
const started =
|
|
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 }));
|
|
2921
3339
|
if (!started.ok)
|
|
2922
3340
|
return { code: started.code, message: `${cls}: ${started.message}` };
|
|
2923
3341
|
}
|
|
@@ -3045,7 +3463,11 @@ function renderVerdict(streams, adapter, codexCommand, verdict) {
|
|
|
3045
3463
|
? allow(streams, verdict.reason, adapter.kind, codexCommand)
|
|
3046
3464
|
: deny(streams, verdict.code, verdict.detail, adapter.kind);
|
|
3047
3465
|
}
|
|
3048
|
-
export function gateHarnessCall(
|
|
3466
|
+
export function gateHarnessCall(...args) {
|
|
3467
|
+
return driveSync(gateHarnessSteps(...args));
|
|
3468
|
+
}
|
|
3469
|
+
/** {@link gateHarnessCall} as steps, for a driver that yields (APRV-473). */
|
|
3470
|
+
function* gateHarnessSteps(streams, run, classes,
|
|
3049
3471
|
/**
|
|
3050
3472
|
* The bytes the grant binds to: `{command, cwd}` for a Bash call, the change
|
|
3051
3473
|
* itself for a file tool (APRV-124). Whatever this is, it is what reaches the
|
|
@@ -3225,6 +3647,9 @@ floor = null) {
|
|
|
3225
3647
|
const registered = register(run.logPath, { task, envelope }, run.actor, {
|
|
3226
3648
|
...run.options,
|
|
3227
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,
|
|
3228
3653
|
});
|
|
3229
3654
|
if (!registered.ok) {
|
|
3230
3655
|
return sayDeny(`hook-gate-refused:${registered.code}`, registered.message);
|
|
@@ -3306,8 +3731,9 @@ floor = null) {
|
|
|
3306
3731
|
// SPEC.md §6.3), so there is nothing to wait for and nothing to spend.
|
|
3307
3732
|
// What there is, since APRV-141, is something to charge: the start event
|
|
3308
3733
|
// is this execution's authorization, and the registration `fresh` just
|
|
3309
|
-
// wrote is what makes it a sampleable one.
|
|
3310
|
-
|
|
3734
|
+
// wrote is what makes it a sampleable one. The shared pause before the
|
|
3735
|
+
// spend is taken inside (APRV-473, APRV-478).
|
|
3736
|
+
const charged = yield* recordUnattended(run, task, classes, hash);
|
|
3311
3737
|
if (charged !== null) {
|
|
3312
3738
|
return sayDeny(`hook-gate-refused:${charged.code}`, charged.message);
|
|
3313
3739
|
}
|
|
@@ -3315,7 +3741,7 @@ floor = null) {
|
|
|
3315
3741
|
}
|
|
3316
3742
|
// Every gated class carried an unspent grant: a human already answered this
|
|
3317
3743
|
// exact question about these exact bytes, and nobody is asked again.
|
|
3318
|
-
const failed = consumeGrants(run, spendKeys, hash, task);
|
|
3744
|
+
const failed = yield* consumeGrants(run, spendKeys, hash, task);
|
|
3319
3745
|
if (failed !== null) {
|
|
3320
3746
|
return sayDeny(`hook-gate-refused:${failed.code}`, failed.message);
|
|
3321
3747
|
}
|
|
@@ -3349,24 +3775,42 @@ floor = null) {
|
|
|
3349
3775
|
// `process.exit` is deliberate and immediate: the default disposition for
|
|
3350
3776
|
// these signals is to die, and a handler that only withdrew would leave the
|
|
3351
3777
|
// hook wedged in its poll loop with the harness waiting on it.
|
|
3778
|
+
//
|
|
3779
|
+
// APRV-473, APRV-475. On every `approval hook <harness>` CLI route this
|
|
3780
|
+
// handler gets its turn: the wait yields to the event loop between polls
|
|
3781
|
+
// (`driveYielding`), so a signal is dispatched within one pause of its
|
|
3782
|
+
// arrival instead of being held until the wait returns. It exits, so nothing
|
|
3783
|
+
// after it in this function runs: no poll reads the grant a human gives
|
|
3784
|
+
// afterwards, and nothing spends it. The synchronous callers (`commandHook`
|
|
3785
|
+
// in process, the Codex bridge's `decideHarnessCall`, a serve worker thread)
|
|
3786
|
+
// drive these steps with `driveSync`, under which this listener is
|
|
3787
|
+
// registered and removed without the loop ever turning, so it never runs
|
|
3788
|
+
// there and never writes into a stdout that is not a hook's (the bridge's is
|
|
3789
|
+
// a JSON-RPC channel). A serve thread's caller departing is `callerGone`.
|
|
3352
3790
|
const onSignal = (signal) => {
|
|
3353
3791
|
leaveWait();
|
|
3354
|
-
|
|
3355
|
-
//
|
|
3356
|
-
//
|
|
3357
|
-
//
|
|
3358
|
-
//
|
|
3359
|
-
|
|
3360
|
-
|
|
3361
|
-
|
|
3362
|
-
|
|
3363
|
-
|
|
3364
|
-
|
|
3365
|
-
|
|
3366
|
-
|
|
3367
|
-
process
|
|
3792
|
+
// APRV-445, APRV-475. A harness tearing down may still read the verdict,
|
|
3793
|
+
// and on several harnesses an exit with nothing on stdout is not a block
|
|
3794
|
+
// (Hermes reads it as an ALLOW unless the exit is 2; Claude Code, Codex
|
|
3795
|
+
// and Muse read most such exits as a failed hook and run the call). So the
|
|
3796
|
+
// harness's own block directive goes out FIRST, in its own dialect,
|
|
3797
|
+
// written synchronously, before the withdrawal: the withdrawal is a
|
|
3798
|
+
// compare-and-append that can wait up to the lock timeout behind another
|
|
3799
|
+
// writer, and a harness that escalates SIGTERM to SIGKILL inside that wait
|
|
3800
|
+
// must already be holding the block (APRV-475 refuter). The directive does
|
|
3801
|
+
// not depend on the withdrawal's outcome, and a withdrawal that never lands
|
|
3802
|
+
// leaves the question open as a timed-out one is.
|
|
3803
|
+
const exitCode = writeInterruptedWait(signal, run.harness);
|
|
3804
|
+
try {
|
|
3805
|
+
withdrawPending(run, streams, ownKeys, `the requesting hook process received ${signal} while waiting; the session is ending, so no retry will adopt this request`);
|
|
3806
|
+
}
|
|
3807
|
+
catch (cause) {
|
|
3808
|
+
// The withdrawal failed outright (an I/O fault inside the append). The
|
|
3809
|
+
// block is already on stdout: a block with the question left standing
|
|
3810
|
+
// is the stricter of the two outcomes this handler can still reach.
|
|
3811
|
+
streams.err(`approval: the hook could not withdraw its request after ${signal}: ${cause instanceof Error ? cause.message : String(cause)}\n`);
|
|
3368
3812
|
}
|
|
3369
|
-
process.exit(
|
|
3813
|
+
process.exit(exitCode);
|
|
3370
3814
|
};
|
|
3371
3815
|
const onTerm = () => onSignal("SIGTERM");
|
|
3372
3816
|
const onInt = () => onSignal("SIGINT");
|
|
@@ -3431,7 +3875,7 @@ floor = null) {
|
|
|
3431
3875
|
streams.err(`approval: the log's last line was incomplete when this hook read it (another writer mid-append); reading again rather than giving up on ${task}\n`);
|
|
3432
3876
|
}
|
|
3433
3877
|
tornReads += 1;
|
|
3434
|
-
|
|
3878
|
+
yield Math.min(run.intervalMs, Math.max(0, deadline - Date.now()));
|
|
3435
3879
|
continue;
|
|
3436
3880
|
}
|
|
3437
3881
|
// AT the deadline (APRV-427 review). A clean read at this tick would
|
|
@@ -3536,13 +3980,22 @@ floor = null) {
|
|
|
3536
3980
|
if (states.every((state) => state === "granted")) {
|
|
3537
3981
|
// The grants are spent before the allow is printed, so this exact
|
|
3538
3982
|
// command cannot ride the same authorization twice.
|
|
3983
|
+
//
|
|
3984
|
+
// APRV-473, APRV-478. `consumeGrants` turns the event loop before it
|
|
3985
|
+
// spends: once per retry while another writer holds the log's lock,
|
|
3986
|
+
// and once more (`BEFORE_SPEND`) immediately before each append. A
|
|
3987
|
+
// signal that arrived while this read ran, or while the spend waited
|
|
3988
|
+
// for the lock, is dispatched there, to the handler above, which
|
|
3989
|
+
// blocks, withdraws (a no-op now the request is decided, refused
|
|
3990
|
+
// `already-decided`) and exits. The grant is then left unspent rather
|
|
3991
|
+
// than recorded as the start of a call the harness gave up on.
|
|
3539
3992
|
leaveWait();
|
|
3540
3993
|
// Looked at again now the lock is held: re-taking it may have waited,
|
|
3541
3994
|
// and a caller that left meanwhile must not have its grant spent on a
|
|
3542
3995
|
// verdict nobody will receive. The retry is who spends it.
|
|
3543
3996
|
if (callerGone())
|
|
3544
3997
|
return sayGone();
|
|
3545
|
-
const failed = consumeGrants(run, spendKeys, hash, task);
|
|
3998
|
+
const failed = yield* consumeGrants(run, spendKeys, hash, task);
|
|
3546
3999
|
if (failed !== null) {
|
|
3547
4000
|
return sayDeny(`hook-gate-refused:${failed.code}`, failed.message);
|
|
3548
4001
|
}
|
|
@@ -3586,7 +4039,7 @@ floor = null) {
|
|
|
3586
4039
|
}
|
|
3587
4040
|
return keptOpen(`no decision on ${waitKeys.join(", ")} within the hook's ${waitText(run)}.`, stillLagging);
|
|
3588
4041
|
}
|
|
3589
|
-
|
|
4042
|
+
yield Math.min(run.intervalMs, Math.max(0, deadline - Date.now()));
|
|
3590
4043
|
}
|
|
3591
4044
|
}
|
|
3592
4045
|
catch (cause) {
|
|
@@ -3930,7 +4383,14 @@ function runPostToolUse(flags, streams, cwd, input, actor, adapter) {
|
|
|
3930
4383
|
// nothing, and the close below finds no start and says so in its own words.
|
|
3931
4384
|
!adapter.readTools.includes(input.toolName) &&
|
|
3932
4385
|
// APRV-445: a tool the rule table gated wrote a start like any other.
|
|
3933
|
-
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))) {
|
|
3934
4394
|
return report(streams, "post-tool-not-gated", `${input.toolName} is not a gated tool, so no execution.started was ever written for it`);
|
|
3935
4395
|
}
|
|
3936
4396
|
if (input.toolUseId === null) {
|
|
@@ -3971,7 +4431,23 @@ function runPostToolUse(flags, streams, cwd, input, actor, adapter) {
|
|
|
3971
4431
|
}
|
|
3972
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 });
|
|
3973
4433
|
}
|
|
3974
|
-
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) {
|
|
3975
4451
|
if (adapter.kind === "codex" && input.toolName === "apply_patch") {
|
|
3976
4452
|
// APRV-363. The app-server's LEGACY `applyPatchApproval` carries its change
|
|
3977
4453
|
// as a MAP of path to change, inline on the request, and never as an
|
|
@@ -4190,6 +4666,42 @@ function describeToolCall(input, adapter, protectedPaths, cwd, readRoots = []) {
|
|
|
4190
4666
|
notes: [],
|
|
4191
4667
|
};
|
|
4192
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
|
+
}
|
|
4193
4705
|
// APRV-347, above the file-tool branch because the two lists are disjoint and
|
|
4194
4706
|
// a read is the cheaper question to answer: a read inside the scope, or one
|
|
4195
4707
|
// naming no path at all, returns `null` here and takes the allow below.
|
|
@@ -4809,7 +5321,7 @@ function bypassBanner(window, classes, seq) {
|
|
|
4809
5321
|
* command that ran and left no record is the one state this feature must not be
|
|
4810
5322
|
* able to reach, so an append failure is a deny.
|
|
4811
5323
|
*/
|
|
4812
|
-
function runBypass(streams, input, adapter, cwd, logPath, flags, actor, window,
|
|
5324
|
+
function* runBypass(streams, input, adapter, cwd, logPath, flags, actor, window,
|
|
4813
5325
|
/**
|
|
4814
5326
|
* The verified read `window` was derived from (APRV-294), handed on to the
|
|
4815
5327
|
* append so the same records answer "is a window open" and "which head does
|
|
@@ -4831,7 +5343,18 @@ decidedOn) {
|
|
|
4831
5343
|
// APRV-347. The window suspends the POLICY, not the classification: a read
|
|
4832
5344
|
// outside the scope is described as one here too, so the bypass RECORD says
|
|
4833
5345
|
// what was actually authorized rather than calling it an ordinary read.
|
|
4834
|
-
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");
|
|
4835
5358
|
if (described.kind === "deny") {
|
|
4836
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);
|
|
4837
5360
|
}
|
|
@@ -4869,7 +5392,12 @@ decidedOn) {
|
|
|
4869
5392
|
// window path is the one a human comes back to read, so the binary that
|
|
4870
5393
|
// printed the allow is named on it.
|
|
4871
5394
|
const provenance = harnessProvenance(adapter.kind, input.harnessVersion);
|
|
4872
|
-
|
|
5395
|
+
// APRV-473 refuter: the bypass record is this path's authorization, exactly
|
|
5396
|
+
// as `execution.started` is the gated path's, so a signal held through the
|
|
5397
|
+
// stdin read is dispatched before it is written (`BEFORE_SPEND`, taken
|
|
5398
|
+
// inside `spendUnderLock`, after any wait for the log's lock, which yields:
|
|
5399
|
+
// APRV-478).
|
|
5400
|
+
const recorded = yield* spendUnderLock(logPath, undefined, (append, round) => recordGateBypass(logPath, {
|
|
4873
5401
|
tool: input.toolName,
|
|
4874
5402
|
summary: truncate(described.headline, SUMMARY_LIMIT),
|
|
4875
5403
|
classes,
|
|
@@ -4878,14 +5406,17 @@ decidedOn) {
|
|
|
4878
5406
|
...(input.toolUseId === null ? {} : { toolUseId: input.toolUseId }),
|
|
4879
5407
|
...(input.cwd.length === 0 ? {} : { cwd: input.cwd }),
|
|
4880
5408
|
...(provenance === null ? {} : { harness: provenance }),
|
|
4881
|
-
}, actor, {},
|
|
4882
|
-
// APRV-294: the window this verdict was decided under, and the read it
|
|
4883
|
-
// decided on. The append uses both, so a window that ended in
|
|
4884
|
-
// reported as the thing that happened rather than as "no
|
|
5409
|
+
}, actor, { append },
|
|
5410
|
+
// APRV-294: the window this verdict was decided under, and the read it
|
|
5411
|
+
// was decided on. The append uses both, so a window that ended in
|
|
5412
|
+
// between is reported as the thing that happened rather than as "no
|
|
5413
|
+
// window is open". The read seeds the FIRST round only: a later round
|
|
5414
|
+
// exists because another writer took the lock, so that read's head is
|
|
5415
|
+
// already known to be stale, and `recordGateBypass` re-reads.
|
|
4885
5416
|
{
|
|
4886
5417
|
openedSeq: window.seq,
|
|
4887
|
-
...(decidedOn === null ? {} : { read: decidedOn }),
|
|
4888
|
-
});
|
|
5418
|
+
...(decidedOn === null || round > 1 ? {} : { read: decidedOn }),
|
|
5419
|
+
}));
|
|
4889
5420
|
if (!recorded.ok) {
|
|
4890
5421
|
// Invariant 8: the record lands before the allow, so a refusal here is a
|
|
4891
5422
|
// deny even though a window is open. `append-failed` reaches the caller
|
|
@@ -4898,7 +5429,7 @@ decidedOn) {
|
|
|
4898
5429
|
const notes = [...described.notes, ...(policyNote === null ? [] : [policyNote])];
|
|
4899
5430
|
return allow(streams, `gate-open: ${classes.join(", ")} bypassed by the window opened at seq ${String(window.seq)} by ${window.openedBy} (expires ${window.expiresAt}); recorded as gate.bypassed seq ${String(recorded.record.seq)}${notes.length === 0 ? "" : ` (${notes.join("; ")})`}`, adapter.kind, codexCommand);
|
|
4900
5431
|
}
|
|
4901
|
-
function runHarnessHook(argv, streams, cwd, readStdin, adapter, waitSeam) {
|
|
5432
|
+
function* runHarnessHook(argv, streams, cwd, readStdin, adapter, waitSeam) {
|
|
4902
5433
|
// Codex and (APRV-445) Hermes answer a misconfigured entry with a verdict:
|
|
4903
5434
|
// on both a usage error's empty stdout is a harness that runs the call, and
|
|
4904
5435
|
// on Hermes a non-zero exit with an empty stdout is an allow outright.
|
|
@@ -5146,28 +5677,41 @@ function runHarnessHook(argv, streams, cwd, readStdin, adapter, waitSeam) {
|
|
|
5146
5677
|
input.toolName !== adapter.shellTool &&
|
|
5147
5678
|
!adapter.fileTools.includes(input.toolName)) {
|
|
5148
5679
|
if (!adapter.readTools.includes(input.toolName)) {
|
|
5149
|
-
|
|
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
|
+
}
|
|
5150
5692
|
}
|
|
5151
|
-
|
|
5152
|
-
|
|
5153
|
-
|
|
5154
|
-
|
|
5155
|
-
|
|
5156
|
-
|
|
5157
|
-
|
|
5158
|
-
|
|
5159
|
-
|
|
5160
|
-
|
|
5161
|
-
|
|
5162
|
-
|
|
5163
|
-
|
|
5164
|
-
|
|
5165
|
-
|
|
5166
|
-
|
|
5167
|
-
|
|
5168
|
-
|
|
5169
|
-
|
|
5170
|
-
|
|
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
|
+
}
|
|
5171
5715
|
}
|
|
5172
5716
|
}
|
|
5173
5717
|
// APRV-188. From here on this process may resume a verified read behind the
|
|
@@ -5199,13 +5743,13 @@ function runHarnessHook(argv, streams, cwd, readStdin, adapter, waitSeam) {
|
|
|
5199
5743
|
// words. The window suspends the POLICY; it never suspends the log.
|
|
5200
5744
|
const looked = lookupWindow(logPath);
|
|
5201
5745
|
if (looked.window !== null) {
|
|
5202
|
-
return runBypass(streams, input, adapter, cwd, logPath, parsed.flags, actor, looked.window,
|
|
5746
|
+
return yield* runBypass(streams, input, adapter, cwd, logPath, parsed.flags, actor, looked.window,
|
|
5203
5747
|
// APRV-294. The records this window was derived from travel with it: the
|
|
5204
5748
|
// bypass record is appended against the head they ended at, so the
|
|
5205
5749
|
// verdict and the record are one read of the log.
|
|
5206
5750
|
looked.records === null ? null : { records: looked.records, head: looked.head });
|
|
5207
5751
|
}
|
|
5208
|
-
return renderVerdict(streams, adapter, codexCommand,
|
|
5752
|
+
return renderVerdict(streams, adapter, codexCommand, yield* decideHarnessSteps({
|
|
5209
5753
|
streams,
|
|
5210
5754
|
input,
|
|
5211
5755
|
adapter,
|
|
@@ -5244,6 +5788,10 @@ function runHarnessHook(argv, streams, cwd, readStdin, adapter, waitSeam) {
|
|
|
5244
5788
|
* progress and withdrawal lines, which are a report rather than a decision.
|
|
5245
5789
|
*/
|
|
5246
5790
|
export function decideHarnessCall(decide) {
|
|
5791
|
+
return driveSync(decideHarnessSteps(decide));
|
|
5792
|
+
}
|
|
5793
|
+
/** {@link decideHarnessCall} as steps, for a driver that yields (APRV-473). */
|
|
5794
|
+
function* decideHarnessSteps(decide) {
|
|
5247
5795
|
const { streams, input, adapter, cwd, logPath, root, options, actor, timeoutMs, intervalMs, graceMs, codexCommand, windowRecords, } = decide;
|
|
5248
5796
|
const harnessCapMs = decide.harnessCapMs ?? null;
|
|
5249
5797
|
const harnessCapStated = decide.harnessCapStated ?? false;
|
|
@@ -5263,7 +5811,7 @@ export function decideHarnessCall(decide) {
|
|
|
5263
5811
|
return {
|
|
5264
5812
|
permission: "deny",
|
|
5265
5813
|
code: "hook-policy-unavailable",
|
|
5266
|
-
detail:
|
|
5814
|
+
detail: policyUnavailableDetail(load),
|
|
5267
5815
|
};
|
|
5268
5816
|
}
|
|
5269
5817
|
const protectedPaths = load.policy.protected_paths ?? [];
|
|
@@ -5275,12 +5823,43 @@ export function decideHarnessCall(decide) {
|
|
|
5275
5823
|
// both paths since APRV-214 (see `describeToolCall`): the open window
|
|
5276
5824
|
// classifies exactly as the closed one does, and a second copy of this would
|
|
5277
5825
|
// be a second answer to "what is this command".
|
|
5278
|
-
const described = describeToolCall(input, adapter, protectedPaths, cwd, readRoots);
|
|
5826
|
+
const described = describeToolCall(input, adapter, protectedPaths, cwd, readRoots, load.policy);
|
|
5279
5827
|
if (described.kind === "deny") {
|
|
5280
5828
|
return { permission: "deny", code: described.code, detail: described.detail };
|
|
5281
5829
|
}
|
|
5282
5830
|
if (described.kind === "allow") {
|
|
5283
|
-
|
|
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
|
+
};
|
|
5284
5863
|
}
|
|
5285
5864
|
const { classes, payload, headline } = described;
|
|
5286
5865
|
/** What the history-rewrite refinement did, for the decision reason. */
|
|
@@ -5335,6 +5914,7 @@ export function decideHarnessCall(decide) {
|
|
|
5335
5914
|
// changed when an operator reordered their policy would read as a change of
|
|
5336
5915
|
// state.
|
|
5337
5916
|
channels: Object.keys(load.policy.channels ?? {}).sort(),
|
|
5917
|
+
...(described.harnessTool === undefined ? {} : { harnessTool: described.harnessTool }),
|
|
5338
5918
|
};
|
|
5339
5919
|
const resolutions = classes.map((cls) => resolvePolicy(load, cls));
|
|
5340
5920
|
const autonomies = resolutions.map((resolution) => resolution.autonomy);
|
|
@@ -5486,8 +6066,10 @@ export function decideHarnessCall(decide) {
|
|
|
5486
6066
|
// §6.3), so nothing is requested, decided or granted here. What IS appended
|
|
5487
6067
|
// since APRV-141 is the execution record itself — the moment the policy
|
|
5488
6068
|
// authorized this command — because a budget the busiest path does not
|
|
5489
|
-
// charge is not a budget. See `recordUnattended`.
|
|
5490
|
-
|
|
6069
|
+
// charge is not a budget. See `recordUnattended`. A held signal is
|
|
6070
|
+
// dispatched first, at the pause it takes before the append (APRV-473,
|
|
6071
|
+
// `BEFORE_SPEND`), and a wait for the log's lock yields (APRV-478).
|
|
6072
|
+
const charged = yield* recordUnattended(run, task, classes, payloadHash(payload));
|
|
5491
6073
|
if (charged !== null) {
|
|
5492
6074
|
return {
|
|
5493
6075
|
permission: "deny",
|
|
@@ -5503,14 +6085,14 @@ export function decideHarnessCall(decide) {
|
|
|
5503
6085
|
// do not survive a merge. An initialized-but-empty `.approval/log/` counts as
|
|
5504
6086
|
// reachable — an audit trail that has recorded nothing is an empty log, not a
|
|
5505
6087
|
// missing one (see `preflightLog`) — and `register` appends the first line.
|
|
5506
|
-
return
|
|
6088
|
+
return yield* gateHarnessSteps(streams, run, classes, payload, headline, task, note, floor);
|
|
5507
6089
|
}
|
|
5508
|
-
function commandHarnessHook(argv, streams, cwd, readStdin, adapter, waitSeam) {
|
|
6090
|
+
function* commandHarnessHook(argv, streams, cwd, readStdin, adapter, waitSeam) {
|
|
5509
6091
|
if (adapter.kind === "hermes") {
|
|
5510
|
-
return hermesFailClosed(argv, streams, cwd, readStdin, adapter, waitSeam);
|
|
6092
|
+
return yield* hermesFailClosed(argv, streams, cwd, readStdin, adapter, waitSeam);
|
|
5511
6093
|
}
|
|
5512
6094
|
try {
|
|
5513
|
-
return runHarnessHook(argv, streams, cwd, readStdin, adapter, waitSeam);
|
|
6095
|
+
return yield* runHarnessHook(argv, streams, cwd, readStdin, adapter, waitSeam);
|
|
5514
6096
|
}
|
|
5515
6097
|
catch (cause) {
|
|
5516
6098
|
// A hook that throws is a hook the harness treats as a non-blocking error,
|
|
@@ -5541,7 +6123,7 @@ function commandHarnessHook(argv, streams, cwd, readStdin, adapter, waitSeam) {
|
|
|
5541
6123
|
* nothing on stdout by design. A throw becomes the same block the other
|
|
5542
6124
|
* adapters' catch produces.
|
|
5543
6125
|
*/
|
|
5544
|
-
function hermesFailClosed(argv, streams, cwd, readStdin, adapter,
|
|
6126
|
+
function* hermesFailClosed(argv, streams, cwd, readStdin, adapter,
|
|
5545
6127
|
/** APRV-427: serve's wait seam, passed through untouched (null on the CLI). */
|
|
5546
6128
|
waitSeam) {
|
|
5547
6129
|
let stdout = "";
|
|
@@ -5567,12 +6149,24 @@ waitSeam) {
|
|
|
5567
6149
|
// handler is prepended, so inside the wait it answers first and exits. CLI
|
|
5568
6150
|
// only: under `approval serve` (a wait seam) the process's signals belong to
|
|
5569
6151
|
// the server.
|
|
6152
|
+
//
|
|
6153
|
+
// A JS listener runs only when the event loop turns (APRV-466). On the CLI
|
|
6154
|
+
// this run is driven by `driveYielding` (APRV-473), so the loop turns at
|
|
6155
|
+
// every pause: between the wait's polls, where the wait's handler answers,
|
|
6156
|
+
// and once before anything appends `execution.started` (`BEFORE_SPEND`),
|
|
6157
|
+
// where whichever of the two is registered answers a signal held through
|
|
6158
|
+
// the synchronous stretch before it (the stdin read, the classification, the
|
|
6159
|
+
// registration). A signal that lands after the last pause is dispatched
|
|
6160
|
+
// once the run has returned, to the `approval` bin's own guard, which exits
|
|
6161
|
+
// with the answer this run gave. While these guards are registered,
|
|
6162
|
+
// HERMES_SIGNAL_OWNER tells the bin's guard to step aside, so the two never
|
|
6163
|
+
// both print.
|
|
5570
6164
|
const onEarlySignal = (signal) => {
|
|
5571
6165
|
if (raw !== null && isHermesPostEvent(raw))
|
|
5572
6166
|
process.exit(EXIT_OK);
|
|
5573
6167
|
if (stdout.length === 0) {
|
|
5574
6168
|
try {
|
|
5575
|
-
writeSync(1,
|
|
6169
|
+
writeSync(1, hermesInterruptedDirective(signal));
|
|
5576
6170
|
}
|
|
5577
6171
|
catch {
|
|
5578
6172
|
// stdout is gone; the exit code below is the whole verdict.
|
|
@@ -5582,23 +6176,27 @@ waitSeam) {
|
|
|
5582
6176
|
};
|
|
5583
6177
|
const onEarlyTerm = () => onEarlySignal("SIGTERM");
|
|
5584
6178
|
const onEarlyInt = () => onEarlySignal("SIGINT");
|
|
6179
|
+
const owner = globalThis;
|
|
6180
|
+
const ownerBefore = owner[HERMES_SIGNAL_OWNER];
|
|
5585
6181
|
if (waitSeam === null) {
|
|
5586
6182
|
process.on("SIGTERM", onEarlyTerm);
|
|
5587
6183
|
process.on("SIGINT", onEarlyInt);
|
|
6184
|
+
owner[HERMES_SIGNAL_OWNER] = true;
|
|
5588
6185
|
}
|
|
5589
6186
|
try {
|
|
5590
|
-
return hermesVerdict();
|
|
6187
|
+
return yield* hermesVerdict();
|
|
5591
6188
|
}
|
|
5592
6189
|
finally {
|
|
5593
6190
|
if (waitSeam === null) {
|
|
6191
|
+
owner[HERMES_SIGNAL_OWNER] = ownerBefore;
|
|
5594
6192
|
process.off("SIGTERM", onEarlyTerm);
|
|
5595
6193
|
process.off("SIGINT", onEarlyInt);
|
|
5596
6194
|
}
|
|
5597
6195
|
}
|
|
5598
|
-
function hermesVerdict() {
|
|
6196
|
+
function* hermesVerdict() {
|
|
5599
6197
|
let code;
|
|
5600
6198
|
try {
|
|
5601
|
-
code = runHarnessHook(argv, tracked, cwd, reading, adapter, waitSeam);
|
|
6199
|
+
code = yield* runHarnessHook(argv, tracked, cwd, reading, adapter, waitSeam);
|
|
5602
6200
|
}
|
|
5603
6201
|
catch (cause) {
|
|
5604
6202
|
// A post-event never blocks: the call already ran (see the post path).
|
|
@@ -5657,6 +6255,48 @@ export function commandHook(argv, streams, cwd, readStdin = defaultStdin,
|
|
|
5657
6255
|
* the call polls. Every CLI route omits it. See {@link HookWaitSeam}.
|
|
5658
6256
|
*/
|
|
5659
6257
|
waitSeam = null) {
|
|
6258
|
+
return driveSync(hookSteps(argv, streams, cwd, readStdin, waitSeam));
|
|
6259
|
+
}
|
|
6260
|
+
/**
|
|
6261
|
+
* {@link commandHook}, with its pauses on the event loop (APRV-473).
|
|
6262
|
+
*
|
|
6263
|
+
* Every `approval hook` CLI route, from `main`: Hermes since APRV-473, the
|
|
6264
|
+
* other five harnesses (and `classify`, which never pauses) since APRV-475.
|
|
6265
|
+
* The same steps as the synchronous form; the difference is that a SIGTERM or
|
|
6266
|
+
* SIGINT reaches the listener in force within one pause of arriving, instead
|
|
6267
|
+
* of after the run returns. In the wait that listener withdraws the question
|
|
6268
|
+
* this invocation opened, prints the harness's own `hook-interrupted` block
|
|
6269
|
+
* directive ({@link interruptedWaitDirective}) and exits with that directive's
|
|
6270
|
+
* exit code, so a grant that lands after the harness abandoned the call is
|
|
6271
|
+
* never spent on it. See {@link GateSteps} and {@link BEFORE_SPEND}.
|
|
6272
|
+
*/
|
|
6273
|
+
export function commandHookYielding(argv, streams, cwd, readStdin = defaultStdin) {
|
|
6274
|
+
// The loop now turns mid-wait, so an asynchronous stderr failure (EPIPE on a
|
|
6275
|
+
// closed pipe after `announceWait`, where pipes are asynchronous) would
|
|
6276
|
+
// surface as an uncaught exception in the middle of the wait and end it
|
|
6277
|
+
// without withdrawing. Stderr carries progress lines and never the verdict,
|
|
6278
|
+
// so its failure is swallowed (APRV-473 refuter).
|
|
6279
|
+
process.stderr.on("error", () => undefined);
|
|
6280
|
+
return driveYielding(hookSteps(argv, streams, cwd, readStdin, null));
|
|
6281
|
+
}
|
|
6282
|
+
/**
|
|
6283
|
+
* The steps {@link commandHook} and {@link commandHookYielding} drive, for a
|
|
6284
|
+
* test that drives them itself (APRV-475 refuter).
|
|
6285
|
+
*
|
|
6286
|
+
* A test seam and nothing more: it exists so a test can stop at each pause and
|
|
6287
|
+
* look at the log, which is the only deterministic way to pin that the pause
|
|
6288
|
+
* before a spend ({@link BEFORE_SPEND}) sits between the read that found a
|
|
6289
|
+
* grant and the append that spends it, on every harness. No CLI route and no
|
|
6290
|
+
* embedding caller uses it; the one entry for those is the two drivers above.
|
|
6291
|
+
*
|
|
6292
|
+
* Pass `true` to each `next()` to step as {@link commandHookYielding} does
|
|
6293
|
+
* (the spend then waits for a held log lock in yields, APRV-478); `false` or
|
|
6294
|
+
* nothing steps as {@link commandHook} does.
|
|
6295
|
+
*/
|
|
6296
|
+
export function commandHookSteps(argv, streams, cwd, readStdin) {
|
|
6297
|
+
return hookSteps(argv, streams, cwd, readStdin, null);
|
|
6298
|
+
}
|
|
6299
|
+
function* hookSteps(argv, streams, cwd, readStdin, waitSeam) {
|
|
5660
6300
|
const sub = argv[0];
|
|
5661
6301
|
const rest = argv.slice(1);
|
|
5662
6302
|
if (sub === undefined) {
|
|
@@ -5670,7 +6310,7 @@ waitSeam = null) {
|
|
|
5670
6310
|
// boundary and doctor use, so a kind that can be recorded is a kind that can
|
|
5671
6311
|
// be invoked, and neither can be added without the other.
|
|
5672
6312
|
if (isHarnessKind(sub)) {
|
|
5673
|
-
return commandHarnessHook(rest, streams, cwd, readStdin, HARNESS_ADAPTERS[sub], waitSeam);
|
|
6313
|
+
return yield* commandHarnessHook(rest, streams, cwd, readStdin, HARNESS_ADAPTERS[sub], waitSeam);
|
|
5674
6314
|
}
|
|
5675
6315
|
if (sub === "classify")
|
|
5676
6316
|
return commandClassify(rest, streams, cwd);
|