@sema-agent/core 7.8.0 → 7.9.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +75 -1
- package/dist/agents/subagent.d.ts +1 -1
- package/dist/core/ask-origin.d.ts +16 -1
- package/dist/core/ask-origin.js +5 -1
- package/dist/core/checkpoint-store.d.ts +36 -35
- package/dist/core/effective-path-target.d.ts +43 -0
- package/dist/core/effective-path-target.js +56 -0
- package/dist/core/engine-notice.d.ts +8 -0
- package/dist/core/fs-write-gate-policy.js +2 -1
- package/dist/core/gate-lanes.d.ts +1 -0
- package/dist/core/gate-lanes.js +60 -28
- package/dist/core/gate-outcome.d.ts +8 -4
- package/dist/core/gate-outcome.js +2 -1
- package/dist/core/governance-codes.d.ts +1 -1
- package/dist/core/governance-codes.js +2 -0
- package/dist/core/hooks.d.ts +66 -32
- package/dist/core/mcp-injection-drop.d.ts +74 -0
- package/dist/core/mcp-injection-drop.js +27 -0
- 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 +311 -96
- package/dist/core/permission-rule-model.js +309 -50
- package/dist/core/permission-rule-org.d.ts +19 -12
- package/dist/core/permission-rule-org.js +13 -16
- 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 +56 -33
- package/dist/core/permission-rules.js +88 -55
- package/dist/core/persisted-rule-arms.d.ts +61 -0
- package/dist/core/persisted-rule-arms.js +50 -0
- package/dist/core/runner/active-skill-scope.js +2 -1
- package/dist/core/runner/contracts.d.ts +21 -2
- package/dist/core/runner/permission-rule-lanes.d.ts +42 -27
- package/dist/core/runner/permission-rule-lanes.js +25 -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/session-rule-policy.js +2 -1
- 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-face-overlay.js +22 -3
- 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/sensitive-path-policy.js +5 -3
- package/dist/core/shell-lexer.d.ts +47 -0
- package/dist/core/shell-lexer.js +478 -0
- package/dist/core/shell-scan.d.ts +60 -0
- package/dist/core/shell-scan.js +183 -0
- package/dist/core/shell-wrapper-table.d.ts +297 -0
- package/dist/core/shell-wrapper-table.js +58 -0
- package/dist/core/store-contracts/permission-rule-sync-contract.js +29 -10
- package/dist/core/tool-catalog-entries.js +6 -6
- package/dist/core/tool-face.d.ts +80 -4
- package/dist/core/tool-face.js +10 -0
- package/dist/core/tool-policy.d.ts +1 -6
- package/dist/core/tool-registry.d.ts +8 -11
- package/dist/core/tool-registry.js +5 -2
- package/dist/core/tool-roster.d.ts +20 -2
- package/dist/core/tool-roster.js +36 -8
- package/dist/index.d.ts +7 -4
- package/dist/index.js +6 -3
- 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/fs-search-tools.d.ts +3 -2
- package/dist/tools/fs/fs-search-tools.js +17 -9
- package/dist/tools/fs/index.js +1 -1
- package/dist/tools/fs/notebook.d.ts +1 -1
- package/dist/tools/fs/safety.d.ts +1 -1
- package/dist/tools/fs/search.d.ts +0 -8
- package/dist/tools/fs/search.js +0 -23
- package/package.json +1 -1
- package/test/export-surface.snapshot.json +99 -11
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { randomBytes } from "node:crypto";
|
|
2
2
|
import { canonicalize } from "./canonical-json.js";
|
|
3
|
-
import { escapeForDisclosure, isRuleLive,
|
|
3
|
+
import { escapeForDisclosure, isRuleBehavior, isRuleLive, parseRuleText } 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
|
}
|
|
@@ -19,19 +19,22 @@ export function sameScope(a, b) {
|
|
|
19
19
|
function sameDot(a, b) {
|
|
20
20
|
return a.actor === b.actor && a.counter === b.counter;
|
|
21
21
|
}
|
|
22
|
+
export function sameRuleIdentity(a, b) {
|
|
23
|
+
return a.behavior === b.behavior && a.rule === b.rule && sameScope(a.scope, b.scope);
|
|
24
|
+
}
|
|
22
25
|
export function applyTombstones(rules, tombstones) {
|
|
23
26
|
return rules
|
|
24
27
|
.map((r) => {
|
|
25
|
-
const removed = tombstones.filter((t) =>
|
|
28
|
+
const removed = tombstones.filter((t) => sameRuleIdentity(t, r)).flatMap((t) => t.removedDots);
|
|
26
29
|
return removed.length === 0 ? r : { ...r, adds: r.adds.filter((a) => !removed.some((d) => sameDot(d, a.dot))) };
|
|
27
30
|
})
|
|
28
31
|
.filter(isRuleLive);
|
|
29
32
|
}
|
|
30
33
|
export function foldDelta(rules, delta) {
|
|
31
34
|
const next = rules.map((r) => ({ ...r, adds: [...r.adds] }));
|
|
32
|
-
const existing = next.find((r) =>
|
|
35
|
+
const existing = next.find((r) => sameRuleIdentity(r, delta));
|
|
33
36
|
if (existing === undefined) {
|
|
34
|
-
next.push({ rule: delta.rule, tool: delta.tool, match: delta.match, command: delta.command, scope: delta.scope, adds: [delta.add] });
|
|
37
|
+
next.push({ behavior: delta.behavior, rule: delta.rule, tool: delta.tool, match: delta.match, command: delta.command, scope: delta.scope, adds: [delta.add] });
|
|
35
38
|
return next;
|
|
36
39
|
}
|
|
37
40
|
if (!existing.adds.some((a) => sameDot(a.dot, delta.add.dot)))
|
|
@@ -41,7 +44,7 @@ export function foldDelta(rules, delta) {
|
|
|
41
44
|
export function addDotsOf(rules) {
|
|
42
45
|
return rules.flatMap((r) => r.adds.map((a) => a.dot));
|
|
43
46
|
}
|
|
44
|
-
const ADD_ONLY_KEYS = ["add", "redemption", "rule", "command", "match", "tool"];
|
|
47
|
+
const ADD_ONLY_KEYS = ["add", "redemption", "rule", "command", "match", "tool", "behavior"];
|
|
45
48
|
export function assertDeleteDeltaCarriesNoAdd(delta) {
|
|
46
49
|
const smuggled = ADD_ONLY_KEYS.filter((k) => Object.hasOwn(delta, k));
|
|
47
50
|
if (smuggled.length > 0) {
|
|
@@ -87,8 +90,8 @@ function scopeKey(scope) {
|
|
|
87
90
|
function dotKey(dot) {
|
|
88
91
|
return JSON.stringify([dot.actor, dot.counter]);
|
|
89
92
|
}
|
|
90
|
-
function ruleIdentityKey(rule, scope) {
|
|
91
|
-
return JSON.stringify([rule, scopeKey(scope)]);
|
|
93
|
+
function ruleIdentityKey(behavior, rule, scope) {
|
|
94
|
+
return JSON.stringify([behavior, rule, scopeKey(scope)]);
|
|
92
95
|
}
|
|
93
96
|
function compareDots(a, b) {
|
|
94
97
|
return a.actor < b.actor ? -1 : a.actor > b.actor ? 1 : a.counter - b.counter;
|
|
@@ -124,7 +127,9 @@ export function joinFrontiers(a, b) {
|
|
|
124
127
|
return out;
|
|
125
128
|
}
|
|
126
129
|
export function normalizePersistedRule(r) {
|
|
127
|
-
|
|
130
|
+
if (!isRuleBehavior(r.behavior))
|
|
131
|
+
return { reject: "invalid_rule_text" };
|
|
132
|
+
const parsed = parseRuleText(r.rule, r.behavior);
|
|
128
133
|
if ("reject" in parsed)
|
|
129
134
|
return { reject: "invalid_rule_text" };
|
|
130
135
|
const p = parsed.rule;
|
|
@@ -142,13 +147,13 @@ export function screenRuleSyncState(state) {
|
|
|
142
147
|
continue;
|
|
143
148
|
if (r.scope?.kind === "session") {
|
|
144
149
|
for (const a of r.adds)
|
|
145
|
-
rejected.push({ rule: r.rule, scope: r.scope, dot: a.dot, reason: "session_scope_not_durable" });
|
|
150
|
+
rejected.push({ behavior: r.behavior, rule: r.rule, scope: r.scope, dot: a.dot, reason: "session_scope_not_durable" });
|
|
146
151
|
continue;
|
|
147
152
|
}
|
|
148
153
|
const n = normalizePersistedRule(r);
|
|
149
154
|
if ("reject" in n) {
|
|
150
155
|
for (const a of r.adds)
|
|
151
|
-
rejected.push({ rule: r.rule, scope: r.scope, dot: a.dot, reason: n.reject });
|
|
156
|
+
rejected.push({ behavior: r.behavior, rule: r.rule, scope: r.scope, dot: a.dot, reason: n.reject });
|
|
152
157
|
continue;
|
|
153
158
|
}
|
|
154
159
|
rules.push(r);
|
|
@@ -156,7 +161,11 @@ export function screenRuleSyncState(state) {
|
|
|
156
161
|
const tombstones = [];
|
|
157
162
|
for (const t of state.tombstones) {
|
|
158
163
|
if (t?.scope?.kind === "session") {
|
|
159
|
-
rejected.push({ rule: t.rule, scope: t.scope, dot: t.deletedBy, reason: "session_scope_not_durable" });
|
|
164
|
+
rejected.push({ behavior: t.behavior, rule: t.rule, scope: t.scope, dot: t.deletedBy, reason: "session_scope_not_durable" });
|
|
165
|
+
continue;
|
|
166
|
+
}
|
|
167
|
+
if (!isRuleBehavior(t.behavior)) {
|
|
168
|
+
rejected.push({ behavior: t.behavior, rule: t.rule, scope: t.scope, dot: t.deletedBy, reason: "invalid_rule_text" });
|
|
160
169
|
continue;
|
|
161
170
|
}
|
|
162
171
|
tombstones.push(t);
|
|
@@ -168,7 +177,7 @@ export function joinRuleStates(a, b) {
|
|
|
168
177
|
const sb = screenRuleSyncState(b).state;
|
|
169
178
|
const ruleGroups = new Map();
|
|
170
179
|
for (const r of [...sa.rules, ...sb.rules]) {
|
|
171
|
-
const key = ruleIdentityKey(r.rule, r.scope);
|
|
180
|
+
const key = ruleIdentityKey(r.behavior, r.rule, r.scope);
|
|
172
181
|
const existing = ruleGroups.get(key);
|
|
173
182
|
if (existing === undefined) {
|
|
174
183
|
ruleGroups.set(key, { ...r, adds: [...r.adds] });
|
|
@@ -184,12 +193,12 @@ export function joinRuleStates(a, b) {
|
|
|
184
193
|
}
|
|
185
194
|
const rules = [...ruleGroups.values()]
|
|
186
195
|
.map((r) => ({ ...r, adds: [...r.adds].sort((x, y) => compareDots(x.dot, y.dot)) }))
|
|
187
|
-
.sort((x, y) => (x
|
|
196
|
+
.sort((x, y) => compareIdentity(x, y));
|
|
188
197
|
const tombGroups = new Map();
|
|
189
198
|
for (const t of [...sa.tombstones, ...sb.tombstones]) {
|
|
190
199
|
if (!Array.isArray(t.removedDots) || t.removedDots.length === 0)
|
|
191
200
|
continue;
|
|
192
|
-
const key = JSON.stringify([t.rule, scopeKey(t.scope), dotKey(t.deletedBy)]);
|
|
201
|
+
const key = JSON.stringify([t.behavior, t.rule, scopeKey(t.scope), dotKey(t.deletedBy)]);
|
|
193
202
|
const existing = tombGroups.get(key);
|
|
194
203
|
if (existing === undefined) {
|
|
195
204
|
tombGroups.set(key, { ...t, removedDots: [...t.removedDots] });
|
|
@@ -201,9 +210,18 @@ export function joinRuleStates(a, b) {
|
|
|
201
210
|
}
|
|
202
211
|
const tombstones = [...tombGroups.values()]
|
|
203
212
|
.map((t) => ({ ...t, removedDots: [...t.removedDots].sort(compareDots) }))
|
|
204
|
-
.sort((x, y) =>
|
|
213
|
+
.sort((x, y) => compareIdentity(x, y) || compareDots(x.deletedBy, y.deletedBy));
|
|
205
214
|
return { rules, tombstones };
|
|
206
215
|
}
|
|
216
|
+
function compareIdentity(x, y) {
|
|
217
|
+
if (x.rule !== y.rule)
|
|
218
|
+
return x.rule < y.rule ? -1 : 1;
|
|
219
|
+
const sx = scopeKey(x.scope);
|
|
220
|
+
const sy = scopeKey(y.scope);
|
|
221
|
+
if (sx !== sy)
|
|
222
|
+
return sx < sy ? -1 : 1;
|
|
223
|
+
return x.behavior === y.behavior ? 0 : x.behavior < y.behavior ? -1 : 1;
|
|
224
|
+
}
|
|
207
225
|
export function collectBelowFrontier(state, frontier) {
|
|
208
226
|
const collectable = state.tombstones.filter((t) => dotAtOrBelowFrontier(t.deletedBy, frontier) && t.removedDots.every((d) => dotAtOrBelowFrontier(d, frontier)));
|
|
209
227
|
if (collectable.length === 0)
|
|
@@ -211,7 +229,7 @@ export function collectBelowFrontier(state, frontier) {
|
|
|
211
229
|
const keep = state.tombstones.filter((t) => !collectable.includes(t));
|
|
212
230
|
const rules = state.rules
|
|
213
231
|
.map((r) => {
|
|
214
|
-
const covered = collectable.filter((t) =>
|
|
232
|
+
const covered = collectable.filter((t) => sameRuleIdentity(t, r)).flatMap((t) => t.removedDots);
|
|
215
233
|
return covered.length === 0 ? r : { ...r, adds: r.adds.filter((a) => !covered.some((d) => sameDot(d, a.dot))) };
|
|
216
234
|
})
|
|
217
235
|
.filter((r) => r.adds.length > 0);
|
|
@@ -230,7 +248,10 @@ export function applySyncJoin(cur, delta, nowMs) {
|
|
|
230
248
|
if (!isValidDurableScope(t.scope) || !isValidDot(t.deletedBy) || !t.removedDots.every(isValidDot)) {
|
|
231
249
|
throw new Error("an inbound tombstone carries a malformed scope or dot — refusing the whole sync-join (a tombstone that cannot identity-match what it deletes would land its covered adds live)");
|
|
232
250
|
}
|
|
233
|
-
|
|
251
|
+
if (!isRuleBehavior(t.behavior)) {
|
|
252
|
+
throw new Error("an inbound tombstone carries a behavior word outside deny/ask/allow — refusing the whole sync-join (a tombstone that cannot identity-match what it deletes would land its covered adds live)");
|
|
253
|
+
}
|
|
254
|
+
const canonicalTomb = parseRuleText(t.rule, t.behavior);
|
|
234
255
|
if ("reject" in canonicalTomb || canonicalTomb.rule.rule !== t.rule) {
|
|
235
256
|
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)`);
|
|
236
257
|
}
|
|
@@ -243,12 +264,12 @@ export function applySyncJoin(cur, delta, nowMs) {
|
|
|
243
264
|
const dotIdentity = new Map();
|
|
244
265
|
for (const r of cur.rules)
|
|
245
266
|
for (const a of r.adds)
|
|
246
|
-
dotIdentity.set(dotKey(a.dot), ruleIdentityKey(r.rule, r.scope));
|
|
267
|
+
dotIdentity.set(dotKey(a.dot), ruleIdentityKey(r.behavior, r.rule, r.scope));
|
|
247
268
|
for (const q of cur.quarantined)
|
|
248
|
-
dotIdentity.set(dotKey(q.add.dot), ruleIdentityKey(q.rule, q.scope));
|
|
269
|
+
dotIdentity.set(dotKey(q.add.dot), ruleIdentityKey(q.behavior, q.rule, q.scope));
|
|
249
270
|
const inboundClaims = new Map();
|
|
250
271
|
for (const r of screened.state.rules) {
|
|
251
|
-
const identity = ruleIdentityKey(r.rule, r.scope);
|
|
272
|
+
const identity = ruleIdentityKey(r.behavior, r.rule, r.scope);
|
|
252
273
|
for (const a of r.adds) {
|
|
253
274
|
const claims = inboundClaims.get(dotKey(a.dot)) ?? new Set();
|
|
254
275
|
claims.add(identity);
|
|
@@ -259,7 +280,7 @@ export function applySyncJoin(cur, delta, nowMs) {
|
|
|
259
280
|
const forged = forgedDot(t.deletedBy) ? t.deletedBy : t.removedDots.find(forgedDot);
|
|
260
281
|
if (forged === undefined)
|
|
261
282
|
return true;
|
|
262
|
-
droppedInbound.push({ rule: t.rule, scope: t.scope, dot: forged, reason: "own_actor_forged" });
|
|
283
|
+
droppedInbound.push({ behavior: t.behavior, rule: t.rule, scope: t.scope, dot: forged, reason: "own_actor_forged" });
|
|
263
284
|
return false;
|
|
264
285
|
});
|
|
265
286
|
const tombstoneRejected = inboundTombstones.length !== screened.state.tombstones.length;
|
|
@@ -272,22 +293,22 @@ export function applySyncJoin(cur, delta, nowMs) {
|
|
|
272
293
|
}
|
|
273
294
|
if (!isValidDurableScope(r.scope)) {
|
|
274
295
|
for (const a of r.adds)
|
|
275
|
-
droppedInbound.push({ rule: r.rule, scope: r.scope, dot: a.dot, reason: "metadata_mismatch" });
|
|
296
|
+
droppedInbound.push({ behavior: r.behavior, rule: r.rule, scope: r.scope, dot: a.dot, reason: "metadata_mismatch" });
|
|
276
297
|
continue;
|
|
277
298
|
}
|
|
278
|
-
const identity = ruleIdentityKey(r.rule, r.scope);
|
|
299
|
+
const identity = ruleIdentityKey(r.behavior, r.rule, r.scope);
|
|
279
300
|
const kept = r.adds.filter((a) => {
|
|
280
301
|
if (!isValidDot(a.dot)) {
|
|
281
|
-
droppedInbound.push({ rule: r.rule, scope: r.scope, dot: a.dot, reason: "metadata_mismatch" });
|
|
302
|
+
droppedInbound.push({ behavior: r.behavior, rule: r.rule, scope: r.scope, dot: a.dot, reason: "metadata_mismatch" });
|
|
282
303
|
return false;
|
|
283
304
|
}
|
|
284
305
|
if (forgedDot(a.dot)) {
|
|
285
|
-
droppedInbound.push({ rule: r.rule, scope: r.scope, dot: a.dot, reason: "own_actor_forged" });
|
|
306
|
+
droppedInbound.push({ behavior: r.behavior, rule: r.rule, scope: r.scope, dot: a.dot, reason: "own_actor_forged" });
|
|
286
307
|
return false;
|
|
287
308
|
}
|
|
288
309
|
const known = dotIdentity.get(dotKey(a.dot));
|
|
289
310
|
if ((known !== undefined && known !== identity) || (inboundClaims.get(dotKey(a.dot))?.size ?? 0) > 1) {
|
|
290
|
-
droppedInbound.push({ rule: r.rule, scope: r.scope, dot: a.dot, reason: "dot_identity_conflict" });
|
|
311
|
+
droppedInbound.push({ behavior: r.behavior, rule: r.rule, scope: r.scope, dot: a.dot, reason: "dot_identity_conflict" });
|
|
291
312
|
return false;
|
|
292
313
|
}
|
|
293
314
|
return true;
|
|
@@ -298,18 +319,18 @@ export function applySyncJoin(cur, delta, nowMs) {
|
|
|
298
319
|
const inbound = { rules: inboundRules, tombstones: inboundTombstones };
|
|
299
320
|
const localScreen = screenRuleSyncState({ rules: cur.rules, tombstones: cur.tombstones });
|
|
300
321
|
const quarantined = [...cur.quarantined];
|
|
301
|
-
const quarantineKey = (rule, scope, dot) => JSON.stringify([rule, scopeKey(scope), dotKey(dot)]);
|
|
302
|
-
const pushQuarantine = (rule, scope, add, reason) => {
|
|
303
|
-
if (quarantined.some((q) => q
|
|
322
|
+
const quarantineKey = (behavior, rule, scope, dot) => JSON.stringify([behavior, rule, scopeKey(scope), dotKey(dot)]);
|
|
323
|
+
const pushQuarantine = (behavior, rule, scope, add, reason) => {
|
|
324
|
+
if (quarantined.some((q) => sameRuleIdentity(q, { behavior, rule, scope }) && sameDot(q.add.dot, add.dot)))
|
|
304
325
|
return;
|
|
305
|
-
quarantined.push({ rule, scope, add, reason, atMs: nowMs });
|
|
306
|
-
quarantinedNow.push({ rule, scope, dot: add.dot, reason });
|
|
326
|
+
quarantined.push({ behavior, rule, scope, add, reason, atMs: nowMs });
|
|
327
|
+
quarantinedNow.push({ behavior, rule, scope, dot: add.dot, reason });
|
|
307
328
|
};
|
|
308
329
|
for (const rej of localScreen.rejected) {
|
|
309
|
-
const row = cur.rules.find((r) =>
|
|
330
|
+
const row = cur.rules.find((r) => sameRuleIdentity(r, rej));
|
|
310
331
|
const add = row?.adds.find((a) => sameDot(a.dot, rej.dot));
|
|
311
332
|
if (add !== undefined && (rej.reason === "invalid_rule_text" || rej.reason === "metadata_mismatch")) {
|
|
312
|
-
pushQuarantine(rej.rule, rej.scope, add, rej.reason);
|
|
333
|
+
pushQuarantine(rej.behavior, rej.rule, rej.scope, add, rej.reason);
|
|
313
334
|
}
|
|
314
335
|
}
|
|
315
336
|
let joined = joinRuleStates(localScreen.state, inbound);
|
|
@@ -323,7 +344,7 @@ export function applySyncJoin(cur, delta, nowMs) {
|
|
|
323
344
|
for (const r of live) {
|
|
324
345
|
for (const a of r.adds) {
|
|
325
346
|
if (dotAtOrBelowFrontier(a.dot, delta.gcFrontier) && !inboundDots.has(dotKey(a.dot))) {
|
|
326
|
-
pushQuarantine(r.rule, r.scope, a, "below_gc_frontier");
|
|
347
|
+
pushQuarantine(r.behavior, r.rule, r.scope, a, "below_gc_frontier");
|
|
327
348
|
}
|
|
328
349
|
}
|
|
329
350
|
}
|
|
@@ -338,16 +359,16 @@ export function applySyncJoin(cur, delta, nowMs) {
|
|
|
338
359
|
if (joined.tombstones.some((t) => sameDot(t.deletedBy, dot))) {
|
|
339
360
|
throw new Error("a quarantine instruction may not touch a tombstone — it moves adds only");
|
|
340
361
|
}
|
|
341
|
-
const row = joined.rules.find((r) =>
|
|
362
|
+
const row = joined.rules.find((r) => sameRuleIdentity(r, q));
|
|
342
363
|
const add = row?.adds.find((a) => sameDot(a.dot, dot));
|
|
343
364
|
if (add === undefined)
|
|
344
365
|
continue;
|
|
345
|
-
pushQuarantine(q.rule, q.scope, add, q.reason);
|
|
366
|
+
pushQuarantine(q.behavior, q.rule, q.scope, add, q.reason);
|
|
346
367
|
}
|
|
347
368
|
}
|
|
348
|
-
const quarantinedRows = new Set(quarantined.map((q) => quarantineKey(q.rule, q.scope, q.add.dot)));
|
|
369
|
+
const quarantinedRows = new Set(quarantined.map((q) => quarantineKey(q.behavior, q.rule, q.scope, q.add.dot)));
|
|
349
370
|
const rules = joined.rules
|
|
350
|
-
.map((r) => ({ ...r, adds: r.adds.filter((a) => !quarantinedRows.has(quarantineKey(r.rule, r.scope, a.dot))) }))
|
|
371
|
+
.map((r) => ({ ...r, adds: r.adds.filter((a) => !quarantinedRows.has(quarantineKey(r.behavior, r.rule, r.scope, a.dot))) }))
|
|
351
372
|
.filter((r) => r.adds.length > 0);
|
|
352
373
|
return {
|
|
353
374
|
next: {
|
|
@@ -373,9 +394,14 @@ export async function ruleStoreChecksum(payload) {
|
|
|
373
394
|
}
|
|
374
395
|
const REMOVE_MAX_ATTEMPTS = 8;
|
|
375
396
|
export async function removePersistedRule(opts) {
|
|
397
|
+
const behavior = opts.behavior;
|
|
376
398
|
const rule = opts.rule;
|
|
377
399
|
const scope = structuredClone(opts.scope);
|
|
378
400
|
const target = opts.principal;
|
|
401
|
+
if (!isRuleBehavior(behavior)) {
|
|
402
|
+
return { status: "failed", error: `removePersistedRule: behavior ${escapeForDisclosure(behavior)} is not one of ${["deny", "ask", "allow"].join("/")} — refusing to name a rule identity nothing can carry` };
|
|
403
|
+
}
|
|
404
|
+
const identity = { behavior, rule, scope };
|
|
379
405
|
const owner = typeof target === "string" ? { kind: "principal", principal: target } : structuredClone(target);
|
|
380
406
|
const provider = opts.provider;
|
|
381
407
|
if (scope?.kind === "session") {
|
|
@@ -401,11 +427,11 @@ export async function removePersistedRule(opts) {
|
|
|
401
427
|
}
|
|
402
428
|
if (snapshot.unreadable !== undefined)
|
|
403
429
|
return { status: "failed", error: `could not read the permission-rule store: ${snapshot.unreadable}` };
|
|
404
|
-
const live = snapshot.rules.filter((r) =>
|
|
430
|
+
const live = snapshot.rules.filter((r) => sameRuleIdentity(r, identity));
|
|
405
431
|
const removedDots = live.flatMap((r) => r.adds.map((a) => a.dot));
|
|
406
432
|
if (removedDots.length === 0)
|
|
407
433
|
return { status: "no-op", rev: snapshot.rev };
|
|
408
|
-
const tombstone = { rule, scope, removedDots, deletedBy: await writer.nextDot() };
|
|
434
|
+
const tombstone = { behavior, rule, scope, removedDots, deletedBy: await writer.nextDot() };
|
|
409
435
|
let expectedRev = snapshot.rev;
|
|
410
436
|
let lastError = "";
|
|
411
437
|
for (let attempt = 0; attempt < REMOVE_MAX_ATTEMPTS; attempt++) {
|
|
@@ -420,7 +446,7 @@ export async function removePersistedRule(opts) {
|
|
|
420
446
|
const after = await store.effective();
|
|
421
447
|
if (after.unreadable !== undefined)
|
|
422
448
|
throw new Error(after.unreadable);
|
|
423
|
-
stillLive = after.rules.some((r) =>
|
|
449
|
+
stillLive = after.rules.some((r) => sameRuleIdentity(r, identity) && isRuleLive(r));
|
|
424
450
|
}
|
|
425
451
|
catch {
|
|
426
452
|
stillLive = false;
|
|
@@ -439,7 +465,7 @@ export async function removePersistedRule(opts) {
|
|
|
439
465
|
const after = await writer.readRaw();
|
|
440
466
|
const landed = after.tombstones.some((t) => t.deletedBy.actor === tombstone.deletedBy.actor && t.deletedBy.counter === tombstone.deletedBy.counter);
|
|
441
467
|
return landed
|
|
442
|
-
? { status: "removed", rev: after.rev, stillLive: applyTombstones(after.rules, after.tombstones).some((r) =>
|
|
468
|
+
? { status: "removed", rev: after.rev, stillLive: applyTombstones(after.rules, after.tombstones).some((r) => sameRuleIdentity(r, identity) && isRuleLive(r)) }
|
|
443
469
|
: { status: "failed", error: `${reason} (read-back confirms no tombstone was written)` };
|
|
444
470
|
}
|
|
445
471
|
catch (err) {
|
|
@@ -25,7 +25,7 @@
|
|
|
25
25
|
* privilege-separation ticket adds device-bound signatures. Deployments should connect knowing which
|
|
26
26
|
* level they are buying.
|
|
27
27
|
*/
|
|
28
|
-
import type {
|
|
28
|
+
import type { PersistedRule, RuleBehavior, RuleDot, RuleRejectCode, RuleScope, RuleTombstone } from "./permission-rule-model.js";
|
|
29
29
|
import type { PermissionRuleStoreProvider } from "./permission-rule-provider.js";
|
|
30
30
|
import type { RuleOwner, RuleSyncDrop, RuleSyncFrontier, RuleSyncState } from "./permission-rule-store.js";
|
|
31
31
|
import { type TracerHook } from "./trace.js";
|
|
@@ -50,7 +50,9 @@ export interface RuleSyncResponseBody {
|
|
|
50
50
|
principal: string;
|
|
51
51
|
merged: RuleSyncState;
|
|
52
52
|
gcFrontier?: RuleSyncFrontier;
|
|
53
|
+
/** Server-side refusals of rows we sent. `behavior` is part of the row identity the twin names. */
|
|
53
54
|
dropped?: Array<{
|
|
55
|
+
behavior: import("./permission-rule-model.js").RuleBehavior;
|
|
54
56
|
rule: string;
|
|
55
57
|
scope: RuleScope;
|
|
56
58
|
dot: RuleDot;
|
|
@@ -70,8 +72,9 @@ export interface PermissionRuleSyncResult {
|
|
|
70
72
|
newAdds: number;
|
|
71
73
|
newTombstones: number;
|
|
72
74
|
};
|
|
73
|
-
/** (rule, scope)
|
|
75
|
+
/** (behavior, rule, scope) identities that went removed → live in this round — add-wins made visible (§4.6). */
|
|
74
76
|
resurrected: Array<{
|
|
77
|
+
behavior: import("./permission-rule-model.js").RuleBehavior;
|
|
75
78
|
rule: string;
|
|
76
79
|
scope: RuleScope;
|
|
77
80
|
byDot: RuleDot;
|
|
@@ -116,11 +119,12 @@ export declare function syncPermissionRules(opts: {
|
|
|
116
119
|
interface ParsedRuleSyncResponse {
|
|
117
120
|
principal: string;
|
|
118
121
|
merged: {
|
|
119
|
-
rules:
|
|
122
|
+
rules: PersistedRule[];
|
|
120
123
|
tombstones: Array<RuleTombstone | undefined>;
|
|
121
124
|
};
|
|
122
125
|
gcFrontier?: RuleSyncFrontier;
|
|
123
126
|
dropped: Array<{
|
|
127
|
+
behavior: RuleBehavior;
|
|
124
128
|
rule: string;
|
|
125
129
|
scope: RuleScope;
|
|
126
130
|
dot: RuleDot;
|
|
@@ -130,6 +134,7 @@ interface ParsedRuleSyncResponse {
|
|
|
130
134
|
* outcome, kept OUT of `merged.tombstones` so it does not read as malformed. See {@link
|
|
131
135
|
* parseRuleSyncResponse} for why this one does not withhold the round's adds (backlog #176). */
|
|
132
136
|
textRefusedTombstones: Array<{
|
|
137
|
+
behavior: RuleBehavior;
|
|
133
138
|
rule: string;
|
|
134
139
|
scope: RuleScope;
|
|
135
140
|
dot: RuleDot;
|
|
@@ -140,6 +145,7 @@ interface ParsedRuleSyncResponse {
|
|
|
140
145
|
* DAMAGED one as generic-malformed would withhold every valid inbound add on every round — one
|
|
141
146
|
* peer row poisoning sync forever, the exact shape the per-row drop arm exists to avoid. */
|
|
142
147
|
sessionScopeTombstones: Array<{
|
|
148
|
+
behavior: RuleBehavior;
|
|
143
149
|
rule: string;
|
|
144
150
|
scope: RuleScope;
|
|
145
151
|
dot: RuleDot;
|
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
import { escapeForDisclosure,
|
|
2
|
-
import { applyTombstones, errText, joinFrontiers, joinRuleStates, normalizePersistedRule, ruleSyncVector,
|
|
1
|
+
import { escapeForDisclosure, isRuleBehavior, parseRuleText } from "./permission-rule-model.js";
|
|
2
|
+
import { applyTombstones, errText, joinFrontiers, joinRuleStates, normalizePersistedRule, ruleSyncVector, sameRuleIdentity, screenRuleSyncState, writerOf, } from "./permission-rule-store.js";
|
|
3
3
|
import { emitTrace } from "./trace.js";
|
|
4
4
|
export const PERMISSION_RULE_SYNC_PATH = "/v1/permission-rules/sync";
|
|
5
5
|
export const LOCAL_OWNER_UNSYNCABLE_CODE = "permission_rules.local_owner_unsyncable";
|
|
@@ -79,7 +79,7 @@ export async function syncPermissionRules(opts) {
|
|
|
79
79
|
}
|
|
80
80
|
const forged = forgedDot(t.deletedBy) ? t.deletedBy : t.removedDots.find(forgedDot);
|
|
81
81
|
if (forged !== undefined) {
|
|
82
|
-
dropped.push({ rule: t.rule, scope: t.scope, dot: forged, reason: "own_actor_forged" });
|
|
82
|
+
dropped.push({ behavior: t.behavior, rule: t.rule, scope: t.scope, dot: forged, reason: "own_actor_forged" });
|
|
83
83
|
tombstoneWithholding = true;
|
|
84
84
|
continue;
|
|
85
85
|
}
|
|
@@ -89,10 +89,10 @@ export async function syncPermissionRules(opts) {
|
|
|
89
89
|
warnings.push("the response carried at least one malformed tombstone — every inbound add is withheld this round (legal tombstones land; the adds re-arrive once the peer repairs the tombstone: full-state exchange has no missed-forever window)");
|
|
90
90
|
}
|
|
91
91
|
for (const t of response.textRefusedTombstones) {
|
|
92
|
-
dropped.push({ rule: t.rule, scope: t.scope, dot: t.dot, reason: "invalid_rule_text" });
|
|
92
|
+
dropped.push({ behavior: t.behavior, rule: t.rule, scope: t.scope, dot: t.dot, reason: "invalid_rule_text" });
|
|
93
93
|
}
|
|
94
94
|
for (const t of response.sessionScopeTombstones) {
|
|
95
|
-
dropped.push({ rule: t.rule, scope: t.scope, dot: t.dot, reason: "session_scope_not_durable" });
|
|
95
|
+
dropped.push({ behavior: t.behavior, rule: t.rule, scope: t.scope, dot: t.dot, reason: "session_scope_not_durable" });
|
|
96
96
|
}
|
|
97
97
|
if (response.textRefusedTombstones.length > 0) {
|
|
98
98
|
const named = response.textRefusedTombstones
|
|
@@ -109,12 +109,12 @@ export async function syncPermissionRules(opts) {
|
|
|
109
109
|
const n = normalizePersistedRule(r);
|
|
110
110
|
if ("reject" in n) {
|
|
111
111
|
for (const a of r.adds)
|
|
112
|
-
dropped.push({ rule: r.rule, scope: r.scope, dot: a.dot, reason: n.reject });
|
|
112
|
+
dropped.push({ behavior: r.behavior, rule: r.rule, scope: r.scope, dot: a.dot, reason: n.reject });
|
|
113
113
|
continue;
|
|
114
114
|
}
|
|
115
115
|
const kept = r.adds.filter((a) => {
|
|
116
116
|
if (a.dot.actor === raw.actor && a.dot.counter > raw.counter) {
|
|
117
|
-
dropped.push({ rule: r.rule, scope: r.scope, dot: a.dot, reason: "own_actor_forged" });
|
|
117
|
+
dropped.push({ behavior: r.behavior, rule: r.rule, scope: r.scope, dot: a.dot, reason: "own_actor_forged" });
|
|
118
118
|
return false;
|
|
119
119
|
}
|
|
120
120
|
return true;
|
|
@@ -128,7 +128,7 @@ export async function syncPermissionRules(opts) {
|
|
|
128
128
|
const localScreen = screenRuleSyncState({ rules: raw.rules, tombstones: raw.tombstones });
|
|
129
129
|
for (const rej of localScreen.rejected) {
|
|
130
130
|
if (rej.reason === "invalid_rule_text" || rej.reason === "metadata_mismatch") {
|
|
131
|
-
quarantine.push({ rule: rej.rule, scope: rej.scope, dots: [rej.dot], reason: rej.reason });
|
|
131
|
+
quarantine.push({ behavior: rej.behavior, rule: rej.rule, scope: rej.scope, dots: [rej.dot], reason: rej.reason });
|
|
132
132
|
dropped.push(rej);
|
|
133
133
|
}
|
|
134
134
|
}
|
|
@@ -140,8 +140,8 @@ export async function syncPermissionRules(opts) {
|
|
|
140
140
|
}
|
|
141
141
|
if (localDots.has(JSON.stringify([d.dot.actor, d.dot.counter]))) {
|
|
142
142
|
const reason = d.reason === "below_gc_frontier" ? "below_gc_frontier" : "server_rejected";
|
|
143
|
-
quarantine.push({ rule: d.rule, scope: d.scope, dots: [d.dot], reason });
|
|
144
|
-
dropped.push({ rule: d.rule, scope: d.scope, dot: d.dot, reason });
|
|
143
|
+
quarantine.push({ behavior: d.behavior, rule: d.rule, scope: d.scope, dots: [d.dot], reason });
|
|
144
|
+
dropped.push({ behavior: d.behavior, rule: d.rule, scope: d.scope, dot: d.dot, reason });
|
|
145
145
|
}
|
|
146
146
|
else {
|
|
147
147
|
warnings.push(`the server dropped an inbound-side record (${escapeForDisclosure(d.rule)}, dot ${escapeForDisclosure(d.dot.actor)}#${d.dot.counter}): ${escapeForDisclosure(d.reason)}`);
|
|
@@ -193,22 +193,22 @@ export async function syncPermissionRules(opts) {
|
|
|
193
193
|
const postLive = applyTombstones(landedRaw.rules, landedRaw.tombstones);
|
|
194
194
|
const resurrected = [];
|
|
195
195
|
for (const r of postLive) {
|
|
196
|
-
const pre = preLive.find((p) =>
|
|
196
|
+
const pre = preLive.find((p) => sameRuleIdentity(p, r));
|
|
197
197
|
if (pre !== undefined)
|
|
198
198
|
continue;
|
|
199
|
-
const hadAdds = current.rules.some((p) =>
|
|
200
|
-
const covered = current.tombstones.some((t) =>
|
|
199
|
+
const hadAdds = current.rules.some((p) => sameRuleIdentity(p, r));
|
|
200
|
+
const covered = current.tombstones.some((t) => sameRuleIdentity(t, r));
|
|
201
201
|
if (hadAdds && covered) {
|
|
202
|
-
const preRuleDots = new Set(current.rules.filter((p) =>
|
|
202
|
+
const preRuleDots = new Set(current.rules.filter((p) => sameRuleIdentity(p, r)).flatMap((p) => p.adds.map((a) => JSON.stringify([a.dot.actor, a.dot.counter]))));
|
|
203
203
|
const by = r.adds.find((a) => !preRuleDots.has(JSON.stringify([a.dot.actor, a.dot.counter]))) ?? r.adds[0];
|
|
204
204
|
if (by !== undefined)
|
|
205
|
-
resurrected.push({ rule: r.rule, scope: r.scope, byDot: by.dot });
|
|
205
|
+
resurrected.push({ behavior: r.behavior, rule: r.rule, scope: r.scope, byDot: by.dot });
|
|
206
206
|
}
|
|
207
207
|
}
|
|
208
208
|
const dotKeyOf = (d) => JSON.stringify([d.actor, d.counter]);
|
|
209
209
|
const preDots = new Set(current.rules.flatMap((r) => r.adds.map((a) => dotKeyOf(a.dot))));
|
|
210
210
|
const newAdds = landedRaw.rules.flatMap((r) => r.adds).filter((a) => !preDots.has(dotKeyOf(a.dot))).length;
|
|
211
|
-
const tombKeyOf = (t) => JSON.stringify([t.rule, t.scope.kind, t.scope.kind === "project" ? t.scope.root : t.scope.kind === "session" ? t.scope.sessionId : null, dotKeyOf(t.deletedBy)]);
|
|
211
|
+
const tombKeyOf = (t) => JSON.stringify([t.behavior, t.rule, t.scope.kind, t.scope.kind === "project" ? t.scope.root : t.scope.kind === "session" ? t.scope.sessionId : null, dotKeyOf(t.deletedBy)]);
|
|
212
212
|
const preTombs = new Set(current.tombstones.map(tombKeyOf));
|
|
213
213
|
const newTombstones = landedRaw.tombstones.filter((t) => !preTombs.has(tombKeyOf(t))).length;
|
|
214
214
|
const landedReport = res.sync;
|
|
@@ -324,7 +324,8 @@ export function parseRuleSyncResponse(raw, expectedPrincipal) {
|
|
|
324
324
|
const eTool = e?.tool;
|
|
325
325
|
const eMatch = e?.match;
|
|
326
326
|
const eAdds = e?.adds;
|
|
327
|
-
|
|
327
|
+
const eBehavior = isRuleBehavior(e?.behavior) ? e.behavior : undefined;
|
|
328
|
+
if (typeof eRule !== "string" || scope === undefined || !Array.isArray(eAdds) || typeof eCommand !== "string" || typeof eTool !== "string" || typeof eMatch !== "string" || eBehavior === undefined) {
|
|
328
329
|
warnings.push("the response carried an unshapeable rule entry — skipped (it never reaches the local store)");
|
|
329
330
|
continue;
|
|
330
331
|
}
|
|
@@ -343,6 +344,7 @@ export function parseRuleSyncResponse(raw, expectedPrincipal) {
|
|
|
343
344
|
if (adds.length === 0)
|
|
344
345
|
continue;
|
|
345
346
|
rules.push({
|
|
347
|
+
behavior: eBehavior,
|
|
346
348
|
rule: eRule,
|
|
347
349
|
tool: eTool,
|
|
348
350
|
match: eMatch,
|
|
@@ -359,6 +361,7 @@ export function parseRuleSyncResponse(raw, expectedPrincipal) {
|
|
|
359
361
|
const pickedScope = pickScopeCaptured(t?.scope);
|
|
360
362
|
const scope = pickedScope.scope;
|
|
361
363
|
const ruleText = t?.rule;
|
|
364
|
+
const behavior = isRuleBehavior(t?.behavior) ? t.behavior : undefined;
|
|
362
365
|
const deletedBy = pickDot(t?.deletedBy);
|
|
363
366
|
const removedRaw = t?.removedDots;
|
|
364
367
|
const removedDots = [];
|
|
@@ -373,28 +376,28 @@ export function parseRuleSyncResponse(raw, expectedPrincipal) {
|
|
|
373
376
|
}
|
|
374
377
|
}
|
|
375
378
|
if (pickedScope.kind === "session") {
|
|
376
|
-
if (typeof ruleText === "string" && deletedBy !== undefined && scope !== undefined) {
|
|
377
|
-
sessionScopeTombstones.push({ rule: ruleText, scope, dot: deletedBy });
|
|
379
|
+
if (typeof ruleText === "string" && deletedBy !== undefined && scope !== undefined && behavior !== undefined) {
|
|
380
|
+
sessionScopeTombstones.push({ behavior, rule: ruleText, scope, dot: deletedBy });
|
|
378
381
|
}
|
|
379
382
|
else {
|
|
380
383
|
warnings.push("the response carried a session-scope tombstone whose identity could not be read — dropped per-row (a session row never lands here, and nothing is withheld for it)");
|
|
381
384
|
}
|
|
382
385
|
continue;
|
|
383
386
|
}
|
|
384
|
-
if (typeof ruleText !== "string" || scope === undefined || deletedBy === undefined || removedDots.length === 0) {
|
|
387
|
+
if (typeof ruleText !== "string" || scope === undefined || deletedBy === undefined || removedDots.length === 0 || behavior === undefined) {
|
|
385
388
|
tombstones.push(undefined);
|
|
386
389
|
continue;
|
|
387
390
|
}
|
|
388
|
-
const canonicalTomb =
|
|
391
|
+
const canonicalTomb = parseRuleText(ruleText, behavior);
|
|
389
392
|
if ("reject" in canonicalTomb) {
|
|
390
|
-
textRefusedTombstones.push({ rule: ruleText, scope, dot: deletedBy, code: canonicalTomb.reject.code });
|
|
393
|
+
textRefusedTombstones.push({ behavior, rule: ruleText, scope, dot: deletedBy, code: canonicalTomb.reject.code });
|
|
391
394
|
continue;
|
|
392
395
|
}
|
|
393
396
|
if (canonicalTomb.rule.rule !== ruleText) {
|
|
394
397
|
tombstones.push(undefined);
|
|
395
398
|
continue;
|
|
396
399
|
}
|
|
397
|
-
tombstones.push({ rule: ruleText, scope, removedDots, deletedBy });
|
|
400
|
+
tombstones.push({ behavior, rule: ruleText, scope, removedDots, deletedBy });
|
|
398
401
|
}
|
|
399
402
|
const dropped = [];
|
|
400
403
|
const rawDropped = r.dropped;
|
|
@@ -409,11 +412,12 @@ export function parseRuleSyncResponse(raw, expectedPrincipal) {
|
|
|
409
412
|
const dRule = d?.rule;
|
|
410
413
|
const dDot = pickDot(d?.dot);
|
|
411
414
|
const dReason = d?.reason;
|
|
412
|
-
|
|
415
|
+
const dBehavior = isRuleBehavior(d?.behavior) ? d.behavior : undefined;
|
|
416
|
+
if (typeof dRule !== "string" || scope === undefined || dDot === undefined || typeof dReason !== "string" || dBehavior === undefined) {
|
|
413
417
|
warnings.push("the response carried an unshapeable dropped entry — ignored (the server's own drop discipline is its contract obligation)");
|
|
414
418
|
continue;
|
|
415
419
|
}
|
|
416
|
-
dropped.push({ rule: dRule, scope, dot: dDot, reason: dReason });
|
|
420
|
+
dropped.push({ behavior: dBehavior, rule: dRule, scope, dot: dDot, reason: dReason });
|
|
417
421
|
}
|
|
418
422
|
}
|
|
419
423
|
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The `Tool(content)` rule SYNTAX — the one reader every rule face shares, as a LEAF module.
|
|
3
|
+
*
|
|
4
|
+
* Two modules read this grammar and, until this file existed, one imported it from the other: the rule
|
|
5
|
+
* DSL compiler (`permission-rules.ts`) declared the parser and the persisted-rule model
|
|
6
|
+
* (`permission-rule-model.ts`) imported it. The model's command/path grammars are now consumed by the
|
|
7
|
+
* compiler too (a content-form `deny`/`ask` rule compiles through the model's own validator and
|
|
8
|
+
* matcher), which would have closed a value-import cycle between the two. The syntax layer has no
|
|
9
|
+
* dependency of its own, so it lives here and both read DOWN.
|
|
10
|
+
*
|
|
11
|
+
* Upstream parity: `Lh` (the split), `Dju`/`Pju` (the escape-aware paren search), `Hju` (the three
|
|
12
|
+
* unescapes) — INCLUDING the lenient downgrades (malformed parens / trailing garbage ⇒ the WHOLE string
|
|
13
|
+
* becomes the bare tool name). The strict complement that refuses the downgrades is the compiler's
|
|
14
|
+
* validator, not this reader.
|
|
15
|
+
*/
|
|
16
|
+
/** Parse result — the tool name and, for a `Tool(content)` spelling, its unescaped content. */
|
|
17
|
+
export interface ParsedPermissionRule {
|
|
18
|
+
toolName: string;
|
|
19
|
+
ruleContent?: string;
|
|
20
|
+
}
|
|
21
|
+
/** First index of `ch` preceded by an EVEN number of backslashes. */
|
|
22
|
+
export declare function indexOfUnescaped(s: string, ch: string): number;
|
|
23
|
+
/** Last index of `ch` preceded by an EVEN number of backslashes. */
|
|
24
|
+
export declare function lastIndexOfUnescaped(s: string, ch: string): number;
|
|
25
|
+
/**
|
|
26
|
+
* Parse one rule string, including the lenient downgrades (malformed parens / trailing garbage ⇒ the
|
|
27
|
+
* WHOLE string becomes the bare tool name). Use the compiler's `validatePermissionRules` (or the policy
|
|
28
|
+
* factory) to refuse the downgrades.
|
|
29
|
+
*/
|
|
30
|
+
export declare function parsePermissionRule(rule: string): ParsedPermissionRule;
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
export function indexOfUnescaped(s, ch) {
|
|
2
|
+
for (let i = 0; i < s.length; i++) {
|
|
3
|
+
if (s[i] !== ch)
|
|
4
|
+
continue;
|
|
5
|
+
let backslashes = 0;
|
|
6
|
+
for (let j = i - 1; j >= 0 && s[j] === "\\"; j--)
|
|
7
|
+
backslashes++;
|
|
8
|
+
if (backslashes % 2 === 0)
|
|
9
|
+
return i;
|
|
10
|
+
}
|
|
11
|
+
return -1;
|
|
12
|
+
}
|
|
13
|
+
export function lastIndexOfUnescaped(s, ch) {
|
|
14
|
+
for (let i = s.length - 1; i >= 0; i--) {
|
|
15
|
+
if (s[i] !== ch)
|
|
16
|
+
continue;
|
|
17
|
+
let backslashes = 0;
|
|
18
|
+
for (let j = i - 1; j >= 0 && s[j] === "\\"; j--)
|
|
19
|
+
backslashes++;
|
|
20
|
+
if (backslashes % 2 === 0)
|
|
21
|
+
return i;
|
|
22
|
+
}
|
|
23
|
+
return -1;
|
|
24
|
+
}
|
|
25
|
+
function unescapeRuleContent(s) {
|
|
26
|
+
return s.replaceAll("\\(", "(").replaceAll("\\)", ")").replaceAll("\\\\", "\\");
|
|
27
|
+
}
|
|
28
|
+
export function parsePermissionRule(rule) {
|
|
29
|
+
const open = indexOfUnescaped(rule, "(");
|
|
30
|
+
if (open === -1)
|
|
31
|
+
return { toolName: rule.trim() };
|
|
32
|
+
const close = lastIndexOfUnescaped(rule, ")");
|
|
33
|
+
if (close === -1 || close <= open)
|
|
34
|
+
return { toolName: rule.trim() };
|
|
35
|
+
if (close !== rule.length - 1)
|
|
36
|
+
return { toolName: rule.trim() };
|
|
37
|
+
const name = rule.substring(0, open);
|
|
38
|
+
const content = rule.substring(open + 1, close);
|
|
39
|
+
if (!name)
|
|
40
|
+
return { toolName: rule.trim() };
|
|
41
|
+
if (content === "" || content === "*")
|
|
42
|
+
return { toolName: name.trim() };
|
|
43
|
+
return { toolName: name.trim(), ruleContent: unescapeRuleContent(content) };
|
|
44
|
+
}
|