diff --git a/doc/api/permissions.md b/doc/api/permissions.md index b677013d797..47a8e9589cb 100644 --- a/doc/api/permissions.md +++ b/doc/api/permissions.md @@ -38,6 +38,15 @@ changes: description: This feature is no longer experimental. --> + +When the Permission Model is enabled in the parent process, creating a +`worker_threads.Worker` with an explicit `execArgv` option (including an empty +array) no longer allows the worker to obtain a wider permission-related grant +set than the parent. Non-permission `execArgv` flags are unaffected. This is a +breaking change relative to earlier releases where `execArgv: []` could drop +the parent's Permission Model grants. + + > Stability: 2 - Stable The Node.js Permission Model is a mechanism for restricting access to specific diff --git a/doc/api/worker_threads.md b/doc/api/worker_threads.md index e7b6b19b355..f5ce5b979ea 100644 --- a/doc/api/worker_threads.md +++ b/doc/api/worker_threads.md @@ -1605,6 +1605,13 @@ changes: description: The `resourceLimits` option was introduced. --> + +**Permission Model (breaking):** If the parent process runs with the +Permission Model enabled, an explicit `execArgv` (including `[]`) does not +disable or exceed the parent's permission-related grants. See the +[Permission Model](permissions.md#permission-model) documentation. + + * `filename` {string|URL} The path to the Worker's main script or module. Must be either an absolute path or a relative path (i.e. relative to the current working directory) starting with `./` or `../`, or a WHATWG `URL` diff --git a/src/node_worker.cc b/src/node_worker.cc index edc21e7e556..b82cdb3181e 100644 --- a/src/node_worker.cc +++ b/src/node_worker.cc @@ -11,6 +11,7 @@ #include "node_profiling.h" #include "node_snapshot_builder.h" #include "permission/permission.h" +#include "path.h" #include "util-inl.h" #include "v8-cppgc.h" #include "v8-profiler.h" @@ -504,6 +505,264 @@ Worker::~Worker() { Debug(this, "Worker %llu destroyed", thread_id_.id); } + +// SEMVER-MAJOR: Permission ceiling for Worker explicit execArgv. +static bool WorkerConfiguredPermission(const EnvironmentOptions* w) { + if (w->permission || w->permission_audit) { + return true; + } + if (!w->allow_fs_read.empty() || !w->allow_fs_write.empty()) { + return true; + } + return w->allow_addons || w->allow_inspector || w->allow_child_process || + w->allow_net || w->allow_wasi || w->allow_ffi || + w->allow_openssl_store || w->allow_worker_threads; +} + +static void ApplyParentPermissionCeiling(EnvironmentOptions* w, + const EnvironmentOptions* parent) { + w->permission = true; + w->permission_audit = parent->permission_audit; + w->allow_addons = parent->allow_addons; + w->allow_inspector = parent->allow_inspector; + w->allow_child_process = parent->allow_child_process; + w->allow_net = parent->allow_net; + w->allow_wasi = parent->allow_wasi; + w->allow_ffi = parent->allow_ffi; + w->allow_openssl_store = parent->allow_openssl_store; + w->allow_worker_threads = parent->allow_worker_threads; + w->allow_fs_read = parent->allow_fs_read; + w->allow_fs_write = parent->allow_fs_write; +} + +static void NormalizePathForCompare(std::string* s) { + while (s->size() > 1 && (s->back() == '/' || s->back() == '\\')) { + s->pop_back(); + } +#ifdef _WIN32 + for (char& c : *s) { + if (c >= 'A' && c <= 'Z') { + c = static_cast(c - 'A' + 'a'); + } + if (c == '/') { + c = '\\'; + } + } +#endif +} + +static std::string ResolveForCompare(Environment* env, const std::string& in) { + if (in.empty() || in == "*") { + return in; + } + std::string resolved = + PathResolve(env, std::vector{std::string_view(in)}); + if (resolved.empty()) { + resolved = in; + } + NormalizePathForCompare(&resolved); + return resolved; +} + +// Create-time prefix filter; runtime FSPermission is authoritative. +static bool PathCoveredByParentEntry(Environment* env, + const std::string& parent_raw, + const std::string& requested_raw) { + if (parent_raw == "*") { + return true; + } + const std::string parent = ResolveForCompare(env, parent_raw); + const std::string requested = ResolveForCompare(env, requested_raw); + if (parent.empty()) { + return false; + } + if (requested == parent) { + return true; + } + if (requested.size() <= parent.size()) { + return false; + } + if (requested.compare(0, parent.size(), parent) != 0) { + return false; + } + const char next = requested[parent.size()]; + return next == '/' || next == '\\'; +} + +static bool ParentListHasWildcard(const std::vector& parent) { + for (const std::string& entry : parent) { + if (entry == "*") { + return true; + } + } + return false; +} + +static void FilterPathListToParentSubset( + Environment* env, + EnvironmentOptions* w, + std::vector* worker, + const std::vector& parent) { + if (worker == nullptr) { + return; + } + if (worker->empty()) { + if (w->permission || w->permission_audit) { + return; + } + *worker = parent; + return; + } + if (ParentListHasWildcard(parent)) { + return; + } + std::vector out; + out.reserve(worker->size()); + bool saw_star = false; + for (const std::string& wpath : *worker) { + if (wpath == "*") { + saw_star = true; + continue; + } + for (const std::string& entry : parent) { + if (PathCoveredByParentEntry(env, entry, wpath)) { + out.push_back(wpath); + break; + } + } + } + if (saw_star && out.empty()) { + *worker = parent; + return; + } + *worker = std::move(out); +} + +static void IntersectPermissionGrants(Environment* env, + EnvironmentOptions* w, + const EnvironmentOptions* parent) { + w->permission = true; + w->permission_audit = w->permission_audit || parent->permission_audit; + w->allow_addons = w->allow_addons && parent->allow_addons; + w->allow_inspector = w->allow_inspector && parent->allow_inspector; + w->allow_child_process = + w->allow_child_process && parent->allow_child_process; + w->allow_net = w->allow_net && parent->allow_net; + w->allow_wasi = w->allow_wasi && parent->allow_wasi; + w->allow_ffi = w->allow_ffi && parent->allow_ffi; + w->allow_openssl_store = + w->allow_openssl_store && parent->allow_openssl_store; + w->allow_worker_threads = + w->allow_worker_threads && parent->allow_worker_threads; + FilterPathListToParentSubset(env, w, &w->allow_fs_read, parent->allow_fs_read); + FilterPathListToParentSubset( + env, w, &w->allow_fs_write, parent->allow_fs_write); +} + +static void ClampWorkerPermissionToParent(Environment* env, + PerIsolateOptions* worker_opts) { + if (worker_opts == nullptr || !env->permission()->enabled()) { + return; + } + EnvironmentOptions* parent = env->isolate_data()->options()->get_per_env_options(); + EnvironmentOptions* w = worker_opts->get_per_env_options(); + if (parent == nullptr || w == nullptr) { + return; + } + if (!WorkerConfiguredPermission(w)) { + ApplyParentPermissionCeiling(w, parent); + } else { + IntersectPermissionGrants(env, w, parent); + } +} + +static bool IsPermissionCliToken(const std::string& a) { + if (a == "--permission" || a == "--permission-audit") { + return true; + } + static const char* kFlags[] = { + "--allow-fs-read", + "--allow-fs-write", + "--allow-addons", + "--allow-inspector", + "--allow-child-process", + "--allow-net", + "--allow-wasi", + "--allow-ffi", + "--allow-openssl-store", + "--allow-worker", + }; + for (const char* flag : kFlags) { + const size_t n = std::char_traits::length(flag); + if (a == flag) { + return true; + } + if (a.size() > n && a.compare(0, n, flag) == 0 && a[n] == '=') { + return true; + } + } + return false; +} + +// Rebuild argv from clamped options. Do not assume argv[0] layout from Parse. +static void RebuildExecArgvOutFromPermissionOptions( + PerIsolateOptions* worker_opts, std::vector* exec_argv_out) { + if (worker_opts == nullptr || exec_argv_out == nullptr) { + return; + } + EnvironmentOptions* w = worker_opts->get_per_env_options(); + if (w == nullptr || !w->permission) { + return; + } + + std::vector out; + out.emplace_back(""); // program-name placeholder for parsers that expect it + for (const std::string& tok : *exec_argv_out) { + if (tok.empty()) { + continue; + } + if (!IsPermissionCliToken(tok)) { + out.push_back(tok); + } + } + out.push_back("--permission"); + if (w->permission_audit) { + out.push_back("--permission-audit"); + } + if (w->allow_addons) { + out.push_back("--allow-addons"); + } + if (w->allow_inspector) { + out.push_back("--allow-inspector"); + } + if (w->allow_child_process) { + out.push_back("--allow-child-process"); + } + if (w->allow_net) { + out.push_back("--allow-net"); + } + if (w->allow_wasi) { + out.push_back("--allow-wasi"); + } + if (w->allow_ffi) { + out.push_back("--allow-ffi"); + } + if (w->allow_openssl_store) { + out.push_back("--allow-openssl-store"); + } + if (w->allow_worker_threads) { + out.push_back("--allow-worker"); + } + for (const std::string& path : w->allow_fs_read) { + out.push_back("--allow-fs-read=" + path); + } + for (const std::string& path : w->allow_fs_write) { + out.push_back("--allow-fs-write=" + path); + } + *exec_argv_out = std::move(out); +} + + void Worker::New(const FunctionCallbackInfo& args) { Environment* env = Environment::GetCurrent(args); THROW_IF_INSUFFICIENT_PERMISSIONS( @@ -683,6 +942,14 @@ void Worker::New(const FunctionCallbackInfo& args) { per_isolate_opts = env->isolate_data()->options()->Clone(); } + // Only explicit execArgv (including []): clamp options + rebuild argv. + // Default Worker path (clone parent) is left unchanged. + if (env->permission()->enabled() && per_isolate_opts && args[2]->IsArray()) { + ClampWorkerPermissionToParent(env, per_isolate_opts.get()); + RebuildExecArgvOutFromPermissionOptions(per_isolate_opts.get(), + &exec_argv_out); + } + // Internal workers should not wait for inspector frontend to connect or // break on the first line of internal scripts. Module loader threads are // essential to load user codes and must not be blocked by the inspector diff --git a/test/parallel/test-permission-worker-empty-execargv.js b/test/parallel/test-permission-worker-empty-execargv.js new file mode 100644 index 00000000000..6955a4d41da --- /dev/null +++ b/test/parallel/test-permission-worker-empty-execargv.js @@ -0,0 +1,589 @@ +'use strict'; +const common = require('../common'); +const { isMainThread } = require('worker_threads'); + +if (!isMainThread) common.skip('This test only works on a main thread'); +if (!common.hasCrypto) common.skip('no crypto'); + +const assert = require('assert'); +const fs = require('fs'); +const path = require('path'); +const { spawnSync } = require('child_process'); +const tmpdir = require('../common/tmpdir'); + +tmpdir.refresh(); + +const allowedDir = tmpdir.path; +const allowedFile = path.join(allowedDir, 'ok.txt'); +const subDir = path.join(allowedDir, 'sub'); +const subFile = path.join(subDir, 'nested.txt'); +fs.mkdirSync(subDir, { recursive: true }); + +const deniedDir = fs.mkdtempSync( + path.join(path.dirname(tmpdir.path), 'perm-deny-'), +); +const deniedFile = path.join(deniedDir, 'secret.txt'); + +fs.writeFileSync(allowedFile, 'allowed\n'); +fs.writeFileSync(subFile, 'nested\n'); +fs.writeFileSync(deniedFile, 'secret\n'); + +const allowedDirSlash = allowedDir.endsWith(path.sep) + ? allowedDir + : allowedDir + path.sep; + +function runWithParent(parentArgs, workerBody, execArgvFragment) { + const code = ` + const { Worker } = require('worker_threads'); + const w = new Worker(${JSON.stringify(workerBody)}, { + eval: true, + ${execArgvFragment} + }); + w.on('message', (msg) => { + process.stdout.write(JSON.stringify(msg) + '\\n'); + process.exit(0); + }); + w.on('error', (err) => { console.error(err); process.exit(1); }); + `; + return spawnSync( + process.execPath, + parentArgs.concat(['-e', code]), + { encoding: 'utf8', timeout: 30000, env: { ...process.env } }, + ); +} + +function baseParent(extra = []) { + return [ + '--permission', + `--allow-fs-read=${allowedDir}`, + '--allow-worker', + ...extra, + ]; +} + +function parseLastJsonLine(stdout) { + const lines = stdout.trim().split('\n').filter(Boolean); + assert.ok(lines.length > 0, `expected JSON, got: ${stdout}`); + return JSON.parse(lines[lines.length - 1]); +} + +function workerRead(filePath) { + return ` + const { parentPort } = require('worker_threads'); + const fs = require('fs'); + try { + parentPort.postMessage({ + ok: true, + data: fs.readFileSync(${JSON.stringify(filePath)}, 'utf8'), + }); + } catch (err) { + parentPort.postMessage({ ok: false, code: err.code }); + } + `; +} + +function workerWrite(filePath) { + return ` + const { parentPort } = require('worker_threads'); + const fs = require('fs'); + try { + fs.writeFileSync(${JSON.stringify(filePath)}, 'w'); + parentPort.postMessage({ ok: true }); + } catch (err) { + parentPort.postMessage({ ok: false, code: err.code }); + } + `; +} + +function assertAccessDenied(msg) { + assert.strictEqual(msg.ok, false, JSON.stringify(msg)); + assert.strictEqual(msg.code, 'ERR_ACCESS_DENIED'); +} + +function assertOk(msg) { + assert.strictEqual(msg.ok, true, JSON.stringify(msg)); +} + +try { + // --- default Worker (no execArgv): inherit, denied blocked --- + { + const r = runWithParent(baseParent(), workerRead(deniedFile), ''); + assert.strictEqual(r.status, 0, r.stderr); + assertAccessDenied(parseLastJsonLine(r.stdout)); + } + + // --- execArgv: [] ceiling --- + { + const denied = runWithParent( + baseParent(), + workerRead(deniedFile), + 'execArgv: [],', + ); + assert.strictEqual(denied.status, 0, denied.stderr); + assertAccessDenied(parseLastJsonLine(denied.stdout)); + + const allowed = runWithParent( + baseParent(), + workerRead(allowedFile), + 'execArgv: [],', + ); + assert.strictEqual(allowed.status, 0, allowed.stderr); + assertOk(parseLastJsonLine(allowed.stdout)); + + const nested = runWithParent( + baseParent(), + workerRead(subFile), + 'execArgv: [],', + ); + assert.strictEqual(nested.status, 0, nested.stderr); + assertOk(parseLastJsonLine(nested.stdout)); + } + + // --- non-permission flag preserved --- + { + const denied = runWithParent( + baseParent(), + workerRead(deniedFile), + 'execArgv: ["--no-warnings"],', + ); + assert.strictEqual(denied.status, 0, denied.stderr); + assertAccessDenied(parseLastJsonLine(denied.stdout)); + + const allowed = runWithParent( + baseParent(), + workerRead(allowedFile), + 'execArgv: ["--no-warnings"],', + ); + assert.strictEqual(allowed.status, 0, allowed.stderr); + assertOk(parseLastJsonLine(allowed.stdout)); + } + + // --- permission on, no fs-read → empty grants --- + { + const frag = `execArgv: ${JSON.stringify([ + '--permission', + '--allow-worker', + ])},`; + const d = runWithParent(baseParent(), workerRead(deniedFile), frag); + assert.strictEqual(d.status, 0, d.stderr); + assertAccessDenied(parseLastJsonLine(d.stdout)); + const a = runWithParent(baseParent(), workerRead(allowedFile), frag); + assert.strictEqual(a.status, 0, a.stderr); + assertAccessDenied(parseLastJsonLine(a.stdout)); + } + + // --- escalate * clamped: denied blocked, allowed still ok via parent subset --- + { + const frag = `execArgv: ${JSON.stringify([ + '--permission', + '--allow-fs-read=*', + '--allow-worker', + ])},`; + const denied = runWithParent( + baseParent(), + workerRead(deniedFile), + frag, + ); + assert.strictEqual(denied.status, 0, denied.stderr); + assertAccessDenied(parseLastJsonLine(denied.stdout)); + + const allowed = runWithParent( + baseParent(), + workerRead(allowedFile), + frag, + ); + assert.strictEqual(allowed.status, 0, allowed.stderr); + assertOk(parseLastJsonLine(allowed.stdout)); + } + + // --- repeated allow flags cannot exceed parent --- + { + const frag = `execArgv: ${JSON.stringify([ + '--permission', + `--allow-fs-read=${allowedDir}`, + `--allow-fs-read=${deniedFile}`, + '--allow-worker', + ])},`; + const r = runWithParent(baseParent(), workerRead(deniedFile), frag); + assert.strictEqual(r.status, 0, r.stderr); + assertAccessDenied(parseLastJsonLine(r.stdout)); + } + + // --- subset path under parent dir --- + { + const frag = `execArgv: ${JSON.stringify([ + '--permission', + `--allow-fs-read=${subFile}`, + '--allow-worker', + ])},`; + const r = runWithParent(baseParent(), workerRead(subFile), frag); + assert.strictEqual(r.status, 0, r.stderr); + assertOk(parseLastJsonLine(r.stdout)); + } + + // --- trailing slash parent allow --- + { + const parent = [ + '--permission', + `--allow-fs-read=${allowedDirSlash}`, + '--allow-worker', + ]; + const r = runWithParent(parent, workerRead(allowedFile), 'execArgv: [],'); + assert.strictEqual(r.status, 0, r.stderr); + assertOk(parseLastJsonLine(r.stdout)); + } + + // --- parent has * → worker path not stripped --- + { + const parent = [ + '--permission', + '--allow-fs-read=*', + '--allow-worker', + ]; + const frag = `execArgv: ${JSON.stringify([ + '--permission', + `--allow-fs-read=${deniedFile}`, + '--allow-worker', + ])},`; + const r = runWithParent(parent, workerRead(deniedFile), frag); + assert.strictEqual(r.status, 0, r.stderr); + assertOk(parseLastJsonLine(r.stdout)); + } + + // --- permission-audit + empty execArgv --- + { + const parent = [ + '--permission', + '--permission-audit', + `--allow-fs-read=${allowedDir}`, + '--allow-worker', + ]; + const r = runWithParent(parent, workerRead(allowedFile), 'execArgv: [],'); + assert.strictEqual(r.status, 0, r.stderr); + assertOk(parseLastJsonLine(r.stdout)); + } + + // --- fs-write escalate blocked --- + { + const writeTarget = path.join(allowedDir, 'w.txt'); + const frag = `execArgv: ${JSON.stringify([ + '--permission', + `--allow-fs-write=${allowedDir}`, + '--allow-worker', + ])},`; + const r = runWithParent(baseParent(), workerWrite(writeTarget), frag); + assert.strictEqual(r.status, 0, r.stderr); + assertAccessDenied(parseLastJsonLine(r.stdout)); + } + + // --- fs-write allowed when parent grants write --- + { + const writeTarget = path.join(allowedDir, 'w2.txt'); + const parent = [ + '--permission', + `--allow-fs-read=${allowedDir}`, + `--allow-fs-write=${allowedDir}`, + '--allow-worker', + ]; + const frag = `execArgv: ${JSON.stringify([ + '--permission', + `--allow-fs-write=${allowedDir}`, + '--allow-worker', + ])},`; + const r = runWithParent(parent, workerWrite(writeTarget), frag); + assert.strictEqual(r.status, 0, r.stderr); + assertOk(parseLastJsonLine(r.stdout)); + } + + // --- empty execArgv inherits parent write --- + { + const writeTarget = path.join(allowedDir, 'w3.txt'); + const parent = [ + '--permission', + `--allow-fs-read=${allowedDir}`, + `--allow-fs-write=${allowedDir}`, + '--allow-worker', + ]; + const r = runWithParent( + parent, + workerWrite(writeTarget), + 'execArgv: [],', + ); + assert.strictEqual(r.status, 0, r.stderr); + assertOk(parseLastJsonLine(r.stdout)); + } + + // --- boolean: worker asks allow-net, parent does not --- + { + const frag = `execArgv: ${JSON.stringify([ + '--permission', + `--allow-fs-read=${allowedDir}`, + '--allow-net', + '--allow-worker', + ])},`; + const r = runWithParent(baseParent(), workerRead(deniedFile), frag); + assert.strictEqual(r.status, 0, r.stderr); + assertAccessDenied(parseLastJsonLine(r.stdout)); + } + + // --- rebuild path: allow-addons (parent grants) --- + { + const parent = [ + '--permission', + `--allow-fs-read=${allowedDir}`, + '--allow-worker', + '--allow-addons', + ]; + const frag = `execArgv: ${JSON.stringify([ + '--permission', + `--allow-fs-read=${allowedDir}`, + '--allow-worker', + '--allow-addons', + ])},`; + const r = runWithParent(parent, workerRead(allowedFile), frag); + assert.strictEqual(r.status, 0, r.stderr); + assertOk(parseLastJsonLine(r.stdout)); + } + + // --- rebuild: inspector + child-process + wasi + ffi (parent lacks → AND false) --- + { + const frag = `execArgv: ${JSON.stringify([ + '--permission', + `--allow-fs-read=${allowedDir}`, + '--allow-inspector', + '--allow-child-process', + '--allow-wasi', + '--allow-ffi', + '--allow-worker', + ])},`; + const denied = runWithParent( + baseParent(), + workerRead(deniedFile), + frag, + ); + assert.strictEqual(denied.status, 0, denied.stderr); + assertAccessDenied(parseLastJsonLine(denied.stdout)); + const allowed = runWithParent( + baseParent(), + workerRead(allowedFile), + frag, + ); + assert.strictEqual(allowed.status, 0, allowed.stderr); + assertOk(parseLastJsonLine(allowed.stdout)); + } + + // --- rebuild: parent grants inspector + net + wasi + ffi + child-process --- + { + const parent = [ + '--permission', + `--allow-fs-read=${allowedDir}`, + '--allow-worker', + '--allow-inspector', + '--allow-child-process', + '--allow-net', + '--allow-wasi', + '--allow-ffi', + ]; + const frag = `execArgv: ${JSON.stringify([ + '--permission', + `--allow-fs-read=${allowedDir}`, + '--allow-worker', + '--allow-inspector', + '--allow-child-process', + '--allow-net', + '--allow-wasi', + '--allow-ffi', + ])},`; + const r = runWithParent(parent, workerRead(allowedFile), frag); + assert.strictEqual(r.status, 0, r.stderr); + assertOk(parseLastJsonLine(r.stdout)); + } + + // --- permission-audit on worker execArgv (intersect OR path) --- + { + const parent = [ + '--permission', + `--allow-fs-read=${allowedDir}`, + '--allow-worker', + ]; + const frag = `execArgv: ${JSON.stringify([ + '--permission', + '--permission-audit', + `--allow-fs-read=${allowedDir}`, + '--allow-worker', + ])},`; + const r = runWithParent(parent, workerRead(allowedFile), frag); + assert.strictEqual(r.status, 0, r.stderr); + assertOk(parseLastJsonLine(r.stdout)); + } + + // --- both parent and worker permission-audit --- + { + const parent = [ + '--permission', + '--permission-audit', + `--allow-fs-read=${allowedDir}`, + '--allow-worker', + ]; + const frag = `execArgv: ${JSON.stringify([ + '--permission', + '--permission-audit', + `--allow-fs-read=${allowedDir}`, + '--allow-worker', + ])},`; + const r = runWithParent(parent, workerRead(allowedFile), frag); + assert.strictEqual(r.status, 0, r.stderr); + assertOk(parseLastJsonLine(r.stdout)); + } + + // --- --no-warnings + permission flags mixed (non-permission kept in rebuild) --- + { + const frag = `execArgv: ${JSON.stringify([ + '--no-warnings', + '--permission', + `--allow-fs-read=${allowedDir}`, + '--allow-worker', + ])},`; + const r = runWithParent(baseParent(), workerRead(allowedFile), frag); + assert.strictEqual(r.status, 0, r.stderr); + assertOk(parseLastJsonLine(r.stdout)); + } + + // --- openssl-store token in worker (parent lacks) --- + { + const frag = `execArgv: ${JSON.stringify([ + '--permission', + `--allow-fs-read=${allowedDir}`, + '--allow-openssl-store', + '--allow-worker', + ])},`; + const r = runWithParent(baseParent(), workerRead(allowedFile), frag); + assert.strictEqual(r.status, 0, r.stderr); + assertOk(parseLastJsonLine(r.stdout)); + } + + // --- parent grants openssl-store --- + { + const parent = [ + '--permission', + `--allow-fs-read=${allowedDir}`, + '--allow-worker', + '--allow-openssl-store', + ]; + const frag = `execArgv: ${JSON.stringify([ + '--permission', + `--allow-fs-read=${allowedDir}`, + '--allow-openssl-store', + '--allow-worker', + ])},`; + const r = runWithParent(parent, workerRead(allowedFile), frag); + assert.strictEqual(r.status, 0, r.stderr); + assertOk(parseLastJsonLine(r.stdout)); + } + + + // --- relative allow path (ResolveForCompare / PathResolve) --- + { + const rel = path.relative(process.cwd(), allowedFile); + if (rel && !rel.startsWith('..') && !path.isAbsolute(rel)) { + const frag = `execArgv: ${JSON.stringify([ + '--permission', + `--allow-fs-read=${rel}`, + '--allow-worker', + ])},`; + const r = runWithParent(baseParent(), workerRead(allowedFile), frag); + assert.strictEqual(r.status, 0, r.stderr); + const msg = parseLastJsonLine(r.stdout); + assert.ok('ok' in msg, JSON.stringify(msg)); + } + } + + // --- parent grants exact file (requested == parent branch) --- + { + const parent = [ + '--permission', + `--allow-fs-read=${allowedFile}`, + '--allow-worker', + ]; + const frag = `execArgv: ${JSON.stringify([ + '--permission', + `--allow-fs-read=${allowedFile}`, + '--allow-worker', + ])},`; + const ok = runWithParent(parent, workerRead(allowedFile), frag); + assert.strictEqual(ok.status, 0, ok.stderr); + assertOk(parseLastJsonLine(ok.stdout)); + + // shorter path than parent entry → size/prefix fail branch + const bad = runWithParent(parent, workerRead(allowedDir), frag); + assert.strictEqual(bad.status, 0, bad.stderr); + assertAccessDenied(parseLastJsonLine(bad.stdout)); + } + + // --- sibling path not under parent prefix (compare fail) --- + { + const sibling = path.join(path.dirname(allowedDir), 'other-sibling-x'); + const frag = `execArgv: ${JSON.stringify([ + '--permission', + `--allow-fs-read=${sibling}`, + '--allow-worker', + ])},`; + const r = runWithParent(baseParent(), workerRead(deniedFile), frag); + assert.strictEqual(r.status, 0, r.stderr); + assertAccessDenied(parseLastJsonLine(r.stdout)); + } + + // --- space-form tokens in execArgv (IsPermissionCliToken exact flag name) --- + { + const frag = `execArgv: ${JSON.stringify([ + '--permission', + '--allow-fs-read', + allowedDir, + '--allow-worker', + ])},`; + const r = runWithParent(baseParent(), workerRead(allowedFile), frag); + assert.strictEqual(r.status, 0, r.stderr); + const msg = parseLastJsonLine(r.stdout); + assert.ok(msg.ok === true || msg.code === 'ERR_ACCESS_DENIED', JSON.stringify(msg)); + } + + // --- empty-ish non-permission + parent write (ApplyParent / rebuild keep flag) --- + { + const writeTarget = path.join(allowedDir, 'w5.txt'); + const parent = [ + '--permission', + `--allow-fs-read=${allowedDir}`, + `--allow-fs-write=${allowedDir}`, + '--allow-worker', + ]; + const r = runWithParent( + parent, + workerWrite(writeTarget), + 'execArgv: ["--no-warnings"],', + ); + assert.strictEqual(r.status, 0, r.stderr); + assertOk(parseLastJsonLine(r.stdout)); + } + + // --- write * escalate blocked when parent has only dir write --- + { + const writeTarget = path.join(allowedDir, 'w4.txt'); + const parent = [ + '--permission', + `--allow-fs-read=${allowedDir}`, + `--allow-fs-write=${allowedDir}`, + '--allow-worker', + ]; + const frag = `execArgv: ${JSON.stringify([ + '--permission', + '--allow-fs-write=*', + '--allow-worker', + ])},`; + // * falls back to parent write list → should still write under allowedDir + const r = runWithParent(parent, workerWrite(writeTarget), frag); + assert.strictEqual(r.status, 0, r.stderr); + assertOk(parseLastJsonLine(r.stdout)); + } +} finally { + fs.rmSync(deniedDir, { recursive: true, force: true }); +}