@openclaw/fs-safe 0.4.4 → 0.4.6

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 CHANGED
@@ -1,5 +1,49 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.4.6 - 2026-07-24
4
+
5
+ ### Highlights
6
+
7
+ - Reject foreign-owned or unverifiable Windows files in secure reads while
8
+ preserving supported trusted local owners and exact extended drive paths.
9
+
10
+ ### Security and Correctness
11
+
12
+ - Report Windows owner SIDs and whether the owner is the current user,
13
+ LocalSystem, or built-in Administrators so credential-bearing executable
14
+ checks can reject foreign-owned paths even when their visible DACL is
15
+ read-only. Secure reads enforce the result and remote filesystems fail
16
+ closed.
17
+ - Read Windows owner and DACL data through the underlying .NET security
18
+ descriptor APIs so verification does not depend on PowerShell security-module
19
+ autoloading.
20
+ - Invoke `icacls.exe` with its supported path-only inspection syntax and use
21
+ the live Windows user/domain environment for named ACE classification, so
22
+ ACL verification works on supported Windows hosts instead of failing on the
23
+ invalid `/sid` argument.
24
+ - Normalize trailing Windows install-root separators with a bounded linear
25
+ scan so library-provided environment maps cannot trigger regex backtracking.
26
+
27
+ ### Docs and Tooling
28
+
29
+ - Add the public repository governance baseline, pinned CodeQL analysis,
30
+ package tarball/import validation, and protected tag-driven npm trusted
31
+ publishing with provenance and changelog-derived GitHub releases.
32
+ - Let tag-driven publishing continue from an expected registry miss into npm
33
+ trusted publishing instead of exiting before the publish attempt.
34
+ - Publish the generated tarball through an explicit relative path so npm treats
35
+ it as a local artifact instead of GitHub repository shorthand.
36
+
37
+ ## 0.4.5 - 2026-07-20
38
+
39
+ ### Highlights
40
+
41
+ - Preserve sidecar-lock ownership across filesystem identity drift without weakening legacy stale-lock checks.
42
+
43
+ ### Security and Correctness
44
+
45
+ - Give new sidecar locks an internal random ownership token outside the parsed JSON payload so release remains ownership-checked when virtual filesystems report different descriptor and pathname identities, while legacy locks retain the stricter identity-plus-content check.
46
+
3
47
  ## 0.4.4 - 2026-07-18
4
48
 
5
49
  ### Security and Correctness
package/SECURITY.md CHANGED
@@ -1,12 +1,45 @@
1
1
  # Security Policy
2
2
 
3
- `@openclaw/fs-safe` is a filesystem-safety library, so potential boundary bypasses should be reported privately first.
3
+ If you believe you found a security issue in `@openclaw/fs-safe`, report it
4
+ privately.
4
5
 
5
- Email security reports to Peter Steinberger at `steipete@gmail.com` with:
6
+ ## Reporting
6
7
 
7
- - affected version or commit
8
- - platform and filesystem details
9
- - minimal reproduction steps
10
- - expected impact
8
+ Open a private report through
9
+ [GitHub Security Advisories](https://github.com/openclaw/fs-safe/security/advisories/new)
10
+ or email `security@openclaw.ai`.
11
11
 
12
- Please do not open a public issue for traversal, symlink, hardlink, archive extraction, or credential-file bugs until we have coordinated disclosure.
12
+ Include:
13
+
14
+ 1. affected version or commit
15
+ 2. Node.js version, operating system, filesystem, and mount details
16
+ 3. minimal reproduction
17
+ 4. demonstrated impact
18
+ 5. suggested remediation, if known
19
+
20
+ Do not open a public issue until maintainers have coordinated disclosure.
21
+
22
+ ## Scope
23
+
24
+ Security issues in scope generally include:
25
+
26
+ - traversal or root-confinement bypasses
27
+ - symlink, hardlink, rename, or identity-swap races that cross a documented
28
+ filesystem boundary
29
+ - archive extraction outside the intended destination
30
+ - unsafe secret-file, permission, ownership, or temporary-file behavior
31
+ - package or release-pipeline compromise affecting the published package
32
+
33
+ Reports must demonstrate a concrete boundary bypass or impact. Applications
34
+ remain responsible for operating-system isolation, authorization, and choosing
35
+ trusted root directories unless this package documents and fails to enforce a
36
+ specific invariant.
37
+
38
+ ## Operational Guidance
39
+
40
+ - Keep `@openclaw/fs-safe`, Node.js, and optional archive dependencies current.
41
+ - Treat pathnames and archive contents as untrusted.
42
+ - Use OS-level sandboxing when the threat model includes a hostile process.
43
+ - Pin and review dependency updates before publishing.
44
+
45
+ There is currently no paid bug bounty program.
@@ -1 +1 @@
1
- {"version":3,"file":"local-file-access.d.ts","sourceRoot":"","sources":["../src/local-file-access.ts"],"names":[],"mappings":"AAWA,wBAAgB,0BAA0B,CAAC,QAAQ,EAAE,MAAM,GAAG,OAAO,CAEpE;AAED,wBAAgB,oBAAoB,CAClC,QAAQ,EAAE,MAAM,EAChB,QAAQ,GAAE,MAAM,CAAC,QAA2B,GAC3C,OAAO,CAMT;AAED,wBAAgB,wBAAwB,CACtC,QAAQ,EAAE,MAAM,EAChB,QAAQ,GAAE,MAAM,CAAC,QAA2B,GAC3C,OAAO,CAET;AAED,wBAAgB,0BAA0B,CAAC,QAAQ,EAAE,MAAM,EAAE,KAAK,SAAS,GAAG,IAAI,CAIjF;AAED,wBAAgB,iBAAiB,CAAC,OAAO,EAAE,MAAM,GAAG,MAAM,CAmBzD;AAED,wBAAgB,oBAAoB,CAAC,OAAO,EAAE,MAAM,GAAG,MAAM,GAAG,SAAS,CAMxE;AAED,wBAAgB,uBAAuB,CAAC,MAAM,CAAC,EAAE,MAAM,GAAG,MAAM,GAAG,SAAS,CAgB3E"}
1
+ {"version":3,"file":"local-file-access.d.ts","sourceRoot":"","sources":["../src/local-file-access.ts"],"names":[],"mappings":"AAWA,wBAAgB,0BAA0B,CAAC,QAAQ,EAAE,MAAM,GAAG,OAAO,CAEpE;AAED,wBAAgB,oBAAoB,CAClC,QAAQ,EAAE,MAAM,EAChB,QAAQ,GAAE,MAAM,CAAC,QAA2B,GAC3C,OAAO,CAeT;AAED,wBAAgB,wBAAwB,CACtC,QAAQ,EAAE,MAAM,EAChB,QAAQ,GAAE,MAAM,CAAC,QAA2B,GAC3C,OAAO,CAET;AAED,wBAAgB,0BAA0B,CAAC,QAAQ,EAAE,MAAM,EAAE,KAAK,SAAS,GAAG,IAAI,CAIjF;AAED,wBAAgB,iBAAiB,CAAC,OAAO,EAAE,MAAM,GAAG,MAAM,CAmBzD;AAED,wBAAgB,oBAAoB,CAAC,OAAO,EAAE,MAAM,GAAG,MAAM,GAAG,SAAS,CAMxE;AAED,wBAAgB,uBAAuB,CAAC,MAAM,CAAC,EAAE,MAAM,GAAG,MAAM,GAAG,SAAS,CAgB3E"}
@@ -14,7 +14,15 @@ export function isWindowsNetworkPath(filePath, platform = process.platform) {
14
14
  return false;
15
15
  }
16
16
  const normalized = filePath.replace(/\//g, "\\");
17
- return normalized.startsWith("\\\\?\\UNC\\") || normalized.startsWith("\\\\");
17
+ const extendedDrive = normalized.length >= 7 &&
18
+ normalized.startsWith("\\\\?\\") &&
19
+ /^[a-z]$/i.test(normalized[4] ?? "") &&
20
+ normalized[5] === ":" &&
21
+ normalized[6] === "\\";
22
+ if (extendedDrive) {
23
+ return false;
24
+ }
25
+ return normalized.startsWith("\\\\");
18
26
  }
19
27
  export function isWindowsDriveLetterPath(filePath, platform = process.platform) {
20
28
  return platform === "win32" && /^[A-Za-z]:[\\/]/.test(filePath);
@@ -13,6 +13,12 @@ export type PermissionCheck = {
13
13
  groupWritable: boolean;
14
14
  worldReadable: boolean;
15
15
  groupReadable: boolean;
16
+ /** Canonical Windows owner SID when the owner query succeeds. */
17
+ ownerSid?: string;
18
+ /** Whether the Windows owner is the current user, LocalSystem, or Administrators. */
19
+ ownerTrusted?: boolean;
20
+ /** Owner-query failure detail when Windows ownership could not be verified. */
21
+ ownerError?: string;
16
22
  aclSummary?: string;
17
23
  error?: string;
18
24
  };
@@ -32,6 +38,8 @@ export type SafeStatResult = {
32
38
  };
33
39
  export type WindowsAclEntry = {
34
40
  principal: string;
41
+ /** Canonical principal SID when resolved from Windows. */
42
+ sid?: string;
35
43
  rights: string[];
36
44
  rawRights: string;
37
45
  canRead: boolean;
@@ -75,6 +83,9 @@ export declare function summarizeWindowsAcl(entries: WindowsAclEntry[], env?: No
75
83
  export declare function inspectWindowsAcl(targetPath: string, opts?: {
76
84
  env?: NodeJS.ProcessEnv;
77
85
  exec?: PermissionExec;
86
+ currentUserSid?: string;
87
+ principalSids?: Record<string, string>;
88
+ principalTranslationFailed?: boolean;
78
89
  }): Promise<WindowsAclSummary>;
79
90
  export declare function formatWindowsAclSummary(summary: WindowsAclSummary): string;
80
91
  export declare function formatIcaclsResetCommand(targetPath: string, opts: IcaclsResetCommandOptions): string;
@@ -1 +1 @@
1
- {"version":3,"file":"permissions.d.ts","sourceRoot":"","sources":["../src/permissions.ts"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"permissions.d.ts","sourceRoot":"","sources":["../src/permissions.ts"],"names":[],"mappings":"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"}
@@ -4,6 +4,8 @@ import os from "node:os";
4
4
  import path from "node:path";
5
5
  import { promisify } from "node:util";
6
6
  import { normalizeLowercaseStringOrEmpty } from "./string-coerce.js";
7
+ import { resolveWindowsSystemCommand } from "./windows-command.js";
8
+ import { inspectWindowsOwner, resolveWindowsCurrentUserSid, resolveWindowsPrincipalSids, } from "./windows-owner.js";
7
9
  const execFileAsync = promisify(execFile);
8
10
  const INHERIT_FLAGS = new Set(["I", "OI", "CI", "IO", "NP"]);
9
11
  const WORLD_PRINCIPALS = new Set(["everyone", "users", "builtin\\users", "authenticated users", "nt authority\\authenticated users", "anonymous logon", "nt authority\\anonymous logon", "guests", "builtin\\guests", "interactive", "nt authority\\interactive", "network", "nt authority\\network", "local"]);
@@ -100,7 +102,23 @@ export async function inspectPathPermissions(targetPath, opts) {
100
102
  const bits = modeBits(effectiveMode);
101
103
  const platform = opts?.platform ?? process.platform;
102
104
  if (platform === "win32") {
103
- const acl = await inspectWindowsAcl(targetPath, { env: opts?.env, exec: opts?.exec });
105
+ const owner = await inspectWindowsOwner({
106
+ targetPath,
107
+ env: opts?.env,
108
+ exec: opts?.exec ?? defaultPermissionExec,
109
+ });
110
+ const acl = await inspectWindowsAcl(targetPath, {
111
+ env: opts?.env,
112
+ exec: opts?.exec,
113
+ currentUserSid: owner.currentUserSid,
114
+ principalSids: owner.principalSids,
115
+ principalTranslationFailed: owner.principalTranslationFailed,
116
+ });
117
+ const ownerFields = {
118
+ ...(owner.sid ? { ownerSid: owner.sid } : {}),
119
+ ...(owner.trusted !== undefined ? { ownerTrusted: owner.trusted } : {}),
120
+ ...(owner.error ? { ownerError: owner.error } : {}),
121
+ };
104
122
  if (!acl.ok) {
105
123
  return {
106
124
  ok: true,
@@ -113,6 +131,7 @@ export async function inspectPathPermissions(targetPath, opts) {
113
131
  groupWritable: false,
114
132
  worldReadable: false,
115
133
  groupReadable: false,
134
+ ...ownerFields,
116
135
  error: acl.error,
117
136
  };
118
137
  }
@@ -127,6 +146,7 @@ export async function inspectPathPermissions(targetPath, opts) {
127
146
  groupWritable: acl.untrustedGroup.some((entry) => entry.canWrite),
128
147
  worldReadable: acl.untrustedWorld.some((entry) => entry.canRead),
129
148
  groupReadable: acl.untrustedGroup.some((entry) => entry.canRead),
149
+ ...ownerFields,
130
150
  aclSummary: formatWindowsAclSummary(acl),
131
151
  };
132
152
  }
@@ -204,36 +224,6 @@ function buildTrustedPrincipals(env) {
204
224
  }
205
225
  return trusted;
206
226
  }
207
- function getEnvValueCaseInsensitive(env, name) {
208
- const direct = env[name];
209
- if (direct !== undefined) {
210
- return direct;
211
- }
212
- const lower = name.toLowerCase();
213
- for (const [key, value] of Object.entries(env)) {
214
- if (key.toLowerCase() === lower) {
215
- return value;
216
- }
217
- }
218
- return undefined;
219
- }
220
- function normalizeWindowsInstallRoot(value) {
221
- const trimmed = value?.trim();
222
- if (!trimmed || !path.win32.isAbsolute(trimmed)) {
223
- return null;
224
- }
225
- return trimmed.replace(/[\\/]+$/, "");
226
- }
227
- function resolveWindowsSystemRoot(env) {
228
- const source = env ?? process.env;
229
- return (normalizeWindowsInstallRoot(getEnvValueCaseInsensitive(source, "SystemRoot")) ??
230
- normalizeWindowsInstallRoot(getEnvValueCaseInsensitive(source, "WINDIR")) ??
231
- "C:\\Windows");
232
- }
233
- function resolveWindowsSystemCommand(command, env) {
234
- const root = resolveWindowsSystemRoot(env);
235
- return path.win32.join(root, "System32", command);
236
- }
237
227
  function classifyPrincipal(principal, trustedPrincipals) {
238
228
  const normalized = normalize(principal);
239
229
  if (SID_RE.test(normalized)) {
@@ -290,7 +280,14 @@ function parseAceEntry(entry) {
290
280
  const rights = tokens.filter((token) => !INHERIT_FLAGS.has(token.toUpperCase()));
291
281
  if (rights.length === 0)
292
282
  return null;
293
- return { principal, rights, rawRights, ...rightsFromTokens(rights) };
283
+ const normalizedPrincipal = normalizeSid(principal);
284
+ return {
285
+ principal,
286
+ ...(SID_RE.test(normalizedPrincipal) ? { sid: normalizedPrincipal } : {}),
287
+ rights,
288
+ rawRights,
289
+ ...rightsFromTokens(rights),
290
+ };
294
291
  }
295
292
  export function parseIcaclsOutput(output, targetPath) {
296
293
  const entries = [];
@@ -325,7 +322,7 @@ export function summarizeWindowsAcl(entries, env) {
325
322
  const untrustedWorld = [];
326
323
  const untrustedGroup = [];
327
324
  for (const entry of entries) {
328
- const classification = classifyPrincipal(entry.principal, trustedPrincipals);
325
+ const classification = classifyPrincipal(entry.sid ?? entry.principal, trustedPrincipals);
329
326
  if (classification === "trusted")
330
327
  trusted.push(entry);
331
328
  else if (classification === "world")
@@ -335,37 +332,41 @@ export function summarizeWindowsAcl(entries, env) {
335
332
  }
336
333
  return { trusted, untrustedWorld, untrustedGroup };
337
334
  }
338
- async function resolveCurrentUserSid(exec, env) {
339
- try {
340
- const { stdout, stderr } = await exec(resolveWindowsSystemCommand("whoami.exe", env), [
341
- "/user",
342
- "/fo",
343
- "csv",
344
- "/nh",
345
- ]);
346
- const match = `${stdout}\n${stderr}`.match(/\*?S-\d+-\d+(?:-\d+)+/i);
347
- return match ? normalizeSid(match[0]) : null;
348
- }
349
- catch {
350
- return null;
351
- }
352
- }
353
335
  export async function inspectWindowsAcl(targetPath, opts) {
354
336
  const exec = opts?.exec ?? defaultPermissionExec;
355
337
  try {
338
+ if (opts?.principalTranslationFailed) {
339
+ throw new Error("Windows ACL principal SID translation failed");
340
+ }
356
341
  const { stdout, stderr } = await exec(resolveWindowsSystemCommand("icacls.exe", opts?.env), [
357
342
  targetPath,
358
- "/sid",
359
343
  ]);
360
- const entries = parseIcaclsOutput(`${stdout}\n${stderr}`.trim(), targetPath);
361
- let effectiveEnv = opts?.env;
344
+ let entries = parseIcaclsOutput(`${stdout}\n${stderr}`.trim(), targetPath);
345
+ const unresolvedPrincipals = entries
346
+ .filter((entry) => !entry.sid)
347
+ .map((entry) => entry.principal);
348
+ const principalSids = await resolveWindowsPrincipalSids({
349
+ principals: unresolvedPrincipals,
350
+ known: opts?.principalSids,
351
+ env: opts?.env,
352
+ exec,
353
+ });
354
+ entries = entries.map((entry) => {
355
+ const sid = entry.sid ?? principalSids[entry.principal.toLowerCase()];
356
+ if (!sid) {
357
+ throw new Error(`Windows ACL principal SID could not be verified: ${entry.principal}`);
358
+ }
359
+ return { ...entry, sid };
360
+ });
361
+ let currentUserSid = normalizeSid(opts?.currentUserSid ?? "");
362
+ let effectiveEnv = currentUserSid ? { USERSID: currentUserSid } : undefined;
362
363
  let { trusted, untrustedWorld, untrustedGroup } = summarizeWindowsAcl(entries, effectiveEnv);
363
- const needsUserSidResolution = !effectiveEnv?.USERSID &&
364
- untrustedGroup.some((entry) => SID_RE.test(normalize(entry.principal)));
364
+ const needsUserSidResolution = !currentUserSid && untrustedGroup.some((entry) => entry.sid && !TRUSTED_SIDS.has(entry.sid));
365
365
  if (needsUserSidResolution) {
366
- const currentUserSid = await resolveCurrentUserSid(exec, effectiveEnv);
366
+ currentUserSid =
367
+ (await resolveWindowsCurrentUserSid({ exec, env: opts?.env })) ?? "";
367
368
  if (currentUserSid) {
368
- effectiveEnv = { ...effectiveEnv, USERSID: currentUserSid };
369
+ effectiveEnv = { USERSID: currentUserSid };
369
370
  ({ trusted, untrustedWorld, untrustedGroup } = summarizeWindowsAcl(entries, effectiveEnv));
370
371
  }
371
372
  }
@@ -1 +1 @@
1
- {"version":3,"file":"secure-file.d.ts","sourceRoot":"","sources":["../src/secure-file.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,KAAK,EAAE,MAAM,SAAS,CAAC;AAYrC,OAAO,EAOL,KAAK,eAAe,EACpB,KAAK,sBAAsB,EAC5B,MAAM,kBAAkB,CAAC;AAK1B,MAAM,MAAM,qBAAqB,GAAG;IAClC,QAAQ,EAAE,MAAM,CAAC;IACjB,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,KAAK,CAAC,EAAE,sBAAsB,CAAC;IAC/B,WAAW,CAAC,EAAE,2BAA2B,CAAC;IAC1C,MAAM,CAAC,EAAE,uBAAuB,CAAC;IACjC,EAAE,CAAC,EAAE,mBAAmB,CAAC;CAC1B,CAAC;AAEF,MAAM,MAAM,sBAAsB,GAAG;IACnC,WAAW,CAAC,EAAE,MAAM,EAAE,CAAC;IACvB,YAAY,CAAC,EAAE,OAAO,CAAC;IACvB,gBAAgB,CAAC,EAAE,OAAO,CAAC;CAC5B,CAAC;AAEF,MAAM,MAAM,2BAA2B,GAAG;IACxC,aAAa,CAAC,EAAE,OAAO,CAAC;IACxB,qBAAqB,CAAC,EAAE,OAAO,CAAC;CACjC,CAAC;AAEF,MAAM,MAAM,uBAAuB,GAAG,sBAAsB,CAAC;AAE7D,MAAM,MAAM,mBAAmB,GAAG;IAChC,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,SAAS,CAAC,EAAE,MAAM,CAAC;CACpB,CAAC;AAEF,MAAM,MAAM,oBAAoB,GAAG;IACjC,MAAM,EAAE,MAAM,CAAC;IACf,QAAQ,EAAE,MAAM,CAAC;IACjB,IAAI,EAAE,KAAK,CAAC;IACZ,WAAW,CAAC,EAAE,eAAe,CAAC;CAC/B,CAAC;AA0KF,wBAAsB,cAAc,CAClC,OAAO,EAAE,qBAAqB,GAC7B,OAAO,CAAC,oBAAoB,CAAC,CAc/B"}
1
+ {"version":3,"file":"secure-file.d.ts","sourceRoot":"","sources":["../src/secure-file.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,KAAK,EAAE,MAAM,SAAS,CAAC;AAYrC,OAAO,EAOL,KAAK,eAAe,EACpB,KAAK,sBAAsB,EAC5B,MAAM,kBAAkB,CAAC;AAK1B,MAAM,MAAM,qBAAqB,GAAG;IAClC,QAAQ,EAAE,MAAM,CAAC;IACjB,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,KAAK,CAAC,EAAE,sBAAsB,CAAC;IAC/B,WAAW,CAAC,EAAE,2BAA2B,CAAC;IAC1C,MAAM,CAAC,EAAE,uBAAuB,CAAC;IACjC,EAAE,CAAC,EAAE,mBAAmB,CAAC;CAC1B,CAAC;AAEF,MAAM,MAAM,sBAAsB,GAAG;IACnC,WAAW,CAAC,EAAE,MAAM,EAAE,CAAC;IACvB,YAAY,CAAC,EAAE,OAAO,CAAC;IACvB,gBAAgB,CAAC,EAAE,OAAO,CAAC;CAC5B,CAAC;AAEF,MAAM,MAAM,2BAA2B,GAAG;IACxC,aAAa,CAAC,EAAE,OAAO,CAAC;IACxB,qBAAqB,CAAC,EAAE,OAAO,CAAC;CACjC,CAAC;AAEF,MAAM,MAAM,uBAAuB,GAAG,sBAAsB,CAAC;AAE7D,MAAM,MAAM,mBAAmB,GAAG;IAChC,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,SAAS,CAAC,EAAE,MAAM,CAAC;CACpB,CAAC;AAEF,MAAM,MAAM,oBAAoB,GAAG;IACjC,MAAM,EAAE,MAAM,CAAC;IACf,QAAQ,EAAE,MAAM,CAAC;IACjB,IAAI,EAAE,KAAK,CAAC;IACZ,WAAW,CAAC,EAAE,eAAe,CAAC;CAC/B,CAAC;AAgLF,wBAAsB,cAAc,CAClC,OAAO,EAAE,qBAAqB,GAC7B,OAAO,CAAC,oBAAoB,CAAC,CAc/B"}
@@ -117,6 +117,9 @@ async function assertSecurePermissions(options, stat, realPath) {
117
117
  if (platform === "win32" && permissions.source === "unknown") {
118
118
  throw new FsSafeError("permission-unverified", `${label(options)} ACL verification unavailable on Windows for ${realPath}.`);
119
119
  }
120
+ if (platform === "win32" && permissions.ownerTrusted !== true) {
121
+ throw new FsSafeError(permissions.ownerTrusted === false ? "not-owned" : "permission-unverified", `${label(options)} owner could not be trusted on Windows: ${realPath}`);
122
+ }
120
123
  const writableByOthers = permissions.worldWritable || permissions.groupWritable;
121
124
  const readableByOthers = permissions.worldReadable || permissions.groupReadable;
122
125
  if (writableByOthers || (!options.permissions?.allowReadableByOthers && readableByOthers)) {
@@ -9,6 +9,12 @@ export type SidecarLockSnapshot = {
9
9
  raw?: string;
10
10
  payload: Record<string, unknown> | null;
11
11
  stat?: Stats;
12
+ ownershipToken?: string;
13
+ };
14
+ export declare function readSidecarLockOwnershipToken(raw: string): string | undefined;
15
+ export declare function serializeSidecarLockPayload(payload: Record<string, unknown>): {
16
+ raw: string;
17
+ ownershipToken: string;
12
18
  };
13
19
  export declare function readSidecarLockSnapshot(lockPath: string): Promise<SidecarLockSnapshot | null>;
14
20
  export declare function sidecarLockSnapshotMatches(current: SidecarLockSnapshot, observed: SidecarLockSnapshot): boolean;
@@ -1 +1 @@
1
- {"version":3,"file":"sidecar-lock-reclaim.d.ts","sourceRoot":"","sources":["../src/sidecar-lock-reclaim.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,KAAK,EAAE,MAAM,SAAS,CAAC;AAIrC,MAAM,MAAM,wBAAwB,GAAG;IACrC,QAAQ,EAAE,MAAM,CAAC;IACjB,oBAAoB,EAAE,MAAM,CAAC;IAC7B,GAAG,EAAE,MAAM,CAAC;IACZ,OAAO,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,GAAG,IAAI,CAAC;CACzC,CAAC;AAEF,MAAM,MAAM,mBAAmB,GAAG;IAChC,GAAG,CAAC,EAAE,MAAM,CAAC;IACb,OAAO,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,GAAG,IAAI,CAAC;IACxC,IAAI,CAAC,EAAE,KAAK,CAAC;CACd,CAAC;AAEF,wBAAsB,uBAAuB,CAC3C,QAAQ,EAAE,MAAM,GACf,OAAO,CAAC,mBAAmB,GAAG,IAAI,CAAC,CAoBrC;AAED,wBAAgB,0BAA0B,CACxC,OAAO,EAAE,mBAAmB,EAC5B,QAAQ,EAAE,mBAAmB,GAC5B,OAAO,CAQT;AAED,wBAAsB,4BAA4B,CAChD,QAAQ,EAAE,MAAM,EAChB,QAAQ,EAAE,mBAAmB,GAAG,IAAI,GACnC,OAAO,CAAC,OAAO,CAAC,CAOlB;AAED,wBAAsB,+BAA+B,CACnD,QAAQ,EAAE,MAAM,EAChB,QAAQ,EAAE,mBAAmB,GAAG,IAAI,GACnC,OAAO,CAAC,OAAO,CAAC,CAGlB;AAED,wBAAsB,yBAAyB,CAAC,QAAQ,EAAE,MAAM,GAAG,OAAO,CAAC,OAAO,CAAC,CAUlF;AAED,wBAAsB,6BAA6B,CACjD,aAAa,EAAE,GAAG,CAAC,MAAM,CAAC,EAC1B,gBAAgB,EAAE,MAAM,GACvB,OAAO,CAAC,OAAO,CAAC,CAWlB;AAED,wBAAsB,0BAA0B,CAC9C,aAAa,EAAE,GAAG,CAAC,MAAM,CAAC,EAC1B,gBAAgB,EAAE,MAAM,GACvB,OAAO,CAAC,IAAI,CAAC,CAGf;AAED,wBAAsB,+BAA+B,CAAC,MAAM,EAAE;IAC5D,QAAQ,EAAE,MAAM,CAAC;IACjB,oBAAoB,EAAE,MAAM,CAAC;IAC7B,QAAQ,EAAE,mBAAmB,CAAC;IAC9B,qBAAqB,CAAC,EAAE,CAAC,QAAQ,EAAE,wBAAwB,KAAK,OAAO,GAAG,OAAO,CAAC,OAAO,CAAC,CAAC;CAC5F,GAAG,OAAO,CAAC,SAAS,GAAG,SAAS,GAAG,cAAc,CAAC,CA6BlD"}
1
+ {"version":3,"file":"sidecar-lock-reclaim.d.ts","sourceRoot":"","sources":["../src/sidecar-lock-reclaim.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAE,KAAK,EAAE,MAAM,SAAS,CAAC;AAWrC,MAAM,MAAM,wBAAwB,GAAG;IACrC,QAAQ,EAAE,MAAM,CAAC;IACjB,oBAAoB,EAAE,MAAM,CAAC;IAC7B,GAAG,EAAE,MAAM,CAAC;IACZ,OAAO,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,GAAG,IAAI,CAAC;CACzC,CAAC;AAEF,MAAM,MAAM,mBAAmB,GAAG;IAChC,GAAG,CAAC,EAAE,MAAM,CAAC;IACb,OAAO,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,GAAG,IAAI,CAAC;IACxC,IAAI,CAAC,EAAE,KAAK,CAAC;IACb,cAAc,CAAC,EAAE,MAAM,CAAC;CACzB,CAAC;AAYF,wBAAgB,6BAA6B,CAAC,GAAG,EAAE,MAAM,GAAG,MAAM,GAAG,SAAS,CAE7E;AAED,wBAAgB,2BAA2B,CAAC,OAAO,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,GAAG;IAC7E,GAAG,EAAE,MAAM,CAAC;IACZ,cAAc,EAAE,MAAM,CAAC;CACxB,CAMA;AAED,wBAAsB,uBAAuB,CAC3C,QAAQ,EAAE,MAAM,GACf,OAAO,CAAC,mBAAmB,GAAG,IAAI,CAAC,CAoBrC;AAED,wBAAgB,0BAA0B,CACxC,OAAO,EAAE,mBAAmB,EAC5B,QAAQ,EAAE,mBAAmB,GAC5B,OAAO,CAkBT;AAED,wBAAsB,4BAA4B,CAChD,QAAQ,EAAE,MAAM,EAChB,QAAQ,EAAE,mBAAmB,GAAG,IAAI,GACnC,OAAO,CAAC,OAAO,CAAC,CAOlB;AAED,wBAAsB,+BAA+B,CACnD,QAAQ,EAAE,MAAM,EAChB,QAAQ,EAAE,mBAAmB,GAAG,IAAI,GACnC,OAAO,CAAC,OAAO,CAAC,CAGlB;AAED,wBAAsB,yBAAyB,CAAC,QAAQ,EAAE,MAAM,GAAG,OAAO,CAAC,OAAO,CAAC,CAUlF;AAED,wBAAsB,6BAA6B,CACjD,aAAa,EAAE,GAAG,CAAC,MAAM,CAAC,EAC1B,gBAAgB,EAAE,MAAM,GACvB,OAAO,CAAC,OAAO,CAAC,CAWlB;AAED,wBAAsB,0BAA0B,CAC9C,aAAa,EAAE,GAAG,CAAC,MAAM,CAAC,EAC1B,gBAAgB,EAAE,MAAM,GACvB,OAAO,CAAC,IAAI,CAAC,CAGf;AAED,wBAAsB,+BAA+B,CAAC,MAAM,EAAE;IAC5D,QAAQ,EAAE,MAAM,CAAC;IACjB,oBAAoB,EAAE,MAAM,CAAC;IAC7B,QAAQ,EAAE,mBAAmB,CAAC;IAC9B,qBAAqB,CAAC,EAAE,CAAC,QAAQ,EAAE,wBAAwB,KAAK,OAAO,GAAG,OAAO,CAAC,OAAO,CAAC,CAAC;CAC5F,GAAG,OAAO,CAAC,SAAS,GAAG,SAAS,GAAG,cAAc,CAAC,CA6BlD"}
@@ -1,5 +1,29 @@
1
+ import { randomBytes } from "node:crypto";
1
2
  import fs from "node:fs/promises";
2
3
  import { sameFileIdentity } from "./file-identity.js";
4
+ const SIDECAR_LOCK_OWNERSHIP_TOKEN_BYTES = 16;
5
+ const SIDECAR_LOCK_OWNERSHIP_TOKEN_BITS = SIDECAR_LOCK_OWNERSHIP_TOKEN_BYTES * 8;
6
+ const SIDECAR_LOCK_OWNERSHIP_TOKEN_PREFIX = "\t".repeat(8);
7
+ const SIDECAR_LOCK_OWNERSHIP_TOKEN_PATTERN = new RegExp(`\\n(${SIDECAR_LOCK_OWNERSHIP_TOKEN_PREFIX}[ \\t]{${SIDECAR_LOCK_OWNERSHIP_TOKEN_BITS}})\\n$`);
8
+ function createSidecarLockOwnershipToken() {
9
+ let token = SIDECAR_LOCK_OWNERSHIP_TOKEN_PREFIX;
10
+ for (const byte of randomBytes(SIDECAR_LOCK_OWNERSHIP_TOKEN_BYTES)) {
11
+ for (let bit = 7; bit >= 0; bit -= 1) {
12
+ token += byte & (1 << bit) ? "\t" : " ";
13
+ }
14
+ }
15
+ return token;
16
+ }
17
+ export function readSidecarLockOwnershipToken(raw) {
18
+ return SIDECAR_LOCK_OWNERSHIP_TOKEN_PATTERN.exec(raw)?.[1];
19
+ }
20
+ export function serializeSidecarLockPayload(payload) {
21
+ const ownershipToken = createSidecarLockOwnershipToken();
22
+ return {
23
+ raw: `${JSON.stringify(payload, null, 2)}\n${ownershipToken}\n`,
24
+ ownershipToken,
25
+ };
26
+ }
3
27
  export async function readSidecarLockSnapshot(lockPath) {
4
28
  try {
5
29
  const stat = await fs.lstat(lockPath);
@@ -23,6 +47,14 @@ export async function readSidecarLockSnapshot(lockPath) {
23
47
  }
24
48
  }
25
49
  export function sidecarLockSnapshotMatches(current, observed) {
50
+ if (observed.ownershipToken !== undefined) {
51
+ return (current.stat?.isFile() === true &&
52
+ current.raw !== undefined &&
53
+ observed.raw !== undefined &&
54
+ readSidecarLockOwnershipToken(current.raw) === observed.ownershipToken &&
55
+ readSidecarLockOwnershipToken(observed.raw) === observed.ownershipToken &&
56
+ current.raw === observed.raw);
57
+ }
26
58
  if (observed.stat && current.stat && !sameFileIdentity(observed.stat, current.stat)) {
27
59
  return false;
28
60
  }
@@ -1 +1 @@
1
- {"version":3,"file":"sidecar-lock.d.ts","sourceRoot":"","sources":["../src/sidecar-lock.ts"],"names":[],"mappings":"AAKA,OAAO,EASL,KAAK,wBAAwB,EAC9B,MAAM,2BAA2B,CAAC;AAEnC,YAAY,EAAE,wBAAwB,EAAE,MAAM,2BAA2B,CAAC;AAE1E,MAAM,MAAM,uBAAuB,GAAG;IACpC,OAAO,CAAC,EAAE,MAAM,CAAC;IACjB,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB,SAAS,CAAC,EAAE,OAAO,CAAC;CACrB,CAAC;AAEF,MAAM,MAAM,wBAAwB,GAAG,aAAa,GAAG,qBAAqB,CAAC;AAE7E,MAAM,MAAM,yBAAyB,CAAC,QAAQ,SAAS,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,IAAI;IAChF,UAAU,EAAE,MAAM,CAAC;IACnB,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,OAAO,EAAE,MAAM,CAAC;IAChB,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,KAAK,CAAC,EAAE,uBAAuB,CAAC;IAChC,aAAa,CAAC,EAAE,wBAAwB,CAAC;IACzC,cAAc,CAAC,EAAE,OAAO,CAAC;IACzB,OAAO,EAAE,MAAM,QAAQ,GAAG,OAAO,CAAC,QAAQ,CAAC,CAAC;IAC5C,aAAa,CAAC,EAAE,CAAC,MAAM,EAAE;QACvB,QAAQ,EAAE,MAAM,CAAC;QACjB,oBAAoB,EAAE,MAAM,CAAC;QAC7B,OAAO,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,GAAG,IAAI,CAAC;QACxC,OAAO,EAAE,MAAM,CAAC;QAChB,KAAK,EAAE,MAAM,CAAC;QACd,iBAAiB,EAAE,OAAO,CAAC;KAC5B,KAAK,OAAO,GAAG,OAAO,CAAC,OAAO,CAAC,CAAC;IACjC,qBAAqB,CAAC,EAAE,CAAC,QAAQ,EAAE,wBAAwB,KAAK,OAAO,GAAG,OAAO,CAAC,OAAO,CAAC,CAAC;IAC3F,QAAQ,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;CACpC,CAAC;AAEF,MAAM,MAAM,iBAAiB,GAAG;IAC9B,QAAQ,EAAE,MAAM,CAAC;IACjB,oBAAoB,EAAE,MAAM,CAAC;IAC7B,OAAO,EAAE,MAAM,OAAO,CAAC,IAAI,CAAC,CAAC;IAC7B,CAAC,MAAM,CAAC,YAAY,CAAC,IAAI,OAAO,CAAC,IAAI,CAAC,CAAC;CACxC,CAAC;AAEF,MAAM,MAAM,oBAAoB,GAAG;IACjC,oBAAoB,EAAE,MAAM,CAAC;IAC7B,QAAQ,EAAE,MAAM,CAAC;IACjB,UAAU,EAAE,MAAM,CAAC;IACnB,QAAQ,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;IAClC,YAAY,EAAE,MAAM,OAAO,CAAC,OAAO,CAAC,CAAC;CACtC,CAAC;AAEF,MAAM,MAAM,sBAAsB,CAAC,QAAQ,SAAS,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,IAAI,IAAI,CACjF,yBAAyB,CAAC,QAAQ,CAAC,EACnC,YAAY,CACb,GAAG;IACF,UAAU,CAAC,EAAE,MAAM,CAAC;CACrB,CAAC;AAmKF,wBAAgB,wBAAwB,CAAC,GAAG,EAAE,MAAM;cAgB3B,QAAQ,SAAS,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,WACpD,yBAAyB,CAAC,QAAQ,CAAC,KAC3C,OAAO,CAAC,iBAAiB,CAAC;eAwKL,CAAC,EAAE,QAAQ,SAAS,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,WACxD,yBAAyB,CAAC,QAAQ,CAAC,MACxC,MAAM,OAAO,CAAC,CAAC,CAAC,KACnB,OAAO,CAAC,CAAC,CAAC;iBASW,OAAO,CAAC,IAAI,CAAC;iBAQnB,IAAI;uBAIE,oBAAoB,EAAE;EAW/C;AAED,wBAAsB,eAAe,CAAC,CAAC,EAAE,QAAQ,SAAS,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,EAC/E,UAAU,EAAE,MAAM,EAClB,OAAO,EAAE,sBAAsB,CAAC,QAAQ,CAAC,EACzC,EAAE,EAAE,MAAM,OAAO,CAAC,CAAC,CAAC,GACnB,OAAO,CAAC,CAAC,CAAC,CAMZ"}
1
+ {"version":3,"file":"sidecar-lock.d.ts","sourceRoot":"","sources":["../src/sidecar-lock.ts"],"names":[],"mappings":"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"}
@@ -2,7 +2,7 @@ import fsSync from "node:fs";
2
2
  import fs from "node:fs/promises";
3
3
  import path from "node:path";
4
4
  import { sameFileIdentity } from "./file-identity.js";
5
- import { readSidecarLockSnapshot, releaseSidecarReclaimGuard, removeSidecarLockIfUnchanged, removeStaleSidecarLockIfAllowed, sidecarLockSnapshotStillPresent, sidecarReclaimGuardExists, tryAcquireSidecarReclaimGuard, } from "./sidecar-lock-reclaim.js";
5
+ import { readSidecarLockSnapshot, releaseSidecarReclaimGuard, removeSidecarLockIfUnchanged, removeStaleSidecarLockIfAllowed, serializeSidecarLockPayload, sidecarLockSnapshotMatches, sidecarLockSnapshotStillPresent, sidecarReclaimGuardExists, tryAcquireSidecarReclaimGuard, } from "./sidecar-lock-reclaim.js";
6
6
  const GLOBAL_STATE_KEY = Symbol.for("fsSafe.sidecarLockManagers");
7
7
  function getGlobalManagers() {
8
8
  const globalWithState = globalThis;
@@ -32,16 +32,45 @@ function resolveManagerState(key) {
32
32
  return state;
33
33
  }
34
34
  function snapshotMatchesSync(lockPath, observed) {
35
+ let fd;
35
36
  try {
36
- const stat = fsSync.lstatSync(lockPath);
37
- if (observed.stat && !sameFileIdentity(observed.stat, stat)) {
37
+ const beforeStat = fsSync.lstatSync(lockPath);
38
+ if (!beforeStat.isFile()) {
38
39
  return false;
39
40
  }
40
- return observed.raw === undefined || fsSync.readFileSync(lockPath, "utf8") === observed.raw;
41
+ const openFlags = fsSync.constants.O_RDONLY |
42
+ (process.platform !== "win32" && typeof fsSync.constants.O_NOFOLLOW === "number"
43
+ ? fsSync.constants.O_NOFOLLOW
44
+ : 0) |
45
+ (typeof fsSync.constants.O_NONBLOCK === "number" ? fsSync.constants.O_NONBLOCK : 0);
46
+ fd = fsSync.openSync(lockPath, openFlags);
47
+ const openedStat = fsSync.fstatSync(fd);
48
+ if (!openedStat.isFile()) {
49
+ return false;
50
+ }
51
+ if (observed.raw !== undefined && openedStat.size !== Buffer.byteLength(observed.raw)) {
52
+ return false;
53
+ }
54
+ const raw = fsSync.readFileSync(fd, "utf8");
55
+ const afterStat = fsSync.lstatSync(lockPath);
56
+ if (!afterStat.isFile() || !sameFileIdentity(beforeStat, afterStat)) {
57
+ return false;
58
+ }
59
+ return sidecarLockSnapshotMatches({ raw, payload: null, stat: afterStat }, observed);
41
60
  }
42
61
  catch {
43
62
  return false;
44
63
  }
64
+ finally {
65
+ if (fd !== undefined) {
66
+ try {
67
+ fsSync.closeSync(fd);
68
+ }
69
+ catch {
70
+ // Best-effort process-exit cleanup.
71
+ }
72
+ }
73
+ }
45
74
  }
46
75
  async function resolveNormalizedTargetPath(targetPath) {
47
76
  const resolved = path.resolve(targetPath);
@@ -197,9 +226,9 @@ export function createSidecarLockManager(key) {
197
226
  try {
198
227
  handle = await fs.open(lockPath, "wx");
199
228
  const payload = await options.payload();
200
- const raw = `${JSON.stringify(payload, null, 2)}\n`;
229
+ const { raw, ownershipToken } = serializeSidecarLockPayload(payload);
201
230
  await handle.writeFile(raw, "utf8");
202
- const snapshot = { raw, payload, stat: await handle.stat() };
231
+ const snapshot = { raw, payload, stat: await handle.stat(), ownershipToken };
203
232
  const createdHeld = {
204
233
  count: 1,
205
234
  handle,
@@ -0,0 +1,2 @@
1
+ export declare function resolveWindowsSystemCommand(command: string, env?: NodeJS.ProcessEnv): string;
2
+ //# sourceMappingURL=windows-command.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"windows-command.d.ts","sourceRoot":"","sources":["../src/windows-command.ts"],"names":[],"mappings":"AAwCA,wBAAgB,2BAA2B,CACzC,OAAO,EAAE,MAAM,EACf,GAAG,CAAC,EAAE,MAAM,CAAC,UAAU,GACtB,MAAM,CAER"}
@@ -0,0 +1,34 @@
1
+ import path from "node:path";
2
+ function getEnvValueCaseInsensitive(env, name) {
3
+ const direct = env[name];
4
+ if (direct !== undefined) {
5
+ return direct;
6
+ }
7
+ const lower = name.toLowerCase();
8
+ for (const [key, value] of Object.entries(env)) {
9
+ if (key.toLowerCase() === lower) {
10
+ return value;
11
+ }
12
+ }
13
+ return undefined;
14
+ }
15
+ function normalizeWindowsInstallRoot(value) {
16
+ const trimmed = value?.trim();
17
+ if (!trimmed || !path.win32.isAbsolute(trimmed)) {
18
+ return null;
19
+ }
20
+ let end = trimmed.length;
21
+ while (end > 0 && (trimmed[end - 1] === "\\" || trimmed[end - 1] === "/")) {
22
+ end -= 1;
23
+ }
24
+ return trimmed.slice(0, end);
25
+ }
26
+ function resolveWindowsSystemRoot(env) {
27
+ const source = env ?? process.env;
28
+ return (normalizeWindowsInstallRoot(getEnvValueCaseInsensitive(source, "SystemRoot")) ??
29
+ normalizeWindowsInstallRoot(getEnvValueCaseInsensitive(source, "WINDIR")) ??
30
+ "C:\\Windows");
31
+ }
32
+ export function resolveWindowsSystemCommand(command, env) {
33
+ return path.win32.join(resolveWindowsSystemRoot(env), "System32", command);
34
+ }
@@ -0,0 +1,29 @@
1
+ export type WindowsOwnerExec = (command: string, args: string[]) => Promise<{
2
+ stdout: string;
3
+ stderr: string;
4
+ }>;
5
+ export type WindowsOwnerSummary = {
6
+ sid?: string;
7
+ currentUserSid?: string;
8
+ principalSids?: Record<string, string>;
9
+ principalTranslationFailed?: boolean;
10
+ remote?: boolean;
11
+ trusted?: boolean;
12
+ error?: string;
13
+ };
14
+ export declare function resolveWindowsPrincipalSids(params: {
15
+ principals: string[];
16
+ known?: Record<string, string>;
17
+ env?: NodeJS.ProcessEnv;
18
+ exec: WindowsOwnerExec;
19
+ }): Promise<Record<string, string>>;
20
+ export declare function resolveWindowsCurrentUserSid(params: {
21
+ env?: NodeJS.ProcessEnv;
22
+ exec: WindowsOwnerExec;
23
+ }): Promise<string | null>;
24
+ export declare function inspectWindowsOwner(params: {
25
+ targetPath: string;
26
+ env?: NodeJS.ProcessEnv;
27
+ exec: WindowsOwnerExec;
28
+ }): Promise<WindowsOwnerSummary>;
29
+ //# sourceMappingURL=windows-owner.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"windows-owner.d.ts","sourceRoot":"","sources":["../src/windows-owner.ts"],"names":[],"mappings":"AAEA,MAAM,MAAM,gBAAgB,GAAG,CAC7B,OAAO,EAAE,MAAM,EACf,IAAI,EAAE,MAAM,EAAE,KACX,OAAO,CAAC;IAAE,MAAM,EAAE,MAAM,CAAC;IAAC,MAAM,EAAE,MAAM,CAAA;CAAE,CAAC,CAAC;AAEjD,MAAM,MAAM,mBAAmB,GAAG;IAChC,GAAG,CAAC,EAAE,MAAM,CAAC;IACb,cAAc,CAAC,EAAE,MAAM,CAAC;IACxB,aAAa,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC;IACvC,0BAA0B,CAAC,EAAE,OAAO,CAAC;IACrC,MAAM,CAAC,EAAE,OAAO,CAAC;IACjB,OAAO,CAAC,EAAE,OAAO,CAAC;IAClB,KAAK,CAAC,EAAE,MAAM,CAAC;CAChB,CAAC;AA4DF,wBAAsB,2BAA2B,CAAC,MAAM,EAAE;IACxD,UAAU,EAAE,MAAM,EAAE,CAAC;IACrB,KAAK,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC;IAC/B,GAAG,CAAC,EAAE,MAAM,CAAC,UAAU,CAAC;IACxB,IAAI,EAAE,gBAAgB,CAAC;CACxB,GAAG,OAAO,CAAC,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC,CAyBlC;AAED,wBAAsB,4BAA4B,CAAC,MAAM,EAAE;IACzD,GAAG,CAAC,EAAE,MAAM,CAAC,UAAU,CAAC;IACxB,IAAI,EAAE,gBAAgB,CAAC;CACxB,GAAG,OAAO,CAAC,MAAM,GAAG,IAAI,CAAC,CAWzB;AAED,wBAAsB,mBAAmB,CAAC,MAAM,EAAE;IAChD,UAAU,EAAE,MAAM,CAAC;IACnB,GAAG,CAAC,EAAE,MAAM,CAAC,UAAU,CAAC;IACxB,IAAI,EAAE,gBAAgB,CAAC;CACxB,GAAG,OAAO,CAAC,mBAAmB,CAAC,CA2C/B"}
@@ -0,0 +1,118 @@
1
+ import { resolveWindowsSystemCommand } from "./windows-command.js";
2
+ const SID_RE = /^\*?s-\d+-\d+(-\d+)+$/i;
3
+ const TRUSTED_OWNER_SIDS = new Set(["s-1-5-18", "s-1-5-32-544"]);
4
+ function normalizeSid(value) {
5
+ const normalized = value.trim().toLowerCase();
6
+ return normalized.startsWith("*") ? normalized.slice(1) : normalized;
7
+ }
8
+ function encodePowerShellCommand(source) {
9
+ return Buffer.from(source, "utf16le").toString("base64");
10
+ }
11
+ function windowsOwnerQueryCommand(targetPath) {
12
+ const encodedPath = Buffer.from(targetPath, "utf8").toString("base64");
13
+ return [
14
+ "$ErrorActionPreference='Stop'",
15
+ `$p=[Text.Encoding]::UTF8.GetString([Convert]::FromBase64String('${encodedPath}'))`,
16
+ "$sections=[System.Security.AccessControl.AccessControlSections]::Access -bor [System.Security.AccessControl.AccessControlSections]::Owner",
17
+ "$acl=if([IO.Directory]::Exists($p)){[IO.Directory]::GetAccessControl($p,$sections)}else{[IO.File]::GetAccessControl($p,$sections)}",
18
+ "$ownerSid=$acl.GetOwner([System.Security.Principal.SecurityIdentifier]).Value",
19
+ "$currentSid=[System.Security.Principal.WindowsIdentity]::GetCurrent().User.Value",
20
+ "$root=[IO.Path]::GetPathRoot($p)",
21
+ "$extendedDrive=$p.Length -ge 7 -and $p.StartsWith('\\\\?\\') -and [char]::IsLetter($p[4]) -and $p[5] -eq ':' -and $p[6] -eq '\\'",
22
+ "$driveRoot=if($extendedDrive){$p.Substring(4,3)}else{$root}",
23
+ "$namespacePath=$p.StartsWith('\\\\')",
24
+ "$remote=($namespacePath -and -not $extendedDrive) -or ([IO.DriveInfo]::new($driveRoot).DriveType -eq [IO.DriveType]::Network)",
25
+ "$rules=$acl.GetAccessRules($true,$true,[System.Security.Principal.SecurityIdentifier])",
26
+ "$principalSids=@($rules|ForEach-Object {$identity=$_.IdentityReference;$sid=$identity.Value;@{name=$sid;sid=$sid};try{@{name=$identity.Translate([System.Security.Principal.NTAccount]).Value;sid=$sid}}catch{}})",
27
+ "@{ownerSid=$ownerSid;currentUserSid=$currentSid;principalSids=$principalSids;principalTranslationFailed=$false;remote=$remote}|ConvertTo-Json -Depth 4 -Compress",
28
+ ].join(";");
29
+ }
30
+ function windowsPrincipalQueryCommand(principals) {
31
+ const encodedPrincipals = Buffer.from(JSON.stringify(principals), "utf8").toString("base64");
32
+ return [
33
+ "$ErrorActionPreference='Stop'",
34
+ `$names=[Text.Encoding]::UTF8.GetString([Convert]::FromBase64String('${encodedPrincipals}'))|ConvertFrom-Json`,
35
+ "$rows=@($names|ForEach-Object {@{name=$_;sid=(New-Object System.Security.Principal.NTAccount($_)).Translate([System.Security.Principal.SecurityIdentifier]).Value}})",
36
+ "ConvertTo-Json -InputObject $rows -Compress",
37
+ ].join(";");
38
+ }
39
+ function parsePrincipalSidRows(value) {
40
+ const rows = Array.isArray(value) ? value : value ? [value] : [];
41
+ const result = {};
42
+ for (const row of rows) {
43
+ if (!row || typeof row !== "object") {
44
+ continue;
45
+ }
46
+ const name = "name" in row && typeof row.name === "string" ? row.name.trim() : "";
47
+ const sid = "sid" in row && typeof row.sid === "string" ? normalizeSid(row.sid) : "";
48
+ if (name && SID_RE.test(sid)) {
49
+ result[name.toLowerCase()] = sid;
50
+ }
51
+ }
52
+ return result;
53
+ }
54
+ export async function resolveWindowsPrincipalSids(params) {
55
+ const principals = [...new Set(params.principals.map((value) => value.trim()).filter(Boolean))];
56
+ const known = Object.fromEntries(Object.entries(params.known ?? {}).map(([name, sid]) => [name.toLowerCase(), normalizeSid(sid)]));
57
+ const unresolved = principals.filter((principal) => !known[principal.toLowerCase()]);
58
+ if (unresolved.length === 0) {
59
+ return known;
60
+ }
61
+ const command = resolveWindowsSystemCommand(String.raw `WindowsPowerShell\v1.0\powershell.exe`, params.env);
62
+ const { stdout } = await params.exec(command, [
63
+ "-NoLogo",
64
+ "-NoProfile",
65
+ "-NonInteractive",
66
+ "-EncodedCommand",
67
+ encodePowerShellCommand(windowsPrincipalQueryCommand(unresolved)),
68
+ ]);
69
+ const resolved = { ...known, ...parsePrincipalSidRows(JSON.parse(stdout.trim())) };
70
+ if (principals.some((principal) => !resolved[principal.toLowerCase()])) {
71
+ throw new Error("Windows ACL principal translation returned incomplete SID data");
72
+ }
73
+ return resolved;
74
+ }
75
+ export async function resolveWindowsCurrentUserSid(params) {
76
+ try {
77
+ const { stdout, stderr } = await params.exec(resolveWindowsSystemCommand("whoami.exe", params.env), ["/user", "/fo", "csv", "/nh"]);
78
+ const match = `${stdout}\n${stderr}`.match(/\*?S-\d+-\d+(?:-\d+)+/i);
79
+ return match ? normalizeSid(match[0]) : null;
80
+ }
81
+ catch {
82
+ return null;
83
+ }
84
+ }
85
+ export async function inspectWindowsOwner(params) {
86
+ try {
87
+ const command = resolveWindowsSystemCommand(String.raw `WindowsPowerShell\v1.0\powershell.exe`, params.env);
88
+ const { stdout } = await params.exec(command, [
89
+ "-NoLogo",
90
+ "-NoProfile",
91
+ "-NonInteractive",
92
+ "-EncodedCommand",
93
+ encodePowerShellCommand(windowsOwnerQueryCommand(params.targetPath)),
94
+ ]);
95
+ const parsed = JSON.parse(stdout.trim());
96
+ const ownerSid = typeof parsed.ownerSid === "string" && SID_RE.test(parsed.ownerSid)
97
+ ? normalizeSid(parsed.ownerSid)
98
+ : undefined;
99
+ const currentUserSid = typeof parsed.currentUserSid === "string" && SID_RE.test(parsed.currentUserSid)
100
+ ? normalizeSid(parsed.currentUserSid)
101
+ : undefined;
102
+ if (!ownerSid || !currentUserSid) {
103
+ return { error: "Windows owner query returned invalid SID data" };
104
+ }
105
+ const remote = parsed.remote === true;
106
+ return {
107
+ sid: ownerSid,
108
+ currentUserSid,
109
+ principalSids: parsePrincipalSidRows(parsed.principalSids),
110
+ principalTranslationFailed: parsed.principalTranslationFailed === true,
111
+ remote,
112
+ trusted: !remote && (ownerSid === currentUserSid || TRUSTED_OWNER_SIDS.has(ownerSid)),
113
+ };
114
+ }
115
+ catch (err) {
116
+ return { error: String(err) };
117
+ }
118
+ }
@@ -10,7 +10,8 @@ cd fs-safe
10
10
  pnpm install
11
11
  ```
12
12
 
13
- Node 22 or newer. The dev toolchain uses pnpm; `npm install` works too but pnpm is what the lockfile is keyed against.
13
+ Node 22 or newer. The dev toolchain and lockfile use pnpm; use the package
14
+ manager version declared in `package.json`.
14
15
 
15
16
  ## Build
16
17
 
@@ -34,9 +35,16 @@ pnpm test test/archive.test.ts
34
35
 
35
36
  Use `vi.mock` sparingly. Most tests should drive real disk operations in a `mkdtemp`-created scratch directory, asserting on observable behavior. The library has [test hooks](testing.md) for the rare cases where you need to inject a TOCTOU race deterministically.
36
37
 
37
- ## Format and types
38
+ ## Checks
38
39
 
39
- The repo doesn't ship a separate lint config; `tsc --noEmit` (run by `pnpm build`) is the type gate. Format with your editor's TypeScript Language Server defaults — keep diffs tight.
40
+ Run the complete repository gate before handoff:
41
+
42
+ ```bash
43
+ pnpm check
44
+ ```
45
+
46
+ This runs the filesystem boundary checks, build, tests, and package
47
+ tarball/import validation.
40
48
 
41
49
  ## Docs
42
50
 
@@ -69,7 +77,14 @@ Small, focused PRs land faster. The general shape:
69
77
 
70
78
  ## Releases
71
79
 
72
- Release process lives in the maintainer's runbook. External contributors don't need to do anything beyond getting the PR merged.
80
+ Maintainers publish from a protected `vX.Y.Z` tag on `main` through
81
+ `.github/workflows/release.yml`. The workflow requires the package version and a
82
+ dated `CHANGELOG.md` section to match the tag, then publishes with npm trusted
83
+ publishing and provenance before creating the GitHub release.
84
+
85
+ External contributors do not need to do anything beyond getting the pull
86
+ request merged. Maintainers must not publish locally or add npm automation
87
+ tokens.
73
88
 
74
89
  ## Reporting security issues
75
90
 
@@ -38,7 +38,7 @@ isWorldReadable(bits);
38
38
  isGroupReadable(bits);
39
39
  ```
40
40
 
41
- `inspectPathPermissions()` follows symlink targets for the effective mode but tells you whether the original path was a symlink. On POSIX it reports owner/group/world bits. On Windows it delegates to the ACL helpers below.
41
+ `inspectPathPermissions()` follows symlink targets for the effective mode but tells you whether the original path was a symlink. On POSIX it reports owner/group/world bits. On Windows it delegates to the ACL helpers below and also reports `ownerSid` plus `ownerTrusted` when ownership can be verified. `ownerTrusted` is true only for a local volume owned by the current user, LocalSystem, or built-in Administrators; remote filesystems fail closed. Secure reads and callers that protect credential-bearing execution require `ownerTrusted === true`.
42
42
 
43
43
  ## Advanced Windows ACL helpers
44
44
 
@@ -82,6 +82,9 @@ type PermissionCheck = {
82
82
  groupWritable: boolean;
83
83
  worldReadable: boolean;
84
84
  groupReadable: boolean;
85
+ ownerSid?: string;
86
+ ownerTrusted?: boolean;
87
+ ownerError?: string;
85
88
  aclSummary?: string;
86
89
  error?: string;
87
90
  };
@@ -23,6 +23,10 @@ The lock file sits next to the protected resource. If a process crashes mid-lock
23
23
 
24
24
  The library installs a `process.on("exit")` handler that releases all currently-held locks synchronously, so well-behaved exits leave no stale sidecars. Crashed holders leave their sidecar behind; recover only after an application-owned liveness policy proves the holder cannot still be writing.
25
25
 
26
+ Each new sidecar also carries an internal random ownership token encoded as JSON trailing whitespace. `JSON.parse()` and every payload callback still see exactly the caller-provided object. Only the process that successfully created the sidecar keeps that token as release authority; merely reading token-shaped bytes from disk does not enable this mode. Release compares the in-memory token and exact serialized bytes, and requires the pathname to remain a regular file, instead of requiring an opened descriptor and pathname lookup to report the same inode identity. This preserves ownership checks on filesystems such as Docker Desktop VirtioFS where those two views can legitimately differ. Sidecars created by older releases have no token and retain the legacy identity-plus-content check.
27
+
28
+ The raw sidecar bytes are not a canonical JSON representation: tools that trim or rewrite the trailing whitespace invalidate the ownership token, so release leaves the changed sidecar in place and fails closed. The token distinguishes cooperating acquisitions; it is not a secret and does not make pathname compare-and-remove atomic against a hostile process that can replace files outside the lock protocol.
29
+
26
30
  ## API
27
31
 
28
32
  ```ts
package/docs/writing.md CHANGED
@@ -250,7 +250,7 @@ await fs.write("state.json", body); // succeeds on rclone FUSE
250
250
 
251
251
  **Security note.** `verify-content-with-lock` proves that the bytes observed after rename match the requested write and prevents *cooperating* writers from interleaving. It does **not** prove that the destination still names the temp-file object, retain the Python helper's fd-relative parent pinning, or stop a same-UID process that ignores the advisory lock. Do not use this option on directories writable by untrusted same-UID processes. Strict identity verification remains the default.
252
252
 
253
- Lock recovery is fail-closed. If a process crashes and leaves the root-level `.fs-safe-write-<sha256>.lock`, a later write reports the stale lock instead of deleting it based on a host-local PID. Recover only under external authority that excludes every competing writer; see [File lock](sidecar-lock.md#stale-recovery-is-fail-closed).
253
+ Lock recovery is fail-closed. If a process crashes and leaves the root-level `.fs-safe-write-<sha256>.lock`, a later write reports the stale lock instead of deleting it based on a host-local PID. Recover only under external authority that excludes every competing writer; see [File lock](sidecar-lock.md#stale-recovery-guarded-remove-if-unchanged).
254
254
 
255
255
  ## See also
256
256
 
package/package.json CHANGED
@@ -1,12 +1,28 @@
1
1
  {
2
2
  "name": "@openclaw/fs-safe",
3
- "version": "0.4.4",
3
+ "version": "0.4.6",
4
4
  "description": "Capability-style filesystem roots for Node.js apps that handle untrusted relative paths.",
5
+ "keywords": [
6
+ "filesystem",
7
+ "openclaw",
8
+ "path-traversal",
9
+ "security",
10
+ "typescript"
11
+ ],
12
+ "homepage": "https://fs-safe.io",
13
+ "bugs": {
14
+ "url": "https://github.com/openclaw/fs-safe/issues"
15
+ },
5
16
  "license": "MIT",
17
+ "author": "OpenClaw Team <dev@openclaw.ai>",
6
18
  "repository": {
7
19
  "type": "git",
8
20
  "url": "git+https://github.com/openclaw/fs-safe.git"
9
21
  },
22
+ "publishConfig": {
23
+ "access": "public",
24
+ "provenance": true
25
+ },
10
26
  "files": [
11
27
  "dist/**/*.js",
12
28
  "dist/**/*.d.ts",
@@ -95,7 +111,8 @@
95
111
  "./test-hooks": {
96
112
  "types": "./dist/test-hooks.d.ts",
97
113
  "default": "./dist/test-hooks.js"
98
- }
114
+ },
115
+ "./package.json": "./package.json"
99
116
  },
100
117
  "scripts": {
101
118
  "benchmark": "node scripts/benchmark.mjs",
@@ -106,9 +123,11 @@
106
123
  "test": "vitest run",
107
124
  "test:coverage": "vitest run --coverage",
108
125
  "test:security": "vitest run test/fs-safe.test.ts test/read-boundary-bypass.test.ts test/write-boundary-bypass.test.ts test/additional-boundary-bypass.test.ts test/adversarial-boundary-payloads.test.ts",
109
- "check": "pnpm lint:file-size && pnpm lint:fs-boundary && pnpm build && pnpm test",
126
+ "check": "pnpm lint:file-size && pnpm lint:fs-boundary && pnpm build && pnpm test && node scripts/check-pack.mjs",
110
127
  "docs:site": "node scripts/build-docs-site.mjs",
128
+ "pack:check": "pnpm build && node scripts/check-pack.mjs",
111
129
  "check:changed": "pnpm run check",
130
+ "release:notes": "node scripts/release-notes.mjs",
112
131
  "test:changed": "pnpm run test",
113
132
  "crabbox:hydrate": "crabbox actions hydrate",
114
133
  "crabbox:run": "crabbox run",