@openclaw/fs-safe 0.4.5 → 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,39 @@
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
+
3
37
  ## 0.4.5 - 2026-07-20
4
38
 
5
39
  ### Highlights
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)) {
@@ -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
  };
package/package.json CHANGED
@@ -1,12 +1,28 @@
1
1
  {
2
2
  "name": "@openclaw/fs-safe",
3
- "version": "0.4.5",
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",