relembra 1.0.0-beta.3 → 1.0.0-beta.4

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/README.md CHANGED
@@ -25,7 +25,7 @@ and storage (an adapter). relembra enforces the rules:
25
25
  - **Measured.** An evaluation harness with leak, scope, retrieval and wake-coverage gates, plus
26
26
  controls that must fail. Results: [docs/eval/](docs/eval/).
27
27
 
28
- Version: 1.0.0-beta.3, a beta ([changelog](CHANGELOG.md),
28
+ Version: 1.0.0-beta.4, a beta ([changelog](CHANGELOG.md),
29
29
  [releases](https://github.com/mneves75/relembra/releases)). Release procedure:
30
30
  [docs/RELEASE.md](docs/RELEASE.md).
31
31
  Design: [docs/ARCHITECTURE.md](docs/ARCHITECTURE.md).
@@ -226,7 +226,7 @@ On Windows, check the hash with `Get-FileHash` and copy the binary onto `PATH` a
226
226
 
227
227
  Before replacing an installed executable, retain a backup and its hash. Verify installed bytes
228
228
  against the release checksum and export metadata from a disposable store
229
- (`relembra: "1.0.0-beta.3"`); there is no `--version` flag. The [release procedure](docs/RELEASE.md) covers isolated checks and
229
+ (`relembra: "1.0.0-beta.4"`); there is no `--version` flag. The [release procedure](docs/RELEASE.md) covers isolated checks and
230
230
  rollback without opening a live memory store. After verification, `relembra init` prints the
231
231
  session hook for your agent.
232
232
 
@@ -33,9 +33,9 @@ export declare function checkBusyTimeout(busyTimeoutMs: number): number;
33
33
  /** Whether file modes say who may read a file: every platform but Windows. */
34
34
  export declare const OWNER_MODES: boolean;
35
35
  /**
36
- * Creates missing directories as 0700 and a missing file as 0600, then checks the files with
37
- * checkOwnerOnly. A symbolic link at the path is refused before anything is created, so no file
38
- * appears behind it.
36
+ * Creates POSIX directories as 0700 and files as 0600. On Windows a new store needs an absent
37
+ * directory, created with a private descriptor, and the empty store file is made in it under the
38
+ * same lock. Existing files keep their existing checks.
39
39
  */
40
40
  export declare function createOwnerOnly(path: string): void;
41
41
  /**
@@ -50,20 +50,6 @@ export declare function windowsSystem32(systemRoot: string | undefined): string;
50
50
  * store's ACL both ask.
51
51
  */
52
52
  export declare function windowsUserSid(systemRoot: string | undefined): string;
53
- /**
54
- * Windows: makes the current user the folder's owner, then replaces its ACL with full control for
55
- * the user, SYSTEM and the Administrators group only, inherited by everything in it. An owner may
56
- * always rewrite an ACL, so a folder another account made would otherwise give that account its
57
- * access back; `/setowner` fails, and no store is created, unless the user may take ownership.
58
- * `/inheritance:r` with the three `/grant:r` then drops what the folder inherited, in one change
59
- * that never widens access: another process may already have made the store here (two first
60
- * opens at once), and a step that restored the parent's grants, as `/reset` does, would let the
61
- * store inherit them for a moment. An explicit grant to anyone else is refused, not dropped: the
62
- * ACL is read back and must hold one entry per account in storeFolderAccounts, and no other.
63
- * Accounts are named by SID, so no account name or system language is involved. Any failure is
64
- * thrown: a store is never created in a folder others may read.
65
- */
66
- export declare function restrictFolderToOwner(dir: string): void;
67
53
  /**
68
54
  * The accounts a store folder grants full control: the user, SYSTEM and Administrators, each once.
69
55
  * A process running as LocalSystem is SYSTEM itself, so its folder holds two entries; counting
@@ -1,15 +1,14 @@
1
1
  import { spawnSync } from "node:child_process";
2
- import { randomBytes } from "node:crypto";
3
- import { closeSync, existsSync, lstatSync, mkdirSync, mkdtempSync, openSync, readFileSync, rmSync, } from "node:fs";
4
- import { tmpdir } from "node:os";
5
- import { dirname, join, win32 } from "node:path";
2
+ import { closeSync, existsSync, lstatSync, mkdirSync, openSync, readFileSync } from "node:fs";
3
+ import { dirname, win32 } from "node:path";
6
4
  import { DatabaseSync } from "node:sqlite";
7
5
  import { pathToFileURL } from "node:url";
8
6
  import { StoreBusyError } from "../errors.js";
9
7
  import { MIGRATIONS, migrate, SchemaTooNewError, schemaVersion } from "./migrations.js";
10
8
  export { StoreBusyError };
11
9
  import { walImage } from "./wal-image.js";
12
- import { chainRefusal, storeFolderChain, trustedAccounts, } from "./windows-acl.js";
10
+ import { chainRefusal, storeFolderChain, trustedAccounts } from "./windows-acl.js";
11
+ import { createStoreUnderLock, readWindowsChain } from "./windows-security.js";
13
12
  /** Thrown when an existing store file, or its -wal or -shm file, grants group or others access. */
14
13
  export class StoreFileModeError extends Error {
15
14
  name = "StoreFileModeError";
@@ -59,31 +58,21 @@ export function checkBusyTimeout(busyTimeoutMs) {
59
58
  /** Whether file modes say who may read a file: every platform but Windows. */
60
59
  export const OWNER_MODES = process.platform !== "win32";
61
60
  /**
62
- * Creates missing directories as 0700 and a missing file as 0600, then checks the files with
63
- * checkOwnerOnly. A symbolic link at the path is refused before anything is created, so no file
64
- * appears behind it.
61
+ * Creates POSIX directories as 0700 and files as 0600. On Windows a new store needs an absent
62
+ * directory, created with a private descriptor, and the empty store file is made in it under the
63
+ * same lock. Existing files keep their existing checks.
65
64
  */
66
65
  export function createOwnerOnly(path) {
67
- mkdirSync(dirname(path), { recursive: true, mode: 0o700 });
66
+ if (OWNER_MODES)
67
+ mkdirSync(dirname(path), { recursive: true, mode: 0o700 });
68
68
  if (isSymbolicLink(path))
69
69
  throw new StoreFileTypeError(path);
70
- // Windows ignores the modes: before a new store's first byte, its folder gets an ACL of its own,
71
- // so the store and the -wal and -shm files SQLite makes beside it inherit owner-only access.
72
- if (!OWNER_MODES && !existsSync(path)) {
73
- restrictFolderToOwner(dirname(path));
74
- // A sidecar without its store was put there by someone: it keeps its own ACL, and Windows
75
- // opens a file by its full path whatever the folder grants, so SQLite would write into a file
76
- // another account can read. Checked after the ACL, when no other account can add one. A
77
- // sidecar beside a store that exists by now belongs to another process that created the
78
- // store first (its folder got the same three grants), so it is no plant.
79
- for (const sidecar of [`${path}-wal`, `${path}-shm`, `${path}-journal`]) {
80
- if (hasEntry(sidecar) && !hasEntry(path)) {
81
- throw new Error(`${sidecar} exists without its store; no store is created beside it`);
82
- }
83
- }
84
- }
70
+ // Windows ignores the modes: the directory must be private from creation, before any file.
71
+ if (!OWNER_MODES && !existsSync(path))
72
+ createWindowsStore(path);
85
73
  try {
86
- // O_EXCL never follows a link, so a link made since the check fails here with EEXIST.
74
+ // O_EXCL never follows a link, so a link made since the check fails here with EEXIST. On
75
+ // Windows the file exists by now, made under the store's lock, and this is a no-op.
87
76
  closeSync(openSync(path, "wx", 0o600));
88
77
  }
89
78
  catch (error) {
@@ -134,134 +123,41 @@ export function windowsUserSid(systemRoot) {
134
123
  return sid;
135
124
  }
136
125
  /**
137
- * Windows: makes the current user the folder's owner, then replaces its ACL with full control for
138
- * the user, SYSTEM and the Administrators group only, inherited by everything in it. An owner may
139
- * always rewrite an ACL, so a folder another account made would otherwise give that account its
140
- * access back; `/setowner` fails, and no store is created, unless the user may take ownership.
141
- * `/inheritance:r` with the three `/grant:r` then drops what the folder inherited, in one change
142
- * that never widens access: another process may already have made the store here (two first
143
- * opens at once), and a step that restored the parent's grants, as `/reset` does, would let the
144
- * store inherit them for a moment. An explicit grant to anyone else is refused, not dropped: the
145
- * ACL is read back and must hold one entry per account in storeFolderAccounts, and no other.
146
- * Accounts are named by SID, so no account name or system language is involved. Any failure is
147
- * thrown: a store is never created in a folder others may read.
126
+ * Creates an absent Windows store folder with its private descriptor supplied to mkdir, then the
127
+ * empty store file in it, under a mutex named for the account and folder: two processes opening
128
+ * a missing store at once both succeed, the second finding the first's file. Whether an existing
129
+ * folder is another creator's or a preexisting uninitialized one is decided under that lock, by
130
+ * the store file's presence: the latter is refused, since changing an ACL cannot revoke old
131
+ * handles. A sidecar without its store (a file another account could read, since Windows opens
132
+ * a file by its path whatever the folder grants) is refused there too, before the first byte.
148
133
  */
149
- export function restrictFolderToOwner(dir) {
150
- // icacls follows a junction or symbolic link: the owner and ACL of its target would change.
151
- if (lstatSync(dir).isSymbolicLink())
152
- throw new Error(`${dir} is a link; no store is created in it`);
134
+ function createWindowsStore(path) {
135
+ const store = win32.resolve(path);
136
+ const folder = win32.dirname(store);
137
+ if (!/^[A-Za-z]:\\/.test(store) || store.slice(2).includes(":")) {
138
+ throw new Error("a Windows store requires a local drive path without alternate streams");
139
+ }
140
+ if (hasEntry(folder) && lstatSync(folder).isSymbolicLink())
141
+ throw new Error(`${folder} is a link`);
153
142
  const systemRoot = process.env["SystemRoot"];
154
143
  const sid = windowsUserSid(systemRoot);
155
- checkFolderChain(systemRoot, dir, sid);
156
- const accounts = storeFolderAccounts(sid);
157
- runSystemTool(systemRoot, "icacls.exe", [dir, "/setowner", `*${sid}`]);
158
- runSystemTool(systemRoot, "icacls.exe", [
159
- dir,
160
- "/inheritance:r",
161
- ...accounts.flatMap((who) => ["/grant:r", `*${who}:(OI)(CI)F`]),
162
- ]);
163
- // icacls prints one `name:(flags)` entry per line; the path, on the first, holds no `:(`.
164
- const entries = runSystemTool(systemRoot, "icacls.exe", [dir])
165
- .split(/\r?\n/)
166
- .filter((line) => line.includes(":("));
167
- if (entries.length !== accounts.length) {
168
- throw new Error(`${dir} grants another account access (${entries.length} entries, not ${accounts.length}); no store is created in it: remove those grants or use another folder`);
169
- }
170
- }
171
- /**
172
- * Windows: refuses a store folder whose path another account could change (docs/specs/w-nv1/spec.md).
173
- * The ACL steps work on the folder's path, so they hold only while no other account can rename,
174
- * delete or re-permission a folder above it, make the store folder first, or open it before its
175
- * ACL is set. Every folder from the store folder's parent up to the drive root, on the path as
176
- * given (storeFolderChain), is read as SDDL with `icacls /save`, which names accounts by SID, and
177
- * judged by chainRefusal. A link on the path is refused: icacls would read its target, and whoever
178
- * may change the link may point it elsewhere. Owners are not read, since no stock tool prints one
179
- * as a SID: an ancestor another account owns, whose access list does not name that account, is
180
- * not detected.
181
- */
182
- function checkFolderChain(systemRoot, dir, sid) {
183
- const folders = storeFolderChain(dir);
184
- for (const folder of folders) {
185
- if (lstatSync(folder).isSymbolicLink()) {
186
- throw new Error(`${folder} is a link; no store is created under it: use a path without links`);
187
- }
188
- }
189
- if (folders.length === 0)
190
- return;
191
- const chain = [];
192
- const scratch = privateScratch(systemRoot, sid);
193
- try {
194
- for (const [index, folder] of folders.entries()) {
195
- chain.push({
196
- path: folder,
197
- dacl: savedDacl(systemRoot, folder, scratch),
198
- holdsStoreFolder: index === 0,
199
- });
200
- }
201
- }
202
- finally {
203
- rmSync(scratch, { recursive: true, force: true });
144
+ const executable = win32.join(windowsSystem32(systemRoot), "WindowsPowerShell", "v1.0", "powershell.exe");
145
+ const ancestors = storeFolderChain(folder);
146
+ const missing = [folder];
147
+ const existing = [];
148
+ for (const ancestor of ancestors) {
149
+ if (hasEntry(ancestor))
150
+ existing.push(ancestor);
151
+ else if (existing.length === 0)
152
+ missing.push(ancestor);
153
+ else
154
+ throw new Error("the Windows store ancestry changed during verification");
204
155
  }
156
+ const chain = readWindowsChain(executable, existing.reverse());
205
157
  const refusal = chainRefusal(chain, trustedAccounts(sid));
206
- if (refusal !== undefined) {
207
- throw new Error(`${refusal}; no store is created under it: remove that grant or keep the store in a folder only you can change, such as your profile`);
208
- }
209
- }
210
- /**
211
- * A folder under TEMP for the `icacls /save` files the chain check trusts, with the store
212
- * folder's own ACL: owned by the user, and full control for the user, SYSTEM and Administrators
213
- * only, read back on stdout. Bun's mkdtemp already gives it those three entries, explicit; Node's
214
- * gives it TEMP's inherited ACL until these steps. Each file gets a random name, so no account can
215
- * make one first. The steps act on the folder's path after it exists, so TEMP is trusted for what
216
- * they cannot see (docs/specs/w-nv1/spec.md): it must give other accounts no inherited access to
217
- * folders made in it (a handle opened before these steps keeps its access, and could turn the
218
- * folder into a junction), must not let them delete or rename its entries, and must not be theirs.
219
- * The default, under the user's profile, meets that.
220
- */
221
- function privateScratch(systemRoot, sid) {
222
- const scratch = mkdtempSync(join(tmpdir(), "relembra-acl-"));
223
- try {
224
- const accounts = storeFolderAccounts(sid);
225
- runSystemTool(systemRoot, "icacls.exe", [scratch, "/setowner", `*${sid}`]);
226
- runSystemTool(systemRoot, "icacls.exe", [
227
- scratch,
228
- "/inheritance:r",
229
- ...accounts.flatMap((who) => ["/grant:r", `*${who}:(OI)(CI)F`]),
230
- ]);
231
- const entries = runSystemTool(systemRoot, "icacls.exe", [scratch])
232
- .split(/\r?\n/)
233
- .filter((line) => line.includes(":("));
234
- if (entries.length !== accounts.length) {
235
- throw new Error(`${scratch} grants another account access (${entries.length} entries, not ${accounts.length}); no store is created: point TEMP at a folder only you can change`);
236
- }
237
- return scratch;
238
- }
239
- catch (error) {
240
- rmSync(scratch, { recursive: true, force: true });
241
- throw error;
242
- }
243
- }
244
- /**
245
- * A folder's access list as SDDL. `icacls <folder> /save <file>` writes the folder's name on one
246
- * line (none for a drive root) and its access list on the next, in UTF-16 or the console's
247
- * encoding; parseDacl drops the SACL that follows it on that line.
248
- */
249
- function savedDacl(systemRoot, folder, scratch) {
250
- // A fresh random name for each save, so no account can make the file first.
251
- const file = join(scratch, randomBytes(16).toString("hex"));
252
- runSystemTool(systemRoot, "icacls.exe", [folder, "/save", file]);
253
- const bytes = readFileSync(file);
254
- const utf16 = (bytes[0] === 0xff && bytes[1] === 0xfe) || bytes[1] === 0;
255
- const lines = (utf16 ? bytes.toString("utf16le") : bytes.toString("utf8"))
256
- .replace(/^\uFEFF/, "")
257
- .split(/\r?\n/)
258
- .filter((line) => line.trim() !== "");
259
- // A drive root has no name, so its file holds the access list alone.
260
- const dacl = lines.at(-1);
261
- if (lines.length > 2 || dacl === undefined || !dacl.startsWith("D:")) {
262
- throw new Error(`icacls /save did not write one access list for ${folder}: ${lines.join(" | ")}`);
263
- }
264
- return dacl.trim();
158
+ if (refusal !== undefined)
159
+ throw new Error(`${refusal}; no store is created under it`);
160
+ createStoreUnderLock(executable, store, folder, missing.reverse(), chain, sid, storeFolderAccounts(sid));
265
161
  }
266
162
  /**
267
163
  * The accounts a store folder grants full control: the user, SYSTEM and Administrators, each once.
@@ -9,10 +9,11 @@ export interface Ace {
9
9
  /** A SID, or an SDDL alias this module does not resolve (domain-relative ones: never trusted). */
10
10
  readonly sid: string;
11
11
  }
12
- /** A folder on the path to the store folder, with its access list as `icacls /save` writes it. */
12
+ /** An existing ancestor with its owner SID and DACL read directly from the filesystem. */
13
13
  export interface ChainFolder {
14
14
  readonly path: string;
15
15
  readonly dacl: string;
16
+ readonly ownerSid: string;
16
17
  /** The folder the store folder is made in. */
17
18
  readonly holdsStoreFolder: boolean;
18
19
  }
@@ -1,9 +1,7 @@
1
1
  /**
2
- * Whether another account could change the folders that hold a Windows store folder, read from
3
- * their access lists as SDDL (docs/specs/w-nv1/spec.md). relembra secures a new store's folder by
4
- * its path, which holds only while no other account can rename, delete, re-permission or pre-create
5
- * a folder on that path, or open the new folder before its ACL is set. Pure: the caller reads the
6
- * access lists with icacls.
2
+ * Current owners and access lists of the existing ancestry of a new Windows store directory.
3
+ * The filesystem adapter reads SIDs and SDDL directly through .NET; these conservative rules
4
+ * neither adopt preexisting store directories nor attest to historical ancestor handles.
7
5
  */
8
6
  import { win32 } from "node:path";
9
7
  const EVERYONE = "S-1-1-0";
@@ -75,6 +73,7 @@ const DENY_TYPES = new Set(["D", "XD", "OD"]);
75
73
  /** Entry types that may end in a condition; their allow ones count as unconditional here. */
76
74
  const CALLBACK_TYPES = new Set(["XA", "XD", "ZA"]);
77
75
  const GUID = /^[0-9a-fA-F]{8}(-[0-9a-fA-F]{4}){3}-[0-9a-fA-F]{12}$/;
76
+ const WRITE_ATTRIBUTES = 0x100;
78
77
  const DELETE = 0x10000;
79
78
  const WRITE_DAC = 0x40000;
80
79
  const WRITE_OWNER = 0x80000;
@@ -210,6 +209,12 @@ export function storeFolderChain(storeFolder) {
210
209
  */
211
210
  export function chainRefusal(chain, trusted) {
212
211
  for (const folder of chain) {
212
+ if (!/^S-1-[0-9]+(?:-[0-9]+)+$/.test(folder.ownerSid) || folder.ownerSid === CREATOR_OWNER) {
213
+ return `${folder.path} has an unverifiable owner`;
214
+ }
215
+ if (!trusted.has(folder.ownerSid)) {
216
+ return `${folder.path} is owned by another account (${folder.ownerSid})`;
217
+ }
213
218
  const aces = parseDacl(folder.dacl) ?? [
214
219
  { allow: true, inheritOnly: false, containerInherit: true, mask: 0x1f01ff, sid: EVERYONE },
215
220
  ];
@@ -221,6 +226,9 @@ export function chainRefusal(chain, trusted) {
221
226
  if (own & (DELETE | WRITE_DAC | WRITE_OWNER | GENERIC_ALL)) {
222
227
  return refuse("rename, delete or re-permission it");
223
228
  }
229
+ if (own & (WRITE_ATTRIBUTES | GENERIC_WRITE)) {
230
+ return refuse("change its attributes or turn it into a reparse point");
231
+ }
224
232
  if (own & DELETE_CHILD)
225
233
  return refuse("rename or delete the folders in it");
226
234
  if (!folder.holdsStoreFolder)
@@ -0,0 +1,23 @@
1
+ import type { ChainFolder } from "./windows-acl.js";
2
+ /** No Get-Acl provider, compiled helper, or temporary file participates in these reads. */
3
+ export declare function readWindowsChain(executable: string, paths: readonly string[]): ChainFolder[];
4
+ /**
5
+ * The name of the mutex that serialises the creators of one store: one per account and store
6
+ * folder, compared as Windows compares paths, without case. A long and a short (8.3) spelling
7
+ * of one folder take two names, so two such creators refuse each other, never both create.
8
+ */
9
+ export declare function storeLockName(ownerSid: string, folder: string): string;
10
+ /** The mutex's own descriptor: full control (0x1f0001) for the accounts, nothing else. */
11
+ export declare function storeLockSddl(ownerSid: string, accounts: readonly string[]): string;
12
+ /**
13
+ * The store folder's descriptor: protected, so nothing is inherited into it, and auto-inherited
14
+ * (`AI`), so what is made in it carries its grants as inherited entries, as a folder set with
15
+ * icacls does. Without `AI` Windows copies the grants into new files unmarked.
16
+ */
17
+ export declare function storeFolderSddl(ownerSid: string, accounts: readonly string[]): string;
18
+ /**
19
+ * Creates the absent folders with protected inheritable grants before they become visible, then
20
+ * the empty store file in the last, all under the store's lock. Returns `exists` when another
21
+ * creator finished the same store under that lock meanwhile.
22
+ */
23
+ export declare function createStoreUnderLock(executable: string, store: string, folder: string, paths: readonly string[], chain: readonly ChainFolder[], ownerSid: string, accounts: readonly string[]): "created" | "exists";
@@ -0,0 +1,256 @@
1
+ import { spawnSync } from "node:child_process";
2
+ import { createHash } from "node:crypto";
3
+ /**
4
+ * Windows PowerShell uses .NET Framework: this overload supplies the descriptor to mkdir, and a
5
+ * named mutex serialises the creators of one store, so two first opens both succeed.
6
+ */
7
+ const PROGRAM = String.raw `
8
+ $ErrorActionPreference = 'Stop'
9
+ $ProgressPreference = 'SilentlyContinue'
10
+ [Console]::OutputEncoding = [Text.UTF8Encoding]::new($false)
11
+ [void][Reflection.Assembly]::Load('System.Web.Extensions, Version=4.0.0.0, Culture=neutral, PublicKeyToken=31bf3856ad364e35')
12
+ $serializer = [System.Web.Script.Serialization.JavaScriptSerializer]::new()
13
+ function Read-Folder([string] $path) {
14
+ $attributes = [IO.File]::GetAttributes($path)
15
+ if (($attributes -band [IO.FileAttributes]::ReparsePoint) -ne 0) { throw "$path is a link or reparse point" }
16
+ if (($attributes -band [IO.FileAttributes]::Directory) -eq 0) { throw "$path is not a directory" }
17
+ $sections = [Security.AccessControl.AccessControlSections]::Owner -bor [Security.AccessControl.AccessControlSections]::Access
18
+ $acl = [IO.Directory]::GetAccessControl($path, $sections)
19
+ @{
20
+ path = $path
21
+ ownerSid = $acl.GetOwner([Security.Principal.SecurityIdentifier]).Value
22
+ dacl = $acl.GetSecurityDescriptorSddlForm([Security.AccessControl.AccessControlSections]::Access)
23
+ }
24
+ }
25
+ function Test-Entry([string] $path) {
26
+ [IO.Directory]::Exists($path) -or [IO.File]::Exists($path)
27
+ }
28
+ # Exactly one full-control allow per account, nothing else: a folder's own entries, a file's
29
+ # inherited ones. A folder is owned by the user, whose SID its descriptor names; a file by the
30
+ # token's default owner, which is Administrators for an administrator, so any of the accounts.
31
+ function Confirm-Rules($acl, [string] $what, [string[]] $owners) {
32
+ $rules = @($acl.GetAccessRules($true, $true, [Security.Principal.SecurityIdentifier]))
33
+ if ($owners -notcontains $acl.GetOwner([Security.Principal.SecurityIdentifier]).Value -or $rules.Count -ne $request.accounts.Count) {
34
+ throw "$what is not private"
35
+ }
36
+ $seen = @{}
37
+ foreach ($rule in $rules) {
38
+ $sid = $rule.IdentityReference.Value
39
+ if ($request.accounts -notcontains $sid -or $seen.ContainsKey($sid) -or
40
+ $rule.AccessControlType -ne 'Allow' -or
41
+ $rule.FileSystemRights -ne [Security.AccessControl.FileSystemRights]::FullControl) {
42
+ throw "$what has unexpected access rules"
43
+ }
44
+ $seen[$sid] = $true
45
+ }
46
+ $rules
47
+ }
48
+ # With $model, the folder must also carry the auto-inherit model (AI), under which the files made
49
+ # in it carry its grants as inherited entries.
50
+ function Confirm-Folder([string] $path, [bool] $model) {
51
+ $folder = Read-Folder $path
52
+ $acl = [IO.Directory]::GetAccessControl($path)
53
+ if (-not $acl.AreAccessRulesProtected) { throw "$path was not created private" }
54
+ foreach ($rule in (Confirm-Rules $acl $path @($request.ownerSid))) {
55
+ if ($rule.IsInherited -or
56
+ $rule.InheritanceFlags -ne ([Security.AccessControl.InheritanceFlags]::ContainerInherit -bor [Security.AccessControl.InheritanceFlags]::ObjectInherit) -or
57
+ $rule.PropagationFlags -ne [Security.AccessControl.PropagationFlags]::None) {
58
+ throw "$path has unexpected access rules"
59
+ }
60
+ }
61
+ if ($model -and -not $folder.dacl.StartsWith('D:PAI')) { throw "$path did not take the auto-inherit model" }
62
+ }
63
+ function Confirm-File([string] $path) {
64
+ $attributes = [IO.File]::GetAttributes($path)
65
+ if (($attributes -band ([IO.FileAttributes]::ReparsePoint -bor [IO.FileAttributes]::Directory)) -ne 0) { throw "$path is not a regular file" }
66
+ [void](Confirm-Rules ([IO.File]::GetAccessControl($path)) $path $request.accounts)
67
+ }
68
+ # The lock is ours only when its owner and every grant are: a squatted name is refused, never
69
+ # re-permissioned.
70
+ function Confirm-Lock($lock) {
71
+ $acl = $lock.GetAccessControl()
72
+ if ($acl.GetOwner([Security.Principal.SecurityIdentifier]).Value -ne $request.ownerSid) { throw 'the store creation lock is owned by another account' }
73
+ $rules = @($acl.GetAccessRules($true, $true, [Security.Principal.SecurityIdentifier]))
74
+ if ($rules.Count -ne $request.accounts.Count) { throw 'the store creation lock grants another account access' }
75
+ foreach ($rule in $rules) {
76
+ if ($request.accounts -notcontains $rule.IdentityReference.Value -or $rule.AccessControlType -ne 'Allow' -or
77
+ $rule.MutexRights -ne [System.Security.AccessControl.MutexRights]::FullControl) {
78
+ throw 'the store creation lock grants another account access'
79
+ }
80
+ }
81
+ }
82
+ try {
83
+ $request = $serializer.DeserializeObject([Console]::In.ReadToEnd())
84
+ if ($request.mode -eq 'read') {
85
+ $result = @(foreach ($path in $request.paths) { Read-Folder $path })
86
+ [Console]::Write($serializer.Serialize([object[]]$result))
87
+ } elseif ($request.mode -eq 'create') {
88
+ if ($request.lockName -notmatch '^Global\\relembra-[0-9a-f]{64}$') { throw 'invalid store creation lock name' }
89
+ $lockSecurity = [System.Security.AccessControl.MutexSecurity]::new()
90
+ $lockSecurity.SetSecurityDescriptorSddlForm($request.lockSddl)
91
+ $createdNew = $false
92
+ $lock = [System.Threading.Mutex]::new($false, $request.lockName, [ref] $createdNew, $lockSecurity)
93
+ $held = $false
94
+ try {
95
+ Confirm-Lock $lock
96
+ try { $held = $lock.WaitOne(10000) } catch [System.Threading.AbandonedMutexException] { $held = $true }
97
+ if (-not $held) { throw 'another process is still creating the store folder' }
98
+ if (Test-Entry $request.store) {
99
+ # Another creator finished under this lock: its folder must be exactly what this one would
100
+ # have made, model included. An abandoned lock never adopts an unfinished folder: the
101
+ # store is absent then.
102
+ Confirm-File $request.store
103
+ Confirm-Folder $request.folder $true
104
+ $result = 'exists'
105
+ } else {
106
+ foreach ($expected in $request.chain) {
107
+ $actual = Read-Folder $expected.path
108
+ if ($actual.ownerSid -ne $expected.ownerSid -or $actual.dacl -cne $expected.dacl) {
109
+ throw 'the store folder ancestry changed during verification'
110
+ }
111
+ }
112
+ foreach ($path in $request.paths) {
113
+ if (Test-Entry $path) { throw "$path already exists; a new Windows store requires a new folder" }
114
+ }
115
+ # What this run made is removed when a later step fails, so a retry finds the leaf absent;
116
+ # nothing that existed before is ever removed.
117
+ $created = @()
118
+ $fileCreated = $false
119
+ try {
120
+ foreach ($path in $request.paths) {
121
+ # A fresh descriptor per call: Persist clears what a reused one would apply again.
122
+ $security = [Security.AccessControl.DirectorySecurity]::new()
123
+ $security.SetSecurityDescriptorSddlForm($request.sddl)
124
+ [void][IO.Directory]::CreateDirectory($path, $security)
125
+ $created += $path
126
+ Confirm-Folder $path $false
127
+ # Files made in the folder carry its grants as inherited entries only under the
128
+ # auto-inherit model (AI). Creation may hand the kernel a descriptor without it; setting
129
+ # the same descriptor again goes through SetNamedSecurityInfo, which adopts the model.
130
+ if (-not (Read-Folder $path).dacl.StartsWith('D:PAI')) {
131
+ $again = [Security.AccessControl.DirectorySecurity]::new()
132
+ $again.SetSecurityDescriptorSddlForm($request.sddl)
133
+ [IO.Directory]::SetAccessControl($path, $again)
134
+ }
135
+ Confirm-Folder $path $true
136
+ }
137
+ foreach ($sidecar in $request.sidecars) {
138
+ if (Test-Entry $sidecar) { throw "$sidecar exists without its store; no store is created beside it" }
139
+ }
140
+ [IO.FileStream]::new($request.store, [IO.FileMode]::CreateNew, [IO.FileAccess]::Write, [IO.FileShare]::None).Dispose()
141
+ $fileCreated = $true
142
+ Confirm-File $request.store
143
+ } catch {
144
+ $failure = $_.Exception
145
+ if ($fileCreated) { try { [IO.File]::Delete($request.store) } catch { } }
146
+ for ($i = $created.Count - 1; $i -ge 0; $i--) { try { [IO.Directory]::Delete($created[$i]) } catch { } }
147
+ throw $failure
148
+ }
149
+ $result = 'created'
150
+ }
151
+ } finally {
152
+ if ($held) { $lock.ReleaseMutex() }
153
+ $lock.Dispose()
154
+ }
155
+ [Console]::Write($serializer.Serialize($result))
156
+ } else { throw 'unknown security operation' }
157
+ } catch {
158
+ [Console]::Error.WriteLine($_.Exception.Message)
159
+ exit 1
160
+ }
161
+ `;
162
+ function run(executable, request) {
163
+ const child = spawnSync(executable, [
164
+ "-NoLogo",
165
+ "-NoProfile",
166
+ "-NonInteractive",
167
+ "-EncodedCommand",
168
+ Buffer.from(PROGRAM, "utf16le").toString("base64"),
169
+ ], {
170
+ input: JSON.stringify(request),
171
+ encoding: "utf8",
172
+ shell: false,
173
+ windowsHide: true,
174
+ timeout: 30_000,
175
+ });
176
+ if (child.error !== undefined || child.status !== 0) {
177
+ throw new Error(`Windows store security failed: ${child.error?.message ?? child.stderr.trim()}; no store is created`);
178
+ }
179
+ try {
180
+ return JSON.parse(child.stdout.replace(/^/, ""));
181
+ }
182
+ catch (cause) {
183
+ throw new Error("Windows store security returned invalid JSON; no store is created", { cause });
184
+ }
185
+ }
186
+ /** No Get-Acl provider, compiled helper, or temporary file participates in these reads. */
187
+ export function readWindowsChain(executable, paths) {
188
+ const result = run(executable, { mode: "read", paths });
189
+ if (!Array.isArray(result) || result.length !== paths.length) {
190
+ throw new Error("Windows store security did not return the complete folder chain");
191
+ }
192
+ return result.map((value, index) => {
193
+ if (typeof value !== "object" ||
194
+ value === null ||
195
+ !("path" in value) ||
196
+ typeof value.path !== "string" ||
197
+ value.path !== paths[index] ||
198
+ !("ownerSid" in value) ||
199
+ typeof value.ownerSid !== "string" ||
200
+ !("dacl" in value) ||
201
+ typeof value.dacl !== "string") {
202
+ throw new Error("Windows store security returned an invalid folder descriptor");
203
+ }
204
+ return {
205
+ path: value.path,
206
+ ownerSid: value.ownerSid,
207
+ dacl: value.dacl,
208
+ holdsStoreFolder: index === paths.length - 1,
209
+ };
210
+ });
211
+ }
212
+ /**
213
+ * The name of the mutex that serialises the creators of one store: one per account and store
214
+ * folder, compared as Windows compares paths, without case. A long and a short (8.3) spelling
215
+ * of one folder take two names, so two such creators refuse each other, never both create.
216
+ */
217
+ export function storeLockName(ownerSid, folder) {
218
+ const digest = createHash("sha256").update(`${ownerSid}\n${folder.toLowerCase()}`).digest("hex");
219
+ return `Global\\relembra-${digest}`;
220
+ }
221
+ /** The mutex's own descriptor: full control (0x1f0001) for the accounts, nothing else. */
222
+ export function storeLockSddl(ownerSid, accounts) {
223
+ return `O:${ownerSid}D:P${accounts.map((sid) => `(A;;0x1f0001;;;${sid})`).join("")}`;
224
+ }
225
+ /**
226
+ * The store folder's descriptor: protected, so nothing is inherited into it, and auto-inherited
227
+ * (`AI`), so what is made in it carries its grants as inherited entries, as a folder set with
228
+ * icacls does. Without `AI` Windows copies the grants into new files unmarked.
229
+ */
230
+ export function storeFolderSddl(ownerSid, accounts) {
231
+ return `O:${ownerSid}D:PAI${accounts.map((sid) => `(A;OICI;FA;;;${sid})`).join("")}`;
232
+ }
233
+ /**
234
+ * Creates the absent folders with protected inheritable grants before they become visible, then
235
+ * the empty store file in the last, all under the store's lock. Returns `exists` when another
236
+ * creator finished the same store under that lock meanwhile.
237
+ */
238
+ export function createStoreUnderLock(executable, store, folder, paths, chain, ownerSid, accounts) {
239
+ const result = run(executable, {
240
+ mode: "create",
241
+ store,
242
+ folder,
243
+ paths,
244
+ sidecars: [`${store}-wal`, `${store}-shm`, `${store}-journal`],
245
+ chain,
246
+ ownerSid,
247
+ accounts,
248
+ sddl: storeFolderSddl(ownerSid, accounts),
249
+ lockName: storeLockName(ownerSid, folder),
250
+ lockSddl: storeLockSddl(ownerSid, accounts),
251
+ });
252
+ if (result !== "created" && result !== "exists") {
253
+ throw new Error("Windows store security did not confirm private store creation");
254
+ }
255
+ return result;
256
+ }
package/dist/version.d.ts CHANGED
@@ -1,2 +1,2 @@
1
1
  /** This relembra's version, as package.json gives it. An export's header carries it. */
2
- export declare const RELEMBRA_VERSION = "1.0.0-beta.3";
2
+ export declare const RELEMBRA_VERSION = "1.0.0-beta.4";
package/dist/version.js CHANGED
@@ -1,2 +1,2 @@
1
1
  /** This relembra's version, as package.json gives it. An export's header carries it. */
2
- export const RELEMBRA_VERSION = "1.0.0-beta.3";
2
+ export const RELEMBRA_VERSION = "1.0.0-beta.4";
package/docs/FORMAT.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # relembra storage format
2
2
 
3
- Status: event formats 1 and 2, SQLite schema version 6, Postgres schema version 3. Release 1.0.0-beta.3.
3
+ Status: event formats 1 and 2, SQLite schema version 6, Postgres schema version 3. Release 1.0.0-beta.4.
4
4
 
5
5
  This document is the contract for what relembra stores. A reader that follows it needs no
6
6
  relembra code.
@@ -175,7 +175,7 @@ the events above, so a reader needs only this document.
175
175
  |---|---|---|
176
176
  | `export` | string | Always `relembra`. What makes the file an export. |
177
177
  | `format` | integer | The newest event format of the lines after it: `1` when none is a `promote` line, so a format 1 reader still reads the file, and `2` when one is. |
178
- | `relembra` | string | The relembra version that wrote the file, such as `1.0.0-beta.3`. Informational: a reader decides by `format` alone. |
178
+ | `relembra` | string | The relembra version that wrote the file, such as `1.0.0-beta.4`. Informational: a reader decides by `format` alone. |
179
179
  | `scopes` | array of strings | The scopes exported. Every event's `scope` is one of them. |
180
180
 
181
181
  ### Events (lines 2 onward)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "relembra",
3
- "version": "1.0.0-beta.3",
3
+ "version": "1.0.0-beta.4",
4
4
  "description": "A curated, scoped, bounded, durable, erasable and measured memory for AI agents.",
5
5
  "license": "MIT",
6
6
  "author": "mneves75",