@wrongstack/persistence 0.306.0 → 0.306.3

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.
@@ -0,0 +1,53 @@
1
+ /**
2
+ * Owner-only file hardening, on both POSIX and Windows.
3
+ *
4
+ * WS-045: this lived as a private function inside `secret-vault.ts` and was
5
+ * therefore reachable only by the config file. Every other secret WrongStack
6
+ * writes — the HQ `auth.json` (bearer tokens), `runtime.json` (local client
7
+ * token), the vault `.key` — got `chmod(0o600)` at best, which on Windows is
8
+ * a no-op for everything except the read-only bit: the file stayed readable by
9
+ * every other account on the machine. One exported module so a new secret file
10
+ * has an obvious thing to call.
11
+ *
12
+ * ## Why it lives in `@wrongstack/persistence`
13
+ *
14
+ * It started in `@wrongstack/core/security`, which put it out of reach of the
15
+ * one project daemon that needed it most: `@wrongstack/kanban` depends only on
16
+ * this package, and it cannot depend on core — core already depends on kanban,
17
+ * so that edge would close a cycle. Every project daemon writes a metadata
18
+ * file holding its per-process IPC auth token, and every one of them needs the
19
+ * same owner-only guarantee, so the helper belongs next to `atomicWrite`: a
20
+ * dependency-free filesystem primitive that all of core, kanban, sage and
21
+ * tools already import. `@wrongstack/core/security` re-exports it, so existing
22
+ * callers are unchanged.
23
+ *
24
+ * @module file-permissions
25
+ */
26
+ /** Owner read/write, nothing for group or other. */
27
+ export declare const SECRET_FILE_MODE = 384;
28
+ /** Owner-only directory: enter + list + write. */
29
+ export declare const SECRET_DIR_MODE = 448;
30
+ export interface RestrictPermissionsOptions {
31
+ warn?: ((msg: string) => void) | undefined;
32
+ /** Label used in warnings, e.g. `secret-vault` or `hq-auth`. */
33
+ label?: string | undefined;
34
+ }
35
+ /**
36
+ * Restrict a file to owner-only access.
37
+ *
38
+ * POSIX: `chmod 0600`. Windows: `chmod` cannot express this, so we use
39
+ * `icacls` to drop inherited ACEs and grant the current user alone.
40
+ *
41
+ * Failures are warned, never thrown — a hardening step must not be able to
42
+ * block the write it protects, and `icacls` is absent in some minimal
43
+ * environments (containers, WINE, restricted CI images).
44
+ */
45
+ export declare function restrictFilePermissions(filePath: string, opts?: RestrictPermissionsOptions): Promise<void>;
46
+ /**
47
+ * `restrictFilePermissions` for a directory: `chmod 0700` on POSIX, the same
48
+ * icacls treatment (applied recursively to new children via inheritance) on
49
+ * Windows. Use on the directory that holds secret files so a file created
50
+ * there by a path we do not control still lands owner-only.
51
+ */
52
+ export declare function restrictDirPermissions(dirPath: string, opts?: RestrictPermissionsOptions): Promise<void>;
53
+ //# sourceMappingURL=file-permissions.d.ts.map
package/dist/index.d.ts CHANGED
@@ -1,4 +1,5 @@
1
1
  export * from './atomic-write.js';
2
+ export * from './file-permissions.js';
2
3
  export * from './project-endpoint.js';
3
4
  export * from './socket-path.js';
4
5
  //# sourceMappingURL=index.d.ts.map
package/dist/index.js CHANGED
@@ -212,6 +212,13 @@ async function renameWithRetry(from, to) {
212
212
  } catch (error) {
213
213
  const code = error.code;
214
214
  let transient = code !== void 0 && TRANSIENT_RENAME_CODES.has(code);
215
+ if (transient) {
216
+ try {
217
+ const target = await fs.stat(to);
218
+ if (target.isDirectory()) transient = false;
219
+ } catch {
220
+ }
221
+ }
215
222
  if (code === "ENOENT") {
216
223
  try {
217
224
  await fs.stat(from);
@@ -223,7 +230,7 @@ async function renameWithRetry(from, to) {
223
230
  if (!transient || attempt === delays.length) {
224
231
  if (attempt === delays.length) {
225
232
  process.emitWarning(
226
- `Windows rename retries exhausted for '${from}' \u2192 '${to}' (code=${code}). Write skipped \u2014 the caller will retry on the next cycle.`,
233
+ `Windows rename retries exhausted for '${from}' \u2192 '${to}' (code=${code}). Atomic write failed; the original target was left untouched.`,
227
234
  { code: "WRONGSTACK_WIN32_RENAME_EXHAUSTED" }
228
235
  );
229
236
  }
@@ -239,6 +246,58 @@ var atomicWrite = defaultPrimitives.atomicWrite;
239
246
  var ensureDir = defaultPrimitives.ensureDir;
240
247
  var withFileLock = defaultPrimitives.withFileLock;
241
248
 
249
+ // src/file-permissions.ts
250
+ import { chmod as chmod2 } from "node:fs/promises";
251
+ var SECRET_FILE_MODE = 384;
252
+ var SECRET_DIR_MODE = 448;
253
+ async function restrictFilePermissions(filePath, opts) {
254
+ const label = opts?.label ?? "file-permissions";
255
+ const warn = opts?.warn ?? ((msg) => console.warn(msg));
256
+ if (process.platform === "win32") {
257
+ try {
258
+ const { execFile } = await import("node:child_process");
259
+ const { promisify } = await import("node:util");
260
+ const execFileAsync = promisify(execFile);
261
+ const user = windowsAccountName();
262
+ if (!user) {
263
+ warn(
264
+ `[${label}] Could not determine the current Windows user for ${filePath}; skipping icacls hardening.`
265
+ );
266
+ return;
267
+ }
268
+ await execFileAsync("icacls", [filePath, "/inheritance:r", "/grant:r", `${user}:(F)`], {
269
+ windowsHide: true
270
+ });
271
+ } catch {
272
+ warn(
273
+ `[${label}] Could not restrict permissions on ${filePath} \u2014 it may be readable by other users on this system.`
274
+ );
275
+ }
276
+ } else {
277
+ try {
278
+ await chmod2(filePath, SECRET_FILE_MODE);
279
+ } catch {
280
+ }
281
+ }
282
+ }
283
+ async function restrictDirPermissions(dirPath, opts) {
284
+ if (process.platform === "win32") {
285
+ await restrictFilePermissions(dirPath, opts);
286
+ return;
287
+ }
288
+ try {
289
+ await chmod2(dirPath, SECRET_DIR_MODE);
290
+ } catch {
291
+ }
292
+ }
293
+ function windowsAccountName() {
294
+ const username = process.env.USERNAME || process.env.USER;
295
+ if (!username || username.includes("\0")) return void 0;
296
+ const domain = process.env.USERDOMAIN;
297
+ if (domain && !domain.includes("\0")) return `${domain}\\${username}`;
298
+ return username;
299
+ }
300
+
242
301
  // src/project-endpoint.ts
243
302
  import * as fsPromises from "node:fs/promises";
244
303
  import * as net from "node:net";
@@ -367,6 +426,8 @@ async function bindProjectEndpoint(options) {
367
426
  }
368
427
  export {
369
428
  PersistenceFsError,
429
+ SECRET_DIR_MODE,
430
+ SECRET_FILE_MODE,
370
431
  assertUnixSocketPathWithinLimit,
371
432
  atomicWrite,
372
433
  bindProjectEndpoint,
@@ -374,6 +435,8 @@ export {
374
435
  createPersistencePrimitives,
375
436
  ensureDir,
376
437
  isProjectEndpointLive,
438
+ restrictDirPermissions,
439
+ restrictFilePermissions,
377
440
  unixSocketPathLimit,
378
441
  withFileLock
379
442
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@wrongstack/persistence",
3
- "version": "0.306.0",
3
+ "version": "0.306.3",
4
4
  "license": "MIT",
5
5
  "description": "Dependency-free filesystem persistence primitives shared across WrongStack packages.",
6
6
  "repository": {