@sema-agent/core 7.24.3 → 7.24.4
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 +23 -1
- package/dist/core/permission-rules.js +14 -10
- package/dist/core/rule-denial-sentence.d.ts +75 -0
- package/dist/core/rule-denial-sentence.js +16 -0
- package/dist/core/runner/prepare-policy-chain.js +30 -20
- package/dist/core/runner/run-leg.js +2 -1
- package/dist/core/runner/session-rule-policy.d.ts +74 -2
- package/dist/core/runner/session-rule-policy.js +113 -21
- package/dist/orchestration/workflow.js +12 -3
- package/package.json +1 -1
- package/wire-changes/core@7.24.4.json +54 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,6 +1,28 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
-
##
|
|
3
|
+
## 7.24.4 — 2026-09-20
|
|
4
|
+
|
|
5
|
+
### Every rule source is re-read at every gate (#978 ①; P2; behavior-face widening of when a session rule binds; no wire key; @server @cli @test)
|
|
6
|
+
- **Before.** `sessionPolicyStore.getRules` was read ONCE, at prepare, and the compiled rules were frozen for the leg; only the ANCESTOR snapshots were re-read live, and only at prepare too. The permission-rule store, meanwhile, is read at every adjudicated call. So a deployment that wrote a `deny` to a session's policy row while the run was going — the ordinary shape of an operator narrowing a task already in flight — watched the run's next calls execute anyway; the deny bound the NEXT run. Measured downstream: the row was written at the first `tool_end` and 3/3 later calls of that tool still ran. Worse, a session with NO row at prepare composed no layer at all, so a session's FIRST rule could never bind mid-run whatever the read tempo.
|
|
7
|
+
- **After — one law, both stores.** A session-rule layer takes its rules from a `SessionRuleSource` at EVERY gate. The prepare read is a SEED, not the reading: it is what the prepare-time policy-name audit sees and the floor a live read may never loosen. The layer is composed whenever the store is wired for the session, including when the row is empty (an empty seed constrains nothing, and a layer that was never composed cannot re-read anything). The ancestor lane uses the same reader rather than its own hand-written live-preferring read.
|
|
8
|
+
- **Direction, unchanged.** Whole-row replacement at `live.rev >= floor.rev` — an operator's AUTHORIZED change propagates in both directions (tighten and relax). What the floor forbids is an UNAUTHORIZED loosening: a lagging replica, a deleted row and an unreachable store each enforce the floor instead. The floor RATCHETS to the highest-rev row the layer has served, so a row tightened mid-run and then unreadable cannot fall back past the tightening.
|
|
9
|
+
- **Cost and loudness.** One store read per adjudicated call per layer, shared inside one call by the same bounded per-call handoff the permission-rule lanes use (first reader hands, second claims). That handoff has a real firing path here: `policyLayers` re-appends the narrowing layers AFTER a rewrite-capable caller layer, so one adjudication asks the layer twice under one id and both passes must judge one set of rules. A source that cannot be read discloses once per distinct reason through `onError` (`phase: "config"`, `code: "config.session_rules_unreadable"`) — the advisory channel the policy-name audit already uses, not a new notice code.
|
|
10
|
+
- **The read is BOUNDED.** This is the only I/O the narrowing fold performs, and the resumed-execution path awaits that fold with a consumed approval already claimed — a store that never settled would strand the call rather than refuse it. The read now rides `withGateTimeout` on the gate's own signal and the tree's default deadline; an elapsed or aborted wait takes the same fail-closed arm as a throwing store (enforce the floor, disclose once).
|
|
11
|
+
- **A vanished row ends its rev line.** `rev` counts within ONE row's lifetime — both bundled backends restart it at 1 after a delete, and the tighten gate has no prior row to judge the first write against, so a recreated row's rev can be climbed past an enforced floor by ordinary non-operator writes (measured: enforce rev 2, delete, three `{}` writes, rev 3 replaces the restriction). Once a row this layer served is observed GONE, nothing that appears afterwards is comparable to it and the floor is final for the leg. COST: a legitimate delete-then-recreate is not picked up until the next run, and a backend that answers "no row" transiently ends the line early — both conservative. A session that simply never had a row is unaffected and silent (absence is not a deletion).
|
|
12
|
+
- **Five bounds an adversarial round added.** (a) A live row replaces the floor only on a STRICTLY GREATER `rev`: both bundled backends compute `rev` as `(prior?.rev ?? 0) + 1`, so a DELETED-and-recreated row restarts at 1, and at `>=` a recreated row that merely tied the enforced one would replace it — an unauthorized loosening wearing an update's shape. A tie now enforces the floor (a no-op for the honest case, where equal rev is the same row). This raises the cost of the recycle rather than closing it — the rev can still be climbed past the floor by repeated writes, because the tighten gate has no prior row to judge against after a delete; closing it belongs to the STORE's rev lifecycle (a counter that survives deletion, or a generation identity) and is a cross-backend contract change. (b) A handed read is CLAIMED only while its `rev` still stands at the enforced floor: `toolCallId` is not always unique (the turn-boundary read-visibility query reuses one synthetic id for every path and every turn), and a claim from a later adjudication would otherwise walk back past a tightening the layer already applied. (c) The enforced row is published for the child chain from the same place it is enforced, each time the floor advances — so a rule written mid-run reaches a child spawned after it, and a child whose own store read fails falls back to its parent's CURRENT floor rather than an older one. The empty seed is never published (it is not a row a child may inherit).
|
|
13
|
+
- **Reach.** `createSessionRulePolicy`'s first parameter widens to `SessionPermissionRules | SessionRuleSource` (additive; a plain rules object is still the constant source and every existing caller is unchanged). No wire key, code or closed-set member moves. RESIDUAL, recorded: a child spawned BEFORE the session's first row exists inherits no ancestor entry — seeding an empty one would put an `ancestorRules` row on every delegated child of every store-bearing deployment, which other readers take to mean "this leg is delegated". `test/backlog978-session-rules-every-gate.test.ts` pins the mid-run first deny, the mid-run tighten, the authorized relaxation, the three fail-closed arms (store down, lagging replica, deleted row), the ancestor lane, the read count, the handoff, the rev-recycle / claim-revalidation / vanished-row / bounded-read arms, the child-chain publication, and the two no-rule controls; 8 of the 10 Runner-level ones are red on 7.24.2.
|
|
14
|
+
|
|
15
|
+
### One sentence for "a rule refused this call" (#978 ②; P2; refusal TEXT change on two lanes; no wire key; @cli @server @test)
|
|
16
|
+
- **Before.** Two rule stores can refuse a call and each spelled it its own way: `tool "X" is denied by a session rule`, `denied by permission rule "R" (layer)`, plus four further shapes for the session lane's containment arms (`write-capable tool "X" denied: …`, `write to "p" is outside …`), and a session `commandDeny` that came out as the coarse policy's generic `command "curl" is denied by policy`. The resumed-execution re-check asserted `a session rule` for a fold that also holds inherited ancestor rules, the frozen constraint projections and the skill scope. A consumer could not read which store had spoken — the confusion that produced the wrong answer to "is the rule store re-read at every gate?".
|
|
17
|
+
- **After.** `src/core/rule-denial-sentence.ts` mints them all: `[tool "<tool>" is ]denied by <source>[ "<rule>"][ (<origin>)][: <detail>]`, over a closed source vocabulary derived from its own phrase table (`session` / `permission` / `narrowing`). The seven session-lane exits now differ only in the `detail` that says WHY; the session `commandDeny` refusal is re-minted as the session lane's own, carrying the coarse policy's sentence as its detail; the resumed re-check names the fold instead of one of its layers.
|
|
18
|
+
- **What a reader keyed on the old text sees.** The permission lane's sentence gained a head and kept its tail byte-for-byte (`denied by permission rule "Bash(rm -r:*)" (settings.permissions.deny)` still `toContain`s). The session lane's plain tool-name denial is unchanged. The containment arms and the allowlist miss are REWORDED and keep their diagnostic substrings (`cannot be path-confined`, `declares no path target`, `no resolvable path`, `outside the session-rule allowDirs`, `not in the session-rule allowlist`). The ASK sentence (`flagged by permission rule …`) is deliberately untouched: an ask is a question on a card, not a refusal in a tool result.
|
|
19
|
+
- **Reach.** No wire key, code, closed-set member or export moves; `PermissionResult.message` is a rendered string and this is a rendering change. `@cli` renders these in tool results — a fence pinned on a session-lane containment wording should re-pin on the substrings above. `test/backlog978-rule-denial-sentence.test.ts` pins the grammar, the two lanes' agreement on it, every session exit, the mint's composition, and a source scan holding the denial spellings to one module (its ceiling is stated: code lines only, and the ask / non-rule-store `denied by policy` faces are out of scope).
|
|
20
|
+
|
|
21
|
+
### A workflow agent's fleet row now says which attempt it is on (#979; P2; presence change on an existing wire key, additive; no new key, no export; @client-core @server)
|
|
22
|
+
- **Before.** `BackgroundChildEvent.seq` is contracted as "present whenever a cycle number is knowable, absent when no carrier exists". The `a*` delegation lanes have carried it on spawn / tick / terminal since #258; the `wa*` workflow lane carried it on NONE of its three frames — while it has held a carrier all along: the orchestration layer's stall/throttle retry re-runs the same logical agent on a fresh session under the SAME `wa*` id, which is exactly the "one row, two cycles" shape the field exists to separate. A fleet consumer folding rows by generation had a structurally unreachable third branch on this lane.
|
|
23
|
+
- **After.** The `wa*` row carries a generation: a fresh spawn is `1`, and each retry of the same agent bumps it, so a tick or terminal says which attempt produced it. The number is the ROW's, taken at emit — never copied from a forwarded descendant's frame, the same rule `agentType` and `model` already follow on this lane.
|
|
24
|
+
- **The carrier is the ATTEMPT counter, not the session rebind.** A retry also rebinds the row's transcript coordinate, and today the two move together — but the coordinate is a different fact (it is skipped for a spec shape that mints no id, and once the run is finalized), so counting rebinds would make the generation a property of whether a coordinate existed.
|
|
25
|
+
- **Reach.** `seq` is now ALWAYS present on a `wa*` row, where it was always absent; a consumer that read that absence as "this lane has no generation" must now read the number, and an absent `seq` on a `wa*` row dates the producer as pre-7.24.4. `task_progress.seq` is UNCHANGED and still absent for workflow agents (no `a*` registry row). `@client-core` B4 folds rows by generation; `@server` relays the frame unchanged; `@cli` has no `wa*` fleet renderer. `docs/wire-consumption/unreleased-979.md` carries the table, including the disclosure that one Workflow fan-out's children all share ONE `parentToolCallId` (up to `maxAgents`, default 50) — `seq` separates one row's cycles, not two rows. `test/backlog979-workflow-bce-seq.test.ts` pins the six readings with a five-row mutation table.
|
|
4
26
|
|
|
5
27
|
## 7.24.3 — 2026-09-20
|
|
6
28
|
|
|
@@ -5,6 +5,7 @@ import { PATH_RULE_BASE_LABEL, isUsablePathBase, parseRuleText, pathRuleReachOf,
|
|
|
5
5
|
import { pathFamilyOf } from "../tools/fs/safety.js";
|
|
6
6
|
import { effectivePathTargetOf } from "./effective-path-target.js";
|
|
7
7
|
import { protectivePathTargetOf } from "./tool-registry.js";
|
|
8
|
+
import { ruleDenialSentence } from "./rule-denial-sentence.js";
|
|
8
9
|
export { parsePermissionRule };
|
|
9
10
|
export const BASH_GENERIC_PARAMS = new Set(catalogRuleFaceOf("Bash")?.params ?? []);
|
|
10
11
|
const DEFAULT_CAPS = {
|
|
@@ -267,8 +268,11 @@ function matchParamRules(rules, args, maxScalarValueChars) {
|
|
|
267
268
|
}
|
|
268
269
|
return undefined;
|
|
269
270
|
}
|
|
270
|
-
function
|
|
271
|
-
return
|
|
271
|
+
function ruleDenial(req, ruleText, source) {
|
|
272
|
+
return ruleDenialSentence({ source: "permission", tool: req.toolName, rule: ruleText, ...(source ? { origin: source } : {}) });
|
|
273
|
+
}
|
|
274
|
+
function ruleAskMessage(ruleText, source) {
|
|
275
|
+
return `flagged by permission rule "${ruleText}"${source ? ` (${source})` : ""}`;
|
|
272
276
|
}
|
|
273
277
|
export function createPermissionRulePolicy(rules, opts) {
|
|
274
278
|
const caps = { ...DEFAULT_CAPS, ...opts?.caps };
|
|
@@ -357,45 +361,45 @@ export function createPermissionRulePolicy(rules, opts) {
|
|
|
357
361
|
const entry = byTool.get(req.toolName);
|
|
358
362
|
const covering = hasCovering && protocolOf(req.toolName) !== undefined;
|
|
359
363
|
if (entry?.bare.deny) {
|
|
360
|
-
return { action: "deny", message:
|
|
364
|
+
return { action: "deny", message: ruleDenial(req, entry.bare.deny.ruleText, entry.bare.deny.source) };
|
|
361
365
|
}
|
|
362
366
|
if (covering) {
|
|
363
367
|
const coveringDeny = coveringHit(namespacedCovering.deny, req.toolName);
|
|
364
368
|
if (coveringDeny)
|
|
365
|
-
return { action: "deny", message:
|
|
369
|
+
return { action: "deny", message: ruleDenial(req, coveringDeny.ruleText, coveringDeny.source) };
|
|
366
370
|
}
|
|
367
371
|
const contentDeny = entry !== undefined ? contentHit(entry.content.deny, req) : undefined;
|
|
368
372
|
if (contentDeny !== undefined && contentDeny.unreadable === undefined) {
|
|
369
|
-
return { action: "deny", message:
|
|
373
|
+
return { action: "deny", message: ruleDenial(req, contentDeny.rule.ruleText, contentDeny.rule.source) };
|
|
370
374
|
}
|
|
371
375
|
if (entry) {
|
|
372
376
|
const paramDeny = matchParamRules(entry.param.deny, req.args, caps.maxScalarValueChars);
|
|
373
377
|
if (paramDeny) {
|
|
374
|
-
return { action: "deny", message:
|
|
378
|
+
return { action: "deny", message: ruleDenial(req, paramDeny.ruleText, paramDeny.source) };
|
|
375
379
|
}
|
|
376
380
|
}
|
|
377
381
|
if (contentDeny?.unreadable !== undefined)
|
|
378
382
|
return unreadableAsk({ rule: contentDeny.rule, unreadable: contentDeny.unreadable }, req);
|
|
379
383
|
if (entry) {
|
|
380
384
|
if (entry.bare.ask) {
|
|
381
|
-
return { action: "ask", message:
|
|
385
|
+
return { action: "ask", message: ruleAskMessage(entry.bare.ask.ruleText, entry.bare.ask.source), matchedAskRule: entry.bare.ask.ruleText };
|
|
382
386
|
}
|
|
383
387
|
}
|
|
384
388
|
if (covering) {
|
|
385
389
|
const coveringAsk = coveringHit(namespacedCovering.ask, req.toolName);
|
|
386
390
|
if (coveringAsk)
|
|
387
|
-
return { action: "ask", message:
|
|
391
|
+
return { action: "ask", message: ruleAskMessage(coveringAsk.ruleText, coveringAsk.source), matchedAskRule: coveringAsk.ruleText };
|
|
388
392
|
}
|
|
389
393
|
if (entry) {
|
|
390
394
|
const contentAsk = contentHit(entry.content.ask, req);
|
|
391
395
|
if (contentAsk !== undefined) {
|
|
392
396
|
if (contentAsk.unreadable !== undefined)
|
|
393
397
|
return unreadableAsk({ rule: contentAsk.rule, unreadable: contentAsk.unreadable }, req);
|
|
394
|
-
return { action: "ask", message:
|
|
398
|
+
return { action: "ask", message: ruleAskMessage(contentAsk.rule.ruleText, contentAsk.rule.source), matchedAskRule: contentAsk.rule.ruleText };
|
|
395
399
|
}
|
|
396
400
|
const paramAsk = matchParamRules(entry.param.ask, req.args, caps.maxScalarValueChars);
|
|
397
401
|
if (paramAsk) {
|
|
398
|
-
return { action: "ask", message:
|
|
402
|
+
return { action: "ask", message: ruleAskMessage(paramAsk.ruleText, paramAsk.source), matchedAskRule: paramAsk.ruleText };
|
|
399
403
|
}
|
|
400
404
|
if (entry.bare.allow) {
|
|
401
405
|
return { action: "allow" };
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The ONE mint for "a rule refused this call".
|
|
3
|
+
*
|
|
4
|
+
* WHY IT EXISTS. The engine has two rule stores that can refuse a tool call — the per-session policy rows
|
|
5
|
+
* a deployment writes against a session id, and the declarative permission rules compiled from a settings
|
|
6
|
+
* layer — and each used to spell its refusal in its own grammar at its own site: `tool "X" is denied by a
|
|
7
|
+
* session rule` beside `denied by permission rule "R" (layer)`, with the session lane's four containment
|
|
8
|
+
* arms in three further shapes and the resumed-execution re-check asserting a source ("a session rule")
|
|
9
|
+
* that was only ever one of the narrowing layers it speaks for. A consumer reading those strings could not
|
|
10
|
+
* tell WHICH store had spoken, which is precisely the confusion that produced the wrong answer to "is the
|
|
11
|
+
* rule store re-read at every gate?" — the two stores were being described as one.
|
|
12
|
+
*
|
|
13
|
+
* So: one grammar, one vocabulary of sources, one place to change either.
|
|
14
|
+
*/
|
|
15
|
+
/**
|
|
16
|
+
* The closed vocabulary of rule sources a refusal may name, each with the two spellings the sentence needs:
|
|
17
|
+
* `bare` when the source has no rule text to quote, `named` when it does. The type is DERIVED from this
|
|
18
|
+
* table, so a source without its spellings is not expressible and no fence is needed to say so.
|
|
19
|
+
*
|
|
20
|
+
* The words are the ENGINE'S rule-bearing faces, not a settings layer's (`source`/`origin` are different
|
|
21
|
+
* questions: WHICH STORE spoke, versus WHERE in that store's layering the row was written — the second
|
|
22
|
+
* rides `origin`).
|
|
23
|
+
*/
|
|
24
|
+
declare const RULE_SOURCE_PHRASE: {
|
|
25
|
+
/** The per-session policy rows (`SessionPolicyStore`), folded in as a deny-narrowing layer. */
|
|
26
|
+
readonly session: {
|
|
27
|
+
readonly bare: "a session rule";
|
|
28
|
+
readonly named: "session rule";
|
|
29
|
+
};
|
|
30
|
+
/** The declarative permission rules compiled from a deployment's settings layers. */
|
|
31
|
+
readonly permission: {
|
|
32
|
+
readonly bare: "a permission rule";
|
|
33
|
+
readonly named: "permission rule";
|
|
34
|
+
};
|
|
35
|
+
/**
|
|
36
|
+
* The deny-narrowing fold as a WHOLE — session rules, inherited ancestor rules, the frozen constraint
|
|
37
|
+
* projections and the active skill scope. Used where the refusing layer's own sentence is carried as the
|
|
38
|
+
* detail and the wrapper must not claim to know which of them spoke.
|
|
39
|
+
*/
|
|
40
|
+
readonly narrowing: {
|
|
41
|
+
readonly bare: "a narrowing policy layer";
|
|
42
|
+
readonly named: "narrowing policy layer";
|
|
43
|
+
};
|
|
44
|
+
};
|
|
45
|
+
/** A rule-bearing face that can refuse a call ({@link RULE_SOURCE_PHRASE} holds the whole vocabulary). */
|
|
46
|
+
export type RuleDenialSource = keyof typeof RULE_SOURCE_PHRASE;
|
|
47
|
+
/** How a refusal names `source` in prose, with no rule text to quote — for a sentence this module does not
|
|
48
|
+
* mint whole (a wrapper that carries the refusing layer's own sentence as its body). */
|
|
49
|
+
export declare function ruleSourcePhrase(source: RuleDenialSource): string;
|
|
50
|
+
/**
|
|
51
|
+
* Mint the refusal.
|
|
52
|
+
*
|
|
53
|
+
* @contract rule_denial.one_sentence — every refusal a RULE STORE mints reads
|
|
54
|
+
* `[tool "<tool>" is ]denied by <source>[ "<rule>"][ (<origin>)][: <detail>]`, composed here and nowhere
|
|
55
|
+
* else: the call is named first, then WHICH rule-bearing face refused (from the closed source vocabulary),
|
|
56
|
+
* then the rule's own text and the layer it was written in where the source has them, and last the reason
|
|
57
|
+
* this call failed the rule. A consumer reading a refusal can therefore always tell the session-policy
|
|
58
|
+
* store's answer from the permission rules' — and a carrier sentence that already named the call omits the
|
|
59
|
+
* head rather than spelling a second grammar.
|
|
60
|
+
*
|
|
61
|
+
* - `tool` — named when the sentence stands alone; omitted when a carrier sentence already named the call.
|
|
62
|
+
* - `rule` — the rule's own text, when the source has one to quote (a permission rule does; a session
|
|
63
|
+
* policy row is structured fields, not a rule DSL, so it does not).
|
|
64
|
+
* - `origin` — where in that store's layering the row was written (e.g. a settings layer's key path).
|
|
65
|
+
* - `detail` — WHY this call failed the rule, when the source word alone does not say it (an allowlist
|
|
66
|
+
* miss, a write the confinement cannot place, the refusing layer's own sentence).
|
|
67
|
+
*/
|
|
68
|
+
export declare function ruleDenialSentence(spec: {
|
|
69
|
+
source: RuleDenialSource;
|
|
70
|
+
tool?: string;
|
|
71
|
+
rule?: string;
|
|
72
|
+
origin?: string;
|
|
73
|
+
detail?: string;
|
|
74
|
+
}): string;
|
|
75
|
+
export {};
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
const RULE_SOURCE_PHRASE = {
|
|
2
|
+
session: { bare: "a session rule", named: "session rule" },
|
|
3
|
+
permission: { bare: "a permission rule", named: "permission rule" },
|
|
4
|
+
narrowing: { bare: "a narrowing policy layer", named: "narrowing policy layer" },
|
|
5
|
+
};
|
|
6
|
+
export function ruleSourcePhrase(source) {
|
|
7
|
+
return RULE_SOURCE_PHRASE[source].bare;
|
|
8
|
+
}
|
|
9
|
+
export function ruleDenialSentence(spec) {
|
|
10
|
+
const phrase = RULE_SOURCE_PHRASE[spec.source];
|
|
11
|
+
const by = spec.rule === undefined ? phrase.bare : `${phrase.named} ${JSON.stringify(spec.rule)}`;
|
|
12
|
+
const head = spec.tool === undefined ? "" : `tool ${JSON.stringify(spec.tool)} is `;
|
|
13
|
+
const origin = spec.origin === undefined ? "" : ` (${spec.origin})`;
|
|
14
|
+
const detail = spec.detail === undefined ? "" : `: ${spec.detail}`;
|
|
15
|
+
return `${head}denied by ${by}${origin}${detail}`;
|
|
16
|
+
}
|
|
@@ -14,7 +14,7 @@ import { probeMandatedAsk } from "../ask-origin.js";
|
|
|
14
14
|
import { headlessDenyAtFold, headlessDenyAtRecheck, inheritedAskCarry, judgeInheritedClassifier, lateAskSettlementObserver, settleDenialLimitFallback } from "./denial-limit-arms.js";
|
|
15
15
|
import { askGrantShapeOf } from "./inherited-ask-grants.js";
|
|
16
16
|
import { createPermissionRuleLanes, inheritedAskRuleEvidence, createRuleOffersOf } from "./permission-rule-lanes.js";
|
|
17
|
-
import { createSessionRulePolicy } from "./session-rule-policy.js";
|
|
17
|
+
import { createLiveSessionRules, createSessionRulePolicy } from "./session-rule-policy.js";
|
|
18
18
|
import { thrownText } from "../thrown-value.js";
|
|
19
19
|
export async function preparePolicyChain(input) {
|
|
20
20
|
const { spec, deps, internals, sessionId, runId, hostTaskId, taskRootFinal, executionEnv, lockedPreflight, tools, harnessTools, toolEffects, egressTools, irreversibilityTier, axisExplicitNegatives, shellGatedTools, envHandToolNames, handsCwdRef, readDenyMatcher, questionToolMounted, runtimeCaps, inheritedAncestorRules, inheritedParentConstraints, seedInheritedGate, hasSkillManifest, skillScope, frozenOnAsk, hookEnvSource, notifyOwnHookCrash, autoModeDecider, stopForDenialLimit, delegation, abortController, now, ownSessionRulesRef } = input;
|
|
@@ -69,32 +69,42 @@ export async function preparePolicyChain(input) {
|
|
|
69
69
|
}
|
|
70
70
|
};
|
|
71
71
|
auditPolicyNames(policy);
|
|
72
|
+
const announceSessionRulesUnreadable = (reason) => {
|
|
73
|
+
try {
|
|
74
|
+
const e = new Error(`session permission rules: ${reason} — the snapshot this leg already enforces stays in force (a failed read never loosens it)`);
|
|
75
|
+
e.code = "config.session_rules_unreadable";
|
|
76
|
+
deps.onError?.(e, { phase: "config", sessionId });
|
|
77
|
+
}
|
|
78
|
+
catch {
|
|
79
|
+
}
|
|
80
|
+
};
|
|
72
81
|
let sessionRulePolicy;
|
|
73
82
|
if (deps.sessionPolicyStore && spec.sessionId) {
|
|
74
|
-
const
|
|
75
|
-
|
|
76
|
-
|
|
83
|
+
const store = deps.sessionPolicyStore;
|
|
84
|
+
const sessionRuleKey = { sessionId: spec.sessionId, ...(spec.principal !== undefined ? { principal: spec.principal } : {}) };
|
|
85
|
+
const rules = await store.getRules(spec.sessionId, spec.principal);
|
|
86
|
+
const seed = rules ?? { rev: 0 };
|
|
87
|
+
const publishOwnRules = (row) => {
|
|
77
88
|
ownSessionRulesRef.current = {
|
|
78
|
-
sessionId:
|
|
79
|
-
...(
|
|
80
|
-
rev:
|
|
81
|
-
rules,
|
|
89
|
+
sessionId: sessionRuleKey.sessionId,
|
|
90
|
+
...(sessionRuleKey.principal !== undefined ? { principal: sessionRuleKey.principal } : {}),
|
|
91
|
+
rev: row.rev,
|
|
92
|
+
rules: row,
|
|
82
93
|
};
|
|
83
|
-
}
|
|
94
|
+
};
|
|
95
|
+
sessionRulePolicy = createSessionRulePolicy({ seed, read: createLiveSessionRules(store, { ...sessionRuleKey, seed }, { onUnreadable: announceSessionRulesUnreadable, onEnforced: publishOwnRules }) }, { env: executionEnv, rootPath: taskRootFinal, toolEffects });
|
|
96
|
+
if (rules)
|
|
97
|
+
publishOwnRules(rules);
|
|
84
98
|
}
|
|
85
99
|
const ancestorRulePolicies = [];
|
|
86
100
|
for (const snap of inheritedAncestorRules ?? []) {
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
catch {
|
|
95
|
-
}
|
|
96
|
-
}
|
|
97
|
-
ancestorRulePolicies.push(createSessionRulePolicy(ancestorRules, { env: executionEnv, rootPath: taskRootFinal, toolEffects }));
|
|
101
|
+
const seed = { ...snap.rules, rev: snap.rev };
|
|
102
|
+
ancestorRulePolicies.push(createSessionRulePolicy({
|
|
103
|
+
seed,
|
|
104
|
+
...(deps.sessionPolicyStore
|
|
105
|
+
? { read: createLiveSessionRules(deps.sessionPolicyStore, { sessionId: snap.sessionId, ...(snap.principal !== undefined ? { principal: snap.principal } : {}), seed }, { onUnreadable: announceSessionRulesUnreadable }) }
|
|
106
|
+
: {}),
|
|
107
|
+
}, { env: executionEnv, rootPath: taskRootFinal, toolEffects }));
|
|
98
108
|
}
|
|
99
109
|
const skillScopePolicy = hasSkillManifest
|
|
100
110
|
? createActiveSkillScopePolicy({
|
|
@@ -30,6 +30,7 @@ import { announceListingTruncation } from "../skill-listing-budget.js";
|
|
|
30
30
|
import { attachmentEnvelopeTags, commitAgentListing, commitSkillsListing, reduceToolEnd, renderAgentListingFrame, renderOrphanedBackgroundTasks, renderSkillsListingFrame, stampWriteAnchor } from "./turn-attachments.js";
|
|
31
31
|
import { flushOwedAnnouncedSnapshot } from "./announce-once-ledger.js";
|
|
32
32
|
import { asThrownError, thrownMarksOf, thrownText } from "../thrown-value.js";
|
|
33
|
+
import { ruleSourcePhrase } from "../rule-denial-sentence.js";
|
|
33
34
|
export async function runLeg(input) {
|
|
34
35
|
const { spec, queue, prepared, resume, rs, isDelegatedChild, entryActor, effectiveTimeoutMs, timeout, pushContent, ident, emitCommitted, writeFamilyOf, startedToolCallIds, announceWorkspaceMove, withBrainSinks, flushGitMirror, loopLatch, notificationLane, unsubscribeTaskNotifications, unsubGitRetry, unsubBoundary, unsub, runner, next } = input;
|
|
35
36
|
let final;
|
|
@@ -645,7 +646,7 @@ async function resolvePendingCall(runner, prepared, resume, emit, emitCommitted,
|
|
|
645
646
|
...advertisedWritableRootsSeat(prepared.advertisedWritableDirs),
|
|
646
647
|
}, prepared.abortController.signal));
|
|
647
648
|
if (narrowed.action === "deny") {
|
|
648
|
-
const narrowedDenial = formatHookFeedback(`The approved tool call "${pendingAction.toolName}" is now denied by
|
|
649
|
+
const narrowedDenial = formatHookFeedback(`The approved tool call "${pendingAction.toolName}" is now denied by ${ruleSourcePhrase("narrowing")} and was not executed${narrowed.message ? `: ${narrowed.message}` : ""}.`, prepared.reminderMark);
|
|
649
650
|
await settleEnd(true, { content: narrowedDenial }, vetoed("policy"));
|
|
650
651
|
const eid = await prepared.session.appendMessage(toolResultMsg(pendingAction.toolCallId, pendingAction.toolName, narrowedDenial, true));
|
|
651
652
|
emitCommitted(eid, "toolResult", pendingAction.toolCallId);
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import type { ExecutionEnv } from "../../internal/harness-types.js";
|
|
2
2
|
import type { ToolEffect } from "../types.js";
|
|
3
3
|
import { type NamedToolPolicy } from "../tool-policy.js";
|
|
4
|
-
import type { SessionPermissionRules } from "../session-policy-store.js";
|
|
4
|
+
import type { SessionPermissionRules, SessionPolicyStore, StoredSessionRules } from "../session-policy-store.js";
|
|
5
5
|
/** Tool names whose write target is the `file_path`/`path` arg ({@link fileArgPath}).
|
|
6
6
|
*
|
|
7
7
|
* REF-B3 — the ONE definition of that narrower set. Production consumer today:
|
|
@@ -48,6 +48,74 @@ export declare const PATH_CONFINABLE_WRITE_TOOLS: ReadonlySet<string>;
|
|
|
48
48
|
* separate functions on purpose — that one is private to the fence and keyed on canonical roots — but
|
|
49
49
|
* they must never disagree on a verdict; the empty-root arm below is that alignment. */
|
|
50
50
|
export declare function isWithin(root: string, p: string): boolean;
|
|
51
|
+
/**
|
|
52
|
+
* Where a session-rule layer takes the rules it enforces.
|
|
53
|
+
*
|
|
54
|
+
* @contract session_rules.every_gate_re_reads — a session-rule layer re-reads its rule source at EVERY
|
|
55
|
+
* tool gate, not once at prepare: an operator's mid-run write (a new deny, a narrowed allowlist) binds the
|
|
56
|
+
* very next call of the SAME run, on the same beat as the permission-rule store's per-call read. `seed` is
|
|
57
|
+
* the prepare-time snapshot and stays the FLOOR — a read that fails, a row that was deleted, or a lagging
|
|
58
|
+
* replica whose `rev` is below the snapshot's enforces the seed rather than loosening toward it — and it is
|
|
59
|
+
* also what the prepare-time policy-name audit sees, so a rule written after prepare is enforced but not
|
|
60
|
+
* audited. A source with no `read` is a constant one: the seed at every gate, which is what a caller that
|
|
61
|
+
* hands a plain rules object gets.
|
|
62
|
+
*/
|
|
63
|
+
export interface SessionRuleSource {
|
|
64
|
+
/** The prepare-time snapshot: the audit's view, and the floor no live read may loosen. */
|
|
65
|
+
seed: SessionPermissionRules;
|
|
66
|
+
/** The per-call read, keyed on the call being adjudicated, and bounded by the gate's own signal. Absent
|
|
67
|
+
* ⇒ the seed is the whole source. */
|
|
68
|
+
read?: (toolCallId: string, signal?: AbortSignal) => Promise<SessionPermissionRules>;
|
|
69
|
+
}
|
|
70
|
+
/**
|
|
71
|
+
* The LIVE half of {@link SessionRuleSource}: re-read `(sessionId, principal)` from the store on each
|
|
72
|
+
* adjudicated call, floored by `seed`.
|
|
73
|
+
*
|
|
74
|
+
* ONE law, two consumers (this task's own session rules and each inherited ancestor's), where the policy
|
|
75
|
+
* chain used to carry a hand-written live-preferring read for ancestors only and a frozen snapshot for the
|
|
76
|
+
* task's own rules. Direction, unchanged from that read and stated once here: a whole-row replacement
|
|
77
|
+
* includes RELAXATION — an operator's authorized post-prepare rule change propagates in BOTH directions;
|
|
78
|
+
* what the floor forbids is an UNAUTHORIZED loosening (a lagging replica, a deleted row, an unreachable
|
|
79
|
+
* store), and nothing task-side can edit the rules row itself.
|
|
80
|
+
*
|
|
81
|
+
* The floor RATCHETS to the highest-rev row this layer has actually served, and a live row replaces it only
|
|
82
|
+
* on a STRICTLY GREATER `rev`. Two reasons, and the second is why "greater or equal" is not good enough:
|
|
83
|
+
* · pinning the floor to the prepare snapshot would make "a failed read never loosens" false in the one
|
|
84
|
+
* sequence that matters — a row tightened mid-run, enforced at one gate, then unreadable at the next
|
|
85
|
+
* would fall back PAST the tightening to the snapshot the run started with;
|
|
86
|
+
* · `rev` is per-ROW, and both bundled backends compute it as `(prior?.rev ?? 0) + 1`, so a row that is
|
|
87
|
+
* DELETED and written again starts over at 1. At `>=`, a recreated row whose rev merely TIES the
|
|
88
|
+
* enforced one would replace it — an unauthorized loosening dressed as an update. At `>`, a tie enforces
|
|
89
|
+
* the floor, which is a no-op for the honest case (equal rev is the same row) and fail-closed for the
|
|
90
|
+
* recycled one. It raises the cost of the recycle rather than closing it: the rev can still be climbed
|
|
91
|
+
* past the floor by repeated writes, because the tighten gate has no prior row to compare against after
|
|
92
|
+
* a delete. Closing it belongs to the STORE's rev lifecycle (a counter that survives deletion, or a
|
|
93
|
+
* generation identity), which is a cross-backend contract change, not a reader-side one.
|
|
94
|
+
*
|
|
95
|
+
* The per-call handoff mirrors the permission-rule lanes': the first reader of a `toolCallId` takes the read
|
|
96
|
+
* and hands it on, a second reader of the SAME call claims that one snapshot, and an unclaimed entry is
|
|
97
|
+
* evicted first-in-first-out past a bound. It is a cost device, never a semantic one — a reader that cannot
|
|
98
|
+
* claim simply reads again. Its firing path here is the fold itself: `policyLayers` re-appends the narrowing
|
|
99
|
+
* layers AFTER a rewrite-capable caller layer, so one adjudication asks this layer twice under one id, and
|
|
100
|
+
* the leading (original args) and trailing (rewritten args) passes must judge ONE set of rules.
|
|
101
|
+
*
|
|
102
|
+
* Two bounds keep a handed entry from ever being a STALE one, because `toolCallId` is not always unique —
|
|
103
|
+
* the turn-boundary read-visibility query reuses one synthetic id for every path it screens and for every
|
|
104
|
+
* turn of the run. The entry is taken ONCE, and a claimed snapshot is served only while its `rev` still
|
|
105
|
+
* stands at the enforced floor; a claim from a later adjudication, whose floor has moved on, falls through
|
|
106
|
+
* to a fresh read. A real tool call's id is minted per call and never repeats.
|
|
107
|
+
*/
|
|
108
|
+
export declare function createLiveSessionRules(store: SessionPolicyStore, floor: {
|
|
109
|
+
sessionId: string;
|
|
110
|
+
principal?: string;
|
|
111
|
+
seed: StoredSessionRules;
|
|
112
|
+
}, hooks?: {
|
|
113
|
+
/** A rule source this layer could not read, once per distinct reason. */
|
|
114
|
+
onUnreadable?: (reason: string) => void;
|
|
115
|
+
/** The store row this layer now enforces, each time the floor ADVANCES. Never called for the synthetic
|
|
116
|
+
* empty seed: it reports rows the store actually served, which is what a child may inherit. */
|
|
117
|
+
onEnforced?: (row: StoredSessionRules) => void;
|
|
118
|
+
}): (toolCallId: string, signal?: AbortSignal) => Promise<SessionPermissionRules>;
|
|
51
119
|
/**
|
|
52
120
|
* Build the per-session deny-narrowing policy. For each tool call:
|
|
53
121
|
* - `toolDeny` → deny the listed tools.
|
|
@@ -63,8 +131,12 @@ export declare function isWithin(root: string, p: string): boolean;
|
|
|
63
131
|
* matching sees the actual filesystem (a remote/E2B task resolves in its container). A relative
|
|
64
132
|
* TARGET prefers the call's live tracked cwd ({@link ToolCallRequest.cwd}) when the engine stamps one;
|
|
65
133
|
* `allowDirs` entries always resolve against `rootPath`.
|
|
134
|
+
*
|
|
135
|
+
* The rules are taken from a {@link SessionRuleSource} at EVERY call
|
|
136
|
+
* ({@link SessionRuleSource} carries the law); a plain rules object is the constant source, which is what
|
|
137
|
+
* every caller outside the policy chain hands, and what a layer whose rules cannot change under it needs.
|
|
66
138
|
*/
|
|
67
|
-
export declare function createSessionRulePolicy(rules: SessionPermissionRules, opts: {
|
|
139
|
+
export declare function createSessionRulePolicy(rules: SessionPermissionRules | SessionRuleSource, opts: {
|
|
68
140
|
env: ExecutionEnv;
|
|
69
141
|
rootPath?: string;
|
|
70
142
|
toolEffects?: ReadonlyMap<string, ToolEffect>;
|
|
@@ -1,5 +1,8 @@
|
|
|
1
1
|
import { canonicalizeTarget, isWinFormPath, pathFamilyOf, writeTargetPath } from "../../tools/fs/safety.js";
|
|
2
2
|
import { createCoarseCommandNamePolicy, namespacedCoveringEntries, namespacedCoveringHit } from "../tool-policy.js";
|
|
3
|
+
import { thrownText } from "../thrown-value.js";
|
|
4
|
+
import { ruleDenialSentence } from "../rule-denial-sentence.js";
|
|
5
|
+
import { DEFAULT_GATE_AWAIT_TIMEOUT_MS, gateWaitFailureOf, withGateTimeout } from "../gate-bounded-await.js";
|
|
3
6
|
import { declaredPathTargetOf, isProtectedWrite, pathConfinableWriteToolNames, skillScopeWriteToolNames } from "../tool-registry.js";
|
|
4
7
|
import { pathTargetBaseOf } from "../effective-path-target.js";
|
|
5
8
|
export const PATH_WRITE_TOOLS = skillScopeWriteToolNames();
|
|
@@ -20,32 +23,121 @@ export function isWithin(root, p) {
|
|
|
20
23
|
const base = r.endsWith("/") ? r : r + "/";
|
|
21
24
|
return c.startsWith(base);
|
|
22
25
|
}
|
|
23
|
-
const deny = (
|
|
26
|
+
const deny = (tool, detail) => ({
|
|
27
|
+
action: "deny",
|
|
28
|
+
message: ruleDenialSentence({ source: "session", tool, ...(detail !== undefined ? { detail } : {}) }),
|
|
29
|
+
decisionReason: "rule",
|
|
30
|
+
});
|
|
31
|
+
const HANDOFF_BOUND = 256;
|
|
32
|
+
const ANNOUNCED_REASONS_BOUND = 8;
|
|
33
|
+
export function createLiveSessionRules(store, floor, hooks) {
|
|
34
|
+
const announced = new Set();
|
|
35
|
+
const announce = (reason) => {
|
|
36
|
+
if (announced.has(reason) || announced.size >= ANNOUNCED_REASONS_BOUND)
|
|
37
|
+
return;
|
|
38
|
+
announced.add(reason);
|
|
39
|
+
hooks?.onUnreadable?.(reason);
|
|
40
|
+
};
|
|
41
|
+
let enforced = floor.seed;
|
|
42
|
+
let servedRow = false;
|
|
43
|
+
let vanished = false;
|
|
44
|
+
const read = async (signal) => {
|
|
45
|
+
let live;
|
|
46
|
+
try {
|
|
47
|
+
live = await withGateTimeout(store.getRules(floor.sessionId, floor.principal), DEFAULT_GATE_AWAIT_TIMEOUT_MS, signal, "sessionRulesRead");
|
|
48
|
+
}
|
|
49
|
+
catch (err) {
|
|
50
|
+
const wait = gateWaitFailureOf(err);
|
|
51
|
+
announce(wait !== undefined
|
|
52
|
+
? `the session-policy store read for session ${JSON.stringify(floor.sessionId)} ended without an answer (${wait})`
|
|
53
|
+
: `the session-policy store threw while re-reading session ${JSON.stringify(floor.sessionId)}: ${thrownText(err)}`);
|
|
54
|
+
return enforced;
|
|
55
|
+
}
|
|
56
|
+
if (live === null) {
|
|
57
|
+
if (servedRow && !vanished) {
|
|
58
|
+
vanished = true;
|
|
59
|
+
announce(`the rules row of session ${JSON.stringify(floor.sessionId)} was deleted while this leg enforced it`);
|
|
60
|
+
}
|
|
61
|
+
return enforced;
|
|
62
|
+
}
|
|
63
|
+
if (vanished) {
|
|
64
|
+
announce(`session ${JSON.stringify(floor.sessionId)} has a new rules row at rev ${live.rev}, which does not continue the deleted row this leg enforces`);
|
|
65
|
+
return enforced;
|
|
66
|
+
}
|
|
67
|
+
if (live.rev <= enforced.rev) {
|
|
68
|
+
if (live.rev < enforced.rev) {
|
|
69
|
+
announce(`the session-policy store served session ${JSON.stringify(floor.sessionId)} at rev ${live.rev}, below the enforced rev ${enforced.rev}`);
|
|
70
|
+
}
|
|
71
|
+
return enforced;
|
|
72
|
+
}
|
|
73
|
+
enforced = live;
|
|
74
|
+
servedRow = true;
|
|
75
|
+
hooks?.onEnforced?.(live);
|
|
76
|
+
return live;
|
|
77
|
+
};
|
|
78
|
+
const handed = new Map();
|
|
79
|
+
return async (toolCallId, signal) => {
|
|
80
|
+
const handedRead = handed.get(toolCallId);
|
|
81
|
+
if (handedRead !== undefined) {
|
|
82
|
+
handed.delete(toolCallId);
|
|
83
|
+
const claimed = await handedRead;
|
|
84
|
+
if (claimed.rev >= enforced.rev)
|
|
85
|
+
return claimed;
|
|
86
|
+
}
|
|
87
|
+
const p = read(signal);
|
|
88
|
+
handed.set(toolCallId, p);
|
|
89
|
+
if (handed.size > HANDOFF_BOUND) {
|
|
90
|
+
const oldest = handed.keys().next().value;
|
|
91
|
+
if (oldest !== undefined)
|
|
92
|
+
handed.delete(oldest);
|
|
93
|
+
}
|
|
94
|
+
return p;
|
|
95
|
+
};
|
|
96
|
+
}
|
|
97
|
+
function compileSessionRules(rules) {
|
|
98
|
+
return {
|
|
99
|
+
toolDeny: new Set(rules.toolDeny ?? []),
|
|
100
|
+
toolAllow: rules.toolAllow ? new Set(rules.toolAllow) : undefined,
|
|
101
|
+
toolDenyCovering: namespacedCoveringEntries(rules.toolDeny),
|
|
102
|
+
toolAllowCovering: namespacedCoveringEntries(rules.toolAllow),
|
|
103
|
+
cmdPolicy: rules.commandAllow || rules.commandDeny
|
|
104
|
+
? createCoarseCommandNamePolicy({
|
|
105
|
+
...(rules.commandAllow ? { allow: rules.commandAllow } : {}),
|
|
106
|
+
...(rules.commandDeny ? { deny: rules.commandDeny } : {}),
|
|
107
|
+
defaultAction: "deny",
|
|
108
|
+
})
|
|
109
|
+
: undefined,
|
|
110
|
+
allowDirs: rules.allowDirs && rules.allowDirs.length > 0 ? rules.allowDirs : undefined,
|
|
111
|
+
};
|
|
112
|
+
}
|
|
24
113
|
export function createSessionRulePolicy(rules, opts) {
|
|
25
114
|
const { env, rootPath, toolEffects } = opts;
|
|
26
|
-
const
|
|
27
|
-
const
|
|
28
|
-
const
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
}
|
|
36
|
-
|
|
37
|
-
|
|
115
|
+
const source = "seed" in rules ? rules : { seed: rules };
|
|
116
|
+
const seed = source.seed;
|
|
117
|
+
const readRules = source.read ?? (() => Promise.resolve(seed));
|
|
118
|
+
let compiledFor;
|
|
119
|
+
let compiled;
|
|
120
|
+
const compile = (r) => {
|
|
121
|
+
if (compiled === undefined || compiledFor !== r) {
|
|
122
|
+
compiled = compileSessionRules(r);
|
|
123
|
+
compiledFor = r;
|
|
124
|
+
}
|
|
125
|
+
return compiled;
|
|
126
|
+
};
|
|
38
127
|
return {
|
|
39
|
-
nameSets: [{ ...(
|
|
128
|
+
nameSets: [{ ...(seed.toolDeny?.length ? { deny: [...seed.toolDeny] } : {}), ...(seed.toolAllow?.length ? { allow: [...seed.toolAllow] } : {}) }],
|
|
40
129
|
async check(req, signal) {
|
|
130
|
+
const { toolDeny, toolAllow, toolDenyCovering, toolAllowCovering, cmdPolicy, allowDirs } = compile(await readRules(req.toolCallId, signal));
|
|
41
131
|
const toolName = req.toolName;
|
|
42
132
|
if (toolDeny.has(toolName) || namespacedCoveringHit(toolDenyCovering, toolName))
|
|
43
|
-
return deny(
|
|
133
|
+
return deny(req.toolName);
|
|
44
134
|
if (toolAllow && !toolAllow.has(toolName) && !namespacedCoveringHit(toolAllowCovering, toolName)) {
|
|
45
|
-
return deny(
|
|
135
|
+
return deny(req.toolName, "it is not in the session-rule allowlist");
|
|
46
136
|
}
|
|
47
137
|
if (cmdPolicy) {
|
|
48
138
|
const d = await cmdPolicy.check(req, signal);
|
|
139
|
+
if (d.action === "deny")
|
|
140
|
+
return { ...d, message: ruleDenialSentence({ source: "session", tool: req.toolName, ...(d.message !== undefined ? { detail: d.message } : {}) }) };
|
|
49
141
|
if (d.action !== "allow")
|
|
50
142
|
return d;
|
|
51
143
|
}
|
|
@@ -53,21 +145,21 @@ export function createSessionRulePolicy(rules, opts) {
|
|
|
53
145
|
if (!isProtectedWrite(req)) {
|
|
54
146
|
const eff = toolEffects?.get(toolName) ?? "write";
|
|
55
147
|
if (eff !== "read") {
|
|
56
|
-
return deny(
|
|
148
|
+
return deny(req.toolName, "it is write-capable and cannot be path-confined, while the rule confines writes to allowDirs");
|
|
57
149
|
}
|
|
58
150
|
}
|
|
59
151
|
else {
|
|
60
152
|
const declared = declaredPathTargetOf(req);
|
|
61
153
|
if (declared === undefined) {
|
|
62
|
-
return deny(
|
|
154
|
+
return deny(req.toolName, "it declares no path target to confine, while the rule confines writes to allowDirs");
|
|
63
155
|
}
|
|
64
156
|
const path = writeTargetPath(req, declared);
|
|
65
157
|
if (typeof path !== "string" || path.length === 0) {
|
|
66
|
-
return deny(
|
|
158
|
+
return deny(req.toolName, "the call has no resolvable path to confine, while the rule confines writes to allowDirs");
|
|
67
159
|
}
|
|
68
160
|
const canon = await canonicalizeTarget(env, path, signal, pathFamilyOf({ root: rootPath, cwd: req.cwd ?? env.cwd }), pathTargetBaseOf(declared, { root: rootPath, cwd: req.cwd }));
|
|
69
161
|
if (!canon.ok) {
|
|
70
|
-
return deny(`write to
|
|
162
|
+
return deny(req.toolName, `the real target of the write to ${JSON.stringify(path)} could not be resolved against the session-rule allowDirs`);
|
|
71
163
|
}
|
|
72
164
|
const roots = [];
|
|
73
165
|
for (const raw of allowDirs) {
|
|
@@ -76,7 +168,7 @@ export function createSessionRulePolicy(rules, opts) {
|
|
|
76
168
|
roots.push(r.key);
|
|
77
169
|
}
|
|
78
170
|
if (!roots.some((root) => isWithin(root, canon.key))) {
|
|
79
|
-
return deny(`write to
|
|
171
|
+
return deny(req.toolName, `the write to ${JSON.stringify(path)} is outside the session-rule allowDirs (${allowDirs.join(", ")})`);
|
|
80
172
|
}
|
|
81
173
|
}
|
|
82
174
|
}
|
|
@@ -714,7 +714,7 @@ export function startWorkflow(runner, fn, opts = {}, internals) {
|
|
|
714
714
|
if (!bceSink)
|
|
715
715
|
return;
|
|
716
716
|
const id = waIdOf(callKey);
|
|
717
|
-
bceLive.set(id, { callKey, label, ...(agentType !== undefined ? { agentType } : {}), ...(sessionId !== undefined ? { sessionId } : {}), ...(model !== undefined ? { model } : {}) });
|
|
717
|
+
bceLive.set(id, { callKey, label, ...(agentType !== undefined ? { agentType } : {}), ...(sessionId !== undefined ? { sessionId } : {}), ...(model !== undefined ? { model } : {}), generation: 1 });
|
|
718
718
|
bceEmit({
|
|
719
719
|
kind: "spawn",
|
|
720
720
|
taskId: id,
|
|
@@ -732,6 +732,7 @@ export function startWorkflow(runner, fn, opts = {}, internals) {
|
|
|
732
732
|
...(opts.originatingSessionId !== undefined ? { parentSessionId: opts.originatingSessionId } : {}),
|
|
733
733
|
...((opts.rootSessionId ?? opts.originatingSessionId) !== undefined ? { rootSessionId: opts.rootSessionId ?? opts.originatingSessionId } : {}),
|
|
734
734
|
startedAt: Date.now(),
|
|
735
|
+
seq: 1,
|
|
735
736
|
});
|
|
736
737
|
};
|
|
737
738
|
const bceBindSession = (callKey, sessionId) => {
|
|
@@ -739,6 +740,11 @@ export function startWorkflow(runner, fn, opts = {}, internals) {
|
|
|
739
740
|
if (row !== undefined)
|
|
740
741
|
row.sessionId = sessionId;
|
|
741
742
|
};
|
|
743
|
+
const bceGeneration = (callKey, generation) => {
|
|
744
|
+
const row = bceLive.get(waIdOf(callKey));
|
|
745
|
+
if (row !== undefined)
|
|
746
|
+
row.generation = generation;
|
|
747
|
+
};
|
|
742
748
|
const bceTick = (callKey, e, fromSessionId) => {
|
|
743
749
|
if (!bceSink)
|
|
744
750
|
return;
|
|
@@ -763,6 +769,7 @@ export function startWorkflow(runner, fn, opts = {}, internals) {
|
|
|
763
769
|
progressTaskId: e.taskId,
|
|
764
770
|
...(e.parentTaskId !== undefined ? { progressParentTaskId: e.parentTaskId } : {}),
|
|
765
771
|
...(e.currentAction !== undefined ? { currentAction: e.currentAction } : {}),
|
|
772
|
+
seq: row.generation,
|
|
766
773
|
usage: e.usage,
|
|
767
774
|
});
|
|
768
775
|
};
|
|
@@ -771,9 +778,9 @@ export function startWorkflow(runner, fn, opts = {}, internals) {
|
|
|
771
778
|
return;
|
|
772
779
|
const id = waIdOf(callKey);
|
|
773
780
|
const row = bceLive.get(id);
|
|
774
|
-
if (!bceLive.delete(id))
|
|
781
|
+
if (row === undefined || !bceLive.delete(id))
|
|
775
782
|
return;
|
|
776
|
-
const coord = sessionId ?? row
|
|
783
|
+
const coord = sessionId ?? row.sessionId;
|
|
777
784
|
bceEmit({
|
|
778
785
|
kind: "terminal",
|
|
779
786
|
taskId: id,
|
|
@@ -784,6 +791,7 @@ export function startWorkflow(runner, fn, opts = {}, internals) {
|
|
|
784
791
|
...(scope !== undefined ? { scope } : {}),
|
|
785
792
|
...(coord !== undefined ? { sessionId: coord, transcriptId: coord } : {}),
|
|
786
793
|
status,
|
|
794
|
+
seq: row.generation,
|
|
787
795
|
summary: boundedRedactedSummary(summary, 300),
|
|
788
796
|
...(stats !== undefined ? { usage: { tokens: stats.tokens, turns: stats.turns, ...(stats.costMicroUsd !== undefined ? { costMicroUsd: stats.costMicroUsd } : {}) } } : {}),
|
|
789
797
|
});
|
|
@@ -1575,6 +1583,7 @@ export function startWorkflow(runner, fn, opts = {}, internals) {
|
|
|
1575
1583
|
let approvalWaivedAttempts = 0;
|
|
1576
1584
|
for (;;) {
|
|
1577
1585
|
attempts += 1;
|
|
1586
|
+
bceGeneration(callKey, attempts);
|
|
1578
1587
|
const attemptStart = now();
|
|
1579
1588
|
const attemptCtl = new AbortController();
|
|
1580
1589
|
let stallFired = false;
|
package/package.json
CHANGED
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
{
|
|
2
|
+
"repo": "core",
|
|
3
|
+
"version": "7.24.4",
|
|
4
|
+
"from": "583db3a8918e747e8dab1c5e2f457cf79171bd3f",
|
|
5
|
+
"to": "v7.24.4",
|
|
6
|
+
"added": [],
|
|
7
|
+
"removed": [],
|
|
8
|
+
"changed": [],
|
|
9
|
+
"narrowed": [
|
|
10
|
+
{
|
|
11
|
+
"id": "F1",
|
|
12
|
+
"source": "behaviorFlips.summary",
|
|
13
|
+
"text": "NARROWING (#978, P2, permissions): every rule source is re-read at every tool gate — a session-policy rule (`PUT /v1/sessions/:id/policy` on server; `sessionPolicyStore` in core) written after a run's first turn denies the matching call in the SAME run; and every rule denial is one minted sentence naming its source."
|
|
14
|
+
}
|
|
15
|
+
],
|
|
16
|
+
"closedSetMembers": [],
|
|
17
|
+
"closedSetsUnreadable": [],
|
|
18
|
+
"typeKeys": [],
|
|
19
|
+
"declarationMoves": [],
|
|
20
|
+
"mintMoves": [],
|
|
21
|
+
"criteriaObserves": [
|
|
22
|
+
{
|
|
23
|
+
"kind": "taskEventField",
|
|
24
|
+
"name": "tool_end.isError",
|
|
25
|
+
"assertions": [
|
|
26
|
+
"G1.a",
|
|
27
|
+
"G1.b"
|
|
28
|
+
]
|
|
29
|
+
},
|
|
30
|
+
{
|
|
31
|
+
"kind": "typeMember",
|
|
32
|
+
"name": "BackgroundChildEvent.seq",
|
|
33
|
+
"assertions": [
|
|
34
|
+
"G2.a"
|
|
35
|
+
]
|
|
36
|
+
},
|
|
37
|
+
{
|
|
38
|
+
"kind": "typeMember",
|
|
39
|
+
"name": "SessionPermissionRules.toolDeny",
|
|
40
|
+
"assertions": [
|
|
41
|
+
"G1.a"
|
|
42
|
+
]
|
|
43
|
+
}
|
|
44
|
+
],
|
|
45
|
+
"consumersHint": {
|
|
46
|
+
"cli": [],
|
|
47
|
+
"server": [],
|
|
48
|
+
"sdk": [],
|
|
49
|
+
"note": "NOT SCANNED: sema-cli, sema-server, sema-sdk — an empty list for those repos means \"not checked out\", not \"no consumer\""
|
|
50
|
+
},
|
|
51
|
+
"breakingCount": 0,
|
|
52
|
+
"breakingItems": [],
|
|
53
|
+
"toResolvedAt": "5cbec5a04d5cd905a121a02e21c3c71087a7b4fe"
|
|
54
|
+
}
|