@sema-agent/core 5.21.0 → 5.22.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 +66 -0
- package/dist/agents/send-message-tool.js +6 -3
- package/dist/agents/subagent.d.ts +6 -0
- package/dist/agents/subagent.js +45 -4
- package/dist/brain/errors.d.ts +20 -0
- package/dist/brain/errors.js +40 -0
- package/dist/brain/retry.d.ts +16 -2
- package/dist/brain/retry.js +3 -2
- package/dist/brain/status-sink.d.ts +9 -2
- package/dist/brain/stream-engine.d.ts +22 -0
- package/dist/brain/stream-engine.js +41 -10
- package/dist/core/ask-class.d.ts +48 -0
- package/dist/core/ask-class.js +33 -0
- package/dist/core/checkpoint-store.d.ts +103 -10
- package/dist/core/checkpoint-store.js +3 -1
- package/dist/core/governance-codes.d.ts +38 -0
- package/dist/core/governance-codes.js +11 -0
- package/dist/core/hooks.d.ts +39 -0
- package/dist/core/hooks.js +26 -2
- package/dist/core/locked-config.d.ts +7 -1
- package/dist/core/locked-config.js +2 -1
- package/dist/core/memory-engine/delegation-provenance.d.ts +62 -0
- package/dist/core/memory-engine/delegation-provenance.js +26 -0
- package/dist/core/memory-engine/engine.d.ts +67 -1
- package/dist/core/memory-engine/engine.js +270 -12
- package/dist/core/memory-engine/header-hints.d.ts +30 -0
- package/dist/core/memory-engine/header-hints.js +41 -0
- package/dist/core/memory-engine/index.d.ts +3 -2
- package/dist/core/memory-engine/index.js +3 -2
- package/dist/core/memory-engine/layout.d.ts +166 -0
- package/dist/core/memory-engine/layout.js +399 -0
- package/dist/core/memory-engine/tools.d.ts +30 -0
- package/dist/core/memory-engine/tools.js +108 -17
- package/dist/core/permission-rule-consent.d.ts +25 -9
- package/dist/core/permission-rule-consent.js +91 -20
- package/dist/core/permission-rule-model.d.ts +9 -1
- package/dist/core/permission-rule-model.js +2 -2
- package/dist/core/permission-rule-org.d.ts +161 -0
- package/dist/core/permission-rule-org.js +211 -0
- package/dist/core/permission-rule-store.d.ts +249 -6
- package/dist/core/permission-rule-store.js +313 -3
- package/dist/core/permission-rule-sync.d.ts +131 -0
- package/dist/core/permission-rule-sync.js +314 -0
- package/dist/core/runner/prepare-memory.js +35 -8
- package/dist/core/runner/prepare-task.d.ts +54 -1
- package/dist/core/runner/prepare-task.js +246 -27
- package/dist/core/runner/runtask.js +147 -6
- package/dist/core/shared-memory/contract.js +19 -4
- package/dist/core/shared-memory/normalize.d.ts +3 -1
- package/dist/core/shared-memory/tools.js +73 -17
- package/dist/core/shared-memory/types.d.ts +27 -1
- package/dist/core/store-contracts/permission-rule-sync-contract.d.ts +33 -0
- package/dist/core/store-contracts/permission-rule-sync-contract.js +186 -0
- package/dist/core/task-notification.d.ts +5 -2
- package/dist/core/task-registry-agent.d.ts +1 -1
- package/dist/core/task-registry-agent.js +6 -2
- package/dist/core/task-registry-shared.d.ts +9 -2
- package/dist/core/task-registry.d.ts +9 -3
- package/dist/core/task-registry.js +2 -0
- package/dist/core/tool-policy.d.ts +120 -2
- package/dist/core/tool-policy.js +116 -6
- package/dist/core/trace.d.ts +32 -1
- package/dist/core/types.d.ts +56 -3
- package/dist/index.d.ts +12 -7
- package/dist/index.js +10 -5
- package/dist/stores/file/checkpoint-store.d.ts +4 -0
- package/dist/stores/file/checkpoint-store.js +1 -0
- package/dist/stores/file/permission-rule-adopt.d.ts +62 -0
- package/dist/stores/file/permission-rule-adopt.js +95 -0
- package/dist/stores/file/permission-rule-store.d.ts +80 -2
- package/dist/stores/file/permission-rule-store.js +189 -46
- package/dist/tools/fs/fs-search-tools.js +0 -1
- package/package.json +1 -1
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
import { existsSync, renameSync } from "node:fs";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import { writerOf } from "../../core/permission-rule-store.js";
|
|
4
|
+
import { syncPermissionRules } from "../../core/permission-rule-sync.js";
|
|
5
|
+
import { FilePermissionRuleStoreProvider, LOCAL_OWNER_FILE, principalRuleFileName, readRuleAdoptionFile, writeRuleAdoptionFile, } from "./permission-rule-store.js";
|
|
6
|
+
export async function adoptFilePermissionRuleStore(opts) {
|
|
7
|
+
if (typeof opts.toPrincipal !== "string" || opts.toPrincipal === "") {
|
|
8
|
+
throw new Error("adoptFilePermissionRuleStore requires the adopting principal — adoption is the act of giving this bucket an identity");
|
|
9
|
+
}
|
|
10
|
+
if (opts.from.kind === "principal" && opts.from.principal === opts.toPrincipal) {
|
|
11
|
+
throw new Error(`the bucket already belongs to "${opts.toPrincipal}" — there is nothing to adopt`);
|
|
12
|
+
}
|
|
13
|
+
const sourceFile = join(opts.dir, opts.from.kind === "local-owner" ? LOCAL_OWNER_FILE : principalRuleFileName(opts.from.principal));
|
|
14
|
+
const targetFile = join(opts.dir, principalRuleFileName(opts.toPrincipal));
|
|
15
|
+
const existing = readRuleAdoptionFile(opts.dir);
|
|
16
|
+
if (existing !== undefined) {
|
|
17
|
+
if ("adopted" in existing) {
|
|
18
|
+
if (existing.adopted.toPrincipal === opts.toPrincipal && sameOwner(existing.adopted.from, opts.from))
|
|
19
|
+
return { status: "adopted" };
|
|
20
|
+
throw new Error(`this directory already carries a completed adoption (${ownerText(existing.adopted.from)} → "${existing.adopted.toPrincipal}") — a second adoption of the same directory is not a shape this helper supports`);
|
|
21
|
+
}
|
|
22
|
+
if (existing.marker.toPrincipal !== opts.toPrincipal || !sameOwner(existing.marker.from, opts.from)) {
|
|
23
|
+
throw new Error(`an adoption toward "${existing.marker.toPrincipal}" is already in flight in this directory — refusing to start a different one (resume the original, or repair the marker by hand)`);
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
else if (existsSync(targetFile)) {
|
|
27
|
+
throw new Error(`the target bucket for "${opts.toPrincipal}" already exists — merging two existing buckets goes through an explicit sync join, not a rename`);
|
|
28
|
+
}
|
|
29
|
+
const provider = new FilePermissionRuleStoreProvider(opts.dir, opts.onError);
|
|
30
|
+
try {
|
|
31
|
+
const lockProbe = writerOf(provider.forPrincipal(opts.toPrincipal));
|
|
32
|
+
if (lockProbe === undefined)
|
|
33
|
+
throw new Error("the target store refused a write face");
|
|
34
|
+
let phase = existing !== undefined && "marker" in existing ? existing.marker.phase : 2;
|
|
35
|
+
if (existing === undefined) {
|
|
36
|
+
writeRuleAdoptionFile(opts.dir, marker(opts.from, opts.toPrincipal, 2));
|
|
37
|
+
phase = 2;
|
|
38
|
+
}
|
|
39
|
+
if (phase < 3) {
|
|
40
|
+
const sourcePresent = existsSync(sourceFile);
|
|
41
|
+
const targetPresent = existsSync(targetFile);
|
|
42
|
+
if (sourcePresent && targetPresent) {
|
|
43
|
+
throw new Error(`both the source and the target bucket exist — merging two existing buckets goes through an explicit sync join, not a rename; the adoption marker is left in place for inspection`);
|
|
44
|
+
}
|
|
45
|
+
if (sourcePresent)
|
|
46
|
+
renameSync(sourceFile, targetFile);
|
|
47
|
+
writeRuleAdoptionFile(opts.dir, marker(opts.from, opts.toPrincipal, 3));
|
|
48
|
+
phase = 3;
|
|
49
|
+
}
|
|
50
|
+
if (phase < 4) {
|
|
51
|
+
writeRuleAdoptionFile(opts.dir, marker(opts.from, opts.toPrincipal, 4));
|
|
52
|
+
phase = 4;
|
|
53
|
+
}
|
|
54
|
+
let syncResult;
|
|
55
|
+
if (phase < 5) {
|
|
56
|
+
try {
|
|
57
|
+
syncResult = await syncPermissionRules({
|
|
58
|
+
provider,
|
|
59
|
+
principal: opts.toPrincipal,
|
|
60
|
+
transport: opts.transport,
|
|
61
|
+
...(opts.now !== undefined ? { now: opts.now } : {}),
|
|
62
|
+
});
|
|
63
|
+
}
|
|
64
|
+
catch (err) {
|
|
65
|
+
return { status: "stalled", phase: 4, error: `the first sync round failed: ${err instanceof Error ? err.message : String(err)}` };
|
|
66
|
+
}
|
|
67
|
+
if (!syncResult.ok) {
|
|
68
|
+
return {
|
|
69
|
+
status: "stalled",
|
|
70
|
+
phase: 4,
|
|
71
|
+
error: `the first sync round did not land clean (${(syncResult.warnings ?? []).join("; ") || `${syncResult.dropped.length} drop(s)`}) — resume when the endpoint is healthy`,
|
|
72
|
+
};
|
|
73
|
+
}
|
|
74
|
+
writeRuleAdoptionFile(opts.dir, marker(opts.from, opts.toPrincipal, 5));
|
|
75
|
+
phase = 5;
|
|
76
|
+
}
|
|
77
|
+
writeRuleAdoptionFile(opts.dir, {
|
|
78
|
+
schemaVersion: 1,
|
|
79
|
+
adopted: { from: opts.from, toPrincipal: opts.toPrincipal, atMs: (opts.now ?? Date.now)() },
|
|
80
|
+
});
|
|
81
|
+
return { status: "adopted", ...(syncResult !== undefined ? { syncResult } : {}) };
|
|
82
|
+
}
|
|
83
|
+
finally {
|
|
84
|
+
provider.dispose();
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
function marker(from, toPrincipal, phase) {
|
|
88
|
+
return { schemaVersion: 1, marker: { from, toPrincipal, phase } };
|
|
89
|
+
}
|
|
90
|
+
function sameOwner(a, b) {
|
|
91
|
+
return a.kind === "principal" ? b.kind === "principal" && a.principal === b.principal : b.kind === "local-owner";
|
|
92
|
+
}
|
|
93
|
+
function ownerText(o) {
|
|
94
|
+
return o.kind === "principal" ? `principal "${o.principal}"` : "the local owner";
|
|
95
|
+
}
|
|
@@ -42,9 +42,63 @@
|
|
|
42
42
|
* and is not: it detects corruption, and it does not detect an editor, because an editor recomputes it.
|
|
43
43
|
* For this backend the trust is host = user, exactly as for the settings file it mirrors.
|
|
44
44
|
*/
|
|
45
|
-
import type {
|
|
45
|
+
import type { PersistedAllowRule, RuleTombstone } from "../../core/permission-rule-model.js";
|
|
46
|
+
import type { PermissionRuleStore, PermissionRuleStoreProvider, PermissionRuleWriter, QuarantinedRuleAdd, RuleOwner, RuleSyncFrontier, StoredAllowRules, WritablePermissionRuleStore } from "../../core/permission-rule-store.js";
|
|
46
47
|
import { PERMISSION_RULE_WRITER } from "../../core/permission-rule-store.js";
|
|
48
|
+
import type { PersistedOrgRuleState } from "../../core/permission-rule-org.js";
|
|
47
49
|
import type { StoreDurability, StoreFidelity } from "../../core/checkpoint-store.js";
|
|
50
|
+
/**
|
|
51
|
+
* The on-disk shape. `schemaVersion` is the only field a future reader may rely on before validating.
|
|
52
|
+
*
|
|
53
|
+
* v1 → v2 (design/182 §8.3): three OPTIONAL blocks — `sync` (observation vector + round timestamp),
|
|
54
|
+
* `org` (last-known-good org snapshot + anti-rollback high-water) and `quarantined` (rows moved out of
|
|
55
|
+
* the live view, bytes preserved). The five v1 fields are byte-identical in meaning. A file only ever
|
|
56
|
+
* ESCALATES to v2 when a write actually carries v2 content (a sync round, an org install, a
|
|
57
|
+
* quarantine): a deployment that never syncs keeps writing v1 files forever, and a v1 reader handed a
|
|
58
|
+
* v2 file refuses the whole file loudly (zero rules + disclosure — fail-closed, more asks, and the
|
|
59
|
+
* documented cost of rolling the engine back under an already-synced store).
|
|
60
|
+
*/
|
|
61
|
+
interface RuleFile {
|
|
62
|
+
schemaVersion: 1 | 2;
|
|
63
|
+
actor: string;
|
|
64
|
+
counter: number;
|
|
65
|
+
rev: number;
|
|
66
|
+
rules: PersistedAllowRule[];
|
|
67
|
+
tombstones: RuleTombstone[];
|
|
68
|
+
sync?: {
|
|
69
|
+
observedVector?: RuleSyncFrontier;
|
|
70
|
+
lastRoundAtMs?: number;
|
|
71
|
+
};
|
|
72
|
+
org?: PersistedOrgRuleState;
|
|
73
|
+
quarantined?: QuarantinedRuleAdd[];
|
|
74
|
+
checksum: string;
|
|
75
|
+
}
|
|
76
|
+
/** The local-owner bucket's FIXED file name — never a principal-hash path (identity absence is a
|
|
77
|
+
* structural position, not a name; design/182 §4.5). Cannot collide with `forPrincipal` names, which
|
|
78
|
+
* are 64 hex characters. */
|
|
79
|
+
declare const LOCAL_OWNER_FILE = "local-owner.json";
|
|
80
|
+
/** The durable adoption marker (design/182 §4.5 — the recoverable six-phase state machine's truth). */
|
|
81
|
+
export type RuleAdoptionFile = {
|
|
82
|
+
schemaVersion: 1;
|
|
83
|
+
marker: {
|
|
84
|
+
from: RuleOwner;
|
|
85
|
+
toPrincipal: string;
|
|
86
|
+
phase: 2 | 3 | 4 | 5;
|
|
87
|
+
};
|
|
88
|
+
} | {
|
|
89
|
+
schemaVersion: 1;
|
|
90
|
+
adopted: {
|
|
91
|
+
from: RuleOwner;
|
|
92
|
+
toPrincipal: string;
|
|
93
|
+
atMs: number;
|
|
94
|
+
};
|
|
95
|
+
};
|
|
96
|
+
/** Read the adoption marker, `undefined` when none. A corrupt marker THROWS: after phase 4 the marker
|
|
97
|
+
* IS the owner-resolution truth, and guessing on a corrupt truth would either resurrect the retired
|
|
98
|
+
* bucket (write fork) or invent an adoption that never happened. */
|
|
99
|
+
export declare function readRuleAdoptionFile(dir: string): RuleAdoptionFile | undefined;
|
|
100
|
+
/** Atomically publish the adoption marker (same write-temp → fsync → rename discipline as the store). */
|
|
101
|
+
export declare function writeRuleAdoptionFile(dir: string, content: RuleAdoptionFile): void;
|
|
48
102
|
/** One principal's rule file. */
|
|
49
103
|
declare class FilePermissionRuleStore implements WritablePermissionRuleStore {
|
|
50
104
|
private readonly dir;
|
|
@@ -59,6 +113,14 @@ declare class FilePermissionRuleStore implements WritablePermissionRuleStore {
|
|
|
59
113
|
private disclose;
|
|
60
114
|
private write;
|
|
61
115
|
list(): Promise<StoredAllowRules>;
|
|
116
|
+
/** The quarantine area (design/182 §5.2/§8.3): introspection only — never part of `list()`. */
|
|
117
|
+
quarantined(): Promise<QuarantinedRuleAdd[]>;
|
|
118
|
+
/** design/182 §7.4 — the durable org block: last-known-good snapshot + anti-rollback high-water,
|
|
119
|
+
* installed in ONE atomic publish, surviving restarts. Reading takes no lock (the gate reads);
|
|
120
|
+
* installing takes the writer lock (it is a write) but does not move `rev` — nothing about the
|
|
121
|
+
* PERSONAL rule set changed, and an OCC holder must not be disturbed (the `nextDot` precedent). */
|
|
122
|
+
readOrgState(): Promise<PersistedOrgRuleState | undefined>;
|
|
123
|
+
installOrgState(state: PersistedOrgRuleState): Promise<void>;
|
|
62
124
|
/**
|
|
63
125
|
* The current file, for a WRITE.
|
|
64
126
|
*
|
|
@@ -104,8 +166,24 @@ export declare class FilePermissionRuleStoreProvider implements PermissionRuleSt
|
|
|
104
166
|
/** Take the writer lock, once, on first use of a write face. Reading never calls this. */
|
|
105
167
|
private acquireWriteLock;
|
|
106
168
|
forPrincipal(principal: string | undefined): PermissionRuleStore;
|
|
169
|
+
/** The read-only resolution of an adoption SOURCE: post-flip (phase ≥ 4 / terminal) always the
|
|
170
|
+
* target; pre-flip, whichever path currently holds the bytes (source first — it is the pre-flip
|
|
171
|
+
* truth when both somehow exist, which the resume path refuses loudly anyway). */
|
|
172
|
+
private readOnlySourceView;
|
|
173
|
+
/**
|
|
174
|
+
* design/182 §4.5 (F-011) — the identity-less LOCAL bucket: a fixed file name, never a principal-hash
|
|
175
|
+
* path. Once an adoption reached phase 4 (or its permanent terminal record), local-owner resolution
|
|
176
|
+
* points at the adopted principal's bucket FOREVER — the marker is the truth the host configuration
|
|
177
|
+
* reads, not the other way round, so a crash between marker and host-config change cannot fork writes
|
|
178
|
+
* into a retired bucket. A corrupt marker throws (fail-closed) rather than guessing an owner.
|
|
179
|
+
*/
|
|
180
|
+
forLocalOwner(): PermissionRuleStore;
|
|
107
181
|
/** Release the writer lock. A long-lived deployment holds it for its lifetime; a test or a short-lived
|
|
108
182
|
* tool releases so the next holder is not told a live process owns the directory. */
|
|
109
183
|
dispose(): void;
|
|
110
184
|
}
|
|
111
|
-
|
|
185
|
+
/** The hash-derived file name of one principal's bucket — shared with the adoption helper so the rename
|
|
186
|
+
* and the resolver can never disagree about where a principal lives. */
|
|
187
|
+
export declare function principalRuleFileName(principal: string): string;
|
|
188
|
+
export { LOCAL_OWNER_FILE };
|
|
189
|
+
export type { FilePermissionRuleStore, RuleFile };
|
|
@@ -1,9 +1,78 @@
|
|
|
1
|
-
import { closeSync, constants as FS, fsyncSync, lstatSync, mkdirSync, openSync, readFileSync, renameSync, unlinkSync, writeFileSync } from "node:fs";
|
|
1
|
+
import { closeSync, constants as FS, existsSync, fsyncSync, lstatSync, mkdirSync, openSync, readFileSync, renameSync, unlinkSync, writeFileSync } from "node:fs";
|
|
2
2
|
import { join } from "node:path";
|
|
3
3
|
import { createHash, randomBytes } from "node:crypto";
|
|
4
|
-
import { PERMISSION_RULE_WRITER, applyTombstones, assertDeleteDeltaCarriesNoAdd, foldDelta } from "../../core/permission-rule-store.js";
|
|
4
|
+
import { PERMISSION_RULE_WRITER, applySyncJoin, applyTombstones, assertDeleteDeltaCarriesNoAdd, assertRedemptionNotQuarantined, foldDelta, } from "../../core/permission-rule-store.js";
|
|
5
5
|
import { canonicalize } from "../../core/canonical-json.js";
|
|
6
6
|
import { BootLock } from "./fs-atomic.js";
|
|
7
|
+
const LOCAL_OWNER_FILE = "local-owner.json";
|
|
8
|
+
const ADOPTION_FILE = "PERMISSION-RULES-ADOPTION.json";
|
|
9
|
+
export function readRuleAdoptionFile(dir) {
|
|
10
|
+
let raw;
|
|
11
|
+
try {
|
|
12
|
+
raw = readFileSync(join(dir, ADOPTION_FILE), "utf8");
|
|
13
|
+
}
|
|
14
|
+
catch (err) {
|
|
15
|
+
if (err?.code === "ENOENT")
|
|
16
|
+
return undefined;
|
|
17
|
+
throw new Error(`could not read the permission-rule adoption marker in ${dir}: ${err.message}`);
|
|
18
|
+
}
|
|
19
|
+
let parsed;
|
|
20
|
+
try {
|
|
21
|
+
parsed = JSON.parse(raw);
|
|
22
|
+
}
|
|
23
|
+
catch (err) {
|
|
24
|
+
throw new Error(`the permission-rule adoption marker in ${dir} is not valid JSON (${err.message}) — refusing to resolve owners until it is repaired`);
|
|
25
|
+
}
|
|
26
|
+
if (!isRuleAdoptionFileShape(parsed)) {
|
|
27
|
+
throw new Error(`the permission-rule adoption marker in ${dir} does not carry a readable shape — refusing to resolve owners until it is repaired`);
|
|
28
|
+
}
|
|
29
|
+
return parsed;
|
|
30
|
+
}
|
|
31
|
+
function isRuleAdoptionFileShape(v) {
|
|
32
|
+
return typeof v === "object" && v !== null && v.schemaVersion === 1 && ("marker" in v || "adopted" in v);
|
|
33
|
+
}
|
|
34
|
+
export function writeRuleAdoptionFile(dir, content) {
|
|
35
|
+
assertSafeDir(dir);
|
|
36
|
+
atomicPublish(dir, join(dir, ADOPTION_FILE), JSON.stringify(content, null, 2));
|
|
37
|
+
}
|
|
38
|
+
function atomicPublish(dir, file, payload) {
|
|
39
|
+
const tmp = join(dir, `.${randomBytes(8).toString("hex")}.tmp`);
|
|
40
|
+
let fd;
|
|
41
|
+
try {
|
|
42
|
+
fd = openSync(tmp, FS.O_WRONLY | FS.O_CREAT | FS.O_EXCL | (FS.O_NOFOLLOW ?? 0), 0o600);
|
|
43
|
+
writeFileSync(fd, payload);
|
|
44
|
+
fsyncSync(fd);
|
|
45
|
+
closeSync(fd);
|
|
46
|
+
fd = undefined;
|
|
47
|
+
renameSync(tmp, file);
|
|
48
|
+
let dfd;
|
|
49
|
+
try {
|
|
50
|
+
dfd = openSync(dir, "r");
|
|
51
|
+
fsyncSync(dfd);
|
|
52
|
+
}
|
|
53
|
+
catch {
|
|
54
|
+
}
|
|
55
|
+
finally {
|
|
56
|
+
if (dfd !== undefined)
|
|
57
|
+
closeSync(dfd);
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
catch (err) {
|
|
61
|
+
if (fd !== undefined) {
|
|
62
|
+
try {
|
|
63
|
+
closeSync(fd);
|
|
64
|
+
}
|
|
65
|
+
catch {
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
try {
|
|
69
|
+
unlinkSync(tmp);
|
|
70
|
+
}
|
|
71
|
+
catch {
|
|
72
|
+
}
|
|
73
|
+
throw err;
|
|
74
|
+
}
|
|
75
|
+
}
|
|
7
76
|
const EMPTY_READ = { rules: [], tombstones: [], rev: 0 };
|
|
8
77
|
function checksumOf(body) {
|
|
9
78
|
return `sha256:${createHash("sha256").update(canonicalize(body), "utf8").digest("hex")}`;
|
|
@@ -64,7 +133,11 @@ class FilePermissionRuleStore {
|
|
|
64
133
|
this.disclose(why);
|
|
65
134
|
return { unreadable: why };
|
|
66
135
|
}
|
|
67
|
-
if (parsed?.schemaVersion !== 1
|
|
136
|
+
if ((parsed?.schemaVersion !== 1 && parsed?.schemaVersion !== 2) ||
|
|
137
|
+
!Array.isArray(parsed.rules) ||
|
|
138
|
+
!Array.isArray(parsed.tombstones) ||
|
|
139
|
+
typeof parsed.rev !== "number" ||
|
|
140
|
+
(parsed.quarantined !== undefined && !Array.isArray(parsed.quarantined))) {
|
|
68
141
|
const why = `${this.file} does not carry a readable rule-file shape; refusing the whole file and loading zero rules`;
|
|
69
142
|
this.disclose(why);
|
|
70
143
|
return { unreadable: why };
|
|
@@ -86,43 +159,7 @@ class FilePermissionRuleStore {
|
|
|
86
159
|
}
|
|
87
160
|
write(next) {
|
|
88
161
|
assertSafeDir(this.dir);
|
|
89
|
-
|
|
90
|
-
const tmp = join(this.dir, `.${randomBytes(8).toString("hex")}.tmp`);
|
|
91
|
-
let fd;
|
|
92
|
-
try {
|
|
93
|
-
fd = openSync(tmp, FS.O_WRONLY | FS.O_CREAT | FS.O_EXCL | (FS.O_NOFOLLOW ?? 0), 0o600);
|
|
94
|
-
writeFileSync(fd, payload);
|
|
95
|
-
fsyncSync(fd);
|
|
96
|
-
closeSync(fd);
|
|
97
|
-
fd = undefined;
|
|
98
|
-
renameSync(tmp, this.file);
|
|
99
|
-
let dfd;
|
|
100
|
-
try {
|
|
101
|
-
dfd = openSync(this.dir, "r");
|
|
102
|
-
fsyncSync(dfd);
|
|
103
|
-
}
|
|
104
|
-
catch {
|
|
105
|
-
}
|
|
106
|
-
finally {
|
|
107
|
-
if (dfd !== undefined)
|
|
108
|
-
closeSync(dfd);
|
|
109
|
-
}
|
|
110
|
-
}
|
|
111
|
-
catch (err) {
|
|
112
|
-
if (fd !== undefined) {
|
|
113
|
-
try {
|
|
114
|
-
closeSync(fd);
|
|
115
|
-
}
|
|
116
|
-
catch {
|
|
117
|
-
}
|
|
118
|
-
}
|
|
119
|
-
try {
|
|
120
|
-
unlinkSync(tmp);
|
|
121
|
-
}
|
|
122
|
-
catch {
|
|
123
|
-
}
|
|
124
|
-
throw err;
|
|
125
|
-
}
|
|
162
|
+
atomicPublish(this.dir, this.file, JSON.stringify({ ...next, checksum: checksumOf(next) }, null, 2));
|
|
126
163
|
}
|
|
127
164
|
async list() {
|
|
128
165
|
const r = this.read();
|
|
@@ -135,14 +172,50 @@ class FilePermissionRuleStore {
|
|
|
135
172
|
checksum: r.file.checksum,
|
|
136
173
|
};
|
|
137
174
|
}
|
|
175
|
+
async quarantined() {
|
|
176
|
+
const r = this.read();
|
|
177
|
+
return "file" in r ? (r.file.quarantined ?? []) : [];
|
|
178
|
+
}
|
|
179
|
+
async readOrgState() {
|
|
180
|
+
const r = this.read();
|
|
181
|
+
return "file" in r ? r.file.org : undefined;
|
|
182
|
+
}
|
|
183
|
+
async installOrgState(state) {
|
|
184
|
+
this.acquireWriteLock();
|
|
185
|
+
await this.serialize(() => {
|
|
186
|
+
const cur = this.current();
|
|
187
|
+
if (cur.org !== undefined && state.revisionHighWater < cur.org.revisionHighWater) {
|
|
188
|
+
throw new Error(`refusing to lower the org revision high-water mark (${cur.org.revisionHighWater} → ${state.revisionHighWater}) — an interleaved install must re-read and re-compare`);
|
|
189
|
+
}
|
|
190
|
+
if (cur.org !== undefined &&
|
|
191
|
+
state.snapshot.revision === cur.org.snapshot.revision &&
|
|
192
|
+
canonicalize(state.snapshot.rules) !== canonicalize(cur.org.snapshot.rules)) {
|
|
193
|
+
throw new Error(`refusing to replace org revision ${state.snapshot.revision} with DIFFERENT policy content under the same revision — content changes must bump the revision`);
|
|
194
|
+
}
|
|
195
|
+
if (!this.writeAndVerify({ ...cur, schemaVersion: 2, org: state })) {
|
|
196
|
+
throw new Error("the permission-rule store did not survive its own write; the org snapshot was not installed");
|
|
197
|
+
}
|
|
198
|
+
});
|
|
199
|
+
}
|
|
138
200
|
current() {
|
|
139
201
|
const r = this.read();
|
|
140
202
|
if ("unreadable" in r) {
|
|
141
203
|
throw new Error(`refusing to write the permission-rule store while it cannot be read: ${r.unreadable}`);
|
|
142
204
|
}
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
205
|
+
if ("absent" in r) {
|
|
206
|
+
return { schemaVersion: 1, actor: `file-${randomBytes(6).toString("hex")}`, counter: 0, rev: 0, rules: [], tombstones: [] };
|
|
207
|
+
}
|
|
208
|
+
return {
|
|
209
|
+
schemaVersion: r.file.schemaVersion,
|
|
210
|
+
actor: r.file.actor,
|
|
211
|
+
counter: r.file.counter,
|
|
212
|
+
rev: r.file.rev,
|
|
213
|
+
rules: r.file.rules,
|
|
214
|
+
tombstones: r.file.tombstones,
|
|
215
|
+
...(r.file.sync !== undefined ? { sync: r.file.sync } : {}),
|
|
216
|
+
...(r.file.org !== undefined ? { org: r.file.org } : {}),
|
|
217
|
+
...(r.file.quarantined !== undefined ? { quarantined: r.file.quarantined } : {}),
|
|
218
|
+
};
|
|
146
219
|
}
|
|
147
220
|
writeAndVerify(next) {
|
|
148
221
|
const expected = checksumOf(next);
|
|
@@ -169,12 +242,50 @@ class FilePermissionRuleStore {
|
|
|
169
242
|
}
|
|
170
243
|
return dot;
|
|
171
244
|
}),
|
|
245
|
+
readRaw: async () => this.serialize(() => {
|
|
246
|
+
const cur = this.current();
|
|
247
|
+
return {
|
|
248
|
+
actor: cur.actor,
|
|
249
|
+
counter: cur.counter,
|
|
250
|
+
rev: cur.rev,
|
|
251
|
+
rules: structuredClone(cur.rules),
|
|
252
|
+
tombstones: structuredClone(cur.tombstones),
|
|
253
|
+
...(cur.sync?.observedVector !== undefined ? { observedVector: structuredClone(cur.sync.observedVector) } : {}),
|
|
254
|
+
quarantined: structuredClone(cur.quarantined ?? []),
|
|
255
|
+
};
|
|
256
|
+
}),
|
|
172
257
|
apply: async (delta, opts) => this.serialize(() => {
|
|
173
258
|
const cur = this.current();
|
|
174
259
|
if (cur.rev !== opts.expectedRev)
|
|
175
260
|
return { conflict: true, rev: cur.rev };
|
|
261
|
+
if (delta.kind === "sync-join") {
|
|
262
|
+
const { next, report } = applySyncJoin({
|
|
263
|
+
actor: cur.actor,
|
|
264
|
+
counter: cur.counter,
|
|
265
|
+
rules: cur.rules,
|
|
266
|
+
tombstones: cur.tombstones,
|
|
267
|
+
quarantined: cur.quarantined ?? [],
|
|
268
|
+
...(cur.sync?.observedVector !== undefined ? { observedVector: cur.sync.observedVector } : {}),
|
|
269
|
+
}, delta, Date.now());
|
|
270
|
+
const landed = {
|
|
271
|
+
...cur,
|
|
272
|
+
schemaVersion: 2,
|
|
273
|
+
rev: cur.rev + 1,
|
|
274
|
+
rules: next.rules,
|
|
275
|
+
tombstones: next.tombstones,
|
|
276
|
+
sync: {
|
|
277
|
+
...(next.observedVector !== undefined ? { observedVector: next.observedVector } : {}),
|
|
278
|
+
lastRoundAtMs: Date.now(),
|
|
279
|
+
},
|
|
280
|
+
...(next.quarantined.length > 0 ? { quarantined: next.quarantined } : {}),
|
|
281
|
+
};
|
|
282
|
+
if (!this.writeAndVerify(landed)) {
|
|
283
|
+
throw new Error("the permission-rule store did not survive its own write; the change was not committed");
|
|
284
|
+
}
|
|
285
|
+
return { rev: cur.rev + 1, sync: report };
|
|
286
|
+
}
|
|
176
287
|
const next = delta.kind === "redemption-add"
|
|
177
|
-
? { ...cur, rev: cur.rev + 1, rules: foldDelta(cur.rules, delta) }
|
|
288
|
+
? (assertRedemptionNotQuarantined(cur.quarantined ?? [], delta), { ...cur, rev: cur.rev + 1, rules: foldDelta(cur.rules, delta) })
|
|
178
289
|
: (assertDeleteDeltaCarriesNoAdd(delta), { ...cur, rev: cur.rev + 1, tombstones: [...cur.tombstones, delta.tombstone] });
|
|
179
290
|
if (!this.writeAndVerify(next)) {
|
|
180
291
|
throw new Error("the permission-rule store did not survive its own write; the change was not committed");
|
|
@@ -203,11 +314,43 @@ export class FilePermissionRuleStoreProvider {
|
|
|
203
314
|
if (typeof principal !== "string" || principal === "") {
|
|
204
315
|
return { list: async () => ({ ...EMPTY_READ }), durability: "process-local" };
|
|
205
316
|
}
|
|
206
|
-
const
|
|
207
|
-
|
|
317
|
+
const adoption = readRuleAdoptionFile(this.dir);
|
|
318
|
+
if (adoption !== undefined) {
|
|
319
|
+
const rec = "adopted" in adoption ? adoption.adopted : adoption.marker;
|
|
320
|
+
if (rec.from.kind === "principal" && rec.from.principal === principal) {
|
|
321
|
+
return this.readOnlySourceView(principalRuleFileName(principal), principalRuleFileName(rec.toPrincipal), "adopted" in adoption || adoption.marker.phase >= 4);
|
|
322
|
+
}
|
|
323
|
+
}
|
|
324
|
+
return new FilePermissionRuleStore(this.dir, join(this.dir, principalRuleFileName(principal)), this.acquireWriteLock, this.onError);
|
|
325
|
+
}
|
|
326
|
+
readOnlySourceView(sourceName, targetName, flipped) {
|
|
327
|
+
const pick = flipped ? targetName : existsSync(join(this.dir, sourceName)) ? sourceName : targetName;
|
|
328
|
+
return readOnlyRuleStoreView(new FilePermissionRuleStore(this.dir, join(this.dir, pick), this.acquireWriteLock, this.onError));
|
|
329
|
+
}
|
|
330
|
+
forLocalOwner() {
|
|
331
|
+
const adoption = readRuleAdoptionFile(this.dir);
|
|
332
|
+
if (adoption !== undefined) {
|
|
333
|
+
const rec = "adopted" in adoption ? adoption.adopted : adoption.marker;
|
|
334
|
+
if (rec.from.kind === "local-owner") {
|
|
335
|
+
return this.readOnlySourceView(LOCAL_OWNER_FILE, principalRuleFileName(rec.toPrincipal), "adopted" in adoption || adoption.marker.phase >= 4);
|
|
336
|
+
}
|
|
337
|
+
}
|
|
338
|
+
return new FilePermissionRuleStore(this.dir, join(this.dir, LOCAL_OWNER_FILE), this.acquireWriteLock, this.onError);
|
|
208
339
|
}
|
|
209
340
|
dispose() {
|
|
210
341
|
this.lock?.release();
|
|
211
342
|
this.lock = undefined;
|
|
212
343
|
}
|
|
213
344
|
}
|
|
345
|
+
function readOnlyRuleStoreView(store) {
|
|
346
|
+
return {
|
|
347
|
+
list: () => store.list(),
|
|
348
|
+
...(store.quarantined !== undefined ? { quarantined: () => store.quarantined() } : {}),
|
|
349
|
+
...(store.durability !== undefined ? { durability: store.durability } : {}),
|
|
350
|
+
...(store.fidelity !== undefined ? { fidelity: store.fidelity } : {}),
|
|
351
|
+
};
|
|
352
|
+
}
|
|
353
|
+
export function principalRuleFileName(principal) {
|
|
354
|
+
return `${createHash("sha256").update(principal, "utf8").digest("hex")}.json`;
|
|
355
|
+
}
|
|
356
|
+
export { LOCAL_OWNER_FILE };
|