@sema-agent/core 5.34.0 → 5.35.0
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/CHANGELOG.md +46 -0
- package/dist/core/checkpoint-store.d.ts +33 -4
- package/dist/core/hooks.d.ts +98 -3
- package/dist/core/hooks.js +146 -8
- package/dist/core/park-selfcheck.d.ts +156 -0
- package/dist/core/park-selfcheck.js +251 -0
- package/dist/core/runner/prepare-acquire-reconcile.d.ts +6 -0
- package/dist/core/runner/prepare-acquire-reconcile.js +2 -1
- package/dist/core/runner/prepare-task.d.ts +12 -4
- package/dist/core/runner/prepare-task.js +60 -19
- package/dist/core/runner/runtask.d.ts +6 -1
- package/dist/core/runner/runtask.js +64 -14
- package/dist/core/tool-errors.d.ts +2 -2
- package/dist/core/tool-policy.d.ts +125 -0
- package/dist/core/tool-policy.js +35 -2
- package/dist/core/types.d.ts +71 -2
- package/dist/index.d.ts +3 -2
- package/dist/index.js +3 -2
- package/dist/orchestration/workflow.d.ts +1 -1
- package/package.json +1 -1
- package/test/export-surface.snapshot.json +17 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,51 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 5.35.0 — 2026-08-15
|
|
4
|
+
|
|
5
|
+
No BREAKING changes. One deliberate fail-closed tighten and one loosening-direction fix are called
|
|
6
|
+
out under Changed; everything else is additive.
|
|
7
|
+
|
|
8
|
+
### Added
|
|
9
|
+
|
|
10
|
+
- `TaskStream.steer` takes an optional `inputId` dedup key (additive; absent ⇒ byte-identical
|
|
11
|
+
behavior). The live steer leg becomes idempotent the way the parked leg already was: an identical
|
|
12
|
+
payload under a seen key is a no-op (nothing injected, no second ledger frame), a DIFFERENT payload
|
|
13
|
+
under a seen key is refused loudly (`steering.duplicate_input_id`, the parked leg's code), and the
|
|
14
|
+
caller's key rides the `human_input` frame verbatim (previously a fresh uuid was always minted, so
|
|
15
|
+
a replayed injection was indistinguishable from an idempotent hit in the receipt). Validation runs
|
|
16
|
+
before liveness (empty/overlong/reserved-sentinel values are refused, never silently treated as
|
|
17
|
+
"no key"). Dedup scope is the live stream's lifetime — narrower than the parked queue's persisted
|
|
18
|
+
window; the contract states the asymmetry rather than pretending parity.
|
|
19
|
+
- `AskRequest.ruleEvidence` (design/252 G-2): an ask carries the rule-provenance evidence behind it —
|
|
20
|
+
the org snapshot `revision`, the org rule text, and the matched personal rule's add dots — each
|
|
21
|
+
member present as a value or as a NAMED absence (`not_wired` / `not_adjudicated` / `unavailable` /
|
|
22
|
+
`no_match` / `not_reported`), so "nothing governed this", "the source was unreadable" and "the lane
|
|
23
|
+
was skipped" stop sharing one wire shape.
|
|
24
|
+
- Settlement approver attribution (design/252 G-7): both settlement lanes accept an optional
|
|
25
|
+
`approver` identity, screened (length/control-characters/lone-surrogates, refused not truncated)
|
|
26
|
+
and TRANSCRIBED — never adjudicated — onto the settled call's `tool_end` frame beside `settledBy`.
|
|
27
|
+
- `probeParkRoundTrip` + `durableParkGapOf` (design/252 G-6): a park-lane liveness self-check (file a
|
|
28
|
+
synthetic checkpoint, read it back, enumerate, fence) plus the static two-halves reading that turns
|
|
29
|
+
a missing-session resume symptom into a named absent seat. `ParkSelfCheckResult.casLossObserved`
|
|
30
|
+
(additive) marks the one green arm a CAS-mis-reporting backend would pass through.
|
|
31
|
+
|
|
32
|
+
### Changed
|
|
33
|
+
|
|
34
|
+
- Fail-closed tighten: a `persistedRules.admits()` answer that is neither a string nor an object no
|
|
35
|
+
longer clears an ask. Previously any non-`undefined` answer cleared it — "`null` admits this call"
|
|
36
|
+
was an allow built on an unreadable answer.
|
|
37
|
+
- The org-verdict normalizer reads ANY non-array carrier (class instances and callables included —
|
|
38
|
+
the structural contract admits them) with own-data field reads; refusing the carrier outright
|
|
39
|
+
could fold a structurally valid org DENY into `unavailable`, i.e. from un-approvable into a
|
|
40
|
+
real-approval ask a person could clear. Inherited members still never authorize; revoked proxies
|
|
41
|
+
are contained; foreign array methods (an overridden or species-substituted `.map`/`.filter`) no
|
|
42
|
+
longer run anywhere on the evidence path.
|
|
43
|
+
- The park probe sweeps behind a late-committing timed-out `put` on either settle, and the
|
|
44
|
+
approver screen refuses oversized values before materializing a code-point array.
|
|
45
|
+
- Dev-surface: the live-gate recipe and examples switch the reference live model to
|
|
46
|
+
`deepseek-v4-flash` (operator directive; prior cache/behavior figures quoted against
|
|
47
|
+
`deepseek-v4-pro` re-anchor on next measurement).
|
|
48
|
+
|
|
3
49
|
## 5.34.0 — 2026-08-15
|
|
4
50
|
|
|
5
51
|
No BREAKING changes. Two behavior-surface additions (both additive seats), one packaging addition,
|
|
@@ -577,6 +577,31 @@ export type ResumeOutcome = {
|
|
|
577
577
|
* with the backends and belongs to its own change, not to this one.
|
|
578
578
|
*/
|
|
579
579
|
settledBy?: import("./tool-policy.js").ApprovalSettledBy;
|
|
580
|
+
/**
|
|
581
|
+
* design/252 G-7 — WHOSE settlement: the identifier the approval channel reports for the party
|
|
582
|
+
* that ended this wait, transcribed onto the resolved call's `tool_end` frame beside
|
|
583
|
+
* {@link settledBy}.
|
|
584
|
+
*
|
|
585
|
+
* Core AUTHENTICATES NOTHING here. On the durable lane the identity work is entirely the
|
|
586
|
+
* deployment's — its approval card, its signature check, its operator console decide who this
|
|
587
|
+
* was; a library holding no identity surface has nothing to check the claim against and does not
|
|
588
|
+
* pretend otherwise. What core owns is that the claim is well-formed: a plain string, bounded,
|
|
589
|
+
* free of control characters, refused pre-CAS (`checkpoint.invalid_outcome`) otherwise, like
|
|
590
|
+
* every other out-of-domain field on this arm. Refused rather than dropped, because a settlement
|
|
591
|
+
* that silently lost its attributed party reads as unattributed — the misreport this seat exists
|
|
592
|
+
* to end.
|
|
593
|
+
*
|
|
594
|
+
* SCOPE — like {@link settledBy}, this rides the SETTLEMENT and not the persisted winner:
|
|
595
|
+
* `winnerFromOutcome` records what an `env_failed` replay must reproduce exactly, and who
|
|
596
|
+
* reported a settlement is not part of that authorization. A replay states its own attribution
|
|
597
|
+
* and is not held to the first one; binding it into the durable row would also make a re-supply
|
|
598
|
+
* that merely omits the field fail closed on the recovery path. Carrying it into the row is a
|
|
599
|
+
* store-shape change owned jointly with the backends and belongs to its own change.
|
|
600
|
+
*
|
|
601
|
+
* OPTIONAL and additive: absent means the caller named nobody, which is not the same as "nobody
|
|
602
|
+
* approved this" and must not be read as one.
|
|
603
|
+
*/
|
|
604
|
+
approver?: string;
|
|
580
605
|
}
|
|
581
606
|
/** design/74: continue a resource-suspended run with the next slice's allowance. NO decision payload and
|
|
582
607
|
* NO budget figure — the allowance is computed from {@link Checkpoint.resourceLedger}, so money never
|
|
@@ -1631,12 +1656,14 @@ export declare class CheckpointError extends Error {
|
|
|
1631
1656
|
* action: re-review), or `"answer"` (the content-ask answer is missing, or was attached to a
|
|
1632
1657
|
* decision that cannot carry one: re-decide with a well-formed answer — ruled 2026-08-04), or
|
|
1633
1658
|
* `"settledBy"` (the settlement source is outside the three-word vocabulary: re-decide naming one
|
|
1634
|
-
* of them, or omit the field)
|
|
1659
|
+
* of them, or omit the field), or — design/252 G-7 — `"approver"` (the attribution is not a
|
|
1660
|
+
* well-formed identifier: re-decide with a plain, bounded, control-character-free string, or omit
|
|
1661
|
+
* it). The throw
|
|
1635
1662
|
* sites always knew which arm fired; this carries that fact instead of dropping it (ruled
|
|
1636
1663
|
* 2026-08-03, cross-repo HITL discussion). Additive and optional — `code` remains the only REQUIRED
|
|
1637
1664
|
* discriminant. */
|
|
1638
1665
|
readonly detail?: {
|
|
1639
|
-
field?: "boundCallId" | "boundInputHash" | "answer" | "settledBy";
|
|
1666
|
+
field?: "boundCallId" | "boundInputHash" | "answer" | "settledBy" | "approver";
|
|
1640
1667
|
/** WHICH pre-CAS refusal arm fired, where one `code` covers several (requested 2026-08-10: a
|
|
1641
1668
|
* deployment retry policy needs to tell "a newer worker can redeem this row" from "this row is
|
|
1642
1669
|
* damaged/caller-declared and no worker ever will" — blanket-retriable and gate-shape heuristics were
|
|
@@ -1752,12 +1779,14 @@ export declare class CheckpointError extends Error {
|
|
|
1752
1779
|
* action: re-review), or `"answer"` (the content-ask answer is missing, or was attached to a
|
|
1753
1780
|
* decision that cannot carry one: re-decide with a well-formed answer — ruled 2026-08-04), or
|
|
1754
1781
|
* `"settledBy"` (the settlement source is outside the three-word vocabulary: re-decide naming one
|
|
1755
|
-
* of them, or omit the field)
|
|
1782
|
+
* of them, or omit the field), or — design/252 G-7 — `"approver"` (the attribution is not a
|
|
1783
|
+
* well-formed identifier: re-decide with a plain, bounded, control-character-free string, or omit
|
|
1784
|
+
* it). The throw
|
|
1756
1785
|
* sites always knew which arm fired; this carries that fact instead of dropping it (ruled
|
|
1757
1786
|
* 2026-08-03, cross-repo HITL discussion). Additive and optional — `code` remains the only REQUIRED
|
|
1758
1787
|
* discriminant. */
|
|
1759
1788
|
detail?: {
|
|
1760
|
-
field?: "boundCallId" | "boundInputHash" | "answer" | "settledBy";
|
|
1789
|
+
field?: "boundCallId" | "boundInputHash" | "answer" | "settledBy" | "approver";
|
|
1761
1790
|
/** WHICH pre-CAS refusal arm fired, where one `code` covers several (requested 2026-08-10: a
|
|
1762
1791
|
* deployment retry policy needs to tell "a newer worker can redeem this row" from "this row is
|
|
1763
1792
|
* damaged/caller-declared and no worker ever will" — blanket-retriable and gate-shape heuristics were
|
package/dist/core/hooks.d.ts
CHANGED
|
@@ -481,6 +481,14 @@ export interface ToolGateResult {
|
|
|
481
481
|
* and no post-tool hook can write.
|
|
482
482
|
*/
|
|
483
483
|
settledBy?: import("./tool-policy.js").ApprovalSettledBy;
|
|
484
|
+
/**
|
|
485
|
+
* design/252 G-7 — WHOSE settlement that was: the identifier the approval channel reported, carried
|
|
486
|
+
* out verbatim beside {@link settledBy}. This layer authenticates nothing and compares nothing; the
|
|
487
|
+
* value was screened for shape at the seam that received it (`screenApproverAttribution`) and is a
|
|
488
|
+
* transcription from there on. Present only where the caller settled something AND reported an
|
|
489
|
+
* identity — absent everywhere else, and absence never means "nobody": it means nobody said.
|
|
490
|
+
*/
|
|
491
|
+
approver?: string;
|
|
484
492
|
/** Rewritten args to execute with (re-validated by the loop); omitted when nothing rewrote. */
|
|
485
493
|
updatedInput?: unknown;
|
|
486
494
|
/**
|
|
@@ -569,11 +577,90 @@ export type OrgGateVerdict = {
|
|
|
569
577
|
behavior: "deny" | "ask";
|
|
570
578
|
rule: string;
|
|
571
579
|
};
|
|
580
|
+
/** design/252 G-2 (additive): the REVISION of the snapshot this answer was read off — the
|
|
581
|
+
* published-policy version an auditor reconciles the resulting decision against. Present on the
|
|
582
|
+
* available arm whether or not a rule spoke: "revision 41 published nothing about this call" is
|
|
583
|
+
* itself the audit answer, and it is a different fact from "we never looked". ABSENT ⇒ the lane
|
|
584
|
+
* did not report one (a foreign overlay, or a revision that failed its own domain screen — the
|
|
585
|
+
* evidence records that as `"not_reported"` rather than inventing a number). Never adjudication
|
|
586
|
+
* input: the verdict above is the whole decision, and dropping this field would leave every
|
|
587
|
+
* outcome byte-identical. */
|
|
588
|
+
revision?: number;
|
|
572
589
|
} | {
|
|
573
590
|
status: "unavailable";
|
|
574
591
|
/** The overlay's own disclosure lines (engine/deployment-authored), for the operator channel. */
|
|
575
592
|
disclosures: readonly string[];
|
|
576
593
|
};
|
|
594
|
+
/**
|
|
595
|
+
* design/252 G-2 — the richer answer a persisted-rule lane may give instead of the bare rule text (see
|
|
596
|
+
* `ToolGateInput.persistedRules.admits`): the same canonical text, plus the matched rule's add dots.
|
|
597
|
+
*
|
|
598
|
+
* Why the dots and not an id: a persisted allow rule HAS no scalar id — it is a (text, scope) group
|
|
599
|
+
* whose identity is the set of add dots that redeemed it, and two concurrently approved records for one
|
|
600
|
+
* text each carry their own. The set is the identity; a joined string would not be one.
|
|
601
|
+
*/
|
|
602
|
+
export interface PersistedRuleHit {
|
|
603
|
+
/** The canonical text of the admitting rule — the same value the bare-string shape returns. */
|
|
604
|
+
readonly rule: string;
|
|
605
|
+
/** The matched rule's LIVE add dots (tombstoned adds already filtered by the store's read). Omitted
|
|
606
|
+
* by a lane that has the text but not the dots; the evidence then reports `"not_reported"`. */
|
|
607
|
+
readonly dots?: readonly import("./permission-rule-model.js").RuleDot[];
|
|
608
|
+
}
|
|
609
|
+
/**
|
|
610
|
+
* design/252 G-2 — the answer a lane gives when it could not READ its store.
|
|
611
|
+
*
|
|
612
|
+
* Its DECISION meaning is identical to "no rule" — a loosening seam that cannot read its source fails
|
|
613
|
+
* toward asking, and this shape changes no verdict anywhere. What it changes is the RECORD: without it,
|
|
614
|
+
* an unreadable rule store and a store that genuinely holds no matching rule are the same answer
|
|
615
|
+
* (`undefined`), and the ask's evidence has to report one of them as the other. The absence vocabulary
|
|
616
|
+
* distinguishes "we looked and found nothing" (`"no_match"`) from "we could not look" (`"unavailable"`),
|
|
617
|
+
* and this is how a lane says which one it means.
|
|
618
|
+
*/
|
|
619
|
+
export interface PersistedRuleUnreadable {
|
|
620
|
+
readonly unreadable: true;
|
|
621
|
+
}
|
|
622
|
+
/** Every shape a lane may answer with. A bare string stays valid and unchanged. */
|
|
623
|
+
export type PersistedRuleAnswer = string | PersistedRuleHit | PersistedRuleUnreadable | undefined;
|
|
624
|
+
/**
|
|
625
|
+
* design/252 review r3 — read a foreign {@link OrgGateVerdict} the way the personal-rule answer is read:
|
|
626
|
+
* OWN DATA properties only, never the prototype chain, never an accessor.
|
|
627
|
+
*
|
|
628
|
+
* Unlike the personal-rule normalizer below, this one accepts ANY non-array object as the carrier —
|
|
629
|
+
* not only a plain record. The two seams tighten in opposite directions when a shape is refused: the
|
|
630
|
+
* personal lane is a LOOSENING seam, so refusing a class instance degrades toward asking; this answer
|
|
631
|
+
* carries the org's DENY, and folding a structurally valid `{status:"available", verdict:{behavior:
|
|
632
|
+
* "deny"}}` class instance into `unavailable` would LOOSEN it — from a deny nobody can approve into a
|
|
633
|
+
* real-approval ask a person can clear. A class instance's fields are its own data properties, so the
|
|
634
|
+
* own-data read below already gives the full pollution guarantee (inherited members never authorize);
|
|
635
|
+
* the prototype test added nothing here but the downgrade.
|
|
636
|
+
*
|
|
637
|
+
* The two seams are the same class of trust boundary and were not being read the same way. What that
|
|
638
|
+
* cost here is worse than on the personal lane, because this answer is GOVERNANCE:
|
|
639
|
+
* · an inherited `revision` (a polluted `Object.prototype`) stamped a fabricated snapshot version onto
|
|
640
|
+
* a human's approval request — a WRONG record, which is worse than an absent one;
|
|
641
|
+
* · an inherited `verdict` invented an org rule, and with it an org ask or deny the lane never gave;
|
|
642
|
+
* · an answer that is not a record at all read as "available, nothing to say", i.e. the governance
|
|
643
|
+
* fail-open the availability contract exists to prevent.
|
|
644
|
+
* Anything this function cannot read as a well-formed answer becomes `unavailable` — the fail-CLOSED
|
|
645
|
+
* word, never the empty one.
|
|
646
|
+
*/
|
|
647
|
+
export declare function normalizeOrgGateVerdict(answer: unknown, unreadable: string): OrgGateVerdict;
|
|
648
|
+
/**
|
|
649
|
+
* Normalize the accepted {@link ToolGateInput.persistedRules} answers into one reading.
|
|
650
|
+
*
|
|
651
|
+
* `{}` = a clean negative (no rule admits this call). `{ unreadable: true }` = the lane could not read
|
|
652
|
+
* its source. `{ hit }` = a match. An answer outside every accepted shape — a number, `null`, an object
|
|
653
|
+
* with no `rule` — is read as a clean negative rather than a match: this is a LOOSENING seam, so an
|
|
654
|
+
* answer nobody can name degrades toward asking, never toward an allow built on it.
|
|
655
|
+
*
|
|
656
|
+
* The hit's dots are COPIED, not aliased. The array travels onto an ask that may sit in front of a
|
|
657
|
+
* person for a long time; a lane that retains and mutates its own array would otherwise change what the
|
|
658
|
+
* approver is looking at, and what an audit later reads, after the evidence was stamped.
|
|
659
|
+
*/
|
|
660
|
+
export declare function normalizePersistedRuleHit(hit: PersistedRuleAnswer): {
|
|
661
|
+
hit?: PersistedRuleHit;
|
|
662
|
+
unreadable?: true;
|
|
663
|
+
};
|
|
577
664
|
/** Inputs to the two-phase tool gate. `adjudicate`/`resolveAsk` are pre-bound to the task abort signal. */
|
|
578
665
|
export interface ToolGateInput {
|
|
579
666
|
/** RB-473 batch 2b — bounded disclosure for a HOST callback that throws inside this gate call's
|
|
@@ -794,9 +881,17 @@ export interface ToolGateInput {
|
|
|
794
881
|
* bullets above are what keep integrity/hook/mandated asks out of that set.
|
|
795
882
|
*/
|
|
796
883
|
persistedRules?: {
|
|
797
|
-
/** The
|
|
798
|
-
*
|
|
799
|
-
|
|
884
|
+
/** The rule that admits this call, or `undefined`. Must not throw: a store that cannot be read
|
|
885
|
+
* answers "no rule" and discloses on its own channel (fail-closed = more asks).
|
|
886
|
+
*
|
|
887
|
+
* TWO ACCEPTED SHAPES (design/252 G-2, additive — a lane returning the bare string is unchanged
|
|
888
|
+
* and stays supported): the canonical rule TEXT, or a {@link PersistedRuleHit} carrying that same
|
|
889
|
+
* text plus the matched rule's add DOTS. The dots are the rule's durable identity, and the text
|
|
890
|
+
* is not one (it reaches a card sanitized and capped); a lane that can name them lets the
|
|
891
|
+
* shadowed-ask evidence say WHICH stored rule matched rather than merely what it looked like.
|
|
892
|
+
* Widening the RETURN is why this is additive for implementers: every existing lane satisfies
|
|
893
|
+
* the union unchanged, and the gate reads both through one normalizer. */
|
|
894
|
+
admits: (req: ToolCallRequest) => Promise<PersistedRuleAnswer>;
|
|
800
895
|
/** Observation sink for an ask a rule resolved — the allow-side disclosure. Never affects the outcome. */
|
|
801
896
|
onResolved?: (info: {
|
|
802
897
|
toolName: string;
|
package/dist/core/hooks.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { decisionText, refuseOutOfContractDecision } from "./tool-policy.js";
|
|
1
|
+
import { decisionText, describeThrown, refuseOutOfContractDecision } from "./tool-policy.js";
|
|
2
2
|
import { brandPolicyAskClass } from "./ask-class.js";
|
|
3
3
|
import { inlineUntrusted } from "./untrusted-text.js";
|
|
4
4
|
import { PROBE_REASON_MAX, normalizeProbeCause } from "./checkpoint-store.js";
|
|
@@ -89,6 +89,99 @@ function withParkFailureCause(reason, parkFailed) {
|
|
|
89
89
|
`(${inlineUntrusted(parkFailed, PARK_FAILURE_CAUSE_MAX)}), so the refusal above is what the fallback had ` +
|
|
90
90
|
`left to say, not the reason the call stopped.`);
|
|
91
91
|
}
|
|
92
|
+
function isPlainOwnRecord(x) {
|
|
93
|
+
try {
|
|
94
|
+
if (x === null || typeof x !== "object" || Array.isArray(x))
|
|
95
|
+
return false;
|
|
96
|
+
const p = Object.getPrototypeOf(x);
|
|
97
|
+
return p === Object.prototype || p === null;
|
|
98
|
+
}
|
|
99
|
+
catch {
|
|
100
|
+
return false;
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
function ownDataValue(obj, key) {
|
|
104
|
+
try {
|
|
105
|
+
const d = Object.getOwnPropertyDescriptor(obj, key);
|
|
106
|
+
return d && "value" in d ? d.value : undefined;
|
|
107
|
+
}
|
|
108
|
+
catch {
|
|
109
|
+
return undefined;
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
export function normalizeOrgGateVerdict(answer, unreadable) {
|
|
113
|
+
const clean = (v) => Object.assign(Object.create(null), v);
|
|
114
|
+
const readable = (x) => {
|
|
115
|
+
try {
|
|
116
|
+
return x !== null && (typeof x === "object" || typeof x === "function") && !Array.isArray(x);
|
|
117
|
+
}
|
|
118
|
+
catch {
|
|
119
|
+
return false;
|
|
120
|
+
}
|
|
121
|
+
};
|
|
122
|
+
if (!readable(answer))
|
|
123
|
+
return clean({ status: "unavailable", disclosures: [unreadable] });
|
|
124
|
+
const status = ownDataValue(answer, "status");
|
|
125
|
+
if (status === "unavailable") {
|
|
126
|
+
const disclosures = ownDataValue(answer, "disclosures");
|
|
127
|
+
let lines = [];
|
|
128
|
+
if (Array.isArray(disclosures)) {
|
|
129
|
+
try {
|
|
130
|
+
for (let i = 0; i < disclosures.length; i++) {
|
|
131
|
+
const d = disclosures[i];
|
|
132
|
+
if (typeof d === "string")
|
|
133
|
+
lines.push(d);
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
catch {
|
|
137
|
+
lines = [];
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
return clean({ status: "unavailable", disclosures: lines.length > 0 ? lines : [unreadable] });
|
|
141
|
+
}
|
|
142
|
+
if (status !== "available")
|
|
143
|
+
return clean({ status: "unavailable", disclosures: [unreadable] });
|
|
144
|
+
const revision = ownDataValue(answer, "revision");
|
|
145
|
+
const revisionCell = typeof revision === "number" && Number.isFinite(revision) ? { revision } : {};
|
|
146
|
+
const verdict = ownDataValue(answer, "verdict");
|
|
147
|
+
if (verdict === undefined)
|
|
148
|
+
return clean({ status: "available", ...revisionCell });
|
|
149
|
+
if (!readable(verdict))
|
|
150
|
+
return clean({ status: "unavailable", disclosures: [unreadable] });
|
|
151
|
+
const behavior = ownDataValue(verdict, "behavior");
|
|
152
|
+
const rule = ownDataValue(verdict, "rule");
|
|
153
|
+
if ((behavior !== "deny" && behavior !== "ask") || typeof rule !== "string")
|
|
154
|
+
return clean({ status: "unavailable", disclosures: [unreadable] });
|
|
155
|
+
return clean({ status: "available", verdict: clean({ behavior: behavior, rule }), ...revisionCell });
|
|
156
|
+
}
|
|
157
|
+
export function normalizePersistedRuleHit(hit) {
|
|
158
|
+
if (typeof hit === "string")
|
|
159
|
+
return { hit: { rule: hit } };
|
|
160
|
+
if (!isPlainOwnRecord(hit))
|
|
161
|
+
return {};
|
|
162
|
+
if (ownDataValue(hit, "unreadable") === true)
|
|
163
|
+
return { unreadable: true };
|
|
164
|
+
const rule = ownDataValue(hit, "rule");
|
|
165
|
+
if (typeof rule !== "string")
|
|
166
|
+
return {};
|
|
167
|
+
const dots = ownDataValue(hit, "dots");
|
|
168
|
+
let copied;
|
|
169
|
+
try {
|
|
170
|
+
if (Array.isArray(dots)) {
|
|
171
|
+
const walked = [];
|
|
172
|
+
for (let i = 0; i < dots.length; i++) {
|
|
173
|
+
const d = dots[i];
|
|
174
|
+
walked.push(isPlainOwnRecord(d) ? { actor: ownDataValue(d, "actor"), counter: ownDataValue(d, "counter") } : { actor: undefined, counter: undefined });
|
|
175
|
+
}
|
|
176
|
+
copied = walked;
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
catch {
|
|
180
|
+
copied = undefined;
|
|
181
|
+
}
|
|
182
|
+
const usable = copied?.every((d) => typeof d.actor === "string" && typeof d.counter === "number" && Number.isFinite(d.counter)) === true && copied.length > 0;
|
|
183
|
+
return { hit: { rule, ...(usable ? { dots: copied } : {}) } };
|
|
184
|
+
}
|
|
92
185
|
function preToolUseCrashReason(subject, err) {
|
|
93
186
|
const raw = err instanceof Error ? err.message.trim() || err.name : String(err);
|
|
94
187
|
const cause = inlineUntrusted(raw, 200);
|
|
@@ -291,22 +384,39 @@ export async function runToolGate(input) {
|
|
|
291
384
|
let orgRealApprovalRequired = false;
|
|
292
385
|
let orgAskOrigin;
|
|
293
386
|
let orgTightenCount = 0;
|
|
387
|
+
let resolvedApprover;
|
|
388
|
+
let orgEvidence = (() => {
|
|
389
|
+
const w = input.orgRules === undefined ? "not_wired" : "not_adjudicated";
|
|
390
|
+
return { revisionAbsent: w, ruleAbsent: w };
|
|
391
|
+
})();
|
|
392
|
+
const mintRuleEvidence = (personal) => ({
|
|
393
|
+
...(orgEvidence.revision !== undefined ? { orgRevision: orgEvidence.revision } : { orgRevisionAbsent: orgEvidence.revisionAbsent ?? "not_reported" }),
|
|
394
|
+
...(orgEvidence.rule !== undefined ? { orgRule: orgEvidence.rule } : { orgRuleAbsent: orgEvidence.ruleAbsent ?? "not_reported" }),
|
|
395
|
+
...(personal.dots !== undefined ? { personalRuleDots: personal.dots } : { personalRuleDotsAbsent: personal.dotsAbsent ?? "not_reported" }),
|
|
396
|
+
});
|
|
294
397
|
const applyOrgLayer = async (current, args) => {
|
|
295
398
|
if (input.orgRules === undefined)
|
|
296
399
|
return current;
|
|
297
400
|
if (toolName === ASK_USER_QUESTION_TOOL_NAME && input.orgRules.contentAskToolMounted === true)
|
|
298
401
|
return current;
|
|
299
402
|
let decided = current;
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
.
|
|
403
|
+
let pending;
|
|
404
|
+
try {
|
|
405
|
+
pending = Promise.resolve(input.orgRules.adjudicate({ toolName, args, toolCallId }));
|
|
406
|
+
}
|
|
407
|
+
catch (err) {
|
|
408
|
+
pending = Promise.resolve({ status: "unavailable", disclosures: [`the org adjudication face threw before answering: ${describeThrown(err)}`] });
|
|
409
|
+
}
|
|
410
|
+
const raw = await settleOrgVerdictWithin(pending.catch((err) => ({
|
|
303
411
|
status: "unavailable",
|
|
304
|
-
disclosures: [`the org adjudication face threw: ${
|
|
412
|
+
disclosures: [`the org adjudication face threw: ${describeThrown(err)}`],
|
|
305
413
|
})), {
|
|
306
414
|
status: "unavailable",
|
|
307
415
|
disclosures: [`the org adjudication face did not answer within ${ORG_ADJUDICATION_TIMEOUT_MS}ms (or the task ended first)`],
|
|
308
416
|
}, { ...(input.abortSignal !== undefined ? { signal: input.abortSignal } : {}), timeoutMs: ORG_ADJUDICATION_TIMEOUT_MS });
|
|
417
|
+
const answer = normalizeOrgGateVerdict(raw, "the org adjudication face answered something this gate cannot read as a verdict — treated as unreadable governance, never as an empty one");
|
|
309
418
|
if (answer.status === "unavailable") {
|
|
419
|
+
orgEvidence = { revisionAbsent: "unavailable", ruleAbsent: "unavailable" };
|
|
310
420
|
orgRealApprovalRequired = true;
|
|
311
421
|
orgAskOrigin = "unavailable";
|
|
312
422
|
await notifier.notifyAsync(() => input.orgRules?.onUnavailable?.({ toolName, toolCallId, message: answer.disclosures.join("; ") }), "toolGate.orgSnapshotUnavailable");
|
|
@@ -327,9 +437,13 @@ export async function runToolGate(input) {
|
|
|
327
437
|
}
|
|
328
438
|
return decided;
|
|
329
439
|
}
|
|
330
|
-
|
|
440
|
+
const revisionCell = answer.revision !== undefined ? { revision: answer.revision } : { revisionAbsent: "not_reported" };
|
|
441
|
+
if (answer.verdict === undefined) {
|
|
442
|
+
orgEvidence = { ...revisionCell, ruleAbsent: "no_match" };
|
|
331
443
|
return decided;
|
|
444
|
+
}
|
|
332
445
|
const { behavior, rule } = answer.verdict;
|
|
446
|
+
orgEvidence = { ...revisionCell, rule };
|
|
333
447
|
if (behavior === "deny") {
|
|
334
448
|
orgTightenCount += 1;
|
|
335
449
|
denySource = "org";
|
|
@@ -357,6 +471,7 @@ export async function runToolGate(input) {
|
|
|
357
471
|
req.args = policyRewrite;
|
|
358
472
|
}
|
|
359
473
|
const persistedRuleMandate = persistedRuleMandateOf(input);
|
|
474
|
+
let personalEvidence = { dotsAbsent: input.persistedRules === undefined ? "not_wired" : "not_adjudicated" };
|
|
360
475
|
if (input.persistedRules &&
|
|
361
476
|
!orgRealApprovalRequired &&
|
|
362
477
|
decision.action === "ask" &&
|
|
@@ -364,7 +479,19 @@ export async function runToolGate(input) {
|
|
|
364
479
|
decision.decisionReason !== "hook" &&
|
|
365
480
|
req.toolName !== ASK_USER_QUESTION_TOOL_NAME &&
|
|
366
481
|
input.isMarkedUnresolvable?.(input.event.toolCallId) !== true) {
|
|
367
|
-
const
|
|
482
|
+
const answer = await (async () => {
|
|
483
|
+
let pendingHit;
|
|
484
|
+
try {
|
|
485
|
+
pendingHit = Promise.resolve(input.persistedRules?.admits(req));
|
|
486
|
+
}
|
|
487
|
+
catch {
|
|
488
|
+
return { unreadable: true };
|
|
489
|
+
}
|
|
490
|
+
return await pendingHit.then(normalizePersistedRuleHit).catch(() => ({ unreadable: true }));
|
|
491
|
+
})();
|
|
492
|
+
const hitEntry = answer.hit;
|
|
493
|
+
const hit = hitEntry?.rule;
|
|
494
|
+
personalEvidence = { dotsAbsent: answer.unreadable === true ? "unavailable" : "no_match" };
|
|
368
495
|
if (hit !== undefined && persistedRuleMandate === undefined && decision.matchedAskRule === undefined) {
|
|
369
496
|
decision = {
|
|
370
497
|
action: "allow",
|
|
@@ -375,6 +502,7 @@ export async function runToolGate(input) {
|
|
|
375
502
|
await notifier.notifyAsync(() => input.persistedRules?.onResolved?.({ toolName: req.toolName, toolCallId, rule: hit }), "toolGate.persistedRuleResolved");
|
|
376
503
|
}
|
|
377
504
|
else if (hit !== undefined) {
|
|
505
|
+
personalEvidence = hitEntry?.dots !== undefined && hitEntry.dots.length > 0 ? { dots: hitEntry.dots } : { dotsAbsent: "not_reported" };
|
|
378
506
|
const shownRule = inlineUntrusted(hit, 200);
|
|
379
507
|
const mandateNoun = persistedRuleMandate === "operator_always"
|
|
380
508
|
? "this deployment mandates per-call confirmation for shell commands (shellGate: always)"
|
|
@@ -388,6 +516,9 @@ export async function runToolGate(input) {
|
|
|
388
516
|
};
|
|
389
517
|
}
|
|
390
518
|
}
|
|
519
|
+
if (decision.action === "ask") {
|
|
520
|
+
decision = { ...decision, ruleEvidence: mintRuleEvidence(personalEvidence) };
|
|
521
|
+
}
|
|
391
522
|
if (input.autoMode &&
|
|
392
523
|
!orgRealApprovalRequired &&
|
|
393
524
|
decision.action === "ask" &&
|
|
@@ -492,6 +623,8 @@ export async function runToolGate(input) {
|
|
|
492
623
|
if (decision.action === "ask") {
|
|
493
624
|
const askBeforeResolve = decision;
|
|
494
625
|
const resolved = await resolveAsk(decision, req);
|
|
626
|
+
if (resolved.action !== "ask" && resolved.approver !== undefined)
|
|
627
|
+
resolvedApprover = resolved.approver;
|
|
495
628
|
decision = resolved;
|
|
496
629
|
if (resolved.action === "deny" && resolved.approverUnavailable === true && suspendAsk && parkFailed === undefined) {
|
|
497
630
|
const suspended = await suspendAsk(req, currentInput, safety, true, realApprovalOf(askBeforeResolve), askBeforeResolve.action === "ask" ? askBeforeResolve.persistedRuleShadowed : undefined, askBeforeResolve.action === "ask" ? askBeforeResolve.decisionReason : undefined, askBeforeResolve.action === "ask" ? askBeforeResolve.probeReason : undefined, askBeforeResolve.action === "ask" ? askBeforeResolve.probeCause : undefined);
|
|
@@ -580,7 +713,8 @@ export async function runToolGate(input) {
|
|
|
580
713
|
}
|
|
581
714
|
if (recheck.updatedInput !== undefined)
|
|
582
715
|
editArgs = recheck.updatedInput;
|
|
583
|
-
const rr = await resolveAsk(recheck, { toolName, args: editArgs, toolCallId });
|
|
716
|
+
const rr = await resolveAsk({ ...recheck, ruleEvidence: mintRuleEvidence({ dotsAbsent: "not_adjudicated" }) }, { toolName, args: editArgs, toolCallId });
|
|
717
|
+
resolvedApprover = rr.action !== "ask" ? rr.approver : undefined;
|
|
584
718
|
if (rr.action !== "allow") {
|
|
585
719
|
editDenied = rr;
|
|
586
720
|
if (!orgRaisedThisRound)
|
|
@@ -610,10 +744,12 @@ export async function runToolGate(input) {
|
|
|
610
744
|
await notifier.notifyAsync(() => input.permissionDenied?.({ toolName, input: cloneObserverInput(currentInput), toolCallId, reason: denyReason, source: denySource }), "toolGate.permissionDenied");
|
|
611
745
|
}
|
|
612
746
|
const denySettledBy = decision.settledBy;
|
|
747
|
+
const denyApprover = denySettledBy !== undefined ? resolvedApprover : undefined;
|
|
613
748
|
return {
|
|
614
749
|
block: true,
|
|
615
750
|
reason: formatHookFeedback(denyReason),
|
|
616
751
|
...(denySettledBy !== undefined ? { settledBy: denySettledBy } : {}),
|
|
752
|
+
...(denyApprover !== undefined ? { approver: denyApprover } : {}),
|
|
617
753
|
preToolContext,
|
|
618
754
|
};
|
|
619
755
|
}
|
|
@@ -623,9 +759,11 @@ export async function runToolGate(input) {
|
|
|
623
759
|
currentInput = rw;
|
|
624
760
|
}
|
|
625
761
|
const allowSettledBy = decision.action === "allow" ? decision.settledBy : undefined;
|
|
762
|
+
const allowApprover = decision.action === "allow" && allowSettledBy !== undefined ? resolvedApprover : undefined;
|
|
626
763
|
return {
|
|
627
764
|
updatedInput: currentInput === event.input ? undefined : currentInput,
|
|
628
765
|
...(allowSettledBy !== undefined ? { settledBy: allowSettledBy } : {}),
|
|
766
|
+
...(allowApprover !== undefined ? { approver: allowApprover } : {}),
|
|
629
767
|
preToolContext,
|
|
630
768
|
};
|
|
631
769
|
}
|