@sema-agent/core 5.26.0 → 5.28.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 +113 -0
- package/dist/agents/agent-transcript-tool.d.ts +5 -2
- package/dist/agents/agent-transcript-tool.js +2 -1
- package/dist/agents/send-message-tool.d.ts +4 -1
- package/dist/agents/subagent.d.ts +5 -2
- package/dist/core/checkpoint-store.d.ts +7 -2
- package/dist/core/hooks.d.ts +61 -4
- package/dist/core/hooks.js +37 -15
- package/dist/core/memory-engine/engine.d.ts +8 -5
- package/dist/core/memory-engine/engine.js +18 -6
- package/dist/core/memory-engine/file-backend.d.ts +144 -4
- package/dist/core/memory-engine/file-backend.js +304 -36
- package/dist/core/memory-engine/layout.d.ts +31 -2
- package/dist/core/memory-engine/layout.js +132 -8
- package/dist/core/memory-engine/types.d.ts +9 -1
- package/dist/core/memory-vector.d.ts +6 -1
- package/dist/core/memory-vector.js +14 -4
- package/dist/core/memory.js +1 -6
- package/dist/core/permission-rule-consent.d.ts +82 -8
- package/dist/core/permission-rule-consent.js +92 -1
- package/dist/core/permission-rule-model.d.ts +87 -6
- package/dist/core/permission-rule-model.js +79 -0
- package/dist/core/permission-rule-org.d.ts +22 -3
- package/dist/core/permission-rule-org.js +67 -20
- 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/prepare-memory.js +14 -9
- package/dist/core/runner/prepare-task.d.ts +9 -3
- package/dist/core/runner/prepare-task.js +37 -11
- package/dist/core/runner/runtask.d.ts +8 -1
- package/dist/core/runner/runtask.js +8 -1
- package/dist/core/task-registry-agent.d.ts +13 -3
- 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 +9 -0
- package/dist/core/task-registry.d.ts +6 -3
- package/dist/core/tool-policy.d.ts +44 -4
- package/dist/core/tool-policy.js +37 -3
- package/dist/core/tool-result-store.d.ts +108 -7
- package/dist/core/tool-result-store.js +95 -15
- package/dist/core/types.d.ts +115 -17
- package/dist/core/types.js +30 -1
- package/dist/engine/loop/types.d.ts +10 -3
- package/dist/index.d.ts +2 -2
- package/dist/index.js +1 -1
- package/dist/orchestration/run-workflow-tool.d.ts +5 -3
- package/dist/orchestration/workflow.d.ts +9 -6
- package/dist/stores/file/checkpoint-store.d.ts +2 -1
- package/dist/stores/file/index.d.ts +1 -1
- package/dist/stores/file/tool-result-store.d.ts +41 -1
- package/dist/stores/file/tool-result-store.js +107 -19
- package/dist/tools/fs/fs-bash.d.ts +7 -0
- package/dist/tools/fs/fs-shared.d.ts +5 -0
- 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/package.json +1 -1
|
@@ -6,6 +6,54 @@ export const BARE_INTERPRETER_NAMES = new Set([
|
|
|
6
6
|
"node", "deno", "bun", "python", "python2", "python3", "perl", "ruby", "php",
|
|
7
7
|
"osascript", "env", "eval", "exec", "xargs", "nohup", "sudo", "doas", "su", "ssh",
|
|
8
8
|
]);
|
|
9
|
+
export const SUGGESTION_LEXICON = [
|
|
10
|
+
"git status", "git log", "git diff", "git show", "git branch", "git checkout", "git switch",
|
|
11
|
+
"git add", "git commit", "git push", "git pull", "git fetch", "git merge", "git rebase",
|
|
12
|
+
"git tag", "git blame", "git describe", "git cherry-pick", "git restore", "git reset",
|
|
13
|
+
"git rev-parse", "git ls-files", "git grep",
|
|
14
|
+
"git stash list", "git stash show", "git stash push", "git stash pop", "git stash drop",
|
|
15
|
+
"git remote show", "git worktree list", "git submodule update", "git submodule status",
|
|
16
|
+
"npm run", "npm test", "npm ci", "npm ls", "npm view", "npm outdated", "npm audit",
|
|
17
|
+
"npm pack", "npm publish", "npm version", "npm why",
|
|
18
|
+
"pnpm run", "pnpm test", "pnpm ls", "pnpm outdated", "pnpm audit", "pnpm why",
|
|
19
|
+
"yarn run", "yarn test", "yarn workspaces list",
|
|
20
|
+
"cargo build", "cargo test", "cargo run", "cargo check", "cargo clippy", "cargo fmt",
|
|
21
|
+
"cargo doc", "cargo tree", "cargo bench", "cargo update", "cargo metadata",
|
|
22
|
+
"go build", "go test", "go vet", "go fmt", "go doc",
|
|
23
|
+
"go mod tidy", "go mod download", "go mod verify", "go mod graph",
|
|
24
|
+
"docker ps", "docker images", "docker logs", "docker inspect", "docker build", "docker pull",
|
|
25
|
+
"docker push", "docker stop", "docker start", "docker restart", "docker rm", "docker rmi",
|
|
26
|
+
"docker compose up", "docker compose down", "docker compose ps", "docker compose logs",
|
|
27
|
+
"docker compose build", "docker compose pull",
|
|
28
|
+
"kubectl get", "kubectl describe", "kubectl logs", "kubectl apply", "kubectl delete",
|
|
29
|
+
"kubectl diff", "kubectl explain", "kubectl top",
|
|
30
|
+
"kubectl rollout status", "kubectl rollout restart", "kubectl rollout history",
|
|
31
|
+
"gh pr view", "gh pr list", "gh pr diff", "gh pr checks", "gh pr status", "gh pr create",
|
|
32
|
+
"gh pr merge", "gh issue view", "gh issue list", "gh issue create", "gh repo view",
|
|
33
|
+
"gh repo clone", "gh release list", "gh release view", "gh run list", "gh run view",
|
|
34
|
+
"gh run watch", "gh workflow list", "gh workflow view", "gh auth status",
|
|
35
|
+
"gh search repos", "gh search issues", "gh search prs", "gh search code",
|
|
36
|
+
];
|
|
37
|
+
const LEXICON_BODIES = SUGGESTION_LEXICON.map((b) => b.split(" "));
|
|
38
|
+
function longestReviewedBody(tokens) {
|
|
39
|
+
let best;
|
|
40
|
+
for (const words of LEXICON_BODIES) {
|
|
41
|
+
if (words.length > tokens.length)
|
|
42
|
+
continue;
|
|
43
|
+
if (best !== undefined && words.length <= best.length)
|
|
44
|
+
continue;
|
|
45
|
+
let hit = true;
|
|
46
|
+
for (let i = 0; i < words.length; i++) {
|
|
47
|
+
if (words[i] !== tokens[i]) {
|
|
48
|
+
hit = false;
|
|
49
|
+
break;
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
if (hit)
|
|
53
|
+
best = words;
|
|
54
|
+
}
|
|
55
|
+
return best?.join(" ");
|
|
56
|
+
}
|
|
9
57
|
function foldSpacing(s) {
|
|
10
58
|
let out = "";
|
|
11
59
|
let quote;
|
|
@@ -43,10 +91,31 @@ function foldSpacing(s) {
|
|
|
43
91
|
function reject(code, message) {
|
|
44
92
|
return { reject: { code, message } };
|
|
45
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
|
+
}
|
|
46
112
|
export function parseAllowRuleText(text, opts) {
|
|
47
113
|
if (text.length > MAX_RULE_TEXT_CHARS) {
|
|
48
114
|
return reject("invalid.too_long", `rule text exceeds ${MAX_RULE_TEXT_CHARS} characters`);
|
|
49
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
|
+
}
|
|
50
119
|
const parsed = parsePermissionRule(text);
|
|
51
120
|
if (parsed.ruleContent === undefined) {
|
|
52
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`);
|
|
@@ -131,5 +200,15 @@ export function suggestRulesForCommand(command) {
|
|
|
131
200
|
const exact = parseAllowRuleText(formatAllowRuleText(folded, "exact"));
|
|
132
201
|
if ("rule" in exact)
|
|
133
202
|
out.push({ rule: exact.rule.rule, match: "exact", command: exact.rule.command });
|
|
203
|
+
if (out.length === 1) {
|
|
204
|
+
const body = longestReviewedBody(folded.split(" "));
|
|
205
|
+
if (body !== undefined) {
|
|
206
|
+
const text = formatAllowRuleText(body, "prefix");
|
|
207
|
+
const parsed = parseAllowRuleText(text);
|
|
208
|
+
if ("rule" in parsed && parsed.rule.match === "prefix" && ruleAdmitsCommand(parsed.rule, command)) {
|
|
209
|
+
out.push({ rule: parsed.rule.rule, match: "prefix", command: parsed.rule.command });
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
}
|
|
134
213
|
return out;
|
|
135
214
|
}
|
|
@@ -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;
|
|
@@ -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);
|
|
@@ -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 = [];
|
|
@@ -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 { PersistedAllowRule, RuleDot, RuleScope, RuleTombstone } from "./permission-rule-model.js";
|
|
28
|
+
import type { PersistedAllowRule, RuleDot, RuleRejectCode, RuleScope, RuleTombstone } from "./permission-rule-model.js";
|
|
29
29
|
import type { PermissionRuleStoreProvider, RuleOwner, RuleSyncDrop, RuleSyncFrontier, RuleSyncState } from "./permission-rule-store.js";
|
|
30
30
|
import { type TracerHook } from "./trace.js";
|
|
31
31
|
/** The injected HTTP seam — core never bundles a fetch. The deployment owns base URL, auth, TLS and
|
|
@@ -125,6 +125,15 @@ interface ParsedRuleSyncResponse {
|
|
|
125
125
|
dot: RuleDot;
|
|
126
126
|
reason: string;
|
|
127
127
|
}>;
|
|
128
|
+
/** Tombstones whose SHAPE is whole but whose rule TEXT the current validator refuses — the third
|
|
129
|
+
* outcome, kept OUT of `merged.tombstones` so it does not read as malformed. See {@link
|
|
130
|
+
* parseRuleSyncResponse} for why this one does not withhold the round's adds (backlog #176). */
|
|
131
|
+
textRefusedTombstones: Array<{
|
|
132
|
+
rule: string;
|
|
133
|
+
scope: RuleScope;
|
|
134
|
+
dot: RuleDot;
|
|
135
|
+
code: RuleRejectCode;
|
|
136
|
+
}>;
|
|
128
137
|
warnings: string[];
|
|
129
138
|
}
|
|
130
139
|
/**
|
|
@@ -134,6 +143,11 @@ interface ParsedRuleSyncResponse {
|
|
|
134
143
|
* reaches the local disk); an unshapeable tombstone is surfaced as `undefined` so the caller's
|
|
135
144
|
* tighten-only partitioning withholds the round's adds. Unknown keys never flow anywhere — every field
|
|
136
145
|
* below is PICKED off the response, the object itself is discarded.
|
|
146
|
+
*
|
|
147
|
+
* A tombstone has THREE outcomes, not two (backlog #176): sound; malformed (bad shape, or a spelling
|
|
148
|
+
* that parses but is not canonical — the withholding arm); and shape-whole-but-TEXT-refused, reported
|
|
149
|
+
* on {@link ParsedRuleSyncResponse.textRefusedTombstones}. The third is a legacy row a tightened
|
|
150
|
+
* validator no longer admits — permanent, so treating it as the second would stall inbound adds forever.
|
|
137
151
|
*/
|
|
138
152
|
export declare function parseRuleSyncResponse(raw: unknown, expectedPrincipal: string): ParsedRuleSyncResponse;
|
|
139
153
|
export {};
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { parseAllowRuleText } from "./permission-rule-model.js";
|
|
1
|
+
import { escapeForDisclosure, parseAllowRuleText } from "./permission-rule-model.js";
|
|
2
2
|
import { applyTombstones, errText, joinFrontiers, joinRuleStates, normalizePersistedRule, ruleSyncVector, sameScope, 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";
|
|
@@ -9,12 +9,13 @@ function refuseLocalOwnerSync() {
|
|
|
9
9
|
throw e;
|
|
10
10
|
}
|
|
11
11
|
const SYNC_MAX_ATTEMPTS = 8;
|
|
12
|
+
const DISCLOSED_REFUSED_TOMBSTONES = 5;
|
|
12
13
|
export async function syncPermissionRules(opts) {
|
|
13
14
|
if (opts.owner !== undefined) {
|
|
14
15
|
if (opts.owner.kind === "local-owner")
|
|
15
16
|
refuseLocalOwnerSync();
|
|
16
17
|
if (opts.owner.principal !== opts.principal) {
|
|
17
|
-
throw new Error(`contradictory identity: principal "${opts.principal}" and owner principal "${opts.owner.principal}" disagree`);
|
|
18
|
+
throw new Error(`contradictory identity: principal "${escapeForDisclosure(opts.principal)}" and owner principal "${escapeForDisclosure(opts.owner.principal)}" disagree`);
|
|
18
19
|
}
|
|
19
20
|
}
|
|
20
21
|
if (typeof opts.principal !== "string" || opts.principal === "") {
|
|
@@ -83,6 +84,18 @@ export async function syncPermissionRules(opts) {
|
|
|
83
84
|
if (tombstoneWithholding) {
|
|
84
85
|
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)");
|
|
85
86
|
}
|
|
87
|
+
for (const t of response.textRefusedTombstones) {
|
|
88
|
+
dropped.push({ rule: t.rule, scope: t.scope, dot: t.dot, reason: "invalid_rule_text" });
|
|
89
|
+
}
|
|
90
|
+
if (response.textRefusedTombstones.length > 0) {
|
|
91
|
+
const named = response.textRefusedTombstones
|
|
92
|
+
.slice(0, DISCLOSED_REFUSED_TOMBSTONES)
|
|
93
|
+
.map((t) => `"${escapeForDisclosure(t.rule)}" (${t.code})`)
|
|
94
|
+
.join(", ");
|
|
95
|
+
const more = response.textRefusedTombstones.length - DISCLOSED_REFUSED_TOMBSTONES;
|
|
96
|
+
warnings.push(`the response carried ${response.textRefusedTombstones.length} tombstone(s) whose rule text this version's validator refuses — ${named}${more > 0 ? ` and ${more} more` : ""}. ` +
|
|
97
|
+
"They are refused here and never land; this round's inbound adds are NOT withheld for them (an add under the same text is refused by the same validator, so such a tombstone covers nothing that could go live). The peer still holds them — removing them there is what ends this notice");
|
|
98
|
+
}
|
|
86
99
|
const inboundRules = [];
|
|
87
100
|
if (!tombstoneWithholding) {
|
|
88
101
|
for (const r of response.merged.rules) {
|
|
@@ -120,7 +133,7 @@ export async function syncPermissionRules(opts) {
|
|
|
120
133
|
dropped.push({ rule: d.rule, scope: d.scope, dot: d.dot, reason });
|
|
121
134
|
}
|
|
122
135
|
else {
|
|
123
|
-
warnings.push(`the server dropped an inbound-side record (${d.rule}, dot ${d.dot.actor}#${d.dot.counter}): ${d.reason}`);
|
|
136
|
+
warnings.push(`the server dropped an inbound-side record (${escapeForDisclosure(d.rule)}, dot ${escapeForDisclosure(d.dot.actor)}#${d.dot.counter}): ${escapeForDisclosure(d.reason)}`);
|
|
124
137
|
}
|
|
125
138
|
}
|
|
126
139
|
const clean = !tombstoneWithholding;
|
|
@@ -147,7 +160,7 @@ export async function syncPermissionRules(opts) {
|
|
|
147
160
|
resurrected: [],
|
|
148
161
|
dropped,
|
|
149
162
|
rev: current.rev,
|
|
150
|
-
warnings: [...warnings, `the store refused the sync landing: ${errText(err)} — nothing landed, the local state is unchanged`],
|
|
163
|
+
warnings: [...warnings, `the store refused the sync landing: ${escapeForDisclosure(errText(err))} — nothing landed, the local state is unchanged`],
|
|
151
164
|
});
|
|
152
165
|
}
|
|
153
166
|
if ("conflict" in res) {
|
|
@@ -205,9 +218,11 @@ export async function syncPermissionRules(opts) {
|
|
|
205
218
|
warnings: [...warnings, `optimistic-concurrency retries exhausted after ${SYNC_MAX_ATTEMPTS} attempts — nothing landed, the local state is unchanged`],
|
|
206
219
|
});
|
|
207
220
|
}
|
|
208
|
-
function
|
|
221
|
+
function pickDot(v) {
|
|
209
222
|
const d = v;
|
|
210
|
-
|
|
223
|
+
const actor = d?.actor;
|
|
224
|
+
const counter = d?.counter;
|
|
225
|
+
return typeof actor === "string" && actor !== "" && typeof counter === "number" && Number.isFinite(counter) ? { actor, counter } : undefined;
|
|
211
226
|
}
|
|
212
227
|
function pickScope(v) {
|
|
213
228
|
const s = v;
|
|
@@ -219,13 +234,16 @@ function pickScope(v) {
|
|
|
219
234
|
}
|
|
220
235
|
function pickAdd(v) {
|
|
221
236
|
const a = v;
|
|
222
|
-
|
|
237
|
+
const dot = pickDot(a?.dot);
|
|
238
|
+
const origin = a?.origin;
|
|
239
|
+
const createdAt = a?.createdAt;
|
|
240
|
+
if (dot === undefined)
|
|
223
241
|
return undefined;
|
|
224
|
-
if (
|
|
242
|
+
if (origin !== "user" && origin !== "imported-cc" && origin !== "starter")
|
|
225
243
|
return undefined;
|
|
226
|
-
if (typeof
|
|
244
|
+
if (typeof createdAt !== "string")
|
|
227
245
|
return undefined;
|
|
228
|
-
return { dot
|
|
246
|
+
return { dot, origin, createdAt };
|
|
229
247
|
}
|
|
230
248
|
function pickFrontier(v) {
|
|
231
249
|
if (v === null || typeof v !== "object" || Array.isArray(v))
|
|
@@ -243,35 +261,44 @@ export function parseRuleSyncResponse(raw, expectedPrincipal) {
|
|
|
243
261
|
throw new Error("the sync response is not an object — protocol error, nothing landed");
|
|
244
262
|
}
|
|
245
263
|
const r = raw;
|
|
246
|
-
|
|
264
|
+
const echoedPrincipal = r.principal;
|
|
265
|
+
if (typeof echoedPrincipal !== "string") {
|
|
247
266
|
throw new Error("the sync response carries no principal echo — protocol error, nothing landed");
|
|
248
267
|
}
|
|
249
|
-
if (
|
|
250
|
-
throw new Error(`the sync response echoes principal "${
|
|
268
|
+
if (echoedPrincipal !== expectedPrincipal) {
|
|
269
|
+
throw new Error(`the sync response echoes principal "${escapeForDisclosure(echoedPrincipal)}" but this round synced "${escapeForDisclosure(expectedPrincipal)}" — the transport is wired to the wrong bucket; refusing the whole round`);
|
|
251
270
|
}
|
|
252
271
|
const merged = r.merged;
|
|
253
|
-
|
|
272
|
+
const mergedRules = merged?.rules;
|
|
273
|
+
const mergedTombstones = merged?.tombstones;
|
|
274
|
+
if (merged === null || typeof merged !== "object" || !Array.isArray(mergedRules) || !Array.isArray(mergedTombstones)) {
|
|
254
275
|
throw new Error("the sync response carries no merged state — protocol error, nothing landed");
|
|
255
276
|
}
|
|
277
|
+
const rawGcFrontier = r.gcFrontier;
|
|
256
278
|
let gcFrontier;
|
|
257
|
-
if (
|
|
258
|
-
gcFrontier = pickFrontier(
|
|
279
|
+
if (rawGcFrontier !== undefined) {
|
|
280
|
+
gcFrontier = pickFrontier(rawGcFrontier);
|
|
259
281
|
if (gcFrontier === undefined) {
|
|
260
282
|
throw new Error("the sync response carries an unparseable gcFrontier — refusing the whole round (a garbled frontier must not drive the fence arm)");
|
|
261
283
|
}
|
|
262
284
|
}
|
|
263
285
|
const warnings = [];
|
|
264
286
|
const rules = [];
|
|
265
|
-
for (const entry of
|
|
287
|
+
for (const entry of mergedRules) {
|
|
266
288
|
const e = entry;
|
|
267
289
|
const scope = pickScope(e?.scope);
|
|
268
|
-
|
|
290
|
+
const eRule = e?.rule;
|
|
291
|
+
const eCommand = e?.command;
|
|
292
|
+
const eTool = e?.tool;
|
|
293
|
+
const eMatch = e?.match;
|
|
294
|
+
const eAdds = e?.adds;
|
|
295
|
+
if (typeof eRule !== "string" || scope === undefined || !Array.isArray(eAdds) || typeof eCommand !== "string" || typeof eTool !== "string" || typeof eMatch !== "string") {
|
|
269
296
|
warnings.push("the response carried an unshapeable rule entry — skipped (it never reaches the local store)");
|
|
270
297
|
continue;
|
|
271
298
|
}
|
|
272
299
|
const adds = [];
|
|
273
300
|
let addDamaged = false;
|
|
274
|
-
for (const a of
|
|
301
|
+
for (const a of eAdds) {
|
|
275
302
|
const picked = pickAdd(a);
|
|
276
303
|
if (picked === undefined) {
|
|
277
304
|
addDamaged = true;
|
|
@@ -280,64 +307,79 @@ export function parseRuleSyncResponse(raw, expectedPrincipal) {
|
|
|
280
307
|
adds.push(picked);
|
|
281
308
|
}
|
|
282
309
|
if (addDamaged)
|
|
283
|
-
warnings.push(`rule "${
|
|
310
|
+
warnings.push(`rule "${escapeForDisclosure(eRule)}" carried at least one unshapeable add — skipped those adds`);
|
|
284
311
|
if (adds.length === 0)
|
|
285
312
|
continue;
|
|
286
313
|
rules.push({
|
|
287
|
-
rule:
|
|
288
|
-
tool:
|
|
289
|
-
match:
|
|
290
|
-
command:
|
|
314
|
+
rule: eRule,
|
|
315
|
+
tool: eTool,
|
|
316
|
+
match: eMatch,
|
|
317
|
+
command: eCommand,
|
|
291
318
|
scope,
|
|
292
319
|
adds,
|
|
293
320
|
});
|
|
294
321
|
}
|
|
295
322
|
const tombstones = [];
|
|
296
|
-
|
|
323
|
+
const textRefusedTombstones = [];
|
|
324
|
+
for (const entry of mergedTombstones) {
|
|
297
325
|
const t = entry;
|
|
298
326
|
const scope = pickScope(t?.scope);
|
|
299
|
-
const
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
327
|
+
const ruleText = t?.rule;
|
|
328
|
+
const deletedBy = pickDot(t?.deletedBy);
|
|
329
|
+
const removedRaw = t?.removedDots;
|
|
330
|
+
const removedDots = [];
|
|
331
|
+
if (Array.isArray(removedRaw)) {
|
|
332
|
+
for (const d of removedRaw) {
|
|
333
|
+
const dot = pickDot(d);
|
|
334
|
+
if (dot === undefined) {
|
|
335
|
+
removedDots.length = 0;
|
|
336
|
+
break;
|
|
337
|
+
}
|
|
338
|
+
removedDots.push(dot);
|
|
339
|
+
}
|
|
340
|
+
}
|
|
341
|
+
if (typeof ruleText !== "string" || scope === undefined || deletedBy === undefined || removedDots.length === 0) {
|
|
309
342
|
tombstones.push(undefined);
|
|
310
343
|
continue;
|
|
311
344
|
}
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
scope,
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
345
|
+
const canonicalTomb = parseAllowRuleText(ruleText);
|
|
346
|
+
if ("reject" in canonicalTomb) {
|
|
347
|
+
textRefusedTombstones.push({ rule: ruleText, scope, dot: deletedBy, code: canonicalTomb.reject.code });
|
|
348
|
+
continue;
|
|
349
|
+
}
|
|
350
|
+
if (canonicalTomb.rule.rule !== ruleText) {
|
|
351
|
+
tombstones.push(undefined);
|
|
352
|
+
continue;
|
|
353
|
+
}
|
|
354
|
+
tombstones.push({ rule: ruleText, scope, removedDots, deletedBy });
|
|
318
355
|
}
|
|
319
356
|
const dropped = [];
|
|
320
|
-
|
|
321
|
-
|
|
357
|
+
const rawDropped = r.dropped;
|
|
358
|
+
if (rawDropped !== undefined) {
|
|
359
|
+
if (!Array.isArray(rawDropped)) {
|
|
322
360
|
warnings.push("the response carried a non-array dropped field — ignored");
|
|
323
361
|
}
|
|
324
362
|
else {
|
|
325
|
-
for (const entry of
|
|
363
|
+
for (const entry of rawDropped) {
|
|
326
364
|
const d = entry;
|
|
327
365
|
const scope = pickScope(d?.scope);
|
|
328
|
-
|
|
366
|
+
const dRule = d?.rule;
|
|
367
|
+
const dDot = pickDot(d?.dot);
|
|
368
|
+
const dReason = d?.reason;
|
|
369
|
+
if (typeof dRule !== "string" || scope === undefined || dDot === undefined || typeof dReason !== "string") {
|
|
329
370
|
warnings.push("the response carried an unshapeable dropped entry — ignored (the server's own drop discipline is its contract obligation)");
|
|
330
371
|
continue;
|
|
331
372
|
}
|
|
332
|
-
dropped.push({ rule:
|
|
373
|
+
dropped.push({ rule: dRule, scope, dot: dDot, reason: dReason });
|
|
333
374
|
}
|
|
334
375
|
}
|
|
335
376
|
}
|
|
336
377
|
return {
|
|
337
|
-
principal:
|
|
378
|
+
principal: echoedPrincipal,
|
|
338
379
|
merged: { rules, tombstones },
|
|
339
380
|
...(gcFrontier !== undefined ? { gcFrontier } : {}),
|
|
340
381
|
dropped,
|
|
382
|
+
textRefusedTombstones,
|
|
341
383
|
warnings,
|
|
342
384
|
};
|
|
343
385
|
}
|