diff --git a/doc/api/permissions.md b/doc/api/permissions.md index b677013d7978..47a8e9589cbb 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 e7b6b19b355c..f5ce5b979ea5 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 edc21e7e5561..3088d43a50f7 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,254 @@ Worker::~Worker() { Debug(this, "Worker %llu destroyed", thread_id_.id); } + +// SEMVER-MAJOR: Permission ceiling for Worker when execArgv is explicit +// (including []). Default Worker (no execArgv) is unchanged. +// +// After options parse, NODE_OPTIONS and repeated --allow-* are already in +// EnvironmentOptions. Runtime FSPermission remains authoritative for FS checks; +// path filtering here is create-time only (prefix / exact / *). +// +// Parent allow-list containing "*" already means unrestricted FS for that +// dimension; FilterPathList early-return keeps worker paths (cannot exceed *). + +namespace { + +bool WorkerConfiguredPermission(const EnvironmentOptions* w) { + if (w == nullptr) return false; + 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; +} + +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; +} + +void NormalizePathForCompare(std::string* s) { + while (s->size() > 1 && + (s->back() == '/' || s->back() == static_cast(92))) { + s->pop_back(); + } +#ifdef _WIN32 + for (char& c : *s) { + if (c >= 'A' && c <= 'Z') { + c = static_cast(c - 'A' + 'a'); + } + if (c == '/') c = static_cast(92); + } +#endif +} + +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 filter only. +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_cast(92); +} + +bool ParentListHasWildcard(const std::vector& parent) { + for (const std::string& entry : parent) { + if (entry == "*") return true; + } + return false; +} + +void FilterPathListToParentSubset(Environment* env, + EnvironmentOptions* w, + std::vector* worker, + const std::vector& parent) { + if (worker == nullptr) return; + + // Worker enabled permission but listed no paths → keep empty (restrict). + if (worker->empty()) { + if (w->permission || w->permission_audit) return; + *worker = parent; + return; + } + + // Parent "*" → FS already unrestricted; worker paths cannot exceed parent. + 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); +} + +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); +} + +void ClampWorkerPermissionToParent(Environment* env, + PerIsolateOptions* worker_opts) { + if (worker_opts == nullptr || env == 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); + } +} + +// Exact flag name or flag=value (not a longer unrelated prefix). +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; +} + +bool PermissionFlagTakesNextArg(const std::string& a) { + return a == "--allow-fs-read" || a == "--allow-fs-write"; +} + +bool PathSafeForAllowFlag(const std::string& path) { + if (path.empty()) return false; + for (unsigned char c : path) { + if (c == 0 || c == 10 || c == 13) return false; + } + return true; +} + +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 kept; + kept.reserve(exec_argv_out->size()); + for (size_t i = 0; i < exec_argv_out->size(); ++i) { + const std::string& tok = (*exec_argv_out)[i]; + if (tok.empty()) continue; + if (IsPermissionCliToken(tok)) { + if (PermissionFlagTakesNextArg(tok) && i + 1 < exec_argv_out->size()) { + const std::string& next = (*exec_argv_out)[i + 1]; + if (!next.empty() && next[0] != '-') ++i; + } + continue; + } + kept.push_back(tok); + } + + std::vector out; + out.reserve(kept.size() + 16 + w->allow_fs_read.size() + + w->allow_fs_write.size()); + for (const std::string& tok : kept) 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& p : w->allow_fs_read) { + if (!PathSafeForAllowFlag(p)) continue; + out.push_back("--allow-fs-read=" + p); + } + for (const std::string& p : w->allow_fs_write) { + if (!PathSafeForAllowFlag(p)) continue; + out.push_back("--allow-fs-write=" + p); + } + *exec_argv_out = std::move(out); +} + +} // namespace + + void Worker::New(const FunctionCallbackInfo& args) { Environment* env = Environment::GetCurrent(args); THROW_IF_INSUFFICIENT_PERMISSIONS( @@ -683,6 +932,16 @@ void Worker::New(const FunctionCallbackInfo& args) { per_isolate_opts = env->isolate_data()->options()->Clone(); } + // + + // Explicit execArgv only (including []). Default Worker path 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 000000000000..c1a2a1081970 --- /dev/null +++ b/test/parallel/test-permission-worker-empty-execargv.js @@ -0,0 +1,394 @@ +'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: parent allowlist applies + { + 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 only + { + 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 a = runWithParent(baseParent(), workerRead(allowedFile), frag); + assert.strictEqual(a.status, 0, a.stderr); + assertAccessDenied(parseLastJsonLine(a.stdout)); + } + + // escalate * clamped + { + 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 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 + { + 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 * → worker path kept + { + 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: parent lacks write → denied; parent grants → ok + { + const writeTarget = path.join(allowedDir, 'w.txt'); + const frag = `execArgv: ${JSON.stringify([ + '--permission', `--allow-fs-write=${allowedDir}`, '--allow-worker', + ])},`; + const blocked = runWithParent(baseParent(), workerWrite(writeTarget), frag); + assert.strictEqual(blocked.status, 0, blocked.stderr); + assertAccessDenied(parseLastJsonLine(blocked.stdout)); + + const parent = [ + '--permission', + `--allow-fs-read=${allowedDir}`, + `--allow-fs-write=${allowedDir}`, + '--allow-worker', + ]; + const ok = runWithParent(parent, workerWrite(writeTarget), frag); + assert.strictEqual(ok.status, 0, ok.stderr); + assertOk(parseLastJsonLine(ok.stdout)); + } + + // empty execArgv inherits parent write + { + const writeTarget = path.join(allowedDir, 'w2.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 AND: worker asks allow-net, parent does not (still fs-clamped) + { + 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: several booleans when parent grants them + { + const parent = [ + '--permission', + `--allow-fs-read=${allowedDir}`, + '--allow-worker', + '--allow-addons', + '--allow-inspector', + '--allow-net', + ]; + const frag = `execArgv: ${JSON.stringify([ + '--permission', + `--allow-fs-read=${allowedDir}`, + '--allow-worker', + '--allow-addons', + '--allow-inspector', + '--allow-net', + ])},`; + const r = runWithParent(parent, workerRead(allowedFile), frag); + assert.strictEqual(r.status, 0, r.stderr); + assertOk(parseLastJsonLine(r.stdout)); + } + + // worker permission-audit only → empty fs list restrict + { + const frag = `execArgv: ${JSON.stringify([ + '--permission', '--permission-audit', '--allow-worker', + ])},`; + const a = runWithParent(baseParent(), workerRead(allowedFile), frag); + assert.strictEqual(a.status, 0, a.stderr); + assertAccessDenied(parseLastJsonLine(a.stdout)); + } + + // --no-warnings kept through rebuild with permission flags + { + 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)); + } + + // exact file grant + shorter path rejected + { + 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)); + const bad = runWithParent(parent, workerRead(allowedDir), frag); + assert.strictEqual(bad.status, 0, bad.stderr); + assertAccessDenied(parseLastJsonLine(bad.stdout)); + } + + // sibling path not under parent + { + 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 --allow-fs-read + { + const frag = `execArgv: ${JSON.stringify([ + '--permission', '--allow-fs-read', allowedDir, '--allow-worker', + ])},`; + const ok = runWithParent(baseParent(), workerRead(allowedFile), frag); + assert.strictEqual(ok.status, 0, ok.stderr); + assertOk(parseLastJsonLine(ok.stdout)); + const denied = runWithParent(baseParent(), workerRead(deniedFile), frag); + assert.strictEqual(denied.status, 0, denied.stderr); + assertAccessDenied(parseLastJsonLine(denied.stdout)); + } + + // space-form --allow-fs-write + { + const writeTarget = path.join(allowedDir, 'w3.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)); + } + + // write * → fall back to parent write list + { + 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', + ])},`; + 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 }); +}