@sema-agent/core 7.24.4 → 7.25.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +78 -0
- package/dist/agents/peer-session-drain.d.ts +16 -1
- package/dist/agents/peer-session-drain.js +16 -1
- package/dist/agents/send-message-tool.d.ts +10 -2
- package/dist/agents/send-message-tool.js +18 -7
- package/dist/agents/subagent.js +13 -10
- package/dist/core/background-agent-store.d.ts +3 -3
- package/dist/core/checkpoint-store.d.ts +1 -0
- package/dist/core/delegation-frames.d.ts +5 -0
- package/dist/core/gate-fold.js +85 -0
- package/dist/core/gate-lanes.js +23 -1
- package/dist/core/gate-outcome.d.ts +8 -2
- package/dist/core/gate-outcome.js +3 -1
- package/dist/core/hooks.d.ts +29 -0
- package/dist/core/hooks.js +2 -0
- package/dist/core/mailbox-store.d.ts +5 -0
- package/dist/core/mailbox-store.js +1 -1
- package/dist/core/parent-spec-seat.d.ts +7 -1
- package/dist/core/parent-spec-seat.js +2 -0
- package/dist/core/permission-rule-consent.js +29 -47
- package/dist/core/permission-rule-provider.js +4 -3
- package/dist/core/permission-rule-store.d.ts +143 -7
- package/dist/core/permission-rule-store.js +165 -15
- package/dist/core/runner/contracts.d.ts +36 -2
- package/dist/core/runner/gate-exit.d.ts +14 -0
- package/dist/core/runner/gate-exit.js +4 -0
- package/dist/core/runner/prepare-delegation-surface.d.ts +4 -4
- package/dist/core/runner/prepare-delegation-surface.js +2 -1
- package/dist/core/runner/prepare-gate-stations.d.ts +4 -0
- package/dist/core/runner/prepare-gate-stations.js +7 -4
- package/dist/core/runner/prepare-hands-readface.d.ts +8 -4
- package/dist/core/runner/prepare-hands-readface.js +25 -10
- package/dist/core/runner/prepare-inherited-gate.d.ts +23 -0
- package/dist/core/runner/prepare-inherited-gate.js +11 -6
- package/dist/core/runner/prepare-policy-chain.d.ts +15 -1
- package/dist/core/runner/prepare-policy-chain.js +14 -4
- package/dist/core/runner/prepare-suspend-saga.d.ts +5 -2
- package/dist/core/runner/prepare-suspend-saga.js +4 -3
- package/dist/core/runner/prepare-task.js +10 -9
- package/dist/core/runner/prepare-wiring-manifest.d.ts +11 -4
- package/dist/core/runner/prepare-wiring-manifest.js +8 -4
- package/dist/core/runner/resume-apply.js +2 -0
- package/dist/core/runner/run-harness-handlers.js +1 -0
- package/dist/core/runner/run-identity-wiring.d.ts +2 -2
- package/dist/core/runner/run-leg.js +26 -0
- package/dist/core/runner/run-notification-lane.d.ts +2 -2
- package/dist/core/runner/run-notification-lane.js +2 -1
- package/dist/core/runner/run-settle-and-teardown.js +1 -0
- package/dist/core/runner/session-rule-policy.d.ts +3 -4
- package/dist/core/runner/session-rule-policy.js +11 -3
- package/dist/core/session-policy-store.d.ts +27 -3
- package/dist/core/session-policy-store.js +12 -4
- package/dist/core/store-contracts/background-agent-store-contract.js +10 -0
- package/dist/core/store-contracts/peer-directory-contract.d.ts +23 -0
- package/dist/core/store-contracts/peer-directory-contract.js +82 -0
- package/dist/core/task-event.d.ts +6 -0
- package/dist/core/task-notification.d.ts +79 -4
- package/dist/core/task-notification.js +90 -1
- package/dist/core/task-registry-agent.js +1 -2
- package/dist/core/task-registry-monitor.js +2 -1
- package/dist/core/task-registry-shared.d.ts +53 -5
- package/dist/core/task-registry-shared.js +13 -2
- package/dist/core/task-registry.js +8 -5
- package/dist/core/task-spec.d.ts +12 -5
- package/dist/core/tool-catalog-entries.js +1 -0
- package/dist/core/tool-spec.d.ts +1 -1
- package/dist/core/wiring-manifest.d.ts +2 -2
- package/dist/engine/loop/agent-loop.js +1 -1
- package/dist/engine/loop/types.d.ts +4 -0
- package/dist/index.d.ts +6 -3
- package/dist/index.js +4 -3
- package/dist/prompts/coordinator.d.ts +2 -1
- package/dist/stores/file/adoption/adopt.js +23 -1
- package/dist/stores/file/permission-rule-store.js +3 -1
- package/dist/stores/file/session-policy-store.d.ts +31 -0
- package/dist/stores/file/session-policy-store.js +87 -7
- package/dist/tools/fs/bash-program-position.d.ts +4 -26
- package/dist/tools/fs/bash-program-position.js +0 -11
- package/dist/tools/fs/bash-readonly-classifier.d.ts +20 -309
- package/dist/tools/fs/bash-readonly-classifier.js +28 -766
- package/dist/tools/fs/fs-bash.d.ts +43 -207
- package/dist/tools/fs/fs-bash.js +20 -341
- package/dist/tools/fs/index.d.ts +12 -13
- package/dist/tools/fs/index.js +3 -4
- package/dist/tools/fs/read-deny.d.ts +25 -0
- package/dist/tools/fs/read-deny.js +19 -0
- package/dist/tools/fs/safety.js +2 -6
- package/dist/tools/fs/shell-read-check.d.ts +99 -0
- package/dist/tools/fs/shell-read-check.js +229 -0
- package/dist/tools/fs/shell-read-establish.d.ts +70 -115
- package/dist/tools/fs/shell-read-establish.js +216 -102
- package/dist/tools/fs/shell-read-judge.d.ts +231 -0
- package/dist/tools/fs/shell-read-judge.js +170 -0
- package/dist/tools/fs/shell-read-plan.d.ts +3 -0
- package/dist/tools/fs/shell-read-plan.js +82 -19
- package/dist/tools/monitor.d.ts +5 -5
- package/dist/tools/monitor.js +3 -5
- package/dist/tools/task-list.d.ts +4 -0
- package/package.json +1 -1
- package/test/export-surface.snapshot.json +19 -3
- package/wire-changes/core@7.25.0.json +671 -0
|
@@ -154,8 +154,11 @@ export type SettlementRefusalTableCoversEveryKind = AssertAllKeysHandled<Exclude
|
|
|
154
154
|
* path that no approval covers.
|
|
155
155
|
* - `ask_resolution` — the ask's own settlement is the refusal (a person said no, a window elapsed,
|
|
156
156
|
* nobody could be asked…); the detail is on {@link GateOutcome.settlement}.
|
|
157
|
+
* - `read_boundary` — the shell read station refused: a deny-listed read (refused on every seat, with no
|
|
158
|
+
* ask), a read the read-only shell face may not make, or a rewritten / re-established command whose reads
|
|
159
|
+
* no longer are what a person approved.
|
|
157
160
|
*/
|
|
158
|
-
export declare const DENIED_BY_VALUES: readonly ["policy", "hook", "org", "persisted_rule", "classifier", "plan_mode", "compliance", "write_protection", "ask_resolution"];
|
|
161
|
+
export declare const DENIED_BY_VALUES: readonly ["policy", "hook", "org", "persisted_rule", "classifier", "plan_mode", "compliance", "write_protection", "ask_resolution", "read_boundary"];
|
|
159
162
|
export type DeniedBy = (typeof DENIED_BY_VALUES)[number];
|
|
160
163
|
/** Membership test for {@link DeniedBy}. */
|
|
161
164
|
export declare function isDeniedBy(v: unknown): v is DeniedBy;
|
|
@@ -165,7 +168,8 @@ export declare function isDeniedBy(v: unknown): v is DeniedBy;
|
|
|
165
168
|
* classifier, plan mode and compliance run before any ask exists, and `ask_resolution` IS the
|
|
166
169
|
* settlement, not a veto of it. `persisted_rule` = a person's own standing DENY row (their settings'
|
|
167
170
|
* deny list, imported) — the personal-store sibling of `org`, and like it re-judged on an edited
|
|
168
|
-
* command.
|
|
171
|
+
* command. `read_boundary` vetoes too: the rewrite re-judge and the pre-spawn re-establishment run after
|
|
172
|
+
* the person's yes, on bytes or targets the person never saw. */
|
|
169
173
|
export declare const DENIED_BY_MAY_VETO: {
|
|
170
174
|
readonly policy: true;
|
|
171
175
|
readonly hook: true;
|
|
@@ -176,6 +180,7 @@ export declare const DENIED_BY_MAY_VETO: {
|
|
|
176
180
|
readonly compliance: false;
|
|
177
181
|
readonly write_protection: true;
|
|
178
182
|
readonly ask_resolution: false;
|
|
183
|
+
readonly read_boundary: true;
|
|
179
184
|
};
|
|
180
185
|
/** Compile-time fence: `never` while every deny layer has a veto row. */
|
|
181
186
|
export type VetoTableCoversEveryDeniedBy = AssertAllKeysHandled<Exclude<DeniedBy, keyof typeof DENIED_BY_MAY_VETO>>;
|
|
@@ -209,6 +214,7 @@ export declare const DENIED_BY_MAY_CARRY_CAUSE: {
|
|
|
209
214
|
readonly compliance: false;
|
|
210
215
|
readonly write_protection: false;
|
|
211
216
|
readonly ask_resolution: false;
|
|
217
|
+
readonly read_boundary: false;
|
|
212
218
|
};
|
|
213
219
|
/** Compile-time fence: `never` while every deny layer has a cause row. */
|
|
214
220
|
export type CauseTableCoversEveryDeniedBy = AssertAllKeysHandled<Exclude<DeniedBy, keyof typeof DENIED_BY_MAY_CARRY_CAUSE>>;
|
|
@@ -39,7 +39,7 @@ export const SETTLEMENT_IS_REFUSAL = {
|
|
|
39
39
|
blanket_allow_refused: true,
|
|
40
40
|
task_aborted: true,
|
|
41
41
|
};
|
|
42
|
-
export const DENIED_BY_VALUES = ["policy", "hook", "org", "persisted_rule", "classifier", "plan_mode", "compliance", "write_protection", "ask_resolution"];
|
|
42
|
+
export const DENIED_BY_VALUES = ["policy", "hook", "org", "persisted_rule", "classifier", "plan_mode", "compliance", "write_protection", "ask_resolution", "read_boundary"];
|
|
43
43
|
const DENIED_BY_SET = new Set(DENIED_BY_VALUES);
|
|
44
44
|
export function isDeniedBy(v) {
|
|
45
45
|
return DENIED_BY_SET.has(v);
|
|
@@ -54,6 +54,7 @@ export const DENIED_BY_MAY_VETO = {
|
|
|
54
54
|
compliance: false,
|
|
55
55
|
write_protection: true,
|
|
56
56
|
ask_resolution: false,
|
|
57
|
+
read_boundary: true,
|
|
57
58
|
};
|
|
58
59
|
export const CLASSIFIER_DENY_CAUSES = ["unavailable", "parse_error"];
|
|
59
60
|
const CLASSIFIER_DENY_CAUSE_SET = new Set(CLASSIFIER_DENY_CAUSES);
|
|
@@ -70,6 +71,7 @@ export const DENIED_BY_MAY_CARRY_CAUSE = {
|
|
|
70
71
|
compliance: false,
|
|
71
72
|
write_protection: false,
|
|
72
73
|
ask_resolution: false,
|
|
74
|
+
read_boundary: false,
|
|
73
75
|
};
|
|
74
76
|
export function screenGateOutcome(g) {
|
|
75
77
|
const defects = [];
|
package/dist/core/hooks.d.ts
CHANGED
|
@@ -732,6 +732,16 @@ export interface HookInvocationIdentity {
|
|
|
732
732
|
* UNTRUSTED display text (model-chosen) — already single-line-bounded at mint (same
|
|
733
733
|
* `inlineUntrusted` treatment as the `task_progress` name lane); never an identity key. */
|
|
734
734
|
readonly agentName?: string;
|
|
735
|
+
/** The EXPLICIT `Agent({name})` spawn identity, when the spawn carried one — sema's teammate key
|
|
736
|
+
* (validated at spawn: `^[A-Za-z0-9][A-Za-z0-9_-]{0,63}$`, never `main`). The same string the
|
|
737
|
+
* child's roster row, its uplink `from` label and its `task_progress.spawnName` carry, so a hook
|
|
738
|
+
* seat binds "which teammate" by string equality. ABSENT = an unnamed delegation (not a
|
|
739
|
+
* teammate); {@link agentName} above stays the DISPLAY label and is never an identity key. */
|
|
740
|
+
readonly spawnName?: string;
|
|
741
|
+
/** The RESOLVED subagent type the leg was spawned as (`"general-purpose"` / `"fork"` / a roster
|
|
742
|
+
* definition's name — the fleet row's `agentType` word, never the description). Absent on a
|
|
743
|
+
* top-level run and on a leg whose spawner threaded no type (a workflow-lane agent). */
|
|
744
|
+
readonly agentType?: string;
|
|
735
745
|
/** The spawning Agent tool call's own id, when this leg runs under one — the delegation-chain
|
|
736
746
|
* position anchor ({@link import("./types.js").TaskEvent}'s `parentToolCallId` twin). Absent on
|
|
737
747
|
* a top-level run and on a directly-started workflow child (no launching tool call — no id is
|
|
@@ -919,6 +929,11 @@ export interface ToolGateResult {
|
|
|
919
929
|
gate?: GateOutcome;
|
|
920
930
|
/** Rewritten args to execute with (re-validated by the loop); omitted when nothing rewrote. */
|
|
921
931
|
updatedInput?: unknown;
|
|
932
|
+
/** A blocking refusal's structured details, when the refusing station minted any (the shell read station's
|
|
933
|
+
* `read_path_denied` / `readonly_out_of_root` cards): the loop attaches them to the error result it mints
|
|
934
|
+
* from `reason`, so the refusal reaches `tool_end.structured` / `errorCode` exactly as the tool's own
|
|
935
|
+
* refusal of the same finding did. Absent on every other block. */
|
|
936
|
+
details?: unknown;
|
|
922
937
|
/**
|
|
923
938
|
* design/45: the gate routed a policy `ask` to a **durable suspension** (no synchronous approver).
|
|
924
939
|
* `suspendAsk` already persisted the checkpoint and aborted the run; the caller records this so the
|
|
@@ -1362,6 +1377,20 @@ export interface ToolGateInput {
|
|
|
1362
1377
|
* point there), a promise for a covered write. Absent ⇒ no table (`[]`) and the decision path is byte-identical.
|
|
1363
1378
|
*/
|
|
1364
1379
|
writeProtectionCheck?: import("./write-protect.js").WriteProtectionCheck;
|
|
1380
|
+
/**
|
|
1381
|
+
* The shell READ STATION — ENGINE-BUILT by the hands phase (`createShellReadStation`,
|
|
1382
|
+
* `src/tools/fs/shell-read-check.ts`) for the shell-running tools of this leg, never a deployment callback.
|
|
1383
|
+
* Judges the FINAL args of a shell call: ONE parse of the command into its read candidates, each
|
|
1384
|
+
* established against the execution environment (symlinks resolved, patterns expanded, traversal roots
|
|
1385
|
+
* enumerated, within a bound) and judged by the leg's read deny seat and its containment roots. A
|
|
1386
|
+
* POLICY-class member of the fold: a deny-listed read is REFUSED (`deniedBy: "read_boundary"`, composing
|
|
1387
|
+
* under `deny > ask > allow`), an out-of-root or unestablishable read tightens a surviving allow to a
|
|
1388
|
+
* MANDATED ask (or marks an existing ask mandated), a read the station declined to judge tightens to an
|
|
1389
|
+
* ordinary ask on the classify seat and a mandated one on the off seat, and a vouched call is untouched.
|
|
1390
|
+
* `null` SYNCHRONOUSLY for a call it does not cover (no suspension point there), a promise for a shell
|
|
1391
|
+
* call. Absent ⇒ no station (no shell mounted) and the decision path is byte-identical.
|
|
1392
|
+
*/
|
|
1393
|
+
shellReadCheck?: import("../tools/fs/shell-read-check.js").ShellReadCheck;
|
|
1365
1394
|
/**
|
|
1366
1395
|
* design/143 批2 (A, CC 2.1.207 auto mode): when present, a surviving `ask` is routed to the
|
|
1367
1396
|
* small-model policy CLASSIFIER before any human/durable resolution:
|
package/dist/core/hooks.js
CHANGED
|
@@ -167,6 +167,8 @@ export function mintHookInvocationIdentity(facts) {
|
|
|
167
167
|
isDelegatedChild: facts.isDelegatedChild,
|
|
168
168
|
...(facts.insideFork === true ? { insideFork: true } : {}),
|
|
169
169
|
...(facts.agentName !== undefined ? { agentName: inlineUntrusted(facts.agentName.slice(0, 320), IDENTITY_AGENT_NAME_MAX) } : {}),
|
|
170
|
+
...(facts.spawnName !== undefined ? { spawnName: inlineUntrusted(facts.spawnName.slice(0, 320), IDENTITY_AGENT_NAME_MAX) } : {}),
|
|
171
|
+
...(facts.agentType !== undefined ? { agentType: inlineUntrusted(facts.agentType.slice(0, 320), IDENTITY_AGENT_NAME_MAX) } : {}),
|
|
170
172
|
...(facts.parentToolCallId !== undefined ? { parentToolCallId: facts.parentToolCallId } : {}),
|
|
171
173
|
}));
|
|
172
174
|
}
|
|
@@ -25,6 +25,11 @@ export interface MailboxMessage {
|
|
|
25
25
|
export interface MailboxPeerMeta {
|
|
26
26
|
/** The sending session's id (reply routing). */
|
|
27
27
|
fromSession?: string;
|
|
28
|
+
/** The delegation-tree ROOT the sender belongs to, stamped by the park seat of a session that
|
|
29
|
+
* received the frame through its own child uplink — never by the sender. A recipient whose
|
|
30
|
+
* session IS this root reads the record as a teammate's words (same tree = same admission as the
|
|
31
|
+
* live uplink: the admission gate runs, the cross-session parity hold does not). */
|
|
32
|
+
fromRoot?: string;
|
|
28
33
|
/** The sender's permission-mode class at send time — the drain judges parity against the RECEIVER. */
|
|
29
34
|
fromMode?: MailboxPeerFromMode;
|
|
30
35
|
/** The admission gate's sender key (rate/dedup axis). */
|
|
@@ -4,7 +4,7 @@ export const MAILBOX_PEER_RECORD_KINDS = ["peer_message", "idle_notice", "delive
|
|
|
4
4
|
export const MAILBOX_PEER_NOTICE_STATES = ["held", "denied", "expired", "delivered", "refused", "dropped", "idle", "exited", "unavailable"];
|
|
5
5
|
export const MAILBOX_INVALID_PEER_META_CODE = "mailbox.invalid_peer_meta";
|
|
6
6
|
export const MAILBOX_CROSS_PROCESS_UNSAFE_CODE = "mailbox.cross_process_unsafe";
|
|
7
|
-
const PEER_META_STRING_KEYS = ["fromSession", "senderKey", "fromScope", "gateReceiptId"];
|
|
7
|
+
const PEER_META_STRING_KEYS = ["fromSession", "fromRoot", "senderKey", "fromScope", "gateReceiptId"];
|
|
8
8
|
export function readMailboxPeerMeta(raw) {
|
|
9
9
|
if (raw === undefined)
|
|
10
10
|
return undefined;
|
|
@@ -9,10 +9,11 @@
|
|
|
9
9
|
* The checkpoint seat (`./checkpoint-seat.ts`) is the same shape for one field; this seat carries the rest.
|
|
10
10
|
*/
|
|
11
11
|
import type { OnQuestion } from "./ask-question.js";
|
|
12
|
+
import type { Hooks } from "./hooks.js";
|
|
12
13
|
import type { ToolExecuteContext } from "./tool-spec.js";
|
|
13
14
|
import type { TaskSpec } from "./types.js";
|
|
14
15
|
/** The fields of a spec the seat carries — one `Pick`, shared by the mint's input and the apply's target. */
|
|
15
|
-
export type ParentSpecSeatFields = Pick<TaskSpec, "principal" | "oneShot" | "clientContext" | "onQuestion" | "additionalDirectories" | "additionalReadDirectories" | "envFacts" | "memoryPersistenceCapable" | "memory">;
|
|
16
|
+
export type ParentSpecSeatFields = Pick<TaskSpec, "principal" | "oneShot" | "clientContext" | "onQuestion" | "hooks" | "additionalDirectories" | "additionalReadDirectories" | "envFacts" | "memoryPersistenceCapable" | "memory">;
|
|
16
17
|
/**
|
|
17
18
|
* The seat a run hands its children. Every member is the parent's own value, frozen at mint (arrays copied and
|
|
18
19
|
* frozen; the facts object copied). Members present iff the parent carries them — an absent member is "nothing to
|
|
@@ -22,6 +23,10 @@ export type ParentSpecSeatFields = Pick<TaskSpec, "principal" | "oneShot" | "cli
|
|
|
22
23
|
* · `clientContext` — the user's TZ/email facts (the child's environment block localizes the same way).
|
|
23
24
|
* · `onQuestion` — the parent's FROZEN question face (`spec.onQuestion ?? deps.onQuestion`, the value the parent's
|
|
24
25
|
* own AskUserQuestion resolves at): the child's question tool MOUNTS on it, so without it the child has no tool.
|
|
26
|
+
* · `hooks` — the parent's SPEC-level hook seams (`spec.hooks`, the request-level lifecycle hooks a host mounts from
|
|
27
|
+
* the user's settings). A deployment's `RunnerDeps.hooks` already serve every leg; the spec-level seams did not,
|
|
28
|
+
* so a `stop` seat a user wired ran for the leader and never for its teammates (CC runs `SubagentStop` from the
|
|
29
|
+
* same settings). Same law as `onQuestion`: a child's own value wins, the seat fills an unset one.
|
|
25
30
|
* · `additionalDirectories` / `additionalReadDirectories` — the fs-fence widening (deployment property).
|
|
26
31
|
* · `envFacts` — the sandbox facts (profile / egress / scratchpad).
|
|
27
32
|
* · `memoryPersistenceCapable` — the deployment's DECLARED persistence statement: `false` is a REMOVAL (floor),
|
|
@@ -34,6 +39,7 @@ export interface ParentSpecSeat {
|
|
|
34
39
|
readonly oneShot?: true;
|
|
35
40
|
readonly clientContext?: TaskSpec["clientContext"];
|
|
36
41
|
readonly onQuestion?: OnQuestion;
|
|
42
|
+
readonly hooks?: Hooks;
|
|
37
43
|
readonly additionalDirectories?: readonly string[];
|
|
38
44
|
readonly additionalReadDirectories?: readonly string[];
|
|
39
45
|
readonly envFacts?: TaskSpec["envFacts"];
|
|
@@ -4,6 +4,7 @@ export function parentSpecSeatOf(spec, frozenOnQuestion) {
|
|
|
4
4
|
...(spec.oneShot === true ? { oneShot: true } : {}),
|
|
5
5
|
...(spec.clientContext !== undefined ? { clientContext: spec.clientContext } : {}),
|
|
6
6
|
...(frozenOnQuestion !== undefined ? { onQuestion: frozenOnQuestion } : {}),
|
|
7
|
+
...(spec.hooks !== undefined ? { hooks: spec.hooks } : {}),
|
|
7
8
|
...(spec.additionalDirectories !== undefined ? { additionalDirectories: Object.freeze([...spec.additionalDirectories]) } : {}),
|
|
8
9
|
...(spec.additionalReadDirectories !== undefined ? { additionalReadDirectories: Object.freeze([...spec.additionalReadDirectories]) } : {}),
|
|
9
10
|
...(spec.envFacts !== undefined ? { envFacts: Object.freeze({ ...spec.envFacts }) } : {}),
|
|
@@ -20,6 +21,7 @@ export function applyParentSpecSeat(child, seat) {
|
|
|
20
21
|
...fill("principal", seat.principal),
|
|
21
22
|
...fill("clientContext", seat.clientContext),
|
|
22
23
|
...fill("onQuestion", seat.onQuestion),
|
|
24
|
+
...fill("hooks", seat.hooks),
|
|
23
25
|
...fill("additionalDirectories", (seat.additionalDirectories !== undefined ? [...seat.additionalDirectories] : undefined)),
|
|
24
26
|
...fill("additionalReadDirectories", (seat.additionalReadDirectories !== undefined ? [...seat.additionalReadDirectories] : undefined)),
|
|
25
27
|
...fill("envFacts", (seat.envFacts !== undefined ? { ...seat.envFacts } : undefined)),
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { randomBytes } from "node:crypto";
|
|
2
2
|
import { escapeForDisclosure, hasUnrenderableCharacters, parseRuleText, ruleAdmitsCommand, ruleBreadthWarningsOf, segmentCoverageOf, suggestRulesForCommand, RULE_BEHAVIORS, RULE_BEHAVIOR_PRECEDENCE, isRuleBehavior, } from "./permission-rule-model.js";
|
|
3
3
|
import { effectiveOrThrow } from "./permission-rule-provider.js";
|
|
4
|
-
import { errText, isValidConsentScope, sameRuleOwner, sameScope, writerOf } from "./permission-rule-store.js";
|
|
4
|
+
import { errText, isValidConsentScope, landRuleAdd, sameRuleOwner, sameScope, writerOf } from "./permission-rule-store.js";
|
|
5
5
|
export class InMemoryRuleApprovalRecordStore {
|
|
6
6
|
rows = new Map();
|
|
7
7
|
async get(id) {
|
|
@@ -600,7 +600,6 @@ export async function redeemRuleTicket(opts) {
|
|
|
600
600
|
}
|
|
601
601
|
return await applyRedemption({ deps: opts.deps, owner: caller, candidate, parsedRule: parsedRule.rule, dot, recordId: rec.id, kind: rec.kind, replay: known !== undefined, ...(siblingsOf !== undefined ? { siblingsOf } : {}) });
|
|
602
602
|
}
|
|
603
|
-
const REDEEM_MAX_ATTEMPTS = 8;
|
|
604
603
|
async function tighteningSiblingsStand(rec, offer, index, store, durableSnapshot) {
|
|
605
604
|
for (const member of offerMembersOf(offer)) {
|
|
606
605
|
if (member === index)
|
|
@@ -630,52 +629,35 @@ async function applyRedemption(args) {
|
|
|
630
629
|
if (writer === undefined)
|
|
631
630
|
return { status: "refused", reason: "the resolved permission-rule store has no write face" };
|
|
632
631
|
const origin = originOfRecordKind(args.kind);
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
632
|
+
const siblings = args.siblingsOf;
|
|
633
|
+
const landing = await landRuleAdd({
|
|
634
|
+
store,
|
|
635
|
+
writer,
|
|
636
|
+
delta: {
|
|
637
|
+
kind: "redemption-add",
|
|
638
|
+
behavior: args.parsedRule.behavior,
|
|
639
|
+
rule: args.parsedRule.rule,
|
|
640
|
+
scope: args.candidate.scope,
|
|
641
|
+
tool: args.parsedRule.tool,
|
|
642
|
+
match: args.parsedRule.match,
|
|
643
|
+
command: args.parsedRule.command,
|
|
644
|
+
add: { dot: args.dot, origin, createdAt: nowIso(args.deps) },
|
|
645
|
+
redemption: {
|
|
646
|
+
recordId: args.recordId,
|
|
647
|
+
...(args.owner.kind === "principal" ? { principal: args.owner.principal } : { owner: args.owner }),
|
|
648
|
+
},
|
|
649
|
+
},
|
|
650
|
+
...(siblings !== undefined
|
|
651
|
+
? {
|
|
652
|
+
preflight: async (snapshot) => (await tighteningSiblingsStand(siblings.rec, siblings.offer, siblings.index, store, snapshot))
|
|
653
|
+
? undefined
|
|
654
|
+
: "withheld: this allow belongs to a batch whose deny/ask members do not all stand in the store — an allow must not stand wider than the list that was confirmed; redeem the batch (redeemRuleBatch) so the tightening members land first, or draw a fresh card",
|
|
643
655
|
}
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
}
|
|
648
|
-
|
|
649
|
-
try {
|
|
650
|
-
res = await writer.apply({
|
|
651
|
-
kind: "redemption-add",
|
|
652
|
-
behavior: args.parsedRule.behavior,
|
|
653
|
-
rule: args.parsedRule.rule,
|
|
654
|
-
scope: args.candidate.scope,
|
|
655
|
-
tool: args.parsedRule.tool,
|
|
656
|
-
match: args.parsedRule.match,
|
|
657
|
-
command: args.parsedRule.command,
|
|
658
|
-
add: { dot: args.dot, origin, createdAt: nowIso(args.deps) },
|
|
659
|
-
redemption: {
|
|
660
|
-
recordId: args.recordId,
|
|
661
|
-
...(args.owner.kind === "principal" ? { principal: args.owner.principal } : { owner: args.owner }),
|
|
662
|
-
},
|
|
663
|
-
}, { expectedRev: rev });
|
|
664
|
-
}
|
|
665
|
-
catch (err) {
|
|
666
|
-
return { status: "refused", reason: `the permission-rule store refused the write: ${errText(err)}` };
|
|
667
|
-
}
|
|
668
|
-
if ("refused" in res) {
|
|
669
|
-
return {
|
|
670
|
-
status: "refused",
|
|
671
|
-
reason: "the session this authorization targets has ended — a session rule dies with its session, and a late or replayed redemption cannot revive it (re-trigger the command in a live session for a fresh card)",
|
|
672
|
-
};
|
|
673
|
-
}
|
|
674
|
-
if (!("conflict" in res)) {
|
|
675
|
-
return { status: "redeemed", behavior: args.parsedRule.behavior, rule: args.parsedRule.rule, scope: args.candidate.scope, dot: args.dot, rev: res.rev, alreadyRedeemed: args.replay };
|
|
676
|
-
}
|
|
677
|
-
}
|
|
678
|
-
return { status: "refused", reason: `optimistic-concurrency retries exhausted after ${REDEEM_MAX_ATTEMPTS} attempts` };
|
|
656
|
+
: {}),
|
|
657
|
+
});
|
|
658
|
+
if (!landing.landed)
|
|
659
|
+
return { status: "refused", reason: landing.reason };
|
|
660
|
+
return { status: "redeemed", behavior: args.parsedRule.behavior, rule: args.parsedRule.rule, scope: args.candidate.scope, dot: args.dot, rev: landing.rev, alreadyRedeemed: args.replay };
|
|
679
661
|
}
|
|
680
662
|
function originOfRecordKind(kind) {
|
|
681
663
|
return kind === "import" ? "imported-cc" : kind === "starter" ? "starter" : "user";
|
|
@@ -111,11 +111,12 @@ function routingWriter(durable, session) {
|
|
|
111
111
|
nextDot: () => durable.nextDot(),
|
|
112
112
|
readRaw: () => durable.readRaw(),
|
|
113
113
|
apply: async (delta, opts) => {
|
|
114
|
-
const
|
|
115
|
-
|
|
114
|
+
const isAdd = delta.kind === "redemption-add" || delta.kind === "tighten-add";
|
|
115
|
+
const scope = isAdd ? structuredClone(delta.scope) : undefined;
|
|
116
|
+
if (!isAdd || scope?.kind !== "session")
|
|
116
117
|
return durable.apply(delta, opts);
|
|
117
118
|
if (session === undefined) {
|
|
118
|
-
const e = new Error(
|
|
119
|
+
const e = new Error(`a session-scope rule has nowhere to land — this deployment configured no session rule partition (createPermissionRuleStoreProvider({ session })); refusing the ${delta.kind}`);
|
|
119
120
|
e.code = "unsupported.session_scope_store";
|
|
120
121
|
throw e;
|
|
121
122
|
}
|
|
@@ -150,6 +150,30 @@ export interface RuleAddDelta {
|
|
|
150
150
|
add: RuleAdd;
|
|
151
151
|
redemption: RedemptionAuthorization;
|
|
152
152
|
}
|
|
153
|
+
/**
|
|
154
|
+
* The TIGHTENING add — the true dual of {@link RuleDeleteDelta}, and the reason it is a separate arm
|
|
155
|
+
* rather than a `redemption-add` with the authorization left out: a delta named for a redemption that
|
|
156
|
+
* carries no redemption is a name that lies. This arm carries NO authorization block at all, exactly as
|
|
157
|
+
* `tighten-delete` does, because it needs none: a host may narrow on a person's behalf, it may not
|
|
158
|
+
* widen — so `behavior` excludes `allow` IN THE TYPE and the widening direction cannot reach this door.
|
|
159
|
+
* Minting a standing approval still takes a person's consent record, which is what `redemption-add` is.
|
|
160
|
+
*
|
|
161
|
+
* The QUARANTINE fence ({@link assertRedemptionNotQuarantined}) applies to `redemption-add` only, and
|
|
162
|
+
* not because it was forgotten here: the fence exists to stop a REPLAYED approval record from reviving
|
|
163
|
+
* an add its dot was quarantined for. A tightening add has no record and is never replayed — its dot is
|
|
164
|
+
* minted once, at the call — so there is no replay for the fence to catch.
|
|
165
|
+
*/
|
|
166
|
+
export interface RuleTightenAddDelta {
|
|
167
|
+
kind: "tighten-add";
|
|
168
|
+
/** The tightening members only. `allow` is not in the type: see this interface's doc. */
|
|
169
|
+
behavior: Exclude<RuleBehavior, "allow">;
|
|
170
|
+
rule: string;
|
|
171
|
+
scope: RuleScope;
|
|
172
|
+
tool: PersistedRule["tool"];
|
|
173
|
+
match: PersistedRule["match"];
|
|
174
|
+
command: string;
|
|
175
|
+
add: RuleAdd;
|
|
176
|
+
}
|
|
153
177
|
/** The delete half: appending one tombstone, and nothing else. */
|
|
154
178
|
export interface RuleDeleteDelta {
|
|
155
179
|
kind: "tighten-delete";
|
|
@@ -196,7 +220,7 @@ export interface RuleSyncJoinDelta {
|
|
|
196
220
|
* again AT THE BACKEND: the fifth door of design/179 §4's validator list closes here, not at the
|
|
197
221
|
* calling layer, and closes identically for every caller.
|
|
198
222
|
*/
|
|
199
|
-
export type RuleWriteDelta = RuleAddDelta | RuleDeleteDelta | RuleSyncJoinDelta;
|
|
223
|
+
export type RuleWriteDelta = RuleAddDelta | RuleTightenAddDelta | RuleDeleteDelta | RuleSyncJoinDelta;
|
|
200
224
|
/**
|
|
201
225
|
* The backend write face. Exported as part of the BACKEND CONTRACT (ruled 2026-08-10) so an
|
|
202
226
|
* out-of-repo store implementation hangs the same face the file backend does, instead of mirroring the
|
|
@@ -269,7 +293,7 @@ export declare function sameRuleIdentity(a: {
|
|
|
269
293
|
export declare function applyTombstones(rules: readonly PersistedRule[], tombstones: readonly RuleTombstone[]): PersistedRule[];
|
|
270
294
|
/** Fold one delta into a raw rule set, returning the new set. Pure — the backends share it so the file and
|
|
271
295
|
* in-memory forms cannot drift in what a delta MEANS. */
|
|
272
|
-
export declare function foldDelta(rules: readonly PersistedRule[], delta: RuleAddDelta): PersistedRule[];
|
|
296
|
+
export declare function foldDelta(rules: readonly PersistedRule[], delta: RuleAddDelta | RuleTightenAddDelta): PersistedRule[];
|
|
273
297
|
/** Every add dot present in a rule set. */
|
|
274
298
|
export declare function addDotsOf(rules: readonly PersistedRule[]): RuleDot[];
|
|
275
299
|
/**
|
|
@@ -307,7 +331,7 @@ export declare function assertDeleteDeltaCarriesNoAdd(delta: RuleDeleteDelta): v
|
|
|
307
331
|
* any lane — host-side import doors included — that writes rows into a persisted store carries the
|
|
308
332
|
* durable two-member face.
|
|
309
333
|
*/
|
|
310
|
-
export declare function assertWriteDeltaScopeDurable(delta: RuleAddDelta | RuleDeleteDelta): void;
|
|
334
|
+
export declare function assertWriteDeltaScopeDurable(delta: RuleAddDelta | RuleTightenAddDelta | RuleDeleteDelta): void;
|
|
311
335
|
/**
|
|
312
336
|
* The state one sync round exchanges: raw adds (tombstoned dots NOT pre-filtered) plus tombstones.
|
|
313
337
|
* The live view is always DERIVED (`applyTombstones`); the join below never deletes anything.
|
|
@@ -497,6 +521,16 @@ export declare function applySyncJoin(cur: RuleSyncJoinBase, delta: RuleSyncJoin
|
|
|
497
521
|
export declare function assertRedemptionNotQuarantined(quarantined: readonly QuarantinedRuleAdd[], delta: RuleAddDelta): void;
|
|
498
522
|
/** Integrity fingerprint over a store's own facts. Corruption detection, not tamper-proofing. */
|
|
499
523
|
export declare function ruleStoreChecksum(payload: unknown): Promise<string>;
|
|
524
|
+
/**
|
|
525
|
+
* What a removal may say about the rule identity it just tombstoned. THREE words, not a boolean, for the
|
|
526
|
+
* same reason {@link RuleAddLanding} has `committed: "no" | "unknown"`: the answer comes from a read-back,
|
|
527
|
+
* and a read-back can fail. `yes` and `no` are things the store said; `unknown` is the honest word for a
|
|
528
|
+
* look that did not happen — reporting `no` there would render "we could not see it" as "it is not there",
|
|
529
|
+
* which is the one sentence this domain refuses to say anywhere else ("we cannot see the rules" is never
|
|
530
|
+
* "there are none").
|
|
531
|
+
*/
|
|
532
|
+
export declare const REMOVAL_LIVENESS_WORDS: readonly ["yes", "no", "unknown"];
|
|
533
|
+
export type RemovalLiveness = (typeof REMOVAL_LIVENESS_WORDS)[number];
|
|
500
534
|
/**
|
|
501
535
|
* The result of a removal.
|
|
502
536
|
*
|
|
@@ -506,19 +540,26 @@ export declare function ruleStoreChecksum(payload: unknown): Promise<string>;
|
|
|
506
540
|
export type RemoveResult =
|
|
507
541
|
/** The tombstone landed and none of the observed dots survive. `stillLive` reports whether an approval
|
|
508
542
|
* that landed DURING this call keeps the rule alive anyway (add-wins) — a real outcome with a name,
|
|
509
|
-
* not an anomaly
|
|
543
|
+
* not an anomaly — or {@link RemovalLiveness} `unknown` when the read-back that answers it could not be
|
|
544
|
+
* taken. A consumer reading `unknown` as "removed" has re-introduced the very over-claim the third word
|
|
545
|
+
* exists to prevent: the tombstone landed, the rule's liveness is UNOBSERVED, reconcile with `list()`. */
|
|
510
546
|
{
|
|
511
547
|
status: "removed";
|
|
512
548
|
rev: number;
|
|
513
|
-
stillLive:
|
|
549
|
+
stillLive: RemovalLiveness;
|
|
514
550
|
}
|
|
515
551
|
/** Nothing to remove — including a repeated delete. No empty tombstone is minted. */
|
|
516
552
|
| {
|
|
517
553
|
status: "no-op";
|
|
518
554
|
rev: number;
|
|
519
555
|
}
|
|
520
|
-
/** Terminal failure,
|
|
521
|
-
*
|
|
556
|
+
/** Terminal failure, in one of two strengths the `error` text distinguishes — word for word the dual of
|
|
557
|
+
* {@link AddResult}'s. When the STORE answered every attempt (the compare-and-set simply lost), the
|
|
558
|
+
* read-back confirms nothing was written and the text says so plainly. When the apply THREW, a missing
|
|
559
|
+
* tombstone cannot tell a write that never landed from one whose evidence a paired collection erased
|
|
560
|
+
* (`collectBelowFrontier` recycles a tombstone together with the adds it covered), so that outcome is
|
|
561
|
+
* reported INDETERMINATE and the caller reconciles with `list()` rather than retrying blind. When the
|
|
562
|
+
* read-back itself failed, `error` says so and the caller reconciles with `list()` too. */
|
|
522
563
|
| {
|
|
523
564
|
status: "failed";
|
|
524
565
|
error: string;
|
|
@@ -544,6 +585,101 @@ export declare function removePersistedRule(opts: {
|
|
|
544
585
|
principal: string | RuleOwner;
|
|
545
586
|
provider: import("./permission-rule-provider.js").PermissionRuleStoreProvider;
|
|
546
587
|
}): Promise<RemoveResult>;
|
|
588
|
+
/**
|
|
589
|
+
* What the ONE add-write path answered. `landed` carries the revision the add committed at; every other
|
|
590
|
+
* terminal state is a single refusal string, which each door renders in its own vocabulary (the consent
|
|
591
|
+
* protocol says `refused`, the record-less entry says `failed`) — one mechanism, two spellings, never
|
|
592
|
+
* two mechanisms.
|
|
593
|
+
*/
|
|
594
|
+
export type RuleAddLanding = {
|
|
595
|
+
landed: true;
|
|
596
|
+
rev: number;
|
|
597
|
+
}
|
|
598
|
+
/** `committed` is what the caller may CLAIM afterwards. `"no"` means the store itself answered — a
|
|
599
|
+
* read that failed before any write, a refused preflight, an epoch seal, or a compare-and-set that
|
|
600
|
+
* lost every attempt: each of those is the store saying nothing landed. `"unknown"` means the apply
|
|
601
|
+
* THREW, which proves nothing either way: a backend can commit and then lose its acknowledgement. */
|
|
602
|
+
| {
|
|
603
|
+
landed: false;
|
|
604
|
+
reason: string;
|
|
605
|
+
committed: "no" | "unknown";
|
|
606
|
+
};
|
|
607
|
+
/**
|
|
608
|
+
* Land ONE add — the terminal write both doors take (the consent protocol's redemption and the
|
|
609
|
+
* record-less tightening entry below). Optimistic: every attempt re-reads the store, anchors the
|
|
610
|
+
* compare-and-set to that revision, and retries a conflict without rebuilding the add (the dot is the
|
|
611
|
+
* add's identity and is minted once, by the caller, so a retry can never mint a second).
|
|
612
|
+
*
|
|
613
|
+
* The scope routing is the writer's ({@link writerOf}): a session-scope add lands in the session
|
|
614
|
+
* partition, everything else in the durable one — one path, so the two doors cannot disagree about
|
|
615
|
+
* where a rule goes.
|
|
616
|
+
*/
|
|
617
|
+
export declare function landRuleAdd(args: {
|
|
618
|
+
store: import("./permission-rule-provider.js").PermissionRuleStore;
|
|
619
|
+
writer: PermissionRuleWriter;
|
|
620
|
+
/** Either add arm, whole. The kinds are dispatched EXHAUSTIVELY below, so a third add arm cannot be
|
|
621
|
+
* added to the union without this function being told what it means. */
|
|
622
|
+
delta: RuleAddDelta | RuleTightenAddDelta;
|
|
623
|
+
/** Judged on EVERY attempt's snapshot — the very revision the write below is anchored to, so a fact
|
|
624
|
+
* that changed in between conflicts the write and is re-judged. A returned string refuses. */
|
|
625
|
+
preflight?: (snapshot: import("./permission-rule-provider.js").EffectivePermissionRules) => Promise<string | undefined>;
|
|
626
|
+
}): Promise<RuleAddLanding>;
|
|
627
|
+
/**
|
|
628
|
+
* The result of an add — the dual of {@link RemoveResult}, word for word where the two mean the same
|
|
629
|
+
* thing. `conflict` is not a member for the same reason: the retry loop is inside.
|
|
630
|
+
*/
|
|
631
|
+
export type AddResult =
|
|
632
|
+
/** The add landed; `dot` is its audit identity (the same value a removal's tombstone will name).
|
|
633
|
+
* Deliberately WITHOUT a `stillLive` twin of {@link RemoveResult}: a removal's twin reports the
|
|
634
|
+
* semilattice's own add-wins resolution, a structural outcome of the write; a fresh dot has no such
|
|
635
|
+
* resolution to report, and a removal that lands afterwards is an ordinary later event `list()`
|
|
636
|
+
* answers. */
|
|
637
|
+
{
|
|
638
|
+
status: "added";
|
|
639
|
+
rev: number;
|
|
640
|
+
dot: RuleDot;
|
|
641
|
+
}
|
|
642
|
+
/** An equivalent rule is already live in this bucket — the instruction is already in effect, and no
|
|
643
|
+
* second dot is minted. Repeating the call is a no-op, exactly as repeating a delete is. */
|
|
644
|
+
| {
|
|
645
|
+
status: "no-op";
|
|
646
|
+
rev: number;
|
|
647
|
+
}
|
|
648
|
+
/** Terminal failure, in one of two strengths the `error` text distinguishes. When the STORE answered
|
|
649
|
+
* (a read that failed before any write, a refused preflight, an epoch seal, a compare-and-set that
|
|
650
|
+
* lost every attempt) nothing landed and the text says so plainly. When the apply THREW, the write
|
|
651
|
+
* may have committed and lost its acknowledgement: the read-back asks the raw face, where an add
|
|
652
|
+
* survives the tombstone a concurrent removal laid on it — but a sync collection can erase even
|
|
653
|
+
* that, so a dot it cannot find is reported INDETERMINATE rather than as a confirmed nothing, and
|
|
654
|
+
* the caller reconciles with `list()` instead of retrying blind (a blind retry mints a fresh dot and
|
|
655
|
+
* can reinstate a restriction somebody had just removed). */
|
|
656
|
+
| {
|
|
657
|
+
status: "failed";
|
|
658
|
+
error: string;
|
|
659
|
+
};
|
|
660
|
+
/**
|
|
661
|
+
* Add a persisted rule in ONE step, with no approval record.
|
|
662
|
+
*
|
|
663
|
+
* The dual of {@link removePersistedRule}, and bounded by the same asymmetry: a host may narrow on a
|
|
664
|
+
* person's behalf, it may not widen. So this entry writes the TIGHTENING direction only — `deny` and
|
|
665
|
+
* `ask` — and `allow` is not spellable here at all (minting a standing approval still takes a person's
|
|
666
|
+
* consent record: prepare → confirm → redeem). A JS caller that spells it anyway meets a loud refusal.
|
|
667
|
+
*
|
|
668
|
+
* The provenance is MINTED HERE as `user`: this entry takes no origin argument, so a host cannot claim
|
|
669
|
+
* a rule came from an import or a starter set. Before it existed, the only way to land a rule was to
|
|
670
|
+
* borrow the approval-record legs, which stamped the borrowed record's kind onto a write nobody had
|
|
671
|
+
* approved — a second, unverified story about where a rule came from.
|
|
672
|
+
*/
|
|
673
|
+
export declare function addPersistedRule(opts: {
|
|
674
|
+
/** The identity's behavior member. `allow` is deliberately not in the type — see the entry's doc. */
|
|
675
|
+
behavior: Exclude<RuleBehavior, "allow">;
|
|
676
|
+
rule: string;
|
|
677
|
+
scope: RuleScope;
|
|
678
|
+
/** Whose bucket — the same two spellings {@link removePersistedRule} takes: a bare string is the
|
|
679
|
+
* principal shorthand, the structural {@link RuleOwner} adds the local-owner bucket. */
|
|
680
|
+
principal: string | RuleOwner;
|
|
681
|
+
provider: import("./permission-rule-provider.js").PermissionRuleStoreProvider;
|
|
682
|
+
}): Promise<AddResult>;
|
|
547
683
|
export declare function errText(err: unknown): string;
|
|
548
684
|
/** A durable partition that always reports zero rules — what an unauthenticated principal resolves to. */
|
|
549
685
|
export declare const EMPTY_DURABLE_RULE_PARTITION: DurableRulePartition;
|