@sema-agent/core 7.8.0 → 7.9.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 +52 -1
- package/dist/agents/subagent.d.ts +1 -1
- package/dist/core/ask-origin.d.ts +12 -1
- package/dist/core/ask-origin.js +5 -1
- package/dist/core/checkpoint-store.d.ts +34 -28
- package/dist/core/gate-lanes.js +49 -23
- package/dist/core/gate-outcome.d.ts +8 -4
- package/dist/core/gate-outcome.js +2 -1
- package/dist/core/hooks.d.ts +56 -31
- package/dist/core/permission-rule-consent.d.ts +29 -36
- package/dist/core/permission-rule-consent.js +110 -60
- package/dist/core/permission-rule-model.d.ts +254 -55
- package/dist/core/permission-rule-model.js +323 -43
- package/dist/core/permission-rule-org.d.ts +9 -8
- package/dist/core/permission-rule-org.js +6 -15
- package/dist/core/permission-rule-provider.d.ts +10 -6
- package/dist/core/permission-rule-provider.js +12 -8
- package/dist/core/permission-rule-session.d.ts +7 -6
- package/dist/core/permission-rule-session.js +29 -10
- package/dist/core/permission-rule-store.d.ts +40 -18
- package/dist/core/permission-rule-store.js +68 -42
- package/dist/core/permission-rule-sync.d.ts +9 -3
- package/dist/core/permission-rule-sync.js +29 -25
- package/dist/core/permission-rule-syntax.d.ts +30 -0
- package/dist/core/permission-rule-syntax.js +44 -0
- package/dist/core/permission-rules.d.ts +55 -33
- package/dist/core/permission-rules.js +65 -55
- package/dist/core/persisted-rule-arms.d.ts +56 -0
- package/dist/core/persisted-rule-arms.js +48 -0
- package/dist/core/runner/contracts.d.ts +21 -2
- package/dist/core/runner/permission-rule-lanes.d.ts +33 -26
- package/dist/core/runner/permission-rule-lanes.js +27 -21
- package/dist/core/runner/prepare-gate-stations.js +1 -1
- package/dist/core/runner/prepare-safety-scan.js +8 -2
- package/dist/core/runner/prepare-task.js +1 -1
- package/dist/core/runner/resume-admission.d.ts +53 -0
- package/dist/core/runner/resume-admission.js +83 -0
- package/dist/core/runner/resume-apply.d.ts +50 -0
- package/dist/core/runner/resume-apply.js +184 -0
- package/dist/core/runner/resume-checkpoint-screen.d.ts +18 -0
- package/dist/core/runner/resume-checkpoint-screen.js +108 -0
- package/dist/core/runner/resume-claim.d.ts +32 -0
- package/dist/core/runner/resume-claim.js +27 -0
- package/dist/core/runner/resume-internals-and-config.d.ts +33 -0
- package/dist/core/runner/resume-internals-and-config.js +50 -0
- package/dist/core/runner/resume-policy-outcome.d.ts +31 -0
- package/dist/core/runner/resume-policy-outcome.js +127 -0
- package/dist/core/runner/resume-preflight.d.ts +40 -0
- package/dist/core/runner/resume-preflight.js +122 -0
- package/dist/core/runner/resume-review-outcome.d.ts +30 -0
- package/dist/core/runner/resume-review-outcome.js +88 -0
- package/dist/core/runner/run-harness-handlers.js +1 -1
- package/dist/core/runner/runtask.d.ts +20 -0
- package/dist/core/runner/runtask.js +85 -734
- package/dist/core/runner/tool-end-body.d.ts +12 -5
- package/dist/core/runner/tool-end-body.js +5 -5
- package/dist/core/runner/tool-output-projection.d.ts +9 -6
- package/dist/core/runner/tool-output-projection.js +1 -18
- package/dist/core/runner/turn-attachments.d.ts +2 -2
- package/dist/core/store-contracts/permission-rule-sync-contract.js +29 -10
- package/dist/core/tool-roster.d.ts +9 -0
- package/dist/core/tool-roster.js +15 -5
- package/dist/index.d.ts +2 -2
- package/dist/index.js +2 -2
- package/dist/stores/file/checkpoint-store.js +1 -1
- package/dist/stores/file/permission-rule-store.d.ts +28 -23
- package/dist/stores/file/permission-rule-store.js +64 -16
- package/dist/tools/fs/bash-readonly-classifier.d.ts +1 -1
- package/dist/tools/fs/fs-pdf.d.ts +1 -1
- package/dist/tools/fs/notebook.d.ts +1 -1
- package/dist/tools/fs/safety.d.ts +1 -1
- package/package.json +1 -1
- package/test/export-surface.snapshot.json +43 -11
package/dist/core/hooks.d.ts
CHANGED
|
@@ -1022,22 +1022,27 @@ export interface PersistedRuleHitRule {
|
|
|
1022
1022
|
readonly dots?: readonly import("./permission-rule-model.js").RuleDot[];
|
|
1023
1023
|
}
|
|
1024
1024
|
/**
|
|
1025
|
-
* design/252 G-2, re-formed by design/375
|
|
1026
|
-
* gives:
|
|
1027
|
-
*
|
|
1028
|
-
*
|
|
1029
|
-
*
|
|
1025
|
+
* design/252 G-2, re-formed by design/375 and again by the three-behavior model (BREAKING) — the
|
|
1026
|
+
* structured answer a persisted-rule lane gives: WHICH behavior spoke for the call, and the rule set it
|
|
1027
|
+
* stands on. For an `allow` the set is the COVERAGE SET that admits the call — a single rule admitting
|
|
1028
|
+
* the whole command is a one-element set (the historical case); a compound admitted by the conjunction
|
|
1029
|
+
* of per-segment rules (design/375 §5.1) is the set of those rules, in segment order. For a `deny` or an
|
|
1030
|
+
* `ask` the set is the one rule of that behavior that reaches the call (the lane's precedence walk —
|
|
1031
|
+
* deny > ask > allow — already chose it). `rules` is non-empty by contract — an empty set covers nothing
|
|
1032
|
+
* and is not a hit (the normalizer reads it as a clean negative).
|
|
1030
1033
|
*
|
|
1031
|
-
* Why dots and not an id: a persisted
|
|
1034
|
+
* Why dots and not an id: a persisted rule HAS no scalar id — it is a (behavior, text, scope) group whose
|
|
1032
1035
|
* identity is the set of add dots that redeemed it, and two concurrently approved records for one text
|
|
1033
1036
|
* each carry their own. The set is the identity; a joined string would not be one.
|
|
1034
1037
|
*
|
|
1035
|
-
* The pre-375 single-rule object shape (`{ rule, dots? }`)
|
|
1036
|
-
* unrecognized answer degrades to a clean negative (more asks),
|
|
1037
|
-
* shape for a lane that missed the same-window upgrade.
|
|
1038
|
+
* The pre-375 single-rule object shape (`{ rule, dots? }`) and the pre-behavior `{ rules }` shape are
|
|
1039
|
+
* NOT accepted: on this loosening seam an unrecognized answer degrades to a clean negative (more asks),
|
|
1040
|
+
* never to a match — the safe failure shape for a lane that missed the same-window upgrade.
|
|
1038
1041
|
*/
|
|
1039
1042
|
export interface PersistedRuleHit {
|
|
1040
|
-
/**
|
|
1043
|
+
/** What the deciding rule(s) say about the call — the closed set's word. */
|
|
1044
|
+
readonly behavior: import("./permission-rule-model.js").RuleBehavior;
|
|
1045
|
+
/** The rule set the verdict stands on. Non-empty. */
|
|
1041
1046
|
readonly rules: readonly PersistedRuleHitRule[];
|
|
1042
1047
|
}
|
|
1043
1048
|
/**
|
|
@@ -1065,8 +1070,9 @@ export interface PersistedRuleUnreadable {
|
|
|
1065
1070
|
export interface PersistedRuleCoverage {
|
|
1066
1071
|
readonly segmentCoverage: readonly import("./permission-rule-model.js").SegmentCoverage[];
|
|
1067
1072
|
}
|
|
1068
|
-
/** Every shape a lane may answer with.
|
|
1069
|
-
|
|
1073
|
+
/** Every shape a lane may answer with. The retired bare-string form (a single allow rule's text) is read
|
|
1074
|
+
* by the normalizer as a clean negative, like every other unrecognized shape. */
|
|
1075
|
+
export type PersistedRuleAnswer = PersistedRuleHit | PersistedRuleCoverage | PersistedRuleUnreadable | undefined;
|
|
1070
1076
|
/** Inputs to the two-phase tool gate. `adjudicate`/`resolveAsk` are pre-bound to the task abort
|
|
1071
1077
|
* signal; when the caller also supplies {@link ToolGateInput.callSignal}, the Runner's closures
|
|
1072
1078
|
* additionally bind their waits to that per-call signal (`AbortSignal.any` of the two), so a turn
|
|
@@ -1404,15 +1410,27 @@ export interface ToolGateInput {
|
|
|
1404
1410
|
*/
|
|
1405
1411
|
isMarkedUnresolvable?: (toolCallId: string) => boolean;
|
|
1406
1412
|
/**
|
|
1407
|
-
* design/179 — the persisted
|
|
1408
|
-
*
|
|
1413
|
+
* design/179 — the persisted-rule lane. Present only when a deployment wired a rule store; absent it,
|
|
1414
|
+
* this whole step does not exist and the gate's decision path is byte-identical to what it was.
|
|
1409
1415
|
*
|
|
1410
|
-
*
|
|
1411
|
-
*
|
|
1412
|
-
*
|
|
1413
|
-
* the
|
|
1416
|
+
* The lane answers with the BEHAVIOR that spoke ({@link PersistedRuleHit}), and the gate consumes the
|
|
1417
|
+
* three behaviors at two different places — the upstream arbiter's order, deny > ask > allow, across
|
|
1418
|
+
* the person's own standing rules:
|
|
1419
|
+
* · a DENY row refuses the call outright, on EVERY decision (an allow from the policy fold included):
|
|
1420
|
+
* it is the person's own "never run this" (their settings' deny list, imported), the personal-store
|
|
1421
|
+
* sibling of an org deny, and it runs right after the org layer — nothing downstream re-opens it;
|
|
1422
|
+
* · an ASK row is the person's standing "ask me each time": it turns a surviving allow into an ask
|
|
1423
|
+
* and stamps `matchedAskRule` on it (the same marker an explicit `ask` permission rule stamps), so
|
|
1424
|
+
* the ask's origin is `ask_rule` — the auto-mode classifier never answers it and a persisted ALLOW
|
|
1425
|
+
* never clears it; on a call that is already an ask it stamps the marker and leaves the rest;
|
|
1426
|
+
* · an ALLOW row is the standing form of ONE recorded human approval, so it resolves the asks a real
|
|
1427
|
+
* person saying "allow" would have resolved — and nothing else. It sits post-fold, at the entry of
|
|
1428
|
+
* the `ask` branch, BEFORE the classifier and AFTER every tighten: earlier and a tighten would
|
|
1429
|
+
* simply put the ask back; later and the classifier would already have spoken.
|
|
1414
1430
|
*
|
|
1415
|
-
*
|
|
1431
|
+
* The deny/ask arms tighten and take no conjunct but the two structural ones every lane shares (the
|
|
1432
|
+
* engine's own content-ask tool is exempt; the lane must be wired). The ALLOW arm's consumption
|
|
1433
|
+
* predicate is its own, not the classifier's blanket flip:
|
|
1416
1434
|
* · `requiresRealApproval` asks are never consumed — the two integrity gates are the floor, and the
|
|
1417
1435
|
* same flag is what a v2 organization-level ask will carry;
|
|
1418
1436
|
* · a PreToolUse hook's ask is never consumed — that is the deployment's own adjudication, and a rule
|
|
@@ -1441,19 +1459,26 @@ export interface ToolGateInput {
|
|
|
1441
1459
|
* bullets above are what keep integrity/hook/mandated asks out of that set.
|
|
1442
1460
|
*/
|
|
1443
1461
|
persistedRules?: {
|
|
1444
|
-
/** The
|
|
1445
|
-
*
|
|
1462
|
+
/** The lane's verdict for this call — a {@link PersistedRuleHit} naming the behavior that spoke and
|
|
1463
|
+
* the rule set it stands on, a coverage-carrying clean negative, an unreadable-store report, or
|
|
1464
|
+
* `undefined` (the store was READ and no rule speaks). Must not throw — and a store that cannot be
|
|
1465
|
+
* read answers `{ unreadable: true }`, NEVER `undefined`: the gate reads the unreadable report as
|
|
1466
|
+
* fail-closed (an allow tightens to a real-approval ask of origin `rule_store_unavailable`), while
|
|
1467
|
+
* `undefined` is a successful no-match that leaves an allow standing. An adapter that answers
|
|
1468
|
+
* `undefined` on a read failure silently disarms every deny/ask row it holds.
|
|
1446
1469
|
*
|
|
1447
|
-
*
|
|
1448
|
-
* single
|
|
1449
|
-
*
|
|
1450
|
-
*
|
|
1451
|
-
*
|
|
1452
|
-
*
|
|
1453
|
-
*
|
|
1454
|
-
*
|
|
1455
|
-
*
|
|
1456
|
-
|
|
1470
|
+
* For an ALLOW the set is the COVERAGE SET — each member's canonical text plus its add DOTS. A
|
|
1471
|
+
* single rule admitting the whole command is a one-element set; a compound admitted by the
|
|
1472
|
+
* per-segment conjunction (design/375 §5.1) is the set of the covering rules. The dots are a rule's
|
|
1473
|
+
* durable identity, and the text is not one (it reaches a card sanitized and capped); a lane that
|
|
1474
|
+
* can name them lets the shadowed-ask evidence say WHICH stored rules matched rather than merely
|
|
1475
|
+
* what they looked like. The retired shapes — a bare rule TEXT, the pre-375 `{ rule, dots? }` object,
|
|
1476
|
+
* a `{ rules }` hit without its behavior — are read as a clean negative by the normalizer: a stale
|
|
1477
|
+
* lane degrades toward asking, never toward an allow. Consulted on EVERY decision (a deny/ask row
|
|
1478
|
+
* must be able to tighten an allow), so the lane pays one store read per call on the tools it
|
|
1479
|
+
* speaks for; the runner's lane hands the org layer's read to it, so a governed deployment still
|
|
1480
|
+
* pays one. */
|
|
1481
|
+
adjudicate: (req: ToolCallRequest) => Promise<PersistedRuleAnswer>;
|
|
1457
1482
|
/** Observation sink for an ask a rule coverage resolved — the allow-side disclosure. `rules` is
|
|
1458
1483
|
* the coverage set's canonical texts in reporting order (design/375: a conjunction names every
|
|
1459
1484
|
* rule the decision stands on). Never affects the outcome. */
|
|
@@ -27,11 +27,14 @@
|
|
|
27
27
|
* this by editing a file backend's file. That is the settings-file trust model, stated rather than
|
|
28
28
|
* defended against: for a file backend, host = user, no more and no less.
|
|
29
29
|
*/
|
|
30
|
-
import { type EditedRuleBreadthWarning, type UncoveredSegmentDetail, type RuleOffer, type RuleRejectCode, type RuleScope, type RuleDot } from "./permission-rule-model.js";
|
|
30
|
+
import { type EditedRuleBreadthWarning, type UncoveredSegmentDetail, type RuleBehavior, type RuleOffer, type RuleRejectCode, type RuleScope, type RuleDot } from "./permission-rule-model.js";
|
|
31
31
|
import type { RuleOwner } from "./permission-rule-store.js";
|
|
32
32
|
import { type PermissionRuleStoreProvider } from "./permission-rule-provider.js";
|
|
33
|
-
/** One candidate rule inside an approval record: the exact text and where it
|
|
33
|
+
/** One candidate rule inside an approval record: what it says (`behavior`), the exact text and where it
|
|
34
|
+
* would apply. A card mints `allow` candidates only (a card is one person's yes); the settings import
|
|
35
|
+
* carries all three behaviors, each entry under the list it was read from. */
|
|
34
36
|
export interface RuleCandidate {
|
|
37
|
+
behavior: RuleBehavior;
|
|
35
38
|
rule: string;
|
|
36
39
|
scope: RuleScope;
|
|
37
40
|
}
|
|
@@ -95,9 +98,10 @@ export interface RuleApprovalRecord {
|
|
|
95
98
|
* as {@link StaleRuleApprovalRecord}. `3` (design/382 §3.5, B8/B7): the card batch's
|
|
96
99
|
* `uncoveredDetail` seat became REQUIRED and `edited.warnings` became storable — a `schema: 2`
|
|
97
100
|
* row is read back stale exactly like the pre-stamp rows before it (the short-lived-pending
|
|
98
|
-
* bargain, unchanged).
|
|
101
|
+
* bargain, unchanged). `4`: every candidate carries its `behavior` (the settings import now carries
|
|
102
|
+
* the deny/ask lists too) — a `schema: 3` row is stale by the same equality gate.
|
|
99
103
|
*/
|
|
100
|
-
schema:
|
|
104
|
+
schema: 4;
|
|
101
105
|
kind: RuleApprovalKind;
|
|
102
106
|
state: "pending" | "approved" | "redeemed";
|
|
103
107
|
candidates: RuleCandidate[];
|
|
@@ -167,7 +171,7 @@ export interface RuleApprovalRecord {
|
|
|
167
171
|
}
|
|
168
172
|
/**
|
|
169
173
|
* design/375 §4.5 — the minimal ENVELOPE a store hands back for a durable row that predates the
|
|
170
|
-
* CURRENT (`schema:
|
|
174
|
+
* CURRENT (`schema: 4`) record form. A typed read cannot honestly return such a row as a
|
|
171
175
|
* {@link RuleApprovalRecord}, and dropping it at the store layer would misreport "stale" as
|
|
172
176
|
* corruption — so the store contract is this variant instead: `staleSchema: true` plus the
|
|
173
177
|
* IDENTITY fields, which are spelled the same way in every version and therefore safe to read.
|
|
@@ -375,7 +379,7 @@ export declare function confirmRuleApproval(opts: {
|
|
|
375
379
|
/** Why a confirmation did not land. A closed set so a host can branch (re-present, re-fetch, give up).
|
|
376
380
|
* The two `record_*` members past `record_not_found` are the durable-row gates (design/375 §4.5/§4.7),
|
|
377
381
|
* disclosed to the record's own owner only — a non-owner gets `record_not_found` for every row:
|
|
378
|
-
* - `"record_schema_stale"` — the record predates the current (`schema:
|
|
382
|
+
* - `"record_schema_stale"` — the record predates the current (`schema: 4`) form; the decision it carried
|
|
379
383
|
* is void, and the recovery path is re-triggering the command for a fresh card (no
|
|
380
384
|
* compatibility read, no migration — `detail.message` says so);
|
|
381
385
|
* - `"record_malformed"` — the record's offer structure failed the read-side integrity checks
|
|
@@ -443,7 +447,7 @@ export type EditedRuleTextPrecheck = {
|
|
|
443
447
|
* for a surface that wants to answer while the person is still typing instead of after a round trip.
|
|
444
448
|
*
|
|
445
449
|
* It is the SAME body the confirmation arm runs (`checkEditedRuleText`), which is the whole point: a
|
|
446
|
-
* boundary that re-implements the check — or calls `
|
|
450
|
+
* boundary that re-implements the check — or calls `parseRuleText` directly — installs a SECOND,
|
|
447
451
|
* STRICTER judge that refuses spellings the edit face accepts (the muscle-memory `Bash(adb *)` form is
|
|
448
452
|
* exactly such a case: the validator alone refuses it as a wildcard; the edit face normalizes it first).
|
|
449
453
|
*
|
|
@@ -476,6 +480,7 @@ export declare function precheckEditedRuleText(text: string, command: string): E
|
|
|
476
480
|
* durable revision is reporting, not the landing's identity). */
|
|
477
481
|
export type RedeemResult = {
|
|
478
482
|
status: "redeemed";
|
|
483
|
+
behavior: RuleBehavior;
|
|
479
484
|
rule: string;
|
|
480
485
|
scope: RuleScope;
|
|
481
486
|
dot: RuleDot;
|
|
@@ -547,29 +552,13 @@ export interface ImportPreview {
|
|
|
547
552
|
layer: ImportedSettingsLayer;
|
|
548
553
|
found: boolean;
|
|
549
554
|
}>;
|
|
555
|
+
/** The two SETTINGS LAYERS this version does not read at all. The three permission lists of the layers it
|
|
556
|
+
* does read — allow, deny AND ask — are all imported (each candidate carries its `behavior`), so there
|
|
557
|
+
* is no longer a "left in place" count beside them: an entry that did not become a candidate is in
|
|
558
|
+
* `skipped` with its reason. */
|
|
550
559
|
uncovered: {
|
|
551
560
|
flagSettings: "not-imported-v1";
|
|
552
561
|
policySettings: "not-imported-v1";
|
|
553
|
-
/**
|
|
554
|
-
* The deny/ask buckets of the layers actually read: how many entries are SITTING THERE, across all
|
|
555
|
-
* layers, that this import deliberately leaves in place (they are the tightening direction and have
|
|
556
|
-
* their own channel — importing them through a loosening lane would be the wrong door).
|
|
557
|
-
*
|
|
558
|
-
* Always present, zero when the buckets are absent or empty: "this settings file has 14 deny entries
|
|
559
|
-
* we did not touch" and "there was nothing there" are different facts, and a key that appears only in
|
|
560
|
-
* the first case makes them indistinguishable for a reader who sees one preview. The count is of
|
|
561
|
-
* ENTRIES IN PLACE, not of a decision — nothing here is skipped-and-lost; `skipped` stays the list of
|
|
562
|
-
* allow-bucket entries that did not become candidates.
|
|
563
|
-
*
|
|
564
|
-
* A bucket that is PRESENT but not an array cannot be counted; it contributes 0 and is disclosed in
|
|
565
|
-
* `skipped` under the layer's path, so a zero is never the report for something unreadable. Same for
|
|
566
|
-
* a layer whose whole `permissions` member is not an object — every bucket in it reads as absent,
|
|
567
|
-
* which is exactly the shape that would otherwise report two clean zeros.
|
|
568
|
-
*/
|
|
569
|
-
denyAskBuckets: {
|
|
570
|
-
deny: number;
|
|
571
|
-
ask: number;
|
|
572
|
-
};
|
|
573
562
|
};
|
|
574
563
|
}
|
|
575
564
|
/**
|
|
@@ -591,6 +580,7 @@ export interface ImportPreview {
|
|
|
591
580
|
*/
|
|
592
581
|
export type RedeemedBatchMember = {
|
|
593
582
|
readonly candidateIndex: number;
|
|
583
|
+
readonly behavior: RuleBehavior;
|
|
594
584
|
readonly rule: string;
|
|
595
585
|
readonly scope: RuleScope;
|
|
596
586
|
readonly status: "persisted" | "deduped";
|
|
@@ -598,22 +588,25 @@ export type RedeemedBatchMember = {
|
|
|
598
588
|
readonly dot: RuleDot;
|
|
599
589
|
} | {
|
|
600
590
|
readonly candidateIndex: number;
|
|
591
|
+
readonly behavior: RuleBehavior;
|
|
601
592
|
readonly rule: string;
|
|
602
593
|
readonly scope: RuleScope;
|
|
603
594
|
readonly status: "refused";
|
|
604
595
|
readonly reason: string;
|
|
605
596
|
};
|
|
606
597
|
/**
|
|
607
|
-
* Read the allow
|
|
608
|
-
* approval record. Nothing is stored until someone confirms that record and the
|
|
609
|
-
* A preview with zero importable candidates returns NO `approvalId` — there is nothing
|
|
610
|
-
* and the absence says so (design/375 §4.1).
|
|
598
|
+
* Read the THREE permission lists (allow, deny, ask) of the user-editable settings layers and produce a
|
|
599
|
+
* preview plus a PENDING approval record. Nothing is stored until someone confirms that record and the
|
|
600
|
+
* batch is redeemed. A preview with zero importable candidates returns NO `approvalId` — there is nothing
|
|
601
|
+
* to confirm, and the absence says so (design/375 §4.1).
|
|
611
602
|
*
|
|
612
|
-
*
|
|
613
|
-
*
|
|
614
|
-
*
|
|
615
|
-
*
|
|
616
|
-
*
|
|
603
|
+
* Every list is read through the ONE validator under its own behavior: a deny/ask entry takes the
|
|
604
|
+
* tightening floor (`Bash(node:*)` as a deny is a legitimately wide tightening and imports), an allow
|
|
605
|
+
* entry the allow floor. A content-form deny/ask so imported is the person's own standing instruction
|
|
606
|
+
* and adjudicates in the persisted-rule lane beside their allows — deny > ask > allow — instead of
|
|
607
|
+
* being escorted by a shell into a bare tool-name ask that widens `Bash(rm -r:*)` into "every Bash call
|
|
608
|
+
* asks". An entry that does not import (a spelling with no rule form, a tool the lane does not speak
|
|
609
|
+
* for, a non-string) is DISCLOSED in `skipped` with its reason and stays in the settings file.
|
|
617
610
|
*/
|
|
618
611
|
export declare function prepareCcImport(opts: {
|
|
619
612
|
layers: CcImportLayer[];
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { randomBytes } from "node:crypto";
|
|
2
|
-
import { escapeForDisclosure, hasUnrenderableCharacters,
|
|
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
4
|
import { errText, isValidConsentScope, sameRuleOwner, sameScope, writerOf } from "./permission-rule-store.js";
|
|
5
5
|
export class InMemoryRuleApprovalRecordStore {
|
|
@@ -8,7 +8,7 @@ export class InMemoryRuleApprovalRecordStore {
|
|
|
8
8
|
const r = this.rows.get(id);
|
|
9
9
|
if (r === undefined)
|
|
10
10
|
return undefined;
|
|
11
|
-
if (r.schema !==
|
|
11
|
+
if (r.schema !== 4) {
|
|
12
12
|
return {
|
|
13
13
|
staleSchema: true,
|
|
14
14
|
id: r.id,
|
|
@@ -122,6 +122,12 @@ function approvalRecordDamageOf(rec) {
|
|
|
122
122
|
if (typeof cand !== "object" || cand === null || typeof cand.rule !== "string") {
|
|
123
123
|
return `offer ${at} references candidate ${member}, which is not a candidate row`;
|
|
124
124
|
}
|
|
125
|
+
if (!isRuleBehavior(cand.behavior)) {
|
|
126
|
+
return `offer ${at} references candidate ${member}, whose behavior is not one of ${RULE_BEHAVIORS.join("/")}`;
|
|
127
|
+
}
|
|
128
|
+
if (rec.kind === "card" && cand.behavior !== "allow") {
|
|
129
|
+
return `card offer ${at} references candidate ${member}, a ${cand.behavior} rule — a card mints allow candidates only`;
|
|
130
|
+
}
|
|
125
131
|
if (!isValidConsentScope(cand.scope)) {
|
|
126
132
|
return `offer ${at} references candidate ${member}, whose scope is not a consent-face scope (global, project, or session)`;
|
|
127
133
|
}
|
|
@@ -198,10 +204,11 @@ function approvalRecordDamageOf(rec) {
|
|
|
198
204
|
}
|
|
199
205
|
export function ruleOffersOfRecord(rec) {
|
|
200
206
|
const tripleOf = (index) => {
|
|
201
|
-
const
|
|
202
|
-
|
|
207
|
+
const cand = rec.candidates[index];
|
|
208
|
+
const text = cand?.rule;
|
|
209
|
+
if (cand === undefined || text === undefined)
|
|
203
210
|
throw new Error(`approval-record offer references candidate ${index}, which the record does not carry`);
|
|
204
|
-
const parsed =
|
|
211
|
+
const parsed = parseRuleText(text, cand.behavior);
|
|
205
212
|
if ("reject" in parsed) {
|
|
206
213
|
throw new Error(`approval-record candidate ${index} does not parse as a rule (${parsed.reject.code}) — refusing to render it as one`);
|
|
207
214
|
}
|
|
@@ -318,7 +325,7 @@ export async function prepareCardApproval(opts) {
|
|
|
318
325
|
if (claimedBy.has(text))
|
|
319
326
|
throw new Error(`engine defect: rule "${text}" was minted into two offers on one card`);
|
|
320
327
|
const index = candidates.length;
|
|
321
|
-
candidates.push({ rule: text, scope });
|
|
328
|
+
candidates.push({ behavior: "allow", rule: text, scope });
|
|
322
329
|
claimedBy.set(text, index);
|
|
323
330
|
return index;
|
|
324
331
|
};
|
|
@@ -336,7 +343,7 @@ export async function prepareCardApproval(opts) {
|
|
|
336
343
|
const record = {
|
|
337
344
|
id: mintId(opts.deps, "rar"),
|
|
338
345
|
...recordIdentityOf(owner),
|
|
339
|
-
schema:
|
|
346
|
+
schema: 4,
|
|
340
347
|
kind: "card",
|
|
341
348
|
state: "pending",
|
|
342
349
|
rev: 0,
|
|
@@ -427,7 +434,7 @@ function normalizeEditedSpelling(text) {
|
|
|
427
434
|
return `${head}(${body}:*)`;
|
|
428
435
|
}
|
|
429
436
|
function parseSubmittedSpelling(text) {
|
|
430
|
-
return
|
|
437
|
+
return parseRuleText(normalizeEditedSpelling(text), "allow");
|
|
431
438
|
}
|
|
432
439
|
function checkEditedRuleText(text, command) {
|
|
433
440
|
const parsed = parseSubmittedSpelling(text);
|
|
@@ -514,7 +521,7 @@ async function confirmEditedCandidate(rec, edit, deps) {
|
|
|
514
521
|
...rec,
|
|
515
522
|
rev: rec.rev + 1,
|
|
516
523
|
state: "approved",
|
|
517
|
-
candidates: [...rec.candidates, { rule: checked.canonicalRule, scope }],
|
|
524
|
+
candidates: [...rec.candidates, { behavior: "allow", rule: checked.canonicalRule, scope }],
|
|
518
525
|
offers: [...rec.offers, { kind: "single", candidate: index }],
|
|
519
526
|
selectedOffer: rec.offers.length,
|
|
520
527
|
edited: { index, text: edit.text, at: nowIso(deps), ...(checked.warnings !== undefined ? { warnings: checked.warnings.map((w) => w.code) } : {}) },
|
|
@@ -559,7 +566,8 @@ export async function redeemRuleTicket(opts) {
|
|
|
559
566
|
if (!offerMembersOf(chosenOffer).includes(parsed.index)) {
|
|
560
567
|
return { status: "refused", reason: "the ticket names an option the person did not choose" };
|
|
561
568
|
}
|
|
562
|
-
const
|
|
569
|
+
const siblingsOf = candidate.behavior === "allow" ? { rec, offer: chosenOffer, index: parsed.index } : undefined;
|
|
570
|
+
const parsedRule = parseRuleText(candidate.rule, candidate.behavior);
|
|
563
571
|
if ("reject" in parsedRule)
|
|
564
572
|
return { status: "refused", reason: `${parsedRule.reject.code}: ${parsedRule.reject.message}` };
|
|
565
573
|
let store;
|
|
@@ -587,12 +595,29 @@ export async function redeemRuleTicket(opts) {
|
|
|
587
595
|
const theirs = again === undefined || isStale(again) ? undefined : again.redeemedDots?.[parsed.index];
|
|
588
596
|
if (theirs === undefined)
|
|
589
597
|
return { status: "refused", reason: "the approval record changed state concurrently" };
|
|
590
|
-
return await applyRedemption({ deps: opts.deps, owner: caller, candidate, parsedRule: parsedRule.rule, dot: theirs, recordId: rec.id, kind: rec.kind, replay: true });
|
|
598
|
+
return await applyRedemption({ deps: opts.deps, owner: caller, candidate, parsedRule: parsedRule.rule, dot: theirs, recordId: rec.id, kind: rec.kind, replay: true, ...(siblingsOf !== undefined ? { siblingsOf } : {}) });
|
|
591
599
|
}
|
|
592
600
|
}
|
|
593
|
-
return await applyRedemption({ deps: opts.deps, owner: caller, candidate, parsedRule: parsedRule.rule, dot, recordId: rec.id, kind: rec.kind, replay: known !== undefined });
|
|
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 } : {}) });
|
|
594
602
|
}
|
|
595
603
|
const REDEEM_MAX_ATTEMPTS = 8;
|
|
604
|
+
async function tighteningSiblingsStand(rec, offer, index, store, durableSnapshot) {
|
|
605
|
+
for (const member of offerMembersOf(offer)) {
|
|
606
|
+
if (member === index)
|
|
607
|
+
continue;
|
|
608
|
+
const sibling = rec.candidates[member];
|
|
609
|
+
if (sibling === undefined || sibling.behavior === "allow")
|
|
610
|
+
continue;
|
|
611
|
+
const dot = rec.redeemedDots?.[member];
|
|
612
|
+
if (dot === undefined)
|
|
613
|
+
return false;
|
|
614
|
+
const view = sibling.scope.kind === "session" ? await effectiveOrThrow(store, { sessionId: sibling.scope.sessionId }) : durableSnapshot;
|
|
615
|
+
const live = view.rules.some((r) => r.behavior === sibling.behavior && r.rule === sibling.rule && sameScope(r.scope, sibling.scope) && r.adds.some((a) => a.dot.actor === dot.actor && a.dot.counter === dot.counter));
|
|
616
|
+
if (!live)
|
|
617
|
+
return false;
|
|
618
|
+
}
|
|
619
|
+
return true;
|
|
620
|
+
}
|
|
596
621
|
async function applyRedemption(args) {
|
|
597
622
|
let store;
|
|
598
623
|
try {
|
|
@@ -608,7 +633,14 @@ async function applyRedemption(args) {
|
|
|
608
633
|
for (let attempt = 0; attempt < REDEEM_MAX_ATTEMPTS; attempt++) {
|
|
609
634
|
let rev;
|
|
610
635
|
try {
|
|
611
|
-
|
|
636
|
+
const snapshot = await effectiveOrThrow(store);
|
|
637
|
+
rev = snapshot.rev;
|
|
638
|
+
if (args.siblingsOf !== undefined && !(await tighteningSiblingsStand(args.siblingsOf.rec, args.siblingsOf.offer, args.siblingsOf.index, store, snapshot))) {
|
|
639
|
+
return {
|
|
640
|
+
status: "refused",
|
|
641
|
+
reason: "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",
|
|
642
|
+
};
|
|
643
|
+
}
|
|
612
644
|
}
|
|
613
645
|
catch (err) {
|
|
614
646
|
return { status: "refused", reason: `could not read the permission-rule store: ${errText(err)}` };
|
|
@@ -617,6 +649,7 @@ async function applyRedemption(args) {
|
|
|
617
649
|
try {
|
|
618
650
|
res = await writer.apply({
|
|
619
651
|
kind: "redemption-add",
|
|
652
|
+
behavior: args.parsedRule.behavior,
|
|
620
653
|
rule: args.parsedRule.rule,
|
|
621
654
|
scope: args.candidate.scope,
|
|
622
655
|
tool: args.parsedRule.tool,
|
|
@@ -639,7 +672,7 @@ async function applyRedemption(args) {
|
|
|
639
672
|
};
|
|
640
673
|
}
|
|
641
674
|
if (!("conflict" in res)) {
|
|
642
|
-
return { status: "redeemed", rule: args.parsedRule.rule, scope: args.candidate.scope, dot: args.dot, rev: res.rev, alreadyRedeemed: args.replay };
|
|
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 };
|
|
643
676
|
}
|
|
644
677
|
}
|
|
645
678
|
return { status: "refused", reason: `optimistic-concurrency retries exhausted after ${REDEEM_MAX_ATTEMPTS} attempts` };
|
|
@@ -654,7 +687,6 @@ export async function prepareCcImport(opts) {
|
|
|
654
687
|
const skipped = [];
|
|
655
688
|
const translated = [];
|
|
656
689
|
const layers = [];
|
|
657
|
-
const denyAskBuckets = { deny: 0, ask: 0 };
|
|
658
690
|
for (const layer of opts.layers) {
|
|
659
691
|
let raw;
|
|
660
692
|
try {
|
|
@@ -700,66 +732,55 @@ export async function prepareCcImport(opts) {
|
|
|
700
732
|
});
|
|
701
733
|
continue;
|
|
702
734
|
}
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
735
|
+
const scope = layer.layer === "userSettings" ? { kind: "global" } : { kind: "project", root: layer.root };
|
|
736
|
+
for (const behavior of RULE_BEHAVIORS) {
|
|
737
|
+
const entries = permissions?.[behavior];
|
|
738
|
+
if (entries === undefined || entries === null)
|
|
739
|
+
continue;
|
|
740
|
+
if (!Array.isArray(entries)) {
|
|
708
741
|
skipped.push({
|
|
709
742
|
rule: layer.path,
|
|
710
|
-
reason: `settings ${
|
|
743
|
+
reason: `settings ${behavior} bucket is not an array — no entry in it could be read (it stays in the settings file either way)`,
|
|
711
744
|
});
|
|
712
|
-
}
|
|
713
|
-
}
|
|
714
|
-
const allow = permissions?.allow;
|
|
715
|
-
if (!Array.isArray(allow))
|
|
716
|
-
continue;
|
|
717
|
-
const scope = layer.layer === "userSettings" ? { kind: "global" } : { kind: "project", root: layer.root };
|
|
718
|
-
for (const entry of allow) {
|
|
719
|
-
if (typeof entry !== "string") {
|
|
720
|
-
skipped.push({ rule: String(entry), reason: "settings entry is not a string" });
|
|
721
745
|
continue;
|
|
722
746
|
}
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
|
|
747
|
+
for (const entry of entries) {
|
|
748
|
+
if (typeof entry !== "string") {
|
|
749
|
+
skipped.push({ rule: String(entry), reason: `settings ${behavior} entry is not a string` });
|
|
750
|
+
continue;
|
|
751
|
+
}
|
|
752
|
+
if (behavior === "allow" && entry.startsWith("Read(") && "reject" in parseRuleText(entry, behavior)) {
|
|
726
753
|
skipped.push({
|
|
727
754
|
rule: entry,
|
|
728
755
|
reason: "unsupported.form: only the Read(//abs-dir/**) directory form imports in v1 — this entry stays in the settings file, unimported",
|
|
729
756
|
});
|
|
730
757
|
continue;
|
|
731
758
|
}
|
|
732
|
-
if (
|
|
733
|
-
|
|
734
|
-
}
|
|
735
|
-
continue;
|
|
736
|
-
}
|
|
737
|
-
if (!entry.startsWith("Bash(")) {
|
|
738
|
-
const reason = /^[A-Za-z][A-Za-z0-9_]*\(.*\)$/.test(entry)
|
|
739
|
-
? "unsupported.tool: only Bash(...) command rules and Read(//abs-dir/**) directory rules import in v1 — this entry stays in the settings file, unimported"
|
|
740
|
-
: /^[A-Za-z][A-Za-z0-9_-]*$/.test(entry)
|
|
759
|
+
if (!/^[A-Za-z][A-Za-z0-9_]*\(.*\)$/.test(entry)) {
|
|
760
|
+
const reason = /^[A-Za-z][A-Za-z0-9_-]*$/.test(entry)
|
|
741
761
|
? "unsupported.form: a bare tool-name entry is a name-set item, not a command rule — it stays in the settings file, unimported"
|
|
742
762
|
: "unsupported.form: not a command rule spelling this version imports — it stays in the settings file, unimported";
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
|
|
763
|
+
skipped.push({ rule: entry, reason });
|
|
764
|
+
continue;
|
|
765
|
+
}
|
|
766
|
+
const parsed = parseRuleText(entry, behavior);
|
|
767
|
+
if ("reject" in parsed) {
|
|
768
|
+
skipped.push({ rule: entry, reason: `${parsed.reject.code}: ${parsed.reject.message}` });
|
|
769
|
+
continue;
|
|
770
|
+
}
|
|
771
|
+
if (candidates.some((c) => c.behavior === behavior && c.rule === parsed.rule.rule && sameScope(c.scope, scope)))
|
|
772
|
+
continue;
|
|
773
|
+
candidates.push({ behavior, rule: parsed.rule.rule, scope });
|
|
750
774
|
}
|
|
751
|
-
if (candidates.some((c) => c.rule === parsed.rule.rule && sameScope(c.scope, scope)))
|
|
752
|
-
continue;
|
|
753
|
-
candidates.push({ rule: parsed.rule.rule, scope });
|
|
754
775
|
}
|
|
755
776
|
}
|
|
756
|
-
const preview = { candidates, skipped, translated, layers, uncovered: { ...IMPORT_UNCOVERED_LAYERS
|
|
777
|
+
const preview = { candidates, skipped, translated, layers, uncovered: { ...IMPORT_UNCOVERED_LAYERS } };
|
|
757
778
|
if (candidates.length === 0)
|
|
758
779
|
return { preview };
|
|
759
780
|
const record = {
|
|
760
781
|
id: mintId(opts.deps, "rar"),
|
|
761
782
|
...recordIdentityOf(owner),
|
|
762
|
-
schema:
|
|
783
|
+
schema: 4,
|
|
763
784
|
kind: "import",
|
|
764
785
|
state: "pending",
|
|
765
786
|
rev: 0,
|
|
@@ -785,15 +806,15 @@ export async function prepareStarterBatch(opts) {
|
|
|
785
806
|
const owner = resolveCallerOwner(opts.principal, opts.owner, "prepareStarterBatch");
|
|
786
807
|
const candidates = [];
|
|
787
808
|
for (const text of STARTER_RULES) {
|
|
788
|
-
const parsed =
|
|
809
|
+
const parsed = parseRuleText(text, "allow");
|
|
789
810
|
if ("reject" in parsed)
|
|
790
811
|
throw new Error(`starter rule "${text}" is not a valid rule: ${parsed.reject.message}`);
|
|
791
|
-
candidates.push({ rule: parsed.rule.rule, scope: { kind: "global" } });
|
|
812
|
+
candidates.push({ behavior: "allow", rule: parsed.rule.rule, scope: { kind: "global" } });
|
|
792
813
|
}
|
|
793
814
|
const record = {
|
|
794
815
|
id: mintId(opts.deps, "rar"),
|
|
795
816
|
...recordIdentityOf(owner),
|
|
796
|
-
schema:
|
|
817
|
+
schema: 4,
|
|
797
818
|
kind: "starter",
|
|
798
819
|
state: "pending",
|
|
799
820
|
rev: 0,
|
|
@@ -833,18 +854,30 @@ export async function redeemRuleBatch(opts) {
|
|
|
833
854
|
}
|
|
834
855
|
const members = [];
|
|
835
856
|
let rev = 0;
|
|
836
|
-
|
|
857
|
+
const ordered = [...offerMembersOf(chosenOffer)].sort((a, b) => {
|
|
858
|
+
const ra = RULE_BEHAVIOR_PRECEDENCE[rec.candidates[a].behavior];
|
|
859
|
+
const rb = RULE_BEHAVIOR_PRECEDENCE[rec.candidates[b].behavior];
|
|
860
|
+
return ra !== rb ? ra - rb : a - b;
|
|
861
|
+
});
|
|
862
|
+
let tighteningFailed;
|
|
863
|
+
for (const candidateIndex of ordered) {
|
|
837
864
|
const candidate = rec.candidates[candidateIndex];
|
|
838
|
-
const row = { candidateIndex, rule: candidate.rule, scope: candidate.scope };
|
|
865
|
+
const row = { candidateIndex, behavior: candidate.behavior, rule: candidate.rule, scope: candidate.scope };
|
|
866
|
+
if (candidate.behavior === "allow" && tighteningFailed !== undefined) {
|
|
867
|
+
members.push({ ...row, status: "refused", reason: `withheld: candidate ${tighteningFailed} (a deny/ask member of this batch) did not land, and an allow must not stand wider than the list that was confirmed — redeem the batch again once the store accepts the tightening member` });
|
|
868
|
+
continue;
|
|
869
|
+
}
|
|
839
870
|
let alreadyThere = false;
|
|
840
871
|
{
|
|
841
872
|
try {
|
|
842
873
|
const snap = await effectiveOrThrow(store, candidate.scope.kind === "session" ? { sessionId: candidate.scope.sessionId } : undefined);
|
|
843
874
|
rev = snap.rev;
|
|
844
|
-
alreadyThere = snap.rules.some((r) => r.rule === candidate.rule && sameScope(r.scope, candidate.scope));
|
|
875
|
+
alreadyThere = snap.rules.some((r) => r.behavior === candidate.behavior && r.rule === candidate.rule && sameScope(r.scope, candidate.scope));
|
|
845
876
|
}
|
|
846
877
|
catch (err) {
|
|
847
878
|
members.push({ ...row, status: "refused", reason: `could not read the permission-rule store: ${errText(err)}` });
|
|
879
|
+
if (candidate.behavior !== "allow")
|
|
880
|
+
tighteningFailed ??= candidateIndex;
|
|
848
881
|
continue;
|
|
849
882
|
}
|
|
850
883
|
}
|
|
@@ -855,8 +888,25 @@ export async function redeemRuleBatch(opts) {
|
|
|
855
888
|
});
|
|
856
889
|
if (res.status === "refused") {
|
|
857
890
|
members.push({ ...row, status: "refused", reason: res.reason });
|
|
891
|
+
if (candidate.behavior !== "allow")
|
|
892
|
+
tighteningFailed ??= candidateIndex;
|
|
858
893
|
continue;
|
|
859
894
|
}
|
|
895
|
+
if (candidate.behavior !== "allow") {
|
|
896
|
+
let live = false;
|
|
897
|
+
try {
|
|
898
|
+
const snap = await effectiveOrThrow(store, candidate.scope.kind === "session" ? { sessionId: candidate.scope.sessionId } : undefined);
|
|
899
|
+
live = snap.rules.some((r) => r.behavior === candidate.behavior && r.rule === candidate.rule && sameScope(r.scope, candidate.scope) && r.adds.some((a) => a.dot.actor === res.dot.actor && a.dot.counter === res.dot.counter));
|
|
900
|
+
}
|
|
901
|
+
catch {
|
|
902
|
+
live = false;
|
|
903
|
+
}
|
|
904
|
+
if (!live) {
|
|
905
|
+
members.push({ ...row, status: "refused", reason: `this ${candidate.behavior} member's redeemed dot ${res.dot.actor}#${res.dot.counter} is not live in the store (removed since it was redeemed) — a removed rule is not re-landed by replaying its record; a new consent is required` });
|
|
906
|
+
tighteningFailed ??= candidateIndex;
|
|
907
|
+
continue;
|
|
908
|
+
}
|
|
909
|
+
}
|
|
860
910
|
rev = candidate.scope.kind === "session" ? Math.max(rev, res.rev) : res.rev;
|
|
861
911
|
members.push({ ...row, status: alreadyThere ? "deduped" : "persisted", alreadyRedeemed: res.alreadyRedeemed, dot: res.dot });
|
|
862
912
|
}
|