@sema-agent/core 5.27.0 → 5.29.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 +97 -0
- package/dist/agents/cascade.d.ts +1 -1
- package/dist/agents/observer.d.ts +3 -3
- package/dist/agents/peer-admission.d.ts +1 -1
- package/dist/agents/teacher.d.ts +1 -1
- package/dist/agents/verify.d.ts +6 -6
- package/dist/bench/metrics.d.ts +1 -1
- package/dist/brain/retry.d.ts +1 -1
- package/dist/core/arg-summary.d.ts +1 -1
- package/dist/core/ask-class.d.ts +2 -2
- package/dist/core/ask-question.d.ts +1 -1
- package/dist/core/ask-question.js +14 -1
- package/dist/core/background-shell.d.ts +5 -5
- package/dist/core/checkpoint-store.d.ts +14 -14
- package/dist/core/file-snapshot-store.d.ts +1 -1
- package/dist/core/hooks.d.ts +25 -3
- package/dist/core/hooks.js +22 -3
- package/dist/core/human-input-projection.d.ts +2 -2
- package/dist/core/memory-admission.d.ts +2 -2
- package/dist/core/memory-engine/engine.d.ts +1 -1
- package/dist/core/memory-engine/engine.js +2 -4
- package/dist/core/memory-engine/file-backend.d.ts +68 -9
- package/dist/core/memory-engine/file-backend.js +69 -27
- package/dist/core/memory-engine/header-hints.d.ts +1 -1
- package/dist/core/memory-engine/layout.d.ts +32 -3
- package/dist/core/memory-engine/layout.js +132 -8
- package/dist/core/memory-engine/types.d.ts +6 -5
- package/dist/core/memory-recall.d.ts +1 -1
- package/dist/core/memory.d.ts +2 -2
- package/dist/core/oracle-isolation.d.ts +2 -2
- package/dist/core/permission-rule-consent.d.ts +83 -9
- package/dist/core/permission-rule-consent.js +92 -1
- package/dist/core/permission-rule-model.d.ts +18 -2
- package/dist/core/permission-rule-model.js +21 -0
- package/dist/core/permission-rule-org.d.ts +23 -4
- package/dist/core/permission-rule-org.js +67 -20
- package/dist/core/permission-rule-store.d.ts +1 -1
- package/dist/core/permission-rule-store.js +2 -2
- package/dist/core/permission-rule-sync.d.ts +15 -1
- package/dist/core/permission-rule-sync.js +89 -47
- package/dist/core/runner/active-skill-scope.d.ts +1 -1
- package/dist/core/runner/memory-consolidation.d.ts +1 -1
- package/dist/core/runner/prepare-task.d.ts +8 -3
- package/dist/core/runner/prepare-task.js +22 -7
- package/dist/core/runner/runtask.d.ts +1 -1
- package/dist/core/runner/runtask.js +13 -6
- package/dist/core/runner/session-rule-policy.d.ts +2 -2
- package/dist/core/runner/teardown-bounded.d.ts +1 -1
- package/dist/core/runner/tool-disclosure.d.ts +1 -1
- package/dist/core/scheduler.d.ts +4 -4
- package/dist/core/session-policy-store.d.ts +3 -3
- package/dist/core/shared-memory/normalize.d.ts +1 -1
- package/dist/core/skill-tool-specifier.d.ts +1 -1
- package/dist/core/task-notification.d.ts +2 -2
- package/dist/core/task-registry-agent.d.ts +18 -9
- package/dist/core/task-registry-agent.js +51 -21
- package/dist/core/task-registry-monitor.js +1 -1
- package/dist/core/task-registry-shared.d.ts +13 -4
- package/dist/core/tool-errors.d.ts +2 -2
- package/dist/core/tool-policy.d.ts +78 -12
- package/dist/core/tool-policy.js +74 -7
- package/dist/core/tool-result-store.d.ts +109 -8
- package/dist/core/tool-result-store.js +95 -15
- package/dist/core/trace.d.ts +1 -1
- package/dist/core/types.d.ts +92 -22
- package/dist/core/types.js +30 -1
- package/dist/core/untrusted-text.d.ts +6 -0
- package/dist/core/untrusted-text.js +1 -0
- package/dist/engine/compaction/compaction.d.ts +1 -1
- package/dist/engine/harness/messages.d.ts +1 -1
- package/dist/engine/harness/types.d.ts +2 -2
- package/dist/engine/llm/types.d.ts +1 -1
- package/dist/engine/loop/types.d.ts +2 -2
- package/dist/engine/session/import-validate.d.ts +1 -1
- package/dist/engine/session/log-digest.d.ts +1 -1
- package/dist/fixtures/index.d.ts +18 -2
- package/dist/fixtures/index.js +11 -0
- package/dist/index.d.ts +2 -2
- package/dist/orchestration/goal.d.ts +1 -1
- package/dist/orchestration/workflow-types.d.ts +5 -4
- package/dist/orchestration/workflow.js +11 -3
- package/dist/prompt-assembly/artifact.d.ts +1 -1
- package/dist/prompts/default.d.ts +1 -1
- package/dist/prompts/default.js +1 -1
- package/dist/scenarios/scenario-registry.d.ts +3 -3
- package/dist/stores/file/tool-result-store.d.ts +43 -3
- package/dist/stores/file/tool-result-store.js +107 -19
- package/dist/tools/fs/bash-readonly-classifier.d.ts +2 -2
- package/dist/tools/fs/fs-bash.d.ts +8 -1
- package/dist/tools/fs/fs-search-tools.d.ts +1 -1
- package/dist/tools/fs/fs-shared.d.ts +6 -1
- package/dist/tools/fs/fs-shared.js +11 -7
- package/dist/tools/fs/index.d.ts +6 -0
- package/dist/tools/fs/index.js +2 -0
- package/dist/tools/fs/safety.d.ts +1 -1
- package/dist/tools/scheduler-tools.d.ts +1 -1
- package/dist/tools/task-list.d.ts +1 -1
- package/dist/tools/web.d.ts +3 -3
- package/package.json +1 -1
package/dist/core/memory.d.ts
CHANGED
|
@@ -194,7 +194,7 @@ export interface StructuredNoteInput {
|
|
|
194
194
|
export interface MemoryNoteHeader {
|
|
195
195
|
/** Store contract: ids must be SINGLE-LINE whitespace-free tokens (uuid-like). The manifest renders
|
|
196
196
|
* `- {id}: …` lines and the side-query matches selected ids by string equality — an id containing
|
|
197
|
-
* a newline could
|
|
197
|
+
* a newline could self-declare manifest lines, and a renderer-side rewrite would break the equality match,
|
|
198
198
|
* so the contract sits on the store (search 1.95.1 review #2). */
|
|
199
199
|
id: string;
|
|
200
200
|
/** A one-line summary used as the relevance signal (derived from the note's first sentence when untyped). */
|
|
@@ -306,7 +306,7 @@ export declare function enforceSecretWriteGate(body: string): void;
|
|
|
306
306
|
* (rendered as `[[name]]` cross-references) and `description` (rendered into the recall manifest + injected
|
|
307
307
|
* into the side-query sub-prompt) are ALSO persisted and surfaced, so a secret hidden there
|
|
308
308
|
* (`appendStructured({ body: "safe", description: "sk_live_…" })`) would otherwise be durably stored and
|
|
309
|
-
* leaked into every later same-scope manifest — a real
|
|
309
|
+
* leaked into every later same-scope manifest — a real circumvention of the body-only scan. `links` slugs are NOT
|
|
310
310
|
* scanned: they are short `[[name]]`-style reference tokens (no credential body shape clears the
|
|
311
311
|
* conservative detector), and scanning them adds FP surface for no realistic gain. Fail-closed: the first
|
|
312
312
|
* field that carries a high-confidence credential throws {@link MemoryGateError}`("memory.secret_detected")`.
|
|
@@ -46,7 +46,7 @@ export interface OracleIsolationVerdict {
|
|
|
46
46
|
export interface GraderEnv {
|
|
47
47
|
/** The isolated env the oracle grades in (service-provisioned; a {@link import("./remote-env.js").RemoteExecutionEnv} in production). */
|
|
48
48
|
env: ExecutionEnv;
|
|
49
|
-
/** Paths the oracle must be protected behind — probed for writability through the WORKER's bash
|
|
49
|
+
/** Paths the oracle must be protected behind — probed for writability through the WORKER's bash circumvention surface. */
|
|
50
50
|
immutableOraclePaths: string[];
|
|
51
51
|
/** Provenance brand — ONLY the control-plane factory sets this. Never derived from worker state. */
|
|
52
52
|
provenance: "control_plane";
|
|
@@ -72,7 +72,7 @@ export interface AssertOracleIsolationOptions {
|
|
|
72
72
|
* `candidate_only`); the class is what gates auto-accept (the 2-of-3 asymmetry).
|
|
73
73
|
*
|
|
74
74
|
* **(C) BASH WRITE-PROBE (necessary-not-sufficient, cheap early signal)** — for each `immutableOraclePath`, run
|
|
75
|
-
* a write probe through the WORKER env's `exec` (the bash
|
|
75
|
+
* a write probe through the WORKER env's `exec` (the bash circumvention surface — see {@link probeCommand}), against
|
|
76
76
|
* the path canonicalized via {@link canonicalizeTarget}. The probe PASSES only if the write returns a non-zero
|
|
77
77
|
* exit code or an exec error (read-only mount / permission denied / not supported). Any SUCCESS (RC 0) ⇒ the
|
|
78
78
|
* oracle dir is writable from the worker ⇒ `protected:false`. A probe THROW / abort / timeout ⇒ fail-closed
|
|
@@ -23,11 +23,11 @@
|
|
|
23
23
|
* ## The boundary this does NOT claim
|
|
24
24
|
*
|
|
25
25
|
* The confirmation transfer is an in-process call. A deployment must reach it only from its authenticated
|
|
26
|
-
* approval channel, and a deployment that shares filesystem credentials with the user can
|
|
26
|
+
* approval channel, and a deployment that shares filesystem credentials with the user can circumvent all of
|
|
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 RuleScope, type RuleDot } from "./permission-rule-model.js";
|
|
30
|
+
import { type RuleRejectCode, type RuleScope, type RuleDot } from "./permission-rule-model.js";
|
|
31
31
|
import type { PermissionRuleStoreProvider, RuleOwner } from "./permission-rule-store.js";
|
|
32
32
|
/** One candidate rule inside an approval record: the exact text and where it would apply. */
|
|
33
33
|
export interface RuleCandidate {
|
|
@@ -52,9 +52,19 @@ export interface RuleApprovalRecord {
|
|
|
52
52
|
state: "pending" | "approved" | "redeemed";
|
|
53
53
|
candidates: RuleCandidate[];
|
|
54
54
|
createdAt: string;
|
|
55
|
-
/** The ask this record was drawn from, for reconciliation. Advisory metadata; never adjudication input.
|
|
55
|
+
/** The ask this record was drawn from, for reconciliation. Advisory metadata; never adjudication input.
|
|
56
|
+
* `boundInputHash` is ALSO the card-edit binding anchor: an edited-candidate confirmation must echo
|
|
57
|
+
* it back, so a record minted without one refuses edits (there is nothing to bind the edit to). */
|
|
56
58
|
toolCallId?: string;
|
|
57
59
|
boundInputHash?: string;
|
|
60
|
+
/**
|
|
61
|
+
* The adjudicated command a CARD record was drawn for — the same post-rewrite bytes the ask carried
|
|
62
|
+
* (the form `boundInputHash` digests). Additive: absent on batch records and on every card a prior
|
|
63
|
+
* version minted. It is the edit gate's coverage input ("the edited rule must still admit THIS
|
|
64
|
+
* command"), so a record without one refuses edited candidates rather than guessing; the engine-
|
|
65
|
+
* candidate paths never read it.
|
|
66
|
+
*/
|
|
67
|
+
command?: string;
|
|
58
68
|
/** Monotonic revision of THIS record, bumped by every accepted transition. The compare-and-set key:
|
|
59
69
|
* comparing state alone cannot separate two different writes that both leave the state unchanged. */
|
|
60
70
|
rev: number;
|
|
@@ -68,6 +78,20 @@ export interface RuleApprovalRecord {
|
|
|
68
78
|
* previewed list by construction.
|
|
69
79
|
*/
|
|
70
80
|
selectedCandidate?: number;
|
|
81
|
+
/**
|
|
82
|
+
* The person-EDITED candidate this record carries, if any — full provenance for the one candidate
|
|
83
|
+
* whose text was authored at the card rather than derived by the engine. `index` names the appended
|
|
84
|
+
* row in `candidates` (whose `rule` holds the CANONICAL spelling); `text` keeps the raw input bytes
|
|
85
|
+
* exactly as submitted (the idempotency primary key — a client retrying a lost response resends the
|
|
86
|
+
* same bytes); `at` is when the edit landed. Present ⇒ `selectedCandidate === index` (the edit and
|
|
87
|
+
* the choice are one CAS write). Absent on every record a prior version minted and on every card
|
|
88
|
+
* settled through an engine candidate.
|
|
89
|
+
*/
|
|
90
|
+
edited?: {
|
|
91
|
+
index: number;
|
|
92
|
+
text: string;
|
|
93
|
+
at: string;
|
|
94
|
+
};
|
|
71
95
|
/** Dots already minted for this record, keyed by candidate index — the replay anchor. */
|
|
72
96
|
redeemedDots?: Record<number, RuleDot>;
|
|
73
97
|
}
|
|
@@ -95,6 +119,16 @@ export interface RuleConsentDeps {
|
|
|
95
119
|
/** Injectable clock/id for deterministic tests; defaults are the real ones. */
|
|
96
120
|
now?: () => Date;
|
|
97
121
|
newId?: () => string;
|
|
122
|
+
/**
|
|
123
|
+
* Deployment lever for the card-edit face: whether `confirmRuleApproval` accepts a FRESH
|
|
124
|
+
* `editedCandidate`. Absent or `false` = OFF (the default — the edit face widens what a fabricated
|
|
125
|
+
* confirmation could mint, from "one of the engine's bounded candidates" to "any same-head rule
|
|
126
|
+
* passing the coverage gate", so it is opt-in). Any other non-boolean value is a configuration
|
|
127
|
+
* mistake and refuses LOUDLY at the read — never silently mapped to a default. Replaying an edit a
|
|
128
|
+
* record already settled is a pure record read and does not consult this switch: the minting already
|
|
129
|
+
* happened, and withholding the receipt helps no one.
|
|
130
|
+
*/
|
|
131
|
+
cardEdits?: boolean;
|
|
98
132
|
}
|
|
99
133
|
/** In-memory approval records — the test backend and the reference CAS semantics. */
|
|
100
134
|
export declare class InMemoryRuleApprovalRecordStore implements RuleApprovalRecordStore {
|
|
@@ -157,21 +191,61 @@ export declare function confirmRuleApproval(opts: {
|
|
|
157
191
|
/** design/182 §4.5 (additive): the structural owner — only the local-owner path needs it. */
|
|
158
192
|
owner?: RuleOwner;
|
|
159
193
|
/**
|
|
160
|
-
* REQUIRED for a card record: the index of the option the person
|
|
161
|
-
* different breadth, so "they said yes" is not an answer on
|
|
162
|
-
* Rejected on a batch record, whose confirmation covers
|
|
194
|
+
* REQUIRED for a card record settled through an ENGINE candidate: the index of the option the person
|
|
195
|
+
* chose. A card presents alternatives of different breadth, so "they said yes" is not an answer on
|
|
196
|
+
* its own — "they said yes to THIS one" is. Rejected on a batch record, whose confirmation covers
|
|
197
|
+
* the previewed list by construction. Mutually exclusive with `editedCandidate`.
|
|
163
198
|
*/
|
|
164
199
|
selectedCandidate?: number;
|
|
200
|
+
/**
|
|
201
|
+
* The person-EDITED rule text for this card, travelling on the SAME authenticated confirmation
|
|
202
|
+
* channel as a choice among the engine's candidates (never the un-authenticated prepare entry, which
|
|
203
|
+
* keeps refusing caller candidates). `text` is the rule as authored; `boundInputHash` must echo the
|
|
204
|
+
* record's own bound-input digest — the submitter's proof of "I am editing the card that showed THIS
|
|
205
|
+
* command", an in-process mis-binding fence (a caller holding only a leaked approvalId cannot spell
|
|
206
|
+
* it), not a cryptographic one. The engine validates the text through the one shared validator,
|
|
207
|
+
* requires it to still ADMIT the adjudicated command, appends it as a new candidate and binds the
|
|
208
|
+
* selection to it, returning the minted ticket. Mutually exclusive with `selectedCandidate`.
|
|
209
|
+
*/
|
|
210
|
+
editedCandidate?: {
|
|
211
|
+
text: string;
|
|
212
|
+
boundInputHash: string;
|
|
213
|
+
};
|
|
165
214
|
deps: RuleConsentDeps;
|
|
166
215
|
}): Promise<ConfirmResult>;
|
|
167
|
-
/** Why a confirmation did not land. A closed set so a host can branch (re-present, re-fetch, give up).
|
|
168
|
-
|
|
169
|
-
|
|
216
|
+
/** Why a confirmation did not land. A closed set so a host can branch (re-present, re-fetch, give up).
|
|
217
|
+
* The three `edit_*` members are the card-edit face's own refusals:
|
|
218
|
+
* - `"edit_disabled"` — the deployment has not opted into card edits (`RuleConsentDeps.cardEdits`);
|
|
219
|
+
* - `"edit_binding_mismatch"` — the confirmation does not echo the record's bound-input digest
|
|
220
|
+
* (missing echo, a record minted without one, or a different card). Refused BEFORE anything else,
|
|
221
|
+
* settled replays included, and never returns a minted ticket;
|
|
222
|
+
* - `"edit_rejected"` — the edited text failed a gate (validator refusal, coverage, record shape);
|
|
223
|
+
* `detail` carries the specifics. */
|
|
224
|
+
export type ConfirmRefusalReason = "record_not_found" | "selection_missing" | "selection_invalid" | "selection_mismatch" | "batch_takes_no_selection" | "not_pending" | "conflict" | "edit_disabled" | "edit_binding_mismatch" | "edit_rejected";
|
|
225
|
+
/** The confirmation outcome: landed, or refused with a named reason.
|
|
226
|
+
*
|
|
227
|
+
* `mintedCandidate` (additive) is present exactly when an EDITED candidate settled this confirmation —
|
|
228
|
+
* fresh mint and idempotent replay alike (a client retrying a lost response gets the same index, the
|
|
229
|
+
* same canonical rule text and the same deterministically re-minted ticket, never a push toward a
|
|
230
|
+
* second card). `rule` is the CANONICAL spelling, which may differ from the submitted bytes (spelling
|
|
231
|
+
* normalization); a surface echoes it back so the person sees the form that will actually persist.
|
|
232
|
+
*
|
|
233
|
+
* `detail` (additive, refusal arm) rides `edit_rejected`: `code` is the shared validator's refusal
|
|
234
|
+
* code when the validator is what refused, absent when another gate did; `message` always says why. */
|
|
170
235
|
export type ConfirmResult = {
|
|
171
236
|
ok: true;
|
|
237
|
+
mintedCandidate?: {
|
|
238
|
+
index: number;
|
|
239
|
+
rule: string;
|
|
240
|
+
ticket: RuleTicket;
|
|
241
|
+
};
|
|
172
242
|
} | {
|
|
173
243
|
ok: false;
|
|
174
244
|
reason: ConfirmRefusalReason;
|
|
245
|
+
detail?: {
|
|
246
|
+
code?: RuleRejectCode;
|
|
247
|
+
message: string;
|
|
248
|
+
};
|
|
175
249
|
};
|
|
176
250
|
/** What a redemption produced. `alreadyRedeemed` marks the replay path — the same dot, no second rule. */
|
|
177
251
|
export type RedeemResult = {
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { randomBytes } from "node:crypto";
|
|
2
|
-
import { parseAllowRuleText, suggestRulesForCommand } from "./permission-rule-model.js";
|
|
2
|
+
import { parseAllowRuleText, ruleAdmitsCommand, suggestRulesForCommand } from "./permission-rule-model.js";
|
|
3
3
|
import { errText, sameRuleOwner, sameScope, writerOf } from "./permission-rule-store.js";
|
|
4
4
|
export class InMemoryRuleApprovalRecordStore {
|
|
5
5
|
rows = new Map();
|
|
@@ -109,6 +109,7 @@ export async function prepareCardApproval(opts) {
|
|
|
109
109
|
state: "pending",
|
|
110
110
|
rev: 0,
|
|
111
111
|
candidates,
|
|
112
|
+
command: opts.command,
|
|
112
113
|
createdAt: nowIso(opts.deps),
|
|
113
114
|
...(opts.toolCallId !== undefined ? { toolCallId: opts.toolCallId } : {}),
|
|
114
115
|
...(opts.boundInputHash !== undefined ? { boundInputHash: opts.boundInputHash } : {}),
|
|
@@ -120,10 +121,17 @@ const CARD_RULE_TOOL = "Bash";
|
|
|
120
121
|
export async function confirmRuleApproval(opts) {
|
|
121
122
|
const caller = resolveCallerOwner(opts.principal, opts.owner, "confirmRuleApproval");
|
|
122
123
|
const no = (reason) => ({ ok: false, reason });
|
|
124
|
+
if (opts.editedCandidate !== undefined && opts.selectedCandidate !== undefined) {
|
|
125
|
+
const e = new Error("confirmRuleApproval takes selectedCandidate OR editedCandidate, never both — one confirmation carries one choice");
|
|
126
|
+
e.code = "config.invalid_argument";
|
|
127
|
+
throw e;
|
|
128
|
+
}
|
|
123
129
|
const rec = await opts.deps.approvals.get(opts.approvalId);
|
|
124
130
|
const recOwner = rec === undefined ? undefined : ownerOfRecord(rec);
|
|
125
131
|
if (rec === undefined || recOwner === undefined || !sameRuleOwner(recOwner, caller))
|
|
126
132
|
return no("record_not_found");
|
|
133
|
+
if (opts.editedCandidate !== undefined)
|
|
134
|
+
return await confirmEditedCandidate(rec, opts.editedCandidate, opts.deps);
|
|
127
135
|
if (rec.kind === "card") {
|
|
128
136
|
const chosen = opts.selectedCandidate;
|
|
129
137
|
if (chosen === undefined)
|
|
@@ -145,6 +153,89 @@ export async function confirmRuleApproval(opts) {
|
|
|
145
153
|
const won = await opts.deps.approvals.cas(rec.id, rec.rev, { ...rec, rev: rec.rev + 1, state: "approved" });
|
|
146
154
|
return won ? { ok: true } : no("conflict");
|
|
147
155
|
}
|
|
156
|
+
function normalizeEditedSpelling(text) {
|
|
157
|
+
const m = /^([A-Za-z][A-Za-z0-9_]*)\((.+) \*\)$/.exec(text);
|
|
158
|
+
if (m === null)
|
|
159
|
+
return text;
|
|
160
|
+
const head = m[1];
|
|
161
|
+
const body = m[2];
|
|
162
|
+
if (head === undefined || body === undefined || body.includes("*"))
|
|
163
|
+
return text;
|
|
164
|
+
return `${head}(${body}:*)`;
|
|
165
|
+
}
|
|
166
|
+
async function confirmEditedCandidate(rec, edit, deps) {
|
|
167
|
+
const no = (reason, detail) => ({
|
|
168
|
+
ok: false,
|
|
169
|
+
reason,
|
|
170
|
+
...(detail !== undefined ? { detail } : {}),
|
|
171
|
+
});
|
|
172
|
+
if (rec.kind !== "card") {
|
|
173
|
+
return no("edit_rejected", { message: "a batch record takes no edited candidate — its confirmation covers the previewed list whole" });
|
|
174
|
+
}
|
|
175
|
+
if (typeof edit.boundInputHash !== "string" || edit.boundInputHash === "")
|
|
176
|
+
return no("edit_binding_mismatch");
|
|
177
|
+
if (rec.boundInputHash === undefined) {
|
|
178
|
+
return no("edit_binding_mismatch");
|
|
179
|
+
}
|
|
180
|
+
if (edit.boundInputHash !== rec.boundInputHash)
|
|
181
|
+
return no("edit_binding_mismatch");
|
|
182
|
+
if (rec.state === "approved" || rec.state === "redeemed") {
|
|
183
|
+
if (rec.edited === undefined)
|
|
184
|
+
return no("selection_mismatch");
|
|
185
|
+
const canonical = rec.candidates[rec.edited.index]?.rule;
|
|
186
|
+
if (canonical === undefined)
|
|
187
|
+
return no("selection_mismatch");
|
|
188
|
+
let hit = edit.text === rec.edited.text;
|
|
189
|
+
if (!hit) {
|
|
190
|
+
const reparsed = parseAllowRuleText(normalizeEditedSpelling(edit.text));
|
|
191
|
+
hit = "rule" in reparsed && reparsed.rule.rule === canonical;
|
|
192
|
+
}
|
|
193
|
+
if (!hit)
|
|
194
|
+
return no("selection_mismatch");
|
|
195
|
+
return { ok: true, mintedCandidate: { index: rec.edited.index, rule: canonical, ticket: mintRuleTicket(rec.id, rec.edited.index) } };
|
|
196
|
+
}
|
|
197
|
+
if (rec.state !== "pending")
|
|
198
|
+
return no("not_pending");
|
|
199
|
+
if (deps.cardEdits !== undefined && typeof deps.cardEdits !== "boolean") {
|
|
200
|
+
throw new Error(`RuleConsentDeps.cardEdits must be a boolean when present (got ${typeof deps.cardEdits}) — refusing to guess whether the card-edit face is enabled`);
|
|
201
|
+
}
|
|
202
|
+
if (deps.cardEdits !== true)
|
|
203
|
+
return no("edit_disabled");
|
|
204
|
+
const scope = rec.candidates[0]?.scope;
|
|
205
|
+
if (scope === undefined || !rec.candidates.every((c) => sameScope(c.scope, scope))) {
|
|
206
|
+
return no("edit_rejected", { message: "the record's candidates carry no single common scope — an edited candidate inherits the card's scope, and a record without one is malformed" });
|
|
207
|
+
}
|
|
208
|
+
if (rec.command === undefined) {
|
|
209
|
+
return no("edit_rejected", { message: "the record does not carry the adjudicated command (minted before card edits existed) — coverage cannot be verified, so the edit is refused" });
|
|
210
|
+
}
|
|
211
|
+
const parsed = parseAllowRuleText(normalizeEditedSpelling(edit.text));
|
|
212
|
+
if ("reject" in parsed)
|
|
213
|
+
return no("edit_rejected", { code: parsed.reject.code, message: parsed.reject.message });
|
|
214
|
+
if (!ruleAdmitsCommand(parsed.rule, rec.command)) {
|
|
215
|
+
return no("edit_rejected", {
|
|
216
|
+
message: `the edited rule "${parsed.rule.rule}" does not admit the command that was decided ("${rec.command}") — a card's edit may widen how much the rule covers, never move it to a different grant`,
|
|
217
|
+
});
|
|
218
|
+
}
|
|
219
|
+
const index = rec.candidates.length;
|
|
220
|
+
const next = {
|
|
221
|
+
...rec,
|
|
222
|
+
rev: rec.rev + 1,
|
|
223
|
+
state: "approved",
|
|
224
|
+
candidates: [...rec.candidates, { rule: parsed.rule.rule, scope }],
|
|
225
|
+
selectedCandidate: index,
|
|
226
|
+
edited: { index, text: edit.text, at: nowIso(deps) },
|
|
227
|
+
};
|
|
228
|
+
const won = await deps.approvals.cas(rec.id, rec.rev, next);
|
|
229
|
+
if (!won) {
|
|
230
|
+
const again = await deps.approvals.get(rec.id);
|
|
231
|
+
if (again === undefined)
|
|
232
|
+
return no("record_not_found");
|
|
233
|
+
if (again.state === "pending")
|
|
234
|
+
return no("conflict");
|
|
235
|
+
return await confirmEditedCandidate(again, edit, deps);
|
|
236
|
+
}
|
|
237
|
+
return { ok: true, mintedCandidate: { index, rule: parsed.rule.rule, ticket: mintRuleTicket(rec.id, index) } };
|
|
238
|
+
}
|
|
148
239
|
export async function redeemRuleTicket(opts) {
|
|
149
240
|
const caller = resolveCallerOwner(opts.principal, opts.owner, "redeemRuleTicket");
|
|
150
241
|
const parsed = parseRuleTicket(opts.ticket);
|
|
@@ -18,7 +18,7 @@
|
|
|
18
18
|
*
|
|
19
19
|
* The floor is `parseLeadingCommandName` — the one simple-command parser already shared by the
|
|
20
20
|
* read-only classifier, the reversibility probe, the coarse command-name policy and the skill tool
|
|
21
|
-
* specifier. A second tokenizer would drift, and drift on a loosening face shows up as a
|
|
21
|
+
* specifier. A second tokenizer would drift, and drift on a loosening face shows up as a circumvention rather
|
|
22
22
|
* than as a test failure.
|
|
23
23
|
*
|
|
24
24
|
* ## Normalization order is load-bearing
|
|
@@ -94,7 +94,7 @@ export interface RuleTombstone {
|
|
|
94
94
|
deletedBy: RuleDot;
|
|
95
95
|
}
|
|
96
96
|
/** Why a rule text was refused. Codes are stable so an import report can group by them. */
|
|
97
|
-
export type RuleRejectCode = "invalid.grammar" | "invalid.empty_command" | "invalid.not_simple_command" | "invalid.bare_interpreter_prefix" | "invalid.unbalanced_quotes" | "invalid.too_long" | "unsupported.tool" | "unsupported.wildcard";
|
|
97
|
+
export type RuleRejectCode = "invalid.grammar" | "invalid.empty_command" | "invalid.not_simple_command" | "invalid.bare_interpreter_prefix" | "invalid.unbalanced_quotes" | "invalid.control_chars" | "invalid.too_long" | "unsupported.tool" | "unsupported.wildcard";
|
|
98
98
|
export interface RuleReject {
|
|
99
99
|
code: RuleRejectCode;
|
|
100
100
|
message: string;
|
|
@@ -167,6 +167,22 @@ export declare const BARE_INTERPRETER_NAMES: ReadonlySet<string>;
|
|
|
167
167
|
* loosening; the other against a person being misled.
|
|
168
168
|
*/
|
|
169
169
|
export declare const SUGGESTION_LEXICON: readonly string[];
|
|
170
|
+
/**
|
|
171
|
+
* Render a peer- or file-controlled value for a DISCLOSURE line (a warning, a refusal message, an
|
|
172
|
+
* operator log). Takes `unknown` on purpose: most of these values are typed but arrive off a wire or a
|
|
173
|
+
* file, so the runtime value can be anything, and a signature that demanded a string would push a bare
|
|
174
|
+
* `String(x)` to every call site — the exact step that gets forgotten. Rule texts are the motivating case; the same treatment is owed to every untrusted
|
|
175
|
+
* field a line interpolates (a wire `reason`, an actor id), since the hazard is the character class,
|
|
176
|
+
* not which field carries it.
|
|
177
|
+
*
|
|
178
|
+
* Quoting a refused text verbatim would carry the exact sequence the refusal exists to keep off a
|
|
179
|
+
* display surface, and would print the two texts a reader has to tell apart — `Bash(echo hi)` and
|
|
180
|
+
* `Bash(echo<ZWSP>hi)` — identically, so the report could not name WHICH rule it means. Every character
|
|
181
|
+
* {@link CONTROL_CHARS_RE} covers is therefore printed as its `\uXXXX` escape (`\u{XXXXX}` above the
|
|
182
|
+
* BMP — the TAG block U+E0020–U+E007F is a `\p{Cf}` family that lives there); everything else passes
|
|
183
|
+
* through, so an ordinary rule text reads normally. The result is length-bounded.
|
|
184
|
+
*/
|
|
185
|
+
export declare function escapeForDisclosure(value: unknown): string;
|
|
170
186
|
/**
|
|
171
187
|
* Parse one rule text into its canonical shape, or refuse it with a reason.
|
|
172
188
|
*
|
|
@@ -91,10 +91,31 @@ function foldSpacing(s) {
|
|
|
91
91
|
function reject(code, message) {
|
|
92
92
|
return { reject: { code, message } };
|
|
93
93
|
}
|
|
94
|
+
const CONTROL_CHARS_RE = /[\u0000-\u0008\u000A-\u001F\u007F-\u009F\p{Cf}\u2028\u2029]/u;
|
|
95
|
+
const CONTROL_CHARS_GLOBAL_RE = new RegExp(CONTROL_CHARS_RE.source, "gu");
|
|
96
|
+
const DISCLOSED_RULE_TEXT_MAX_CHARS = 120;
|
|
97
|
+
export function escapeForDisclosure(value) {
|
|
98
|
+
let text;
|
|
99
|
+
try {
|
|
100
|
+
text = typeof value === "string" ? value : String(value);
|
|
101
|
+
}
|
|
102
|
+
catch {
|
|
103
|
+
return "<unprintable>";
|
|
104
|
+
}
|
|
105
|
+
const escaped = text.replace(CONTROL_CHARS_GLOBAL_RE, (ch) => {
|
|
106
|
+
const cp = ch.codePointAt(0) ?? 0;
|
|
107
|
+
const hex = cp.toString(16).toUpperCase();
|
|
108
|
+
return cp > 0xffff ? `\\u{${hex}}` : `\\u${hex.padStart(4, "0")}`;
|
|
109
|
+
});
|
|
110
|
+
return escaped.length <= DISCLOSED_RULE_TEXT_MAX_CHARS ? escaped : `${escaped.slice(0, DISCLOSED_RULE_TEXT_MAX_CHARS)}…`;
|
|
111
|
+
}
|
|
94
112
|
export function parseAllowRuleText(text, opts) {
|
|
95
113
|
if (text.length > MAX_RULE_TEXT_CHARS) {
|
|
96
114
|
return reject("invalid.too_long", `rule text exceeds ${MAX_RULE_TEXT_CHARS} characters`);
|
|
97
115
|
}
|
|
116
|
+
if (CONTROL_CHARS_RE.test(text)) {
|
|
117
|
+
return reject("invalid.control_chars", "rule text contains control characters (C0/C1, tab excepted), which cannot be part of a readable command");
|
|
118
|
+
}
|
|
98
119
|
const parsed = parsePermissionRule(text);
|
|
99
120
|
if (parsed.ruleContent === undefined) {
|
|
100
121
|
return reject("invalid.grammar", `"${text}" is not a Tool(content) rule — a bare tool name claims the whole tool and is not a command rule`);
|
|
@@ -34,7 +34,7 @@
|
|
|
34
34
|
* accept revision N−1 after a restart. Signature / predecessor chaining belongs to the privilege-
|
|
35
35
|
* separation ticket; the high-water mark is the floor that needs no key distribution.
|
|
36
36
|
*/
|
|
37
|
-
import { type RuleScope } from "./permission-rule-model.js";
|
|
37
|
+
import { type RuleRejectCode, type RuleScope } from "./permission-rule-model.js";
|
|
38
38
|
import { type PermissionRuleStore, type PermissionRuleStoreProvider, type RuleOwner } from "./permission-rule-store.js";
|
|
39
39
|
/** One org rule. There is structurally no allow bucket (design/179 §9: the org layer only tightens). */
|
|
40
40
|
export interface OrgPermissionRule {
|
|
@@ -160,13 +160,32 @@ export declare function createOrgRuleOverlay(cfg: {
|
|
|
160
160
|
stalenessBoundMs: number;
|
|
161
161
|
now?: () => number;
|
|
162
162
|
}): OrgRuleOverlay;
|
|
163
|
+
/**
|
|
164
|
+
* Which entries of an org snapshot the CURRENT rule validator refuses — the published deny/ask rules
|
|
165
|
+
* that can match nothing on this build. Empty ⟺ every rule is enforceable.
|
|
166
|
+
*
|
|
167
|
+
* Separate from {@link validateOrgSnapshot} because BOTH gates need it: the install gate (a fetched
|
|
168
|
+
* snapshot) and the serving gate (backlog #177 — a snapshot persisted by an earlier build is read back
|
|
169
|
+
* and served without ever having met this validator). It reports every offender rather than the first,
|
|
170
|
+
* since a refusal an administrator cannot act on names no line to fix.
|
|
171
|
+
*/
|
|
172
|
+
export declare function unenforceableOrgRules(rules: readonly OrgPermissionRule[]): Array<{
|
|
173
|
+
rule: string;
|
|
174
|
+
code: RuleRejectCode;
|
|
175
|
+
}>;
|
|
163
176
|
/**
|
|
164
177
|
* design/182 §7.2 — which org rule speaks for this command, if any. Deny outranks ask; within a
|
|
165
178
|
* behavior the first textual match wins (reporting order only — all denies are the same one answer).
|
|
166
179
|
* Matching uses the SAME parser and matcher the personal lane uses (tighten direction: an
|
|
167
180
|
* interpreter-headed prefix deny like `Bash(node:*)` is a legitimately wide tightening and matches).
|
|
168
|
-
*
|
|
169
|
-
*
|
|
181
|
+
*
|
|
182
|
+
* The skip below is a defensive floor, and what stands behind it is worth stating precisely: rules that
|
|
183
|
+
* arrive from {@link OrgRuleOverlay.resolve} have passed {@link validateOrgSnapshot} on BOTH the install
|
|
184
|
+
* and the serving path (backlog #177), so an unenforceable entry cannot reach here through the overlay
|
|
185
|
+
* — the whole snapshot is refused, or the resolution reports `unavailable`, and either way it is
|
|
186
|
+
* disclosed. A caller that hands this function a snapshot it obtained ELSEWHERE gets no such guarantee:
|
|
187
|
+
* the skip is silent, and naming the dead entries is that caller's job — {@link unenforceableOrgRules}
|
|
188
|
+
* is the shared way to do it.
|
|
170
189
|
*/
|
|
171
190
|
export declare function orgRuleVerdictFor(rules: readonly OrgPermissionRule[], call: {
|
|
172
191
|
tool: string;
|
|
@@ -184,7 +203,7 @@ export interface EffectivePermissionRule {
|
|
|
184
203
|
/**
|
|
185
204
|
* design/182 §7.3 — the introspection face: every personal rule with its effective status under the org
|
|
186
205
|
* overlay. An org deny does not delete a personal rule — the personal rule is the user's approval
|
|
187
|
-
* record, and the org's power is to OVERRIDE it, not to
|
|
206
|
+
* record, and the org's power is to OVERRIDE it, not to self-declare its removal — so a shadowed rule reports
|
|
188
207
|
* `shadowed-by-org` and comes back by itself when the org deny is withdrawn. A rule whose every add is
|
|
189
208
|
* tombstoned reports `removed` (its tombstone identity is still visible in the store).
|
|
190
209
|
*
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { parseAllowRuleText, ruleAdmitsCommand } from "./permission-rule-model.js";
|
|
1
|
+
import { escapeForDisclosure, parseAllowRuleText, ruleAdmitsCommand } from "./permission-rule-model.js";
|
|
2
2
|
import { sameScope, writerOf } from "./permission-rule-store.js";
|
|
3
3
|
export function orgRuleStatePersistenceOf(store) {
|
|
4
4
|
const s = store;
|
|
@@ -46,7 +46,7 @@ export function createOrgRuleOverlay(cfg) {
|
|
|
46
46
|
throw new Error("org-governed is declared but no OrgRuleSnapshotProvider is wired — a governed deployment without an org source is a configuration contradiction; refusing to boot rather than running silently ungoverned");
|
|
47
47
|
}
|
|
48
48
|
if (typeof cfg.stalenessBoundMs !== "number" || !Number.isFinite(cfg.stalenessBoundMs) || cfg.stalenessBoundMs <= 0) {
|
|
49
|
-
throw new Error(`org stalenessBoundMs must be a finite positive number of milliseconds (got ${
|
|
49
|
+
throw new Error(`org stalenessBoundMs must be a finite positive number of milliseconds (got ${escapeForDisclosure(cfg.stalenessBoundMs)})`);
|
|
50
50
|
}
|
|
51
51
|
const provider = cfg.provider;
|
|
52
52
|
const now = cfg.now ?? Date.now;
|
|
@@ -75,11 +75,11 @@ export function createOrgRuleOverlay(cfg) {
|
|
|
75
75
|
let fetched;
|
|
76
76
|
let fetchFailed = false;
|
|
77
77
|
try {
|
|
78
|
-
fetched = await provider.current();
|
|
78
|
+
fetched = structuredClone(await provider.current());
|
|
79
79
|
}
|
|
80
80
|
catch (err) {
|
|
81
81
|
fetchFailed = true;
|
|
82
|
-
disclosures.push(`org snapshot provider failed: ${err instanceof Error ? err.message :
|
|
82
|
+
disclosures.push(`org snapshot provider failed: ${escapeForDisclosure(err instanceof Error ? err.message : err)}`);
|
|
83
83
|
}
|
|
84
84
|
return await serialize(async () => {
|
|
85
85
|
const nowMs = now();
|
|
@@ -88,7 +88,7 @@ export function createOrgRuleOverlay(cfg) {
|
|
|
88
88
|
return await readState();
|
|
89
89
|
}
|
|
90
90
|
catch (err) {
|
|
91
|
-
disclosures.push(`org state ${context} read failed: ${err instanceof Error ? err.message :
|
|
91
|
+
disclosures.push(`org state ${context} read failed: ${escapeForDisclosure(err instanceof Error ? err.message : err)}`);
|
|
92
92
|
return undefined;
|
|
93
93
|
}
|
|
94
94
|
};
|
|
@@ -101,8 +101,12 @@ export function createOrgRuleOverlay(cfg) {
|
|
|
101
101
|
disclosures.push(`org snapshot refused: ${invalid}; the previously installed snapshot (if any) remains in force`);
|
|
102
102
|
return persisted;
|
|
103
103
|
}
|
|
104
|
-
|
|
105
|
-
|
|
104
|
+
const persistedFloor = persisted === undefined ? undefined : antiRollbackFloor(persisted);
|
|
105
|
+
if (persisted !== undefined && !persistedMarkIsCoherent(persisted)) {
|
|
106
|
+
disclosures.push(`the durable org state carries a high-water mark (${escapeForDisclosure(persisted.revisionHighWater)}) that is not a finite number at or above its own snapshot revision (${escapeForDisclosure(persisted.snapshot?.revision)}) — the anti-rollback fence is being held at ${escapeForDisclosure(persistedFloor)} instead, and the durable state should be repaired`);
|
|
107
|
+
}
|
|
108
|
+
if (persistedFloor !== undefined && fetched.revision < persistedFloor) {
|
|
109
|
+
disclosures.push(`org snapshot revision ${fetched.revision} is below the installed high-water mark ${escapeForDisclosure(persistedFloor)} — refusing the rollback; the current deny set is unchanged`);
|
|
106
110
|
return persisted;
|
|
107
111
|
}
|
|
108
112
|
if (persisted !== undefined && fetched.revision === persisted.snapshot.revision && !sameOrgPolicyContent(fetched, persisted.snapshot)) {
|
|
@@ -110,7 +114,7 @@ export function createOrgRuleOverlay(cfg) {
|
|
|
110
114
|
return persisted;
|
|
111
115
|
}
|
|
112
116
|
const state = {
|
|
113
|
-
revisionHighWater: Math.max(
|
|
117
|
+
revisionHighWater: Math.max(persistedFloor !== undefined && Number.isFinite(persistedFloor) ? persistedFloor : 0, fetched.revision),
|
|
114
118
|
snapshot: fetched,
|
|
115
119
|
installedAtMs: nowMs,
|
|
116
120
|
};
|
|
@@ -119,11 +123,11 @@ export function createOrgRuleOverlay(cfg) {
|
|
|
119
123
|
}
|
|
120
124
|
catch (err) {
|
|
121
125
|
const reread = await guardedRead("reconciliation");
|
|
122
|
-
if (reread !== undefined && reread
|
|
123
|
-
disclosures.push(`org snapshot install was superseded by a concurrent install at revision ${reread.snapshot.revision} — continuing on the newer installed state`);
|
|
126
|
+
if (reread !== undefined && antiRollbackFloor(reread) >= state.revisionHighWater) {
|
|
127
|
+
disclosures.push(`org snapshot install was superseded by a concurrent install at revision ${escapeForDisclosure(reread.snapshot.revision)} — continuing on the newer installed state`);
|
|
124
128
|
return reread;
|
|
125
129
|
}
|
|
126
|
-
disclosures.push(`org snapshot install failed (${err instanceof Error ? err.message :
|
|
130
|
+
disclosures.push(`org snapshot install failed (${escapeForDisclosure(err instanceof Error ? err.message : err)}) and no superseding installed state exists — org adjudication is UNAVAILABLE`);
|
|
127
131
|
return undefined;
|
|
128
132
|
}
|
|
129
133
|
return state;
|
|
@@ -132,7 +136,12 @@ export function createOrgRuleOverlay(cfg) {
|
|
|
132
136
|
const withinBound = (snap) => decisionMs - snap.fetchedAtMs <= cfg.stalenessBoundMs && snap.fetchedAtMs <= decisionMs + ORG_FETCHED_AT_SKEW_ALLOWANCE_MS;
|
|
133
137
|
if (installed !== undefined) {
|
|
134
138
|
if (installed.snapshot.fetchedAtMs > decisionMs + ORG_FETCHED_AT_SKEW_ALLOWANCE_MS) {
|
|
135
|
-
disclosures.push(`the installed org snapshot claims a FUTURE observation time (${installed.snapshot.fetchedAtMs} vs now ${decisionMs}) — org adjudication is UNAVAILABLE; the consuming gate must tighten every terminal allow to a real-approval ask`);
|
|
139
|
+
disclosures.push(`the installed org snapshot claims a FUTURE observation time (${escapeForDisclosure(installed.snapshot.fetchedAtMs)} vs now ${decisionMs}) — org adjudication is UNAVAILABLE; the consuming gate must tighten every terminal allow to a real-approval ask`);
|
|
140
|
+
return { status: "unavailable", rules: [], disclosures };
|
|
141
|
+
}
|
|
142
|
+
const servingRefusal = validateOrgSnapshot(installed.snapshot, decisionMs);
|
|
143
|
+
if (servingRefusal !== undefined) {
|
|
144
|
+
disclosures.push(`the installed org snapshot (revision ${escapeForDisclosure(installed.snapshot.revision)}) does not meet this version's validator: ${servingRefusal} — org adjudication is UNAVAILABLE; the consuming gate must tighten every terminal allow to a real-approval ask`);
|
|
136
145
|
return { status: "unavailable", rules: [], disclosures };
|
|
137
146
|
}
|
|
138
147
|
if (withinBound(installed.snapshot)) {
|
|
@@ -156,16 +165,50 @@ export function createOrgRuleOverlay(cfg) {
|
|
|
156
165
|
};
|
|
157
166
|
return { resolve };
|
|
158
167
|
}
|
|
168
|
+
function antiRollbackFloor(state) {
|
|
169
|
+
const mark = typeof state.revisionHighWater === "number" && Number.isFinite(state.revisionHighWater) ? state.revisionHighWater : undefined;
|
|
170
|
+
const own = typeof state.snapshot?.revision === "number" && Number.isFinite(state.snapshot.revision) ? state.snapshot.revision : undefined;
|
|
171
|
+
if (mark === undefined)
|
|
172
|
+
return own ?? Number.NEGATIVE_INFINITY;
|
|
173
|
+
return own === undefined ? mark : Math.max(mark, own);
|
|
174
|
+
}
|
|
175
|
+
function persistedMarkIsCoherent(state) {
|
|
176
|
+
return (typeof state.revisionHighWater === "number" &&
|
|
177
|
+
Number.isFinite(state.revisionHighWater) &&
|
|
178
|
+
typeof state.snapshot?.revision === "number" &&
|
|
179
|
+
Number.isFinite(state.snapshot.revision) &&
|
|
180
|
+
state.revisionHighWater >= state.snapshot.revision);
|
|
181
|
+
}
|
|
159
182
|
function sameOrgPolicyContent(a, b) {
|
|
160
183
|
if (a.rules.length !== b.rules.length)
|
|
161
184
|
return false;
|
|
162
185
|
return a.rules.every((r, i) => b.rules[i]?.rule === r.rule && b.rules[i]?.behavior === r.behavior);
|
|
163
186
|
}
|
|
187
|
+
const DISCLOSED_UNENFORCEABLE_RULES = 20;
|
|
188
|
+
export function unenforceableOrgRules(rules) {
|
|
189
|
+
const out = [];
|
|
190
|
+
for (const r of rules) {
|
|
191
|
+
if (typeof r?.rule !== "string" || r.rule === "")
|
|
192
|
+
continue;
|
|
193
|
+
const parsed = parseAllowRuleText(r.rule, { direction: "tighten" });
|
|
194
|
+
if ("reject" in parsed)
|
|
195
|
+
out.push({ rule: r.rule, code: parsed.reject.code });
|
|
196
|
+
}
|
|
197
|
+
return out;
|
|
198
|
+
}
|
|
199
|
+
function describeUnenforceable(unenforceable) {
|
|
200
|
+
const named = unenforceable
|
|
201
|
+
.slice(0, DISCLOSED_UNENFORCEABLE_RULES)
|
|
202
|
+
.map((u) => `"${escapeForDisclosure(u.rule)}" (${u.code})`)
|
|
203
|
+
.join(", ");
|
|
204
|
+
const more = unenforceable.length - DISCLOSED_UNENFORCEABLE_RULES;
|
|
205
|
+
return more > 0 ? `${named} and ${more} more` : named;
|
|
206
|
+
}
|
|
164
207
|
function validateOrgSnapshot(s, nowMs) {
|
|
165
208
|
if (typeof s.revision !== "number" || !Number.isFinite(s.revision))
|
|
166
|
-
return `revision is not a finite number (${
|
|
209
|
+
return `revision is not a finite number (${escapeForDisclosure(s.revision)})`;
|
|
167
210
|
if (typeof s.fetchedAtMs !== "number" || !Number.isFinite(s.fetchedAtMs))
|
|
168
|
-
return `fetchedAtMs is not a finite number (${
|
|
211
|
+
return `fetchedAtMs is not a finite number (${escapeForDisclosure(s.fetchedAtMs)})`;
|
|
169
212
|
if (s.fetchedAtMs > nowMs + ORG_FETCHED_AT_SKEW_ALLOWANCE_MS) {
|
|
170
213
|
return `fetchedAtMs is ${s.fetchedAtMs - nowMs}ms in the future (allowance ${ORG_FETCHED_AT_SKEW_ALLOWANCE_MS}ms) — a future-dated observation would satisfy the staleness bound indefinitely`;
|
|
171
214
|
}
|
|
@@ -174,11 +217,15 @@ function validateOrgSnapshot(s, nowMs) {
|
|
|
174
217
|
for (const r of s.rules) {
|
|
175
218
|
if (typeof r?.rule !== "string" || r.rule === "")
|
|
176
219
|
return "a rule entry carries no rule text";
|
|
177
|
-
if (r.behavior !== "deny" && r.behavior !== "ask")
|
|
178
|
-
return `rule "${r.rule}" carries behavior "${
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
220
|
+
if (r.behavior !== "deny" && r.behavior !== "ask") {
|
|
221
|
+
return `rule "${escapeForDisclosure(r.rule)}" carries behavior "${escapeForDisclosure(r.behavior)}" — the org layer has no allow bucket`;
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
const unenforceable = unenforceableOrgRules(s.rules);
|
|
225
|
+
const first = unenforceable[0];
|
|
226
|
+
if (first !== undefined) {
|
|
227
|
+
const rest = unenforceable.length > 1 ? ` (${unenforceable.length} in all — ${describeUnenforceable(unenforceable)})` : "";
|
|
228
|
+
return `rule "${escapeForDisclosure(first.rule)}" does not parse (${first.code}) — an unenforceable deny/ask must not stand as policy${rest}`;
|
|
182
229
|
}
|
|
183
230
|
return undefined;
|
|
184
231
|
}
|
|
@@ -231,7 +278,7 @@ function resolveIntrospectionStore(opts) {
|
|
|
231
278
|
return store;
|
|
232
279
|
}
|
|
233
280
|
if (opts.owner !== undefined && opts.owner.kind === "principal" && opts.principal !== undefined && opts.owner.principal !== opts.principal) {
|
|
234
|
-
throw new Error(`contradictory identity: principal "${opts.principal}" and owner principal "${opts.owner.principal}" disagree`);
|
|
281
|
+
throw new Error(`contradictory identity: principal "${escapeForDisclosure(opts.principal)}" and owner principal "${escapeForDisclosure(opts.owner.principal)}" disagree`);
|
|
235
282
|
}
|
|
236
283
|
const principal = opts.owner?.kind === "principal" ? opts.owner.principal : opts.principal;
|
|
237
284
|
return opts.provider.forPrincipal(principal);
|
|
@@ -159,7 +159,7 @@ export interface RuleSyncJoinDelta {
|
|
|
159
159
|
* What a write may say. Authorization discriminates on the DELTA SHAPE, not on a full snapshot: only the
|
|
160
160
|
* add arm can introduce a dot, and the delete arm carries a tombstone and no adds. A backend additionally
|
|
161
161
|
* REFUSES at runtime any delete that would introduce a new add dot — structure and runtime check together,
|
|
162
|
-
* so "pick the delete arm and
|
|
162
|
+
* so "pick the delete arm and carry across an add" is neither expressible nor accepted. The `sync-join` arm's
|
|
163
163
|
* safety does not rest on hiding the type (ruled 2026-08-10: the backend contract IS exported for
|
|
164
164
|
* out-of-repo store twins) — it rests on every inbound record inside it passing the single validator
|
|
165
165
|
* again AT THE BACKEND: the fifth door of design/179 §4's validator list closes here, not at the
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { randomBytes } from "node:crypto";
|
|
2
2
|
import { canonicalize } from "./canonical-json.js";
|
|
3
|
-
import { isRuleLive, parseAllowRuleText } from "./permission-rule-model.js";
|
|
3
|
+
import { escapeForDisclosure, isRuleLive, parseAllowRuleText } from "./permission-rule-model.js";
|
|
4
4
|
export function sameRuleOwner(a, b) {
|
|
5
5
|
return a.kind === "principal" ? b.kind === "principal" && a.principal === b.principal : b.kind === "local-owner";
|
|
6
6
|
}
|
|
@@ -191,7 +191,7 @@ export function applySyncJoin(cur, delta, nowMs) {
|
|
|
191
191
|
}
|
|
192
192
|
const canonicalTomb = parseAllowRuleText(t.rule);
|
|
193
193
|
if ("reject" in canonicalTomb || canonicalTomb.rule.rule !== t.rule) {
|
|
194
|
-
throw new Error(`an inbound tombstone names a non-canonical or invalid rule text ("${t.rule}") — refusing the whole sync-join (the client's partitioning must withhold the round's adds alongside a malformed tombstone)`);
|
|
194
|
+
throw new Error(`an inbound tombstone names a non-canonical or invalid rule text ("${escapeForDisclosure(t.rule)}") — refusing the whole sync-join (the client's partitioning must withhold the round's adds alongside a malformed tombstone)`);
|
|
195
195
|
}
|
|
196
196
|
}
|
|
197
197
|
const droppedInbound = [];
|