@sema-agent/core 7.50.0 → 7.50.1
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 +20 -1
- package/dist/core/gate-fold.js +15 -6
- package/dist/core/gate-lanes.js +3 -2
- package/dist/core/runner/policy-throw.d.ts +34 -0
- package/dist/core/runner/policy-throw.js +25 -0
- package/dist/core/runner/prepare-ask-lane.d.ts +2 -1
- package/dist/core/runner/prepare-ask-lane.js +14 -16
- package/dist/core/runner/prepare-policy-chain.js +5 -3
- package/dist/tools/fs/shell-read-check.js +16 -9
- package/dist/tools/fs/shell-read-face.d.ts +15 -0
- package/dist/tools/fs/shell-read-face.js +5 -0
- package/package.json +1 -1
- package/wire-changes/core@7.50.1.json +65 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,3 +1,22 @@
|
|
|
1
|
+
## 7.50.1 — 2026-10-14
|
|
2
|
+
|
|
3
|
+
**Hard-breaking changes:** none. **Closed sets:** none — the read station's details code stays `read_unestablished` on both faces (whether a write finding gets its own code is a closed-set question, left to 7.51.0). **Package exports:** +0 / −0 names (`npm run handoff:diff v7.50.0 HEAD`); no type, signature or closed-set change (`wire-changes` preview: 0 / 0 / 0). **Behavior:** a tool policy that throws is denied in its own words with one redacted line and reported once on `onError`, never as a timeout; a write the read station could not place is carded as a write. Both defects were present in 7.49.5 and 7.50.0.
|
|
4
|
+
|
|
5
|
+
### A tool policy that throws is a throw, not a timeout — and its text reaches the model only redacted (#1710; fix; truthfulness and secret hygiene; hard-breaking: none; exports: +0 / −0) (@server @test @cli)
|
|
6
|
+
|
|
7
|
+
- **Before (v7.50.0; the same in v7.49.5).** The Runner's adjudicate seat raced the policy chain with a two-state race that resolves a REJECTION on its abort arm, so a policy that threw was denied as `policy check aborted (task timed out or cancelled)` — a run that neither timed out nor was cancelled read as one that did — and the error reached nobody: no `onError` line, nothing of it on the deny. A policy check or a target-reading stamp that threw SYNCHRONOUSLY escaped the seat altogether. Four sibling seats put the thrown text RAW on the model's frame and reported nothing to the deployment (a secret-shaped token in a policy's error message reached `tool_end.output`): the gate's own target reading, stamped before the seat (a deployment policy whose `pathRules` threw for the call being judged — the throw escaped the gate, so the frame carried the bare thrown text and no gate record); the inherited-constraint station (`inherited parent policy errored for "<tool>": …`); its re-check of an approved edit (`inherited parent policy re-check of the approved edit errored for "<tool>": …`); and the gate's own re-check of an approved edit (`policy re-check of the approved edit for "<tool>" errored: …`).
|
|
8
|
+
- **Now.** The adjudicate seat races ONE promise holding the stamp and the check, three-state (`raceSettlementAgainstSignal`): a value is the decision as before; an abort is the aborted deny, byte-identical; a throw is `policy check threw — the tool policy chain failed while judging this call (not a timeout or cancellation), so it was denied fail-closed: <line>`. Every seat that consults a policy chain — the adjudicate seat and the four siblings — takes the model's line, the two deny sentences and the deployment's report from one mint (`src/core/runner/policy-throw.ts`). The line is `redactSecrets` and then `inlineUntrusted(…, 200)`: one line, capped, the reminder fence neutralized, `(no message)` when empty. The report is one `onError(…, { phase: "hook" })` per throw, on the seat's hook-failure lane (the gate's: `onHookError`): an `Error` naming the seat, the tool and the call id with the redacted class and message, and no `cause` (a host's serializer walks a cause chain); a rejection that arrives after the judgment's signal aborted is a cancellation and is not reported. The gate's target reading that throws or rejects is the judgment failing: the call is denied in the seat's threw sentence (the aborted one when the call's signal had ended), the policy is not consulted. The inherited station and the two re-checks keep their sentence heads — only the spliced text changes. A thrown policy's deny stands under `bypassPermissions` (`deniedBy: "policy"`). The ask lane's crashing-hook sink is that same lane (the duplicate copy is gone; no behavior change). Unchanged: a HOST-supplied `adjudicate` (a host driving `runToolGate` directly) that throws at the first adjudication still rejects `runToolGate`, as in 7.50.0.
|
|
9
|
+
- **Who is hurt.** A consumer that counted `policy check aborted` as a timeout no longer counts thrown policies (they were not timeouts). A host's `onError` receives one new `hook`-phase line per thrown policy, on the inherited station too. The model reads `[redacted…]` where a secret-shaped run of the thrown text was, and at most 200 characters of it.
|
|
10
|
+
- **Compensation.** None needed. The operator's report carries the redacted text, not the thrown value's own stack (the report's stack is the seat's) — the price of keeping the unredacted value off the operator lane.
|
|
11
|
+
- **Pins.** `test/backlog1710-policy-throw-is-not-abort.test.ts`: (a) / (a3) the seat and the production path, (h) / (a2) / (a4) / (g) the four siblings with a planted token, (b) the seat's stamp (rejecting and synchronous), (c) abort controls, (d) answer controls, (e) `bypassPermissions`, (f) the edit re-check as the seat's second consumer.
|
|
12
|
+
|
|
13
|
+
### One finding, one verb: the read station's card says a write as a write (#1711; fix; one reading; hard-breaking: none; closed sets: none; exports: +0 / −0) (@server @test @cli)
|
|
14
|
+
|
|
15
|
+
- **Before (v7.50.0; the same in v7.49.5).** The gate's card sentence for the read station's tighten read the finding's face only on `outside_roots`: a written target the station could not establish (`echo x > $DEST`, a write target whose lookup gave no answer) was carded `tool "Bash" reads where the session's read boundary could not establish what is read`, while the station's own account of the same finding — shown on the same card — said `writes`.
|
|
16
|
+
- **Now.** The verb and its participle are minted once, by the finding's face (`shellReadFaceWords`, `src/tools/fs/shell-read-face.ts`); the card and the station's account both take them there, and the station reads the face once (`faceOf`). A write finding the station could not establish is carded `tool "<tool>" writes where the session's read boundary could not establish what is written — explicit approval required`. The read sentence and both `outside_roots` sentences are byte-identical. The details code stays `read_unestablished` on both faces.
|
|
17
|
+
- **Who is hurt.** The auto-mode classifier's `askMessage` and any consumer matching the card text read `writes … what is written` on these calls.
|
|
18
|
+
- **Pins.** `test/backlog1711-card-sentence-by-face.test.ts`: ① finding × face through the gate (four findings, both faces, `outside_roots` control), ② the station's real verdicts with the card's and the account's verbs, ③ the production path.
|
|
19
|
+
|
|
1
20
|
## 7.50.0 — 2026-10-13
|
|
2
21
|
|
|
3
22
|
**Read face, rule lifetime and consolidation wave — BREAKING.** A person's standing path rules (a rule store with a durable partition; a session-only store has no personal lane, as before) now judge every tool on their face at the gate, are read once per call and fail closed when they cannot be read; RepoMap and LSP join the read face; background memory consolidation asks the same rules before it writes; a delegated child's start is one frame; a host can attach files to an objective; `tool_end.output` carries images. Every entry below states Before / Now / who is hurt / compensation; this head lists the package exports, the hard-breaking items (each open to veto; rulings in parentheses), the closed sets that moved, the behaviour narrowings, what is not in this release and the known limits.
|
|
@@ -131,7 +150,7 @@
|
|
|
131
150
|
**Hard-breaking (open to veto).** (1) **What changed:** one rule for a blocks array: every block keeps its slot, and `truncated` is `true` exactly when `output` is not the content the tool returned. **Media:** an `image` / `document` block no longer degrades to a single `[image: image/png]`-style string when the content's serialized size passes 16384; it rides `tool_end.output` whole, in its slot, until the event's media (each block's serialized UTF-8 size on the wire) has spent what is left of ONE ceiling for the whole output. That ceiling is the leg's media budget — `RunnerDeps.mediaByteCapBytes`, the budget the request strip reads, through the same `mediaBudgetBytes` (default `AGGREGATE_MEDIA_BUDGET_BYTES` = 30 MiB decoded; `Infinity` / `0` = no budget) — spent as base64 (x 4/3: **41 943 040 bytes on the wire at the default**), after the text part's serialized size. A block past it becomes the descriptor `{ type, mimeType?, bytes, truncated: true }` in its slot (never a string; a media type over 255 characters is not echoed); descriptors spend the same budget, and when not even a descriptor fits, that block and every later media block fold into ONE `{ type: "omitted", blocks, bytes, truncated: true }` in its slot, so the media in an event is bounded at any block count. **Text:** the 16384 cap counts characters, never serialized length. The text blocks — each at its text plus whatever else it carries, at its serialized length (the contract's text block carries nothing else) — and any block that is neither text nor admitted media, at its serialized length, are one stream, with one character between consecutive blocks. Within the cap they ride as the very objects; escapes and framing no longer trip it. Over the cap, the block where the stream passes 16384 is cut IN PLACE to `{ type: "text", text }`: its first characters, then `…[+N chars truncated]`, where N counts every stream character not shown. The stream's later blocks leave. A text-only array over the cap is therefore an array cut in place, no longer a string. A tool's bare-string return reaches the projection as a one-text-block array, so this is every large text result. `totalChars` stays the sum of each block's own size. (2) **Who breaks:** a consumer that reads `truncated: true` as "`output` is a string" — a truncated result now arrives as a block array (only a body that is itself a string — a refusal or reconcile text, or the unreadable-content string — stays a string); a consumer that assumes a result carrying an image is a string once it is large; a consumer that bounds frame or replay size by the 16384 cap — one event can now carry up to the leg's media budget on the wire, 41 943 040 bytes at the default. (3) **How to migrate:** handle both shapes of `output` (it was always `string | blocks`); render a block with `truncated: true` and no `data` as "one PNG, N KB, not delivered" from its `mimeType` and `bytes`; bound a frame by the blocks' `data` lengths, not by the cap; a durable consumer may store each media block as its descriptor (`type`, `mimeType`, `bytes`) and drop `data`.
|
|
132
151
|
|
|
133
152
|
- **Before (v7.49.5).** `toolOutputFrom` measured the serialized size of the WHOLE content, base64 included: a screenshot (hundreds of KB) always tripped the 16384 gate, and the result became the string `[image: image/png]` with `truncated: true` — the SSE / replay surface never carried a real image although the model had been shown it (admission and the media byte cap already bound what reaches the model). A text-only array whose serialized size passed 16384 was re-rendered as ONE string, its blocks joined by newlines, even when its characters fitted (`truncated: false` then).
|
|
134
|
-
- **Now.** The one rule above.
|
|
153
|
+
- **Now.** The one rule above. An output that is already a string at the projection's door (a refusal sentence, `[unserializable tool output]`) is byte-identical to v7.49.5; a tool's bare-string return is wrapped as one text block before the projection, so a large one is a one-text-block array cut in place (the hard-breaking paragraph's "every large text result"). A text-only array within the cap in characters rides as-is; over it, the array is cut in place. Media blocks are measured against the leg's one budget — each block's serialized UTF-8 size (payload, type, media type, any other member, escapes), first-fit in block order — and pass as the very objects the tool returned. Content that cannot be read or serialized (a throwing accessor, a cycle) still answers `"[unserializable tool output]"`, now with `truncated: true` (it is not the returned content; v7.49.5 said `false`) and no `totalChars` (its size could not be read). A media block past the budget carries `truncated: true` on its own descriptor; `bytes` is the engine's decoded-size measure and `mimeType` the block's own media type, both kept. `totalChars` (iff `truncated` and the content could be read) is each block's own size: text `.text.length` plus whatever else the block carries, media `.data.length`, any other block its serialized length — never the JSON length of the whole content. Two engine readers hand `output` to a model: the observer digest, and the sub-agent step outcome (the parent's `<recent-steps>` and the auto-mode handback review's `toolSteps`). Both render a block through the one block rendering (`renderContentBlock`), which the projection's cut block, the auto-mode classifier window and the user-attachment view also use: a text block's text, any other block its `[type: mimeType]` marker, never its payload. A child's screenshot `Read` therefore reads `Read <path> → [image: image/png]` in the parent's step tail (v7.49.5 showed this for a large image; the 7.50.0 stage had dropped it). C-R441: CC 2.1.296 drops non-text blocks from the observer digest; the marker keeps one rendering rule and is what 7.49.5 showed for a large image.
|
|
135
154
|
- **Who is hurt.** See the hard-breaking paragraph. **server**: the durable ledger persists every `tool_end` row with `output` in one SQL INSERT, and TiDB's default entry wall is 6 MiB. Server stores media blocks as descriptors (`type` / `mimeType` / `bytes`) and strips `data` before `redactDeep`; the live stream keeps whole blocks (S-1518, 7.127.0, in lockstep with this release). **cli** and **client-core**: the transcript / render path receives real image and document blocks it never saw on a large result, and a truncated text result as a one-block array. client-core reads both shapes through one text reading, `toolEndOutputText`. Its transcript shape is CC's `{type:'image', source:{type:'base64', media_type, data}}`; the descriptor keeps the engine's `mimeType` spelling for the consumer to convert at its own boundary. A read of server `57356167`, cli `68aae766` and client-core `28015df` (origin/main) found no `tool_end.output` reader that assumes a string. One downgrade on that read: client-core's block reader (`src/toolResultContent.ts` `blockOf`) passes text and four image media types only, so a `document` block, a media descriptor and the `omitted` summary fold into one trailing `[unsupported content omitted]` note where v7.49.5 showed `[document: application/pdf]`. On pickup, client-core renders a descriptor from its `mimeType` and `bytes`, and the summary from `blocks` and `bytes`. **sdk**: the `tool_end.output` media block, descriptor and summary shapes ride the 7.50.0 pickup (server K-79). **Direct callers of the media budget**: `capAggregateMediaBytes` (package root) now reads `limitBytes: undefined` as the 30 MiB default, where v7.49.5 left the messages uncapped. Only an untyped or cast call can pass `undefined`; the engine itself always passes a number, and no sibling repo calls it. A deployment whose `RunnerDeps.mediaByteCapBytes` is a non-number, `NaN` or negative is now refused at `new Runner` (`config.invalid_argument`); v7.49.5 silently read it as "no budget". `0` and `Infinity` still mean "no budget". The deps are read ONCE at `new Runner` into the plain-data copy the Runner keeps — every own seat, enumerable or not, each getter invoked once, each DEFINED on the copy as a data property so no setter ever runs (an own `__proto__` key installs nothing) — and the value doors judge that copy. A deps object that is not a plain object (a class instance: a seat its prototype would supply is never read) is refused (`config.invalid_argument`) rather than wired without that seat; v7.49.5 kept the caller's object by reference when it had nothing to add and spread it otherwise, so a non-enumerable or inherited seat worked or vanished by accident. A deployment that mutates its deps object after construction is not seen (`swapDeps` is the path). No sibling repo constructs a Runner from anything but a literal (server `src/boot/runner-deps.ts`, `src/bench/s1/live-deps.ts`).
|
|
136
155
|
- **Compensation.** None for text consumers beyond the array shape. A host that wants a small frame drops `data` from the media blocks it receives; the descriptor (`type`, `mimeType`, `bytes`) is the durable form. `output` is for observability. The engine's two model-facing readers (the observer digest, the sub-agent step outcome) render media as their markers, never their bytes; build model prompts from the session. No new knob: the budget is the deployment's existing `mediaByteCapBytes`.
|
|
137
156
|
- **Pins.** `test/backlog1668-tool-end-image-blocks.test.ts` (text cut in place × image rides; image-only / document identity; first-fit descriptor; order; `totalChars`; the block-count bound under a deployment budget; real Runner Read of a PNG; live vs durable-resume frame bytes; every cell has its own timeout); `test/backlog1668-one-rule.test.ts` (the observer digest of a screenshot is exactly `[image: image/png]`; every block kind through the one renderer; the leg's budget on the projection, the live frame and the durable-resume frame, and the four disabled forms; a caption, an image and long text keep their slots; escape-heavy text within the cap rides as-is; a text-only array is cut in place; the empty-block bound; a 2 MB member on a text block cannot ride; unreadable content is flagged with no `totalChars`; `truncated` iff the output is not the returned content; a child's screenshot step reads `[image: image/png]` on the live and the pull face and in `<recent-steps>`; the auto-mode window and the user-attachment view render the same marker; a non-number, `NaN` or negative `mediaByteCapBytes` is refused at `new Runner` while absent, a positive number, `0` and `Infinity` are accepted; the deps are read once at `new Runner` into the copy the Runner keeps — a getter that answers 1024 and then `NaN` is held at 1024); `test/streaming.test.ts` RB-210 cells re-pinned (a text-only array over the cap is an array; framing alone rides as-is). Known limits: KL-1668-b, KL-1668-c; KL-1668-a closed in this release (the projection reads the leg's budget).
|
package/dist/core/gate-fold.js
CHANGED
|
@@ -5,6 +5,8 @@ import { normalizeReadRootCandidate } from "./read-root-candidate.js";
|
|
|
5
5
|
import { traceHookCrash } from "./gate-crash-trace.js";
|
|
6
6
|
import { writeProtectionMandated, writeProtectionTightenDecision } from "./write-protect.js";
|
|
7
7
|
import { createGateDeadline, awaitWriteProtectionJudgment, gateWaitFailureOf, withGateTimeout } from "./gate-bounded-await.js";
|
|
8
|
+
import { shellReadFaceWords } from "../tools/fs/shell-read-face.js";
|
|
9
|
+
import { failedJudgmentDeny } from "./runner/policy-throw.js";
|
|
8
10
|
import { thrownText } from "./thrown-value.js";
|
|
9
11
|
import { refuseOutOfContractDecision } from "./tool-policy.js";
|
|
10
12
|
import { applyUnansweredSilence, silencedBy } from "./shell-read-silence.js";
|
|
@@ -13,10 +15,16 @@ export async function runGateFold(pass) {
|
|
|
13
15
|
const { input, toolName, toolCallId, callSignal, ledger, notifier, adjudicate } = pass;
|
|
14
16
|
const boundedAwaitDeadlineMs = createGateDeadline(input, notifier);
|
|
15
17
|
pass.req = { toolName, args: pass.currentInput, toolCallId, ...(pass.req.face !== undefined ? { face: pass.req.face } : {}) };
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
pass.req
|
|
19
|
-
|
|
18
|
+
let unread;
|
|
19
|
+
try {
|
|
20
|
+
const stamped = input.stampCallTarget?.(pass.req, callSignal ?? input.abortSignal);
|
|
21
|
+
if (stamped !== undefined)
|
|
22
|
+
pass.req = await stamped;
|
|
23
|
+
}
|
|
24
|
+
catch (error) {
|
|
25
|
+
unread = { error };
|
|
26
|
+
}
|
|
27
|
+
pass.decision = unread !== undefined ? failedJudgmentDeny(unread.error, callSignal ?? input.abortSignal, (e) => traceHookCrash(input, e, notifier), "the call's target reading", { toolName, toolCallId }) : adjudicate ? refuseOutOfContractDecision(await (callSignal !== undefined ? adjudicate(pass.req, callSignal) : adjudicate(pass.req))) : { action: "allow" };
|
|
20
28
|
{
|
|
21
29
|
const read = ledger.fromDecision(pass.decision);
|
|
22
30
|
pass.decision = read.decision;
|
|
@@ -181,9 +189,10 @@ export async function runGateFold(pass) {
|
|
|
181
189
|
return await pass.afterFold(pass);
|
|
182
190
|
}
|
|
183
191
|
function shellReadAskMessage(toolName, verdict) {
|
|
192
|
+
const { verb, participle } = shellReadFaceWords(verdict.face);
|
|
184
193
|
if (verdict.finding !== "outside_roots")
|
|
185
|
-
return `tool "${toolName}"
|
|
186
|
-
return `tool "${toolName}" ${
|
|
194
|
+
return `tool "${toolName}" ${verb} where the session's read boundary could not establish what is ${participle} — explicit approval required`;
|
|
195
|
+
return `tool "${toolName}" ${verb} outside the directories allowed for this session — explicit approval required`;
|
|
187
196
|
}
|
|
188
197
|
function applyShellReadVerdict(pass, verdict, toolName, shellGated) {
|
|
189
198
|
const account = verdict.sentence !== undefined && verdict.sentence.trim().length > 0 ? inlineUntrusted(verdict.sentence, PROBE_REASON_MAX) : undefined;
|
package/dist/core/gate-lanes.js
CHANGED
|
@@ -21,7 +21,7 @@ import { exitGate, passAskFace } from "./runner/gate-exit.js";
|
|
|
21
21
|
import { readOrgRuleLane, readPersistedRuleLane } from "./rule-lane-reads.js";
|
|
22
22
|
import { traceHookCrash } from "./gate-crash-trace.js";
|
|
23
23
|
import { createGateDeadline, awaitWriteProtectionJudgment, withGateTimeout } from "./gate-bounded-await.js";
|
|
24
|
-
import {
|
|
24
|
+
import { policyThrowLine, reportPolicyThrow } from "./runner/policy-throw.js";
|
|
25
25
|
const QUESTION_TOOL_NAMES = new Set([ASK_USER_QUESTION_TOOL_NAME]);
|
|
26
26
|
function parkWith(suspendAsk, parkArgs, carry) {
|
|
27
27
|
return suspendAsk(...parkArgs, carry);
|
|
@@ -555,9 +555,10 @@ export async function runGateLanes(pass) {
|
|
|
555
555
|
recheck = refuseOutOfContractDecision(await (callSignal !== undefined ? adjudicate(editReq, callSignal) : adjudicate(editReq)));
|
|
556
556
|
}
|
|
557
557
|
catch (err) {
|
|
558
|
+
reportPolicyThrow((e) => traceHookCrash(input, e, notifier), "the tool policy's re-check of the approved edit", { toolName, toolCallId }, err, callSignal ?? input.abortSignal);
|
|
558
559
|
recheck = {
|
|
559
560
|
action: "deny",
|
|
560
|
-
message: `policy re-check of the approved edit for "${toolName}" errored: ${
|
|
561
|
+
message: `policy re-check of the approved edit for "${toolName}" errored: ${policyThrowLine(err)}`,
|
|
561
562
|
};
|
|
562
563
|
}
|
|
563
564
|
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A tool policy that THREW — the one form its failure takes on each of the two surfaces it reaches, for every seat that
|
|
3
|
+
* consults a policy chain (the gate's target reading, the adjudicate seat, the inherited-constraint station, the two
|
|
4
|
+
* re-checks of an approved edit).
|
|
5
|
+
*
|
|
6
|
+
* The MODEL reads one line of the thrown value inside the seat's deny: secret-shaped runs redacted, then folded to one line
|
|
7
|
+
* and capped (`inlineUntrusted`, which also neutralizes a reminder fence), so a policy's error text never carries a
|
|
8
|
+
* credential, a multi-kilobyte body or a forged frame into the conversation. The DEPLOYMENT hears the failure once, on the
|
|
9
|
+
* seat's hook-failure lane (`onError`, phase `hook`): an error whose message names the seat and the call and carries the
|
|
10
|
+
* class and the redacted text — and no `cause`, because a host's serializer walks a cause chain and the raw value would
|
|
11
|
+
* carry the unredacted text there.
|
|
12
|
+
*/
|
|
13
|
+
import type { PermissionResult } from "../tool-policy.js";
|
|
14
|
+
/** The model's line of a thrown policy value: redacted BEFORE the cap (a cut must never leave half a token unredacted),
|
|
15
|
+
* one line, at most {@link PROBE_REASON_MAX} code points; never empty. */
|
|
16
|
+
export declare function policyThrowLine(err: unknown): string;
|
|
17
|
+
/** The deny of a judgment whose wait the task's signal ended — a timeout or a cancellation, nothing the policy said. */
|
|
18
|
+
export declare function policyCheckAbortedDeny(): PermissionResult;
|
|
19
|
+
/** The deny of a judgment that FAILED (threw or rejected) while `signal` bounded its wait: the aborted deny when the signal had
|
|
20
|
+
* already ended the wait (a cancellation — not reported), else the policy's own throw — its sentence and one line of what it
|
|
21
|
+
* threw — reported once through {@link reportPolicyThrow}. A seat that raced the judgment against its signal has told the two
|
|
22
|
+
* apart already and passes no signal. */
|
|
23
|
+
export declare function failedJudgmentDeny(err: unknown, signal: AbortSignal | undefined, report: (err: Error) => void, seat: string, call: {
|
|
24
|
+
readonly toolName: string;
|
|
25
|
+
readonly toolCallId: string;
|
|
26
|
+
}): PermissionResult;
|
|
27
|
+
/** Hand the deployment, through `report` (the seat's hook-failure lane), the failure of the policy that threw while `seat`
|
|
28
|
+
* judged `call`. A rejection that arrives with the judgment's `signal` already aborted is the task ending mid-judgment — a
|
|
29
|
+
* cancellation, not a policy defect — and is not reported (the deny it produced stands either way). A report lane that
|
|
30
|
+
* throws is swallowed: the fail-closed deny is decided before the report and must not depend on the sink. */
|
|
31
|
+
export declare function reportPolicyThrow(report: (err: Error) => void, seat: string, call: {
|
|
32
|
+
readonly toolName: string;
|
|
33
|
+
readonly toolCallId: string;
|
|
34
|
+
}, err: unknown, signal?: AbortSignal): void;
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { thrownLabel, thrownText } from "../thrown-value.js";
|
|
2
|
+
import { redactSecrets } from "../untrusted-egress.js";
|
|
3
|
+
import { inlineUntrusted, PROBE_REASON_MAX } from "../untrusted-text.js";
|
|
4
|
+
export function policyThrowLine(err) {
|
|
5
|
+
const line = inlineUntrusted(redactSecrets(thrownText(err)), PROBE_REASON_MAX);
|
|
6
|
+
return line === "" ? "(no message)" : line;
|
|
7
|
+
}
|
|
8
|
+
export function policyCheckAbortedDeny() {
|
|
9
|
+
return { action: "deny", message: "policy check aborted (task timed out or cancelled)" };
|
|
10
|
+
}
|
|
11
|
+
export function failedJudgmentDeny(err, signal, report, seat, call) {
|
|
12
|
+
if (signal?.aborted === true)
|
|
13
|
+
return policyCheckAbortedDeny();
|
|
14
|
+
reportPolicyThrow(report, seat, call, err);
|
|
15
|
+
return { action: "deny", message: `policy check threw — the tool policy chain failed while judging this call (not a timeout or cancellation), so it was denied fail-closed: ${policyThrowLine(err)}` };
|
|
16
|
+
}
|
|
17
|
+
export function reportPolicyThrow(report, seat, call, err, signal) {
|
|
18
|
+
if (signal?.aborted === true)
|
|
19
|
+
return;
|
|
20
|
+
try {
|
|
21
|
+
report(new Error(`${seat} threw while judging "${call.toolName}" (toolCallId ${call.toolCallId}); the call was denied fail-closed: ${redactSecrets(thrownLabel(err))}`));
|
|
22
|
+
}
|
|
23
|
+
catch {
|
|
24
|
+
}
|
|
25
|
+
}
|
|
@@ -93,7 +93,8 @@ export interface PrepareAskLaneInput {
|
|
|
93
93
|
hooks: Hooks | undefined;
|
|
94
94
|
/** borrowed-readonly — the validated per-prepare hook seat bound (the deny observer's timeout). */
|
|
95
95
|
hookTimeoutMs: number;
|
|
96
|
-
/** borrowed-readonly — the own hook-
|
|
96
|
+
/** borrowed-readonly — the own hook-failure lane (`onError`, phase `hook`, swallow-guarded): the deny observer's report sink,
|
|
97
|
+
* the gate's crashing-PreToolUse sink and the adjudicate seat's report of a policy chain that threw. */
|
|
97
98
|
notifyOwnHookCrash: (err: unknown) => void;
|
|
98
99
|
/** borrowed-readonly — the hands phase's in-flight read-root widening (an approver's accepted `readRootGrant`), or undefined (hands-less). */
|
|
99
100
|
grantReadRoot: GrantReadRoot | undefined;
|
|
@@ -3,13 +3,13 @@ import { primaryActivityArg } from "../arg-summary.js";
|
|
|
3
3
|
import { hookSeatExpiredError, runHookSeat } from "../hooks.js";
|
|
4
4
|
import { advertisedWritableRootsSeat, exemptRootsSeat, attest, attestationOf, isLiveApproverSeat, resolveAsk } from "../tool-policy.js";
|
|
5
5
|
import { boundInputHashOf } from "../canonical-json.js";
|
|
6
|
-
import { composeCallSignal,
|
|
6
|
+
import { composeCallSignal, raceSettlementAgainstSignal } from "./abort-race.js";
|
|
7
|
+
import { failedJudgmentDeny, policyCheckAbortedDeny } from "./policy-throw.js";
|
|
7
8
|
import { askMandateMember } from "../ask-origin.js";
|
|
8
9
|
import { approvalCardMemberOf } from "../tool-roster.js";
|
|
9
10
|
import { gateAskCarry, lateAskSettlementObserver } from "./denial-limit-arms.js";
|
|
10
11
|
import { consumeInheritedAskGrant } from "./inherited-ask-grants.js";
|
|
11
12
|
import { settleReadRootGrant } from "./read-root-grant.js";
|
|
12
|
-
import { asThrownError } from "../thrown-value.js";
|
|
13
13
|
function createDenyObserverNotifier(hooks, hookTimeoutMs, signal, report) {
|
|
14
14
|
if (hooks?.permissionDenied === undefined)
|
|
15
15
|
return undefined;
|
|
@@ -66,14 +66,19 @@ export function prepareAskLane(input) {
|
|
|
66
66
|
return { askLane: undefined };
|
|
67
67
|
const composedCallSignal = (callSignal) => composeCallSignal(abortController.signal, callSignal);
|
|
68
68
|
const adjudicate = effectivePolicy
|
|
69
|
-
? (req, callSignal) => {
|
|
69
|
+
? async (req, callSignal) => {
|
|
70
70
|
const signal = composedCallSignal(callSignal);
|
|
71
71
|
const stampedReq = () => ({ ...req, budget: budgetSnapshot, ...(handsCwdRef !== undefined ? { cwd: handsCwdRef.current } : {}), ...(handsRoot !== undefined ? { root: handsRoot } : {}), ...advertisedWritableRootsSeat(advertisedWritableDirs), ...exemptRootsSeat(exemptRoots) });
|
|
72
|
-
const
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
72
|
+
const judged = (async () => {
|
|
73
|
+
const withReading = stampCallTarget?.(stampedReq(), signal);
|
|
74
|
+
return withReading === undefined ? effectivePolicy.check(stampedReq(), signal) : effectivePolicy.check(await withReading, signal);
|
|
75
|
+
})();
|
|
76
|
+
const raced = await raceSettlementAgainstSignal(judged, signal);
|
|
77
|
+
if (raced.tag === "value")
|
|
78
|
+
return raced.value;
|
|
79
|
+
if (raced.tag === "aborted")
|
|
80
|
+
return policyCheckAbortedDeny();
|
|
81
|
+
return failedJudgmentDeny(raced.error, undefined, notifyOwnHookCrash, "the tool policy chain", req);
|
|
77
82
|
}
|
|
78
83
|
: undefined;
|
|
79
84
|
const approvalPreviewOf = (toolName, args) => resolveApprovalPreview(tools, toolName, args);
|
|
@@ -138,12 +143,5 @@ export function prepareAskLane(input) {
|
|
|
138
143
|
return resolved;
|
|
139
144
|
};
|
|
140
145
|
const notifyPermissionDenied = createDenyObserverNotifier(hooks, hookTimeoutMs, abortController.signal, notifyOwnHookCrash);
|
|
141
|
-
|
|
142
|
-
try {
|
|
143
|
-
deps.onError?.(asThrownError(err), { phase: "hook", sessionId });
|
|
144
|
-
}
|
|
145
|
-
catch {
|
|
146
|
-
}
|
|
147
|
-
};
|
|
148
|
-
return { askLane: { composedCallSignal, adjudicate, approvalPreviewOf, resolveAskBound, notifyPermissionDenied, notifyHookError } };
|
|
146
|
+
return { askLane: { composedCallSignal, adjudicate, approvalPreviewOf, resolveAskBound, notifyPermissionDenied, notifyHookError: notifyOwnHookCrash } };
|
|
149
147
|
}
|
|
@@ -23,7 +23,7 @@ import { settleReadRootGrant } from "./read-root-grant.js";
|
|
|
23
23
|
import { askGrantShapeOf } from "./inherited-ask-grants.js";
|
|
24
24
|
import { createPermissionRuleLanes, inheritedAskRuleEvidence, createRuleOffersOf } from "./permission-rule-lanes.js";
|
|
25
25
|
import { createLiveSessionRules, createSessionRulePolicy } from "./session-rule-policy.js";
|
|
26
|
-
import {
|
|
26
|
+
import { policyThrowLine, reportPolicyThrow } from "./policy-throw.js";
|
|
27
27
|
import { heldBy, heldValues, seatOf, wireOf } from "../tool-name-readings.js";
|
|
28
28
|
export async function preparePolicyChain(input) {
|
|
29
29
|
const { spec, deps, internals, sessionId, runId, hostTaskId, taskRootFinal, additionalRootsCanonical, executionEnv, lockedPreflight, pathRuleAnchors, headLeads, tools, harnessTools, toolEffects, egressTools, irreversibilityTier, axisExplicitNegatives, shellGatedTools, toolMarkSources, envHandToolNames, handsCwdRef, readDenyMatcher, personRows, personRowsBeyondRun, questionToolMounted, runtimeCaps, inheritedAncestorRules, ancestorSessionRulesRef, inheritedParentConstraints, seedInheritedGate, skillGrants, skillSpecs, allowedToolTables, frozenOnAsk, hookEnvSource, notifyOwnHookCrash, autoModeDecider, readFace, permissionMode, scratchpadDir, toolRosterDeltas, stopForDenialLimit, delegation, abortController, now, ownSessionRulesRef } = input;
|
|
@@ -372,9 +372,10 @@ export async function preparePolicyChain(input) {
|
|
|
372
372
|
re = refuseOutOfContractDecision(await pol.check(editReq !== undefined ? await editReq : { ...creq, args: editArgs }, csignal ?? abortController.signal));
|
|
373
373
|
}
|
|
374
374
|
catch (err) {
|
|
375
|
+
reportPolicyThrow(notifyOwnHookCrash, "an inherited parent policy's re-check of the approved edit", creq, err, csignal ?? abortController.signal);
|
|
375
376
|
return vetoed({
|
|
376
377
|
action: "deny",
|
|
377
|
-
message: `inherited parent policy re-check of the approved edit errored for "${creq.toolName}": ${
|
|
378
|
+
message: `inherited parent policy re-check of the approved edit errored for "${creq.toolName}": ${policyThrowLine(err)}`,
|
|
378
379
|
});
|
|
379
380
|
}
|
|
380
381
|
if (re.action === "deny")
|
|
@@ -513,9 +514,10 @@ export async function preparePolicyChain(input) {
|
|
|
513
514
|
decision = refuseOutOfContractDecision(await pc.policy.check(creq, csignal ?? abortController.signal));
|
|
514
515
|
}
|
|
515
516
|
catch (err) {
|
|
517
|
+
reportPolicyThrow(notifyOwnHookCrash, "an inherited parent policy", creq, err, csignal ?? abortController.signal);
|
|
516
518
|
return {
|
|
517
519
|
action: "deny",
|
|
518
|
-
message: `inherited parent policy errored for "${creq.toolName}": ${
|
|
520
|
+
message: `inherited parent policy errored for "${creq.toolName}": ${policyThrowLine(err)}`,
|
|
519
521
|
};
|
|
520
522
|
}
|
|
521
523
|
if (decision.action !== "ask")
|
|
@@ -9,6 +9,7 @@ import { planShellReads } from "./shell-read-plan.js";
|
|
|
9
9
|
import { createShellReadEstablisher, establishShellReadsLexically } from "./shell-read-establish.js";
|
|
10
10
|
import { declaredReadDenyHomes, homeViewsAt } from "./read-deny-home.js";
|
|
11
11
|
import { anyOptionWord, programArgs } from "./shell-read-write-targets.js";
|
|
12
|
+
import { shellReadFaceWords } from "./shell-read-face.js";
|
|
12
13
|
import { passesUnasked } from "./shell-read-device.js";
|
|
13
14
|
import { programRow, selectedOptions } from "./program-read-model.js";
|
|
14
15
|
import { allFindings, anyFaceGuards, decideShellRead, denyRowAtOrBelow, foldShellReadFindings, judgeEstablishedPlan, readRootGrantCandidateOf, stationGuards, writeFaceGuards, } from "./shell-read-judge.js";
|
|
@@ -199,31 +200,36 @@ const CAUSE_SPELLING_MAX = 64;
|
|
|
199
200
|
function fitCause(cause) {
|
|
200
201
|
return cause.replace(/^"([^"]*)"/, (_m, spelling) => `"${fitMiddle(spelling, CAUSE_SPELLING_MAX)}"`);
|
|
201
202
|
}
|
|
203
|
+
function faceOf(fold) {
|
|
204
|
+
return fold.witness?.read.candidate.face === "write" ? "write" : undefined;
|
|
205
|
+
}
|
|
202
206
|
function findingAccount(fold, boundary) {
|
|
203
207
|
const w = fold.witness;
|
|
204
|
-
const
|
|
208
|
+
const face = faceOf(fold);
|
|
209
|
+
const { verb } = shellReadFaceWords(face);
|
|
210
|
+
const writes = w !== undefined && face === "write";
|
|
205
211
|
const roots = writes ? (boundary.writeRoots ?? []) : boundary.roots;
|
|
206
212
|
const allowed = fitMiddle(roots.length > 0 ? roots.join(", ") : "(none)", ACCOUNT_PART_MAX);
|
|
207
213
|
switch (fold.finding) {
|
|
208
214
|
case "outside_roots":
|
|
209
|
-
return `${
|
|
215
|
+
return `${verb} outside the allowed directories for this session: "${fitMiddle(w?.read.target ?? "?", ACCOUNT_PART_MAX)}" is not under ${allowed} ${NOT_AUTO_ALLOWED}`;
|
|
210
216
|
case "unestablishable":
|
|
211
217
|
case "unanswered":
|
|
212
218
|
if (writes && boundary.writeRoots === undefined)
|
|
213
|
-
return `this command
|
|
219
|
+
return `this command ${verb} "${fitMiddle(w.read.target ?? w.read.candidate.spelled, ACCOUNT_PART_MAX)}", and this session declares no directories it may write ${NOT_AUTO_ALLOWED}`;
|
|
214
220
|
if (writes)
|
|
215
|
-
return `where this command
|
|
221
|
+
return `where this command ${verb} could not be established${w.read.cause !== undefined ? `: ${fitCause(w.read.cause)}` : ""}, so it cannot be confirmed to write only inside the allowed directories for this session (${allowed}) ${NOT_AUTO_ALLOWED}`;
|
|
216
222
|
if (w?.read.cause !== undefined)
|
|
217
|
-
return `where this command
|
|
223
|
+
return `where this command ${verb} could not be established: ${fitCause(w.read.cause)}, so it cannot be confirmed ${boundary.face === "open" ? "not to read a path the deny list names" : `to read only inside the allowed directories for this session (${allowed})`} ${NOT_AUTO_ALLOWED}`;
|
|
218
224
|
if (fold.cause !== undefined)
|
|
219
|
-
return `where this command
|
|
225
|
+
return `where this command ${verb} could not be established: ${fitCause(fold.cause)}, so it cannot be confirmed ${boundary.face === "open" ? "not to read a path the deny list names" : `to read only inside the allowed directories for this session (${allowed})`} ${NOT_AUTO_ALLOWED}`;
|
|
220
226
|
if (fold.declined?.baseMove !== undefined)
|
|
221
227
|
return `"${fitMiddle(fold.declined.text.trim(), ACCOUNT_PART_MAX)}" moves the working directory somewhere this command's text does not settle ${NOT_AUTO_ALLOWED}`;
|
|
222
228
|
if (fold.declined?.unreadProgramText !== undefined)
|
|
223
|
-
return `"${fitMiddle(fold.declined.text.trim(), ACCOUNT_PART_MAX)}": ${fold.declined.unreadProgramText}, so where it
|
|
229
|
+
return `"${fitMiddle(fold.declined.text.trim(), ACCOUNT_PART_MAX)}": ${fold.declined.unreadProgramText}, so where it ${verb} cannot be established ${NOT_AUTO_ALLOWED}`;
|
|
224
230
|
if (fold.declined !== undefined)
|
|
225
231
|
return `"${fitMiddle(fold.declined.text.trim(), ACCOUNT_PART_MAX)}" runs its argument as shell text this station does not parse ${NOT_AUTO_ALLOWED}`;
|
|
226
|
-
return `the command's text could not be read as shell, so where it
|
|
232
|
+
return `the command's text could not be read as shell, so where it ${verb} cannot be established ${NOT_AUTO_ALLOWED}`;
|
|
227
233
|
case "declined":
|
|
228
234
|
return fold.declined !== undefined ? declineSentence(fold.declined) : `the station could not read which program runs ${NOT_AUTO_ALLOWED}`;
|
|
229
235
|
case "identity_changed":
|
|
@@ -285,7 +291,8 @@ function verdictOf(tool, seat, fold, judged, boundary) {
|
|
|
285
291
|
return { disposition, finding: fold.finding };
|
|
286
292
|
const paths = outsidePathsOf(judged);
|
|
287
293
|
const unestablished = fold.unestablished === true ? { unestablished: true } : {};
|
|
288
|
-
const
|
|
294
|
+
const face = faceOf(fold);
|
|
295
|
+
const writes = face !== undefined ? { face } : {};
|
|
289
296
|
const by = fold.finding === "unanswered" ? { environmentSilent: true } : {};
|
|
290
297
|
if (disposition === "refuse") {
|
|
291
298
|
const r = refusalOf(tool, fold, boundary, paths);
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The words a shell-read finding is said in, by the FACE it is about — the ONE choice of verb the read station's account
|
|
3
|
+
* (what the approval card discloses and the refusal states) and the gate's approval card sentence both take, so one finding
|
|
4
|
+
* is never "writes" on one surface and "reads" on the other. A finding whose witness is a write target
|
|
5
|
+
* (`ShellReadVerdict.face === "write"`) is said as a write; every other finding — a read's, the text's own structure —
|
|
6
|
+
* as a read. A leaf: the station and the gate both import it, and it imports nothing.
|
|
7
|
+
*/
|
|
8
|
+
export interface ShellReadFaceWords {
|
|
9
|
+
/** The claim's verb: `this command writes …`, `tool "Bash" reads …`. */
|
|
10
|
+
readonly verb: "writes" | "reads";
|
|
11
|
+
/** Its object's participle: `… could not establish what is written / read`. */
|
|
12
|
+
readonly participle: "written" | "read";
|
|
13
|
+
}
|
|
14
|
+
/** The words of a finding on `face` (absent ⇒ a read's). */
|
|
15
|
+
export declare function shellReadFaceWords(face: "write" | undefined): ShellReadFaceWords;
|
package/package.json
CHANGED
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
{
|
|
2
|
+
"repo": "core",
|
|
3
|
+
"version": "7.50.1",
|
|
4
|
+
"from": "58268b78d35cf5de2ac1ed91a537f079c96c9e86",
|
|
5
|
+
"to": "ea511aa66e9208e20862272624bafb4d4654c805",
|
|
6
|
+
"added": [],
|
|
7
|
+
"removed": [],
|
|
8
|
+
"changed": [],
|
|
9
|
+
"narrowed": [],
|
|
10
|
+
"closedSetMembers": [],
|
|
11
|
+
"closedSetsUnreadable": [],
|
|
12
|
+
"typeKeys": [],
|
|
13
|
+
"typeKeysUnreadableArms": [],
|
|
14
|
+
"typeKeysFlattenedCount": 229,
|
|
15
|
+
"signatures": [],
|
|
16
|
+
"declarationMoves": [],
|
|
17
|
+
"mintMoves": [],
|
|
18
|
+
"criteriaObserves": [
|
|
19
|
+
{
|
|
20
|
+
"kind": "taskEventField",
|
|
21
|
+
"name": "tool_end.gate",
|
|
22
|
+
"assertions": [
|
|
23
|
+
"G1.c"
|
|
24
|
+
]
|
|
25
|
+
},
|
|
26
|
+
{
|
|
27
|
+
"kind": "taskEventField",
|
|
28
|
+
"name": "tool_end.output",
|
|
29
|
+
"assertions": [
|
|
30
|
+
"G1.a",
|
|
31
|
+
"G2.a"
|
|
32
|
+
]
|
|
33
|
+
},
|
|
34
|
+
{
|
|
35
|
+
"kind": "typeMember",
|
|
36
|
+
"name": "AskRequest.message",
|
|
37
|
+
"assertions": [
|
|
38
|
+
"G3.a"
|
|
39
|
+
]
|
|
40
|
+
},
|
|
41
|
+
{
|
|
42
|
+
"kind": "typeMember",
|
|
43
|
+
"name": "AskRequest.probeReason",
|
|
44
|
+
"assertions": [
|
|
45
|
+
"G3.b"
|
|
46
|
+
]
|
|
47
|
+
},
|
|
48
|
+
{
|
|
49
|
+
"kind": "typeMember",
|
|
50
|
+
"name": "RunnerDeps.onError",
|
|
51
|
+
"assertions": [
|
|
52
|
+
"G1.b",
|
|
53
|
+
"G2.b"
|
|
54
|
+
]
|
|
55
|
+
}
|
|
56
|
+
],
|
|
57
|
+
"consumersHint": {
|
|
58
|
+
"cli": [],
|
|
59
|
+
"server": [],
|
|
60
|
+
"sdk": [],
|
|
61
|
+
"note": "scanned cli@a1c8abf4 server@9786dd3a sdk@a32bca5 — a name listed is MENTIONED there today, which is evidence of consumption, not proof of it"
|
|
62
|
+
},
|
|
63
|
+
"breakingCount": 0,
|
|
64
|
+
"breakingItems": []
|
|
65
|
+
}
|