From ff9381b37055b4b0835dccde8797a971fbca5220 Mon Sep 17 00:00:00 2001 From: Suzhen Zheng Date: Thu, 23 Jul 2026 03:34:07 +0800 Subject: [PATCH 1/3] fix(privacy): implement balanced tier protections Add balanced PII confirmation, strict redaction controls, and enforce allow-once approval for sensitive file reads across sandbox file and child-process APIs. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: e15bb9c2-9b3b-4b6d-937f-1fe080776731 --- appcontainer/README.md | 2 + appcontainer/sandbox-cp-hooks.js | 483 ++++++++++-- appcontainer/sandbox-fs-hooks.js | 360 +++++---- appcontainer/sandbox-permission.js | 99 ++- appcontainer/sandbox-sensitive.js | 71 +- appcontainer/sandbox-state.js | 57 +- desktop/renderer/env.d.ts | 11 +- desktop/renderer/src/App.vue | 8 +- .../src/components/PermissionDialog.vue | 18 +- .../src/components/chat/ChatMessageList.vue | 3 +- desktop/renderer/src/i18n/en-US.ts | 18 +- desktop/renderer/src/i18n/zh-CN.ts | 16 +- desktop/renderer/src/stores/chat.test.ts | 78 +- desktop/renderer/src/stores/chat.ts | 65 +- .../renderer/src/utils/pii-scanner.test.ts | 43 +- desktop/renderer/src/utils/pii-scanner.ts | 57 +- desktop/renderer/src/views/ChatView.vue | 8 +- desktop/renderer/src/views/SettingsView.vue | 53 +- desktop/src/main.ts | 54 +- desktop/src/preload.ts | 2 +- desktop/src/sandbox-logic.test.ts | 726 +++++++++++++++++- 21 files changed, 1935 insertions(+), 297 deletions(-) diff --git a/appcontainer/README.md b/appcontainer/README.md index a084fcc..f027837 100644 --- a/appcontainer/README.md +++ b/appcontainer/README.md @@ -19,6 +19,7 @@ Two layers of enforcement: - **JS layer** (preload modules): Pre-blocks known-bad paths before execution, prompts user via IPC dialog - **OS layer** (AppContainer + ACL): Blocks all unauthorized access regardless of JS-layer decisions - **Sensitive path shield**: Hard-denies access to credential directories (`.ssh`, `.azure`, etc.) — no permission dialog, no override +- **Sensitive file guard**: In Balanced and Strict privacy modes, prompts before reading credential-like files such as `.env`, private keys, and certificates ## Files @@ -118,3 +119,4 @@ The JS files don't need building — they're plain ES5 (no transpilation) for ma | `OPENCLAW_SANDBOX_DIRS_RO` | Comma-separated directories with read-only access | | `OPENCLAW_SANDBOX_HMAC_KEY` | Secret key for verifying external apps whitelist file | | `OPENCLAW_SANDBOX_PERMISSION_TIMEOUT` | Timeout in ms for permission dialogs (0 = wait forever) | +| `OPENCLAW_PRIVACY_LEVEL` | `basic`, `balanced`, or `strict`; controls sensitive filename read prompts | diff --git a/appcontainer/sandbox-cp-hooks.js b/appcontainer/sandbox-cp-hooks.js index 7e56ae6..58ba70e 100644 --- a/appcontainer/sandbox-cp-hooks.js +++ b/appcontainer/sandbox-cp-hooks.js @@ -107,17 +107,14 @@ var SAFE_DIAG_TOOLS = new Set([ "arp", ]); -// Read-only filters allowed in pipes after a diagnostic tool -var SAFE_PIPE_FILTERS = new Set(["findstr", "find", "more", "sort", "head", "tail"]); - // Shell metacharacters that should never appear in a safe diagnostic command. // Catches: redirection (>, <), subexpression $(), backtick escapes, for/if blocks. var UNSAFE_SHELL_CHARS_RE = /[><]|\$\(|`[^`]/; /** - * Check if a shell payload consists entirely of safe diagnostic commands - * (optionally piped through read-only filters). Returns true if the entire - * command line can safely bypass AppContainer. + * Check if a shell payload consists entirely of safe diagnostic commands. + * Piped commands do not bypass AppContainer because filter tools can accept + * file operands in addition to stdin. * * Security: rejects any payload containing redirection, subexpressions, * or backtick escapes. Splits on ALL cmd/PS separators including single &. @@ -137,8 +134,9 @@ function isSafeDiagnosticCommand(cmd, args) { var stmt = statements[s].trim(); if (!stmt) continue; - // Split by pipe — first segment must be a diag tool, rest must be filters + // Filters such as find/more/sort can also open named files. var segments = stmt.split(/\s*\|\s*/); + if (segments.length !== 1) return false; for (var p = 0; p < segments.length; p++) { var seg = segments[p].trim(); if (!seg) continue; @@ -150,13 +148,7 @@ function isSafeDiagnosticCommand(cmd, args) { .toLowerCase() .replace(/\.exe$/i, ""); - if (p === 0) { - // First segment: must be a safe diagnostic tool - if (!SAFE_DIAG_TOOLS.has(exeName)) return false; - } else { - // Pipe target: must be a safe read-only filter - if (!SAFE_PIPE_FILTERS.has(exeName)) return false; - } + if (!SAFE_DIAG_TOOLS.has(exeName)) return false; } } @@ -178,6 +170,7 @@ function isSafeDiagnosticCommandStr(cmdStr) { var stmt = statements[s].trim(); if (!stmt) continue; var segments = stmt.split(/\s*\|\s*/); + if (segments.length !== 1) return false; for (var p = 0; p < segments.length; p++) { var seg = segments[p].trim(); if (!seg) continue; @@ -186,11 +179,7 @@ function isSafeDiagnosticCommandStr(cmdStr) { .basename(firstToken) .toLowerCase() .replace(/\.exe$/i, ""); - if (p === 0) { - if (!SAFE_DIAG_TOOLS.has(exeName)) return false; - } else { - if (!SAFE_PIPE_FILTERS.has(exeName)) return false; - } + if (!SAFE_DIAG_TOOLS.has(exeName)) return false; } } return true; @@ -209,6 +198,58 @@ function buildCmdPreview(cmd, args) { return String(cmd) + " " + argArr.join(" "); } +function getChildOptions(args, opts) { + if (Array.isArray(args)) return opts; + if (args === null || args === undefined) { + return opts && typeof opts === "object" ? opts : undefined; + } + return typeof args === "object" ? args : undefined; +} + +var TRUSTED_CHILD_ENV_KEYS = [ + "COMSPEC", + "OPENCLAW_ORIGINAL_COMSPEC", + "OPENCLAW_SANDBOX_NAME", + "OPENCLAW_SANDBOX_CAPS", + "OPENCLAW_SANDBOX_DIRS_RW", + "OPENCLAW_SANDBOX_DIRS_RO", + "OPENCLAW_SANDBOX_PERMISSION_TIMEOUT", + "OPENCLAW_SANDBOX_HMAC_KEY", + "OPENCLAW_AC_EXTERNAL_APPS", + "OPENCLAW_STATE_DIR", + "OPENCLAW_NODE_DIR", + "USERPROFILE", + "HOME", + "APPDATA", + "LOCALAPPDATA", + "TEMP", + "TMP", + "ProgramFiles", + "SystemDrive", + "SystemRoot", + "windir", +]; + +function withCurrentPrivacyEnv(options) { + if (!options || typeof options !== "object") return options; + var updated = Object.assign({}, options); + if (options.env && typeof options.env === "object") { + updated.env = Object.assign({}, options.env); + for (var i = 0; i < TRUSTED_CHILD_ENV_KEYS.length; i++) { + var key = TRUSTED_CHILD_ENV_KEYS[i]; + if (process.env[key] !== undefined) updated.env[key] = process.env[key]; + } + var pathValue = process.env.PATH || process.env.Path; + if (pathValue !== undefined) updated.env.PATH = pathValue; + updated.env.OPENCLAW_PRIVACY_LEVEL = S.state.privacyLevel; + if (process.env.NODE_OPTIONS) { + updated.env.NODE_OPTIONS = process.env.NODE_OPTIONS; + } + } + if (options.shell && S.LAUNCHER) updated.shell = S.LAUNCHER; + return updated; +} + function notifyExecCommand(cmd, args) { if (typeof process.send !== "function") return; var payload = extractShellPayload(cmd, Array.isArray(args) ? args : []); @@ -464,16 +505,179 @@ function hasSensitivePaths(writePaths, readPaths) { return null; } +var SENSITIVE_READ_OPERATION_RE = + /\b(?:Get-Content|gc|Select-String|sls|Import-Csv|Get-FileHash|type|more|find|findstr|sort|cat|head|tail|Copy-Item|Move-Item|Rename-Item|New-Item|copy|move|rename|ren|mklink|cp|mv)\b|(?:(?:fs|require\s*\(\s*["']fs["']\s*\))(?:\.promises)?\.(?:readFile|readFileSync|createReadStream|open)|\.(?:readFile|readFileSync|createReadStream|open|read_text|read_bytes)\s*\(|\bopen\s*\(|System\.IO\.(?:File|Directory).*::(?:Read|Get|Exists|Open))/i; + +function extractSensitiveRelativeReadPaths(command, cwd, scanAllCandidates) { + var results = []; + var seen = {}; + var baseDir = cwd ? sensitive.normalizeFilePath(cwd) : process.cwd(); + var statements = String(command || "").split(/\s*(?:&&|\|\||[;&\r\n])\s*/); + + function addCandidate(token) { + token = String(token || "").replace(/[)\]}]+$/g, ""); + if (!token) return; + var normalized = /^file:/i.test(token) ? sensitive.normalizeFilePath(token) : token; + if (!normalized) return; + var resolved = pathMod.isAbsolute(normalized) + ? pathMod.resolve(normalized) + : pathMod.resolve(baseDir, normalized); + if (!sensitive.isSensitiveFile(normalized) && !sensitive.isSensitivePath(resolved)) return; + var key = resolved.toLowerCase(); + if (!seen[key]) { + seen[key] = true; + results.push(resolved); + } + } + + for (var i = 0; i < statements.length; i++) { + var statement = statements[i]; + if (!scanAllCandidates && !SENSITIVE_READ_OPERATION_RE.test(statement)) continue; + var quotedPatterns = [/"([^"]+)"/g, /'([^']+)'/g]; + for (var q = 0; q < quotedPatterns.length; q++) { + var quotedMatch; + while ((quotedMatch = quotedPatterns[q].exec(statement)) !== null) { + addCandidate(quotedMatch[1]); + } + } + var tokenPattern = /"([^"]+)"|'([^']+)'|([^\s|(),]+)/g; + var match; + while ((match = tokenPattern.exec(statement)) !== null) { + addCandidate(match[1] || match[2] || match[3] || ""); + } + } + return results; +} + +function addSensitiveRelativeReadPaths(command, readPaths, cwd, scanAllCandidates) { + if (S.state.privacyLevel === "basic") return readPaths; + var combined = readPaths.slice(); + var seen = {}; + for (var i = 0; i < combined.length; i++) seen[combined[i].toLowerCase()] = true; + var relative = extractSensitiveRelativeReadPaths(command, cwd, scanAllCandidates); + for (var j = 0; j < relative.length; j++) { + var key = relative[j].toLowerCase(); + if (!seen[key]) { + seen[key] = true; + combined.push(relative[j]); + } + } + return combined; +} + +function isScriptInterpreterCommand(cmd, payload) { + var candidates = [String(cmd || "")]; + var match = String(payload || "").match(/^\s*(?:"([^"]+)"|'([^']+)'|([^\s]+))/); + if (match) candidates.push(match[1] || match[2] || match[3] || ""); + for (var i = 0; i < candidates.length; i++) { + var name = pathMod + .basename(candidates[i]) + .toLowerCase() + .replace(/\.exe$/i, ""); + if (name === "node" || name === "python" || name === "python3" || name === "py") return true; + } + return false; +} + +function preBlockSensitiveCommand(cmd, args, cwd, commandIsPayload) { + var cmdPreview = buildCmdPreview(cmd, args).trim(); + var payload = commandIsPayload ? cmdPreview : extractShellPayload(cmd, args); + var writePaths = extractWritePaths(payload); + var readPaths = addSensitiveRelativeReadPaths( + payload, + extractReadPaths(payload), + cwd, + isScriptInterpreterCommand(cmd, payload), + ); + var sensitiveHit = hasSensitivePaths(writePaths, readPaths); + if (sensitiveHit) return { reason: "sensitive", path: sensitiveHit }; + + S.state._currentCmdPreview = cmdPreview; + for (var i = 0; i < readPaths.length; i++) { + if (perm.shouldBlockSensitiveRead(readPaths[i])) { + return { reason: "permission", path: readPaths[i] }; + } + } + return false; +} + +function blockedError(denied) { + return denied.reason === "sensitive" + ? sensitive.throwSensitiveDenied(denied.path) + : S.throwReadBlocked("sandbox permission denied"); +} + +function blockedMessage(denied) { + return denied.reason === "sensitive" + ? blockedError(denied).message + : "EACCES: sandbox permission denied"; +} + +function createBlockedChild(denied, callback) { + var EventEmitter = require("events"); + var streams = require("stream"); + var child = new EventEmitter(); + var message = blockedMessage(denied); + child.stdin = new streams.Writable({ + write: function (chunk, encoding, callback) { + callback(); + }, + }); + child.stdout = new streams.Readable({ + read: function () { + this.push(null); + }, + }); + child.stderr = new streams.Readable({ + read: function () { + this.push(message + "\n"); + this.push(null); + message = null; + }, + }); + child.pid = 0; + child.killed = true; + child.connected = false; + child.kill = function () { + return true; + }; + child.ref = function () { + return child; + }; + child.unref = function () { + return child; + }; + process.nextTick(function () { + if (typeof callback === "function") callback(blockedError(denied), "", ""); + child.emit("exit", 1, null); + child.emit("close", 1, null); + }); + return child; +} + +function createBlockedSyncResult(denied) { + var message = Buffer.from(blockedMessage(denied)); + return { + pid: 0, + status: 1, + signal: null, + stdout: Buffer.alloc(0), + stderr: message, + output: [null, Buffer.alloc(0), message], + error: null, + }; +} + // NOTE: Pre-blocking is a UX convenience layer, not a security boundary. // Commands that pass pre-blocking still run inside AppContainer (OS ACLs). // A crafted command may evade regex extraction — that's acceptable because // AppContainer will still enforce access control at the kernel level. -function preBlockShellCommand(cmd, args) { +function preBlockShellCommand(cmd, args, cwd) { var cmdPreview = buildCmdPreview(cmd, args); S.state._currentCmdPreview = cmdPreview; var payload = extractShellPayload(cmd, args); var writePaths = extractWritePaths(payload); - var readPaths = extractReadPaths(payload); + var readPaths = addSensitiveRelativeReadPaths(payload, extractReadPaths(payload), cwd); process.stderr.write( "[sandbox] Pre-block: cmd=" + cmdPreview.substring(0, 120) + @@ -1001,13 +1205,48 @@ function rebuildShellArgs(shellExe, origArgs, newPayload) { // ── Install hooks ─────────────────────────────────────────────────────── function install(cp, getExternalApps) { + // Node's fork() uses an internal spawn reference, so it must be wrapped + // separately to keep the preload and live privacy level in child processes. + if (cp.fork) { + var _fork = cp.fork; + cp.fork = function (modulePath, args, opts) { + if (!S.state.sandboxActive) return _fork.apply(this, arguments); + var forkOpts = getChildOptions(args, opts); + if (forkOpts) { + forkOpts = withCurrentPrivacyEnv(forkOpts); + if (Array.isArray(args) || args === null || args === undefined) { + opts = forkOpts; + arguments[2] = opts; + } else { + args = forkOpts; + arguments[1] = args; + } + } + return _fork.apply(this, arguments); + }; + } + // ── cp.spawn ── var _spawn = cp.spawn; cp.spawn = function (cmd, args, opts) { + if (S.state.sandboxActive) { + var effectiveSpawnOpts = getChildOptions(args, opts); + if (effectiveSpawnOpts) { + effectiveSpawnOpts = withCurrentPrivacyEnv(effectiveSpawnOpts); + if (Array.isArray(args) || args === null || args === undefined) { + opts = effectiveSpawnOpts; + arguments[2] = opts; + } else { + args = effectiveSpawnOpts; + arguments[1] = args; + } + } + } var bn = pathMod.basename(String(cmd)); var isShell = isShellExe(cmd); - var hasShellOpt = (opts && opts.shell) || (args && !Array.isArray(args) && args.shell); + var spawnOpts = getChildOptions(args, opts); + var hasShellOpt = spawnOpts && spawnOpts.shell; process.stderr.write( "[sandbox-diag] spawn: cmd=" + String(cmd).substring(0, 80) + @@ -1019,6 +1258,24 @@ function install(cp, getExternalApps) { !!hasShellOpt + "\n", ); + if (S.state.sandboxActive) { + var _sensitiveDenied = preBlockSensitiveCommand( + cmd, + Array.isArray(args) ? args : [], + spawnOpts && spawnOpts.cwd, + !!hasShellOpt, + ); + if (_sensitiveDenied) { + process.stderr.write( + "[sandbox] spawn: " + + bn + + " -> BLOCKED (" + + (_sensitiveDenied.reason || "permission") + + ")\n", + ); + return createBlockedChild(_sensitiveDenied); + } + } if (S.state.sandboxActive && isShell) { // Intercept declare-access magic command — return synthetic stream var _spawnPayload = extractShellPayload(cmd, Array.isArray(args) ? args : []); @@ -1092,7 +1349,7 @@ function install(cp, getExternalApps) { return _sensChild; } var _newArgs = rebuildShellArgs(cmd, Array.isArray(args) ? args : [], _spawnStripped); - var _newOpts = stripShell(Array.isArray(args) ? opts : args); + var _newOpts = stripShell(spawnOpts); var _laArgs = buildLA(cmd, ensureUtf8Args(cmd, _newArgs)); process.stderr.write( "[sandbox] spawn: " + bn + " -> AC (inline declare-access stripped)\\n", @@ -1109,8 +1366,12 @@ function install(cp, getExternalApps) { return _spawn.apply(this, arguments); } var la = buildLA(cmd, ensureUtf8Args(cmd, Array.isArray(args) ? args : [])); - var co = stripShell(Array.isArray(args) ? opts : args); - var _denied = preBlockShellCommand(cmd, Array.isArray(args) ? args : []); + var co = stripShell(spawnOpts); + var _denied = preBlockShellCommand( + cmd, + Array.isArray(args) ? args : [], + co && co.cwd, + ); if (_denied) { var _blockMsg = _denied.reason === "sensitive" @@ -1183,8 +1444,23 @@ function install(cp, getExternalApps) { var _spawnSync = cp.spawnSync; cp.spawnSync = function (cmd, args, opts) { + if (S.state.sandboxActive) { + var effectiveSyncOpts = getChildOptions(args, opts); + if (effectiveSyncOpts) { + effectiveSyncOpts = withCurrentPrivacyEnv(effectiveSyncOpts); + if (Array.isArray(args) || args === null || args === undefined) { + opts = effectiveSyncOpts; + arguments[2] = opts; + } else { + args = effectiveSyncOpts; + arguments[1] = args; + } + } + } var bn = pathMod.basename(String(cmd)); var isShell = isShellExe(cmd); + var syncOpts = getChildOptions(args, opts); + var hasShellOpt = syncOpts && syncOpts.shell; process.stderr.write( "[sandbox-diag] spawnSync: cmd=" + String(cmd).substring(0, 80) + @@ -1194,6 +1470,24 @@ function install(cp, getExternalApps) { S.state.sandboxActive + "\n", ); + if (S.state.sandboxActive) { + var _syncSensitiveDenied = preBlockSensitiveCommand( + cmd, + Array.isArray(args) ? args : [], + syncOpts && syncOpts.cwd, + !!hasShellOpt, + ); + if (_syncSensitiveDenied) { + process.stderr.write( + "[sandbox] spawnSync: " + + bn + + " -> BLOCKED (" + + (_syncSensitiveDenied.reason || "permission") + + ")\n", + ); + return createBlockedSyncResult(_syncSensitiveDenied); + } + } if (S.state.sandboxActive && isShell) { // Intercept declare-access magic command — return synthetic result var _syncPayload = extractShellPayload(cmd, Array.isArray(args) ? args : []); @@ -1220,8 +1514,12 @@ function install(cp, getExternalApps) { return _spawnSync.apply(this, arguments); } var la = buildLA(cmd, ensureUtf8Args(cmd, Array.isArray(args) ? args : [])); - var co = stripShell(Array.isArray(args) ? opts : args); - var _denied = preBlockShellCommand(cmd, Array.isArray(args) ? args : []); + var co = stripShell(syncOpts); + var _denied = preBlockShellCommand( + cmd, + Array.isArray(args) ? args : [], + co && co.cwd, + ); if (_denied) { var _blockMsg = _denied.reason === "sensitive" @@ -1263,6 +1561,32 @@ function install(cp, getExternalApps) { var _execFile = cp.execFile; cp.execFile = function (file, args, opts, cb) { + if (S.state.sandboxActive) { + var effectiveEfOpts = getChildOptions(args, opts); + if (effectiveEfOpts) { + effectiveEfOpts = withCurrentPrivacyEnv(effectiveEfOpts); + if (Array.isArray(args) || args === null || args === undefined) { + opts = effectiveEfOpts; + arguments[2] = opts; + } else { + args = effectiveEfOpts; + arguments[1] = args; + } + } + } + if (S.state.sandboxActive) { + var _earlyEfOpts = getChildOptions(args, opts); + var _earlyEfDenied = preBlockSensitiveCommand( + file, + Array.isArray(args) ? args : [], + _earlyEfOpts && _earlyEfOpts.cwd, + !!(_earlyEfOpts && _earlyEfOpts.shell), + ); + if (_earlyEfDenied) { + var _earlyEfCb = typeof args === "function" ? args : typeof opts === "function" ? opts : cb; + return createBlockedChild(_earlyEfDenied, _earlyEfCb); + } + } if (S.state.sandboxActive && isShellExe(file)) { // Intercept declare-access magic command var _efPayload = extractShellPayload(file, Array.isArray(args) ? args : []); @@ -1282,9 +1606,7 @@ function install(cp, getExternalApps) { if (_efStripped) { var _efNewArgs = rebuildShellArgs(file, Array.isArray(args) ? args : [], _efStripped); var _efNewla = buildLA(file, ensureUtf8Args(file, _efNewArgs)); - var _efCleanOpts = stripShell( - typeof args === "object" && !Array.isArray(args) ? args : opts, - ); + var _efCleanOpts = stripShell(getChildOptions(args, opts)); var _efCb2 = typeof args === "function" ? args : typeof opts === "function" ? opts : cb; return _execFile.call(this, S.LAUNCHER, _efNewla, _efCleanOpts, _efCb2); } @@ -1335,14 +1657,14 @@ function install(cp, getExternalApps) { return _execFile.apply(this, arguments); } var la = buildLA(file, ensureUtf8Args(file, Array.isArray(args) ? args : [])); - var cleanOpts = stripShell(typeof args === "object" && !Array.isArray(args) ? args : opts); + var cleanOpts = stripShell(getChildOptions(args, opts)); var callback = typeof args === "function" ? args : typeof opts === "function" ? opts : cb; - var _denied = preBlockShellCommand(file, Array.isArray(args) ? args : []); + var _denied = preBlockShellCommand( + file, + Array.isArray(args) ? args : [], + cleanOpts && cleanOpts.cwd, + ); if (_denied) { - var _blockErr = - _denied.reason === "sensitive" - ? sensitive.throwSensitiveDenied(_denied.path) - : S.throwReadBlocked("sandbox permission denied"); process.stderr.write( "[sandbox] execFile: " + pathMod.basename(String(file)) + @@ -1350,12 +1672,7 @@ function install(cp, getExternalApps) { (_denied.reason || "permission") + ")\n", ); - if (typeof callback === "function") { - process.nextTick(function () { - callback(_blockErr); - }); - } - return; + return createBlockedChild(_denied, callback); } notifyExecCommand(file, Array.isArray(args) ? args : []); process.stderr.write("[sandbox] execFile: " + pathMod.basename(String(file)) + " -> AC\n"); @@ -1408,12 +1725,17 @@ function install(cp, getExternalApps) { } var _launcherCb2 = typeof args === "function" ? args : typeof opts === "function" ? opts : cb; - var _launcherOpts = typeof args === "object" && !Array.isArray(args) ? args : opts; + var _launcherOpts = getChildOptions(args, opts); return _execFile.call(this, file, _newArgs, _launcherOpts, _launcherCb2); } process.stderr.write("[sandbox] execFile(launcher): " + innerCmd.substring(0, 120) + "\n"); var innerWritePaths = extractWritePaths(innerCmd); - var innerReadPaths = extractReadPaths(innerCmd); + var _launcherOptsForRead = getChildOptions(args, opts); + var innerReadPaths = addSensitiveRelativeReadPaths( + innerCmd, + extractReadPaths(innerCmd), + _launcherOptsForRead && _launcherOptsForRead.cwd, + ); if (innerWritePaths.length > 0 || innerReadPaths.length > 0) { var _sensitiveHit = hasSensitivePaths(innerWritePaths, innerReadPaths); if (_sensitiveHit) { @@ -1457,7 +1779,7 @@ function install(cp, getExternalApps) { } } var _cbOrig = typeof args === "function" ? args : typeof opts === "function" ? opts : cb; - var _optsOrig = typeof args === "object" && !Array.isArray(args) ? args : opts; + var _optsOrig = getChildOptions(args, opts); return _execFile.call(this, file, argArr, _optsOrig, function (err, stdout, stderr) { if (innerCmd) { var deniedPath = detectAccessDenied(stderr || "", stdout || ""); @@ -1475,6 +1797,29 @@ function install(cp, getExternalApps) { var _execFileSync = cp.execFileSync; cp.execFileSync = function (file, args, opts) { + if (S.state.sandboxActive) { + var effectiveEfsOpts = getChildOptions(args, opts); + if (effectiveEfsOpts) { + effectiveEfsOpts = withCurrentPrivacyEnv(effectiveEfsOpts); + if (Array.isArray(args) || args === null || args === undefined) { + opts = effectiveEfsOpts; + arguments[2] = opts; + } else { + args = effectiveEfsOpts; + arguments[1] = args; + } + } + } + if (S.state.sandboxActive) { + var _earlyEfsOpts = getChildOptions(args, opts); + var _earlyEfsDenied = preBlockSensitiveCommand( + file, + Array.isArray(args) ? args : [], + _earlyEfsOpts && _earlyEfsOpts.cwd, + !!(_earlyEfsOpts && _earlyEfsOpts.shell), + ); + if (_earlyEfsDenied) throw blockedError(_earlyEfsDenied); + } if (S.state.sandboxActive && isShellExe(file)) { // Intercept declare-access magic command var _efsPayload = extractShellPayload(file, Array.isArray(args) ? args : []); @@ -1528,8 +1873,12 @@ function install(cp, getExternalApps) { return _execFileSync.apply(this, arguments); } var la = buildLA(file, ensureUtf8Args(file, Array.isArray(args) ? args : [])); - var co = stripShell(Array.isArray(args) ? opts : args); - var _denied = preBlockShellCommand(file, Array.isArray(args) ? args : []); + var co = stripShell(getChildOptions(args, opts)); + var _denied = preBlockShellCommand( + file, + Array.isArray(args) ? args : [], + co && co.cwd, + ); if (_denied) { process.stderr.write( "[sandbox] execFileSync: " + @@ -1558,6 +1907,8 @@ function install(cp, getExternalApps) { var _exec = cp.exec; cp.exec = function (command, opts, cb) { if (!S.state.sandboxActive) return _exec.apply(this, arguments); + opts = withCurrentPrivacyEnv(opts); + arguments[1] = opts; var callback = typeof opts === "function" ? opts : cb; var execOpts = typeof opts === "object" ? opts : undefined; var cmdStr = String(command || ""); @@ -1572,6 +1923,20 @@ function install(cp, getExternalApps) { } return; } + var _execSensitiveDenied = preBlockSensitiveCommand( + cmdStr, + [], + execOpts && execOpts.cwd, + true, + ); + if (_execSensitiveDenied) { + if (typeof callback === "function") { + process.nextTick(function () { + callback(blockedError(_execSensitiveDenied)); + }); + } + return; + } // Intercept inline declare-access comment — strip it, request permissions, then execute the rest var strippedCmd = tryInlineDeclareAccess(cmdStr); if (strippedCmd) { @@ -1594,7 +1959,11 @@ function install(cp, getExternalApps) { // Extract shell payload to avoid matching shell exe path (e.g. C:\Program Files\...\pwsh.exe) var _execPayload = extractShellPayloadFromString(cmdStr); var innerWritePaths = extractWritePaths(_execPayload); - var innerReadPaths = extractReadPaths(_execPayload); + var innerReadPaths = addSensitiveRelativeReadPaths( + _execPayload, + extractReadPaths(_execPayload), + execOpts && execOpts.cwd, + ); if (innerWritePaths.length > 0 || innerReadPaths.length > 0) { process.stderr.write("[sandbox] exec: " + cmdStr.substring(0, 120) + "\n"); var _sensitiveHitE = hasSensitivePaths(innerWritePaths, innerReadPaths); @@ -1655,12 +2024,21 @@ function install(cp, getExternalApps) { var _execSync = cp.execSync; cp.execSync = function (command, opts) { if (!S.state.sandboxActive) return _execSync.apply(this, arguments); + opts = withCurrentPrivacyEnv(opts); + arguments[1] = opts; var cmdStr = String(command || ""); // Intercept standalone declare-access magic command — never reaches the real shell var declareResult = tryDeclareAccess(cmdStr); if (declareResult) { return Buffer.from(formatDeclareResult(declareResult), "utf-8"); } + var _execSyncSensitiveDenied = preBlockSensitiveCommand( + cmdStr, + [], + opts && opts.cwd, + true, + ); + if (_execSyncSensitiveDenied) throw blockedError(_execSyncSensitiveDenied); // Intercept inline declare-access comment — strip it, request permissions, then execute the rest var strippedCmd = tryInlineDeclareAccess(cmdStr); if (strippedCmd) { @@ -1678,7 +2056,11 @@ function install(cp, getExternalApps) { // Extract shell payload to avoid matching shell exe path (e.g. C:\Program Files\...\pwsh.exe) var _execSyncPayload = extractShellPayloadFromString(cmdStr); var innerWritePaths = extractWritePaths(_execSyncPayload); - var innerReadPaths = extractReadPaths(_execSyncPayload); + var innerReadPaths = addSensitiveRelativeReadPaths( + _execSyncPayload, + extractReadPaths(_execSyncPayload), + opts && opts.cwd, + ); if (innerWritePaths.length > 0 || innerReadPaths.length > 0) { process.stderr.write("[sandbox] execSync: " + cmdStr.substring(0, 120) + "\n"); var _sensitiveHitES = hasSensitivePaths(innerWritePaths, innerReadPaths); @@ -1748,6 +2130,9 @@ module.exports = { // Exposed for testing detectAccessDenied: detectAccessDenied, preBlockShellCommand: preBlockShellCommand, + preBlockSensitiveCommand: preBlockSensitiveCommand, + withCurrentPrivacyEnv: withCurrentPrivacyEnv, + extractSensitiveRelativeReadPaths: extractSensitiveRelativeReadPaths, isShellExe: isShellExe, classifyAuthLevel: classifyAuthLevel, ensureUtf8Args: ensureUtf8Args, diff --git a/appcontainer/sandbox-fs-hooks.js b/appcontainer/sandbox-fs-hooks.js index 42e3079..771a0a3 100644 --- a/appcontainer/sandbox-fs-hooks.js +++ b/appcontainer/sandbox-fs-hooks.js @@ -10,6 +10,30 @@ "use strict"; var path = require("path"); +var fsConstants = require("fs").constants; + +function classifyOpenFlags(flags) { + if (typeof flags === "number") { + var accessMode = flags & 3; + var mutationMask = + (fsConstants.O_CREAT || 0) | (fsConstants.O_TRUNC || 0) | (fsConstants.O_APPEND || 0); + return { + read: accessMode !== 1, + write: accessMode !== 0 || (flags & mutationMask) !== 0, + }; + } + var value = String(flags === undefined ? "r" : flags).toLowerCase(); + if (value === "r" || value === "rs" || value === "sr") { + return { read: true, write: false }; + } + if (/^(?:w|wx|xw|a|ax|xa|as|sa)$/.test(value)) { + return { read: false, write: true }; + } + if (/^(?:r|rs|sr|w|wx|xw|a|ax|xa|as|sa)\+$/.test(value)) { + return { read: true, write: true }; + } + return { read: false, write: false }; +} function install(fsMod) { var S = require(path.join(__dirname, "sandbox-state.js")); @@ -18,6 +42,7 @@ function install(fsMod) { var throwReadOnly = S.throwReadOnly; var throwReadBlocked = S.throwReadBlocked; var _shouldBlockWrite = perm.shouldBlockWrite; + var _shouldBlockEntryWrite = perm.shouldBlockEntryWrite; var _shouldBlockRead = perm.shouldBlockRead; var isSensitivePath = sensitive.isSensitivePath; var throwSensitiveDenied = sensitive.throwSensitiveDenied; @@ -30,6 +55,10 @@ function install(fsMod) { if (isSensitivePath(filePath)) return true; return _shouldBlockWrite(filePath); } + function shouldBlockEntryWrite(filePath) { + if (isSensitivePath(filePath)) return true; + return _shouldBlockEntryWrite(filePath); + } function shouldBlockRead(filePath, shellContext) { if (isSensitivePath(filePath)) return true; return _shouldBlockRead(filePath, shellContext); @@ -51,6 +80,40 @@ function install(fsMod) { return typeof a === "function" ? a : b; } + function callbackError(callback, error) { + if (typeof callback !== "function") throw error; + process.nextTick(function () { + callback(error); + }); + } + + function resolveSymlinkTarget(target, linkPath) { + var normalizedTarget = sensitive.normalizeFilePath(target); + if (!normalizedTarget || path.isAbsolute(normalizedTarget)) return normalizedTarget; + var normalizedLink = sensitive.normalizeFilePath(linkPath); + return path.resolve(path.dirname(normalizedLink), normalizedTarget); + } + + function withCopySourceFilter(options) { + if (options !== undefined && (options === null || typeof options !== "object")) return options; + var copyOptions = Object.assign({}, options || {}); + var originalFilter = copyOptions.filter; + copyOptions.filter = function (src, dest) { + function authorize(included) { + if (!included) return false; + if (shouldBlockRead(src)) throw throwReadBlocked(src); + return true; + } + if (originalFilter === undefined) return authorize(true); + var included = originalFilter.call(this, src, dest); + if (included && typeof included.then === "function") { + return included.then(authorize); + } + return authorize(included); + }; + return copyOptions; + } + // ── Write operations ────────────────────────────────────────────────── var _writeFileSync = fsMod.writeFileSync; @@ -70,11 +133,8 @@ function install(fsMod) { if (shouldBlockWrite(file)) { S.state._currentCmdPreview = null; var callback = getCb2(optsOrCb, cb); - if (typeof callback === "function") { - callback(throwReadOnly(file)); - return; - } - throw throwReadOnly(file); + callbackError(callback, throwReadOnly(file)); + return; } S.state._currentCmdPreview = null; return _writeFile.apply(this, arguments); @@ -97,11 +157,8 @@ function install(fsMod) { if (shouldBlockWrite(file)) { S.state._currentCmdPreview = null; var callback = getCb2(optsOrCb, cb); - if (typeof callback === "function") { - callback(throwReadOnly(file)); - return; - } - throw throwReadOnly(file); + callbackError(callback, throwReadOnly(file)); + return; } S.state._currentCmdPreview = null; return _appendFile.apply(this, arguments); @@ -114,6 +171,10 @@ function install(fsMod) { S.state._currentCmdPreview = null; throw throwReadOnly(dest); } + if (shouldBlockRead(src)) { + S.state._currentCmdPreview = null; + throw throwReadBlocked(src); + } S.state._currentCmdPreview = null; return _copyFileSync.apply(this, arguments); }; @@ -121,14 +182,16 @@ function install(fsMod) { var _copyFile = fsMod.copyFile; fsMod.copyFile = function (src, dest, flagsOrCb, cb) { S.state._currentCmdPreview = 'fs.copyFile("' + src + '", "' + dest + '")'; + var callback = getCb2(flagsOrCb, cb); if (shouldBlockWrite(dest)) { S.state._currentCmdPreview = null; - var callback = getCb2(flagsOrCb, cb); - if (typeof callback === "function") { - callback(throwReadOnly(dest)); - return; - } - throw throwReadOnly(dest); + callbackError(callback, throwReadOnly(dest)); + return; + } + if (shouldBlockRead(src)) { + S.state._currentCmdPreview = null; + callbackError(callback, throwReadBlocked(src)); + return; } S.state._currentCmdPreview = null; return _copyFile.apply(this, arguments); @@ -137,14 +200,18 @@ function install(fsMod) { var _renameSync = fsMod.renameSync; fsMod.renameSync = function (oldPath, newPath) { S.state._currentCmdPreview = 'fs.renameSync("' + oldPath + '", "' + newPath + '")'; - if (shouldBlockWrite(oldPath)) { + if (shouldBlockEntryWrite(oldPath)) { S.state._currentCmdPreview = null; throw throwReadOnly(oldPath); } - if (shouldBlockWrite(newPath)) { + if (shouldBlockEntryWrite(newPath)) { S.state._currentCmdPreview = null; throw throwReadOnly(newPath); } + if (shouldBlockRead(oldPath)) { + S.state._currentCmdPreview = null; + throw throwReadBlocked(oldPath); + } S.state._currentCmdPreview = null; return _renameSync.apply(this, arguments); }; @@ -152,14 +219,20 @@ function install(fsMod) { var _rename = fsMod.rename; fsMod.rename = function (oldPath, newPath, cb) { S.state._currentCmdPreview = 'fs.rename("' + oldPath + '", "' + newPath + '")'; - if (shouldBlockWrite(oldPath) || shouldBlockWrite(newPath)) { + if (shouldBlockEntryWrite(oldPath)) { S.state._currentCmdPreview = null; - var target = shouldBlockWrite(oldPath) ? oldPath : newPath; - if (typeof cb === "function") { - cb(throwReadOnly(target)); - return; - } - throw throwReadOnly(target); + callbackError(cb, throwReadOnly(oldPath)); + return; + } + if (shouldBlockEntryWrite(newPath)) { + S.state._currentCmdPreview = null; + callbackError(cb, throwReadOnly(newPath)); + return; + } + if (shouldBlockRead(oldPath)) { + S.state._currentCmdPreview = null; + callbackError(cb, throwReadBlocked(oldPath)); + return; } S.state._currentCmdPreview = null; return _rename.apply(this, arguments); @@ -168,7 +241,7 @@ function install(fsMod) { var _unlinkSync = fsMod.unlinkSync; fsMod.unlinkSync = function (file) { S.state._currentCmdPreview = 'fs.unlinkSync("' + file + '")'; - if (shouldBlockWrite(file)) { + if (shouldBlockEntryWrite(file)) { S.state._currentCmdPreview = null; throw throwReadOnly(file); } @@ -179,13 +252,10 @@ function install(fsMod) { var _unlink = fsMod.unlink; fsMod.unlink = function (file, cb) { S.state._currentCmdPreview = 'fs.unlink("' + file + '")'; - if (shouldBlockWrite(file)) { + if (shouldBlockEntryWrite(file)) { S.state._currentCmdPreview = null; - if (typeof cb === "function") { - cb(throwReadOnly(file)); - return; - } - throw throwReadOnly(file); + callbackError(cb, throwReadOnly(file)); + return; } S.state._currentCmdPreview = null; return _unlink.apply(this, arguments); @@ -194,7 +264,7 @@ function install(fsMod) { var _mkdirSync = fsMod.mkdirSync; fsMod.mkdirSync = function (dir) { S.state._currentCmdPreview = 'fs.mkdirSync("' + dir + '")'; - if (shouldBlockWrite(dir)) { + if (shouldBlockEntryWrite(dir)) { S.state._currentCmdPreview = null; throw throwReadOnly(dir); } @@ -205,14 +275,11 @@ function install(fsMod) { var _mkdir = fsMod.mkdir; fsMod.mkdir = function (dir, optsOrCb, cb) { S.state._currentCmdPreview = 'fs.mkdir("' + dir + '")'; - if (shouldBlockWrite(dir)) { + if (shouldBlockEntryWrite(dir)) { S.state._currentCmdPreview = null; var callback = getCb2(optsOrCb, cb); - if (typeof callback === "function") { - callback(throwReadOnly(dir)); - return; - } - throw throwReadOnly(dir); + callbackError(callback, throwReadOnly(dir)); + return; } S.state._currentCmdPreview = null; return _mkdir.apply(this, arguments); @@ -221,7 +288,7 @@ function install(fsMod) { var _rmdirSync = fsMod.rmdirSync; fsMod.rmdirSync = function (dir) { S.state._currentCmdPreview = 'fs.rmdirSync("' + dir + '")'; - if (shouldBlockWrite(dir)) { + if (shouldBlockEntryWrite(dir)) { S.state._currentCmdPreview = null; throw throwReadOnly(dir); } @@ -232,14 +299,11 @@ function install(fsMod) { var _rmdir = fsMod.rmdir; fsMod.rmdir = function (dir, optsOrCb, cb) { S.state._currentCmdPreview = 'fs.rmdir("' + dir + '")'; - if (shouldBlockWrite(dir)) { + if (shouldBlockEntryWrite(dir)) { S.state._currentCmdPreview = null; var callback = getCb2(optsOrCb, cb); - if (typeof callback === "function") { - callback(throwReadOnly(dir)); - return; - } - throw throwReadOnly(dir); + callbackError(callback, throwReadOnly(dir)); + return; } S.state._currentCmdPreview = null; return _rmdir.apply(this, arguments); @@ -249,7 +313,7 @@ function install(fsMod) { var _rmSync = fsMod.rmSync; fsMod.rmSync = function (p) { S.state._currentCmdPreview = 'fs.rmSync("' + p + '")'; - if (shouldBlockWrite(p)) { + if (shouldBlockEntryWrite(p)) { S.state._currentCmdPreview = null; throw throwReadOnly(p); } @@ -262,14 +326,11 @@ function install(fsMod) { var _rm = fsMod.rm; fsMod.rm = function (p, optsOrCb, cb) { S.state._currentCmdPreview = 'fs.rm("' + p + '")'; - if (shouldBlockWrite(p)) { + if (shouldBlockEntryWrite(p)) { S.state._currentCmdPreview = null; var callback = getCb2(optsOrCb, cb); - if (typeof callback === "function") { - callback(throwReadOnly(p)); - return; - } - throw throwReadOnly(p); + callbackError(callback, throwReadOnly(p)); + return; } S.state._currentCmdPreview = null; return _rm.apply(this, arguments); @@ -298,11 +359,8 @@ function install(fsMod) { if (shouldBlockWrite(p)) { S.state._currentCmdPreview = null; var callback = getCb2(lenOrCb, cb); - if (typeof callback === "function") { - callback(throwReadOnly(p)); - return; - } - throw throwReadOnly(p); + callbackError(callback, throwReadOnly(p)); + return; } S.state._currentCmdPreview = null; return _truncate.apply(this, arguments); @@ -315,10 +373,15 @@ function install(fsMod) { var _symlinkSync = fsMod.symlinkSync; fsMod.symlinkSync = function (target, p) { S.state._currentCmdPreview = 'fs.symlinkSync("' + target + '", "' + p + '")'; - if (shouldBlockWrite(p)) { + if (shouldBlockEntryWrite(p)) { S.state._currentCmdPreview = null; throw throwReadOnly(p); } + var resolvedTarget = resolveSymlinkTarget(target, p); + if (shouldBlockRead(resolvedTarget)) { + S.state._currentCmdPreview = null; + throw throwReadBlocked(target); + } S.state._currentCmdPreview = null; return _symlinkSync.apply(this, arguments); }; @@ -328,14 +391,18 @@ function install(fsMod) { var _symlink = fsMod.symlink; fsMod.symlink = function (target, p, typeOrCb, cb) { S.state._currentCmdPreview = 'fs.symlink("' + target + '", "' + p + '")'; - if (shouldBlockWrite(p)) { + if (shouldBlockEntryWrite(p)) { S.state._currentCmdPreview = null; var callback = getCb2(typeOrCb, cb); - if (typeof callback === "function") { - callback(throwReadOnly(p)); - return; - } - throw throwReadOnly(p); + callbackError(callback, throwReadOnly(p)); + return; + } + var resolvedTarget = resolveSymlinkTarget(target, p); + if (shouldBlockRead(resolvedTarget)) { + S.state._currentCmdPreview = null; + var callback = getCb2(typeOrCb, cb); + callbackError(callback, throwReadBlocked(target)); + return; } S.state._currentCmdPreview = null; return _symlink.apply(this, arguments); @@ -348,10 +415,14 @@ function install(fsMod) { var _linkSync = fsMod.linkSync; fsMod.linkSync = function (existingPath, newPath) { S.state._currentCmdPreview = 'fs.linkSync("' + existingPath + '", "' + newPath + '")'; - if (shouldBlockWrite(newPath)) { + if (shouldBlockEntryWrite(newPath)) { S.state._currentCmdPreview = null; throw throwReadOnly(newPath); } + if (shouldBlockRead(existingPath)) { + S.state._currentCmdPreview = null; + throw throwReadBlocked(existingPath); + } S.state._currentCmdPreview = null; return _linkSync.apply(this, arguments); }; @@ -361,13 +432,15 @@ function install(fsMod) { var _link = fsMod.link; fsMod.link = function (existingPath, newPath, cb) { S.state._currentCmdPreview = 'fs.link("' + existingPath + '", "' + newPath + '")'; - if (shouldBlockWrite(newPath)) { + if (shouldBlockEntryWrite(newPath)) { S.state._currentCmdPreview = null; - if (typeof cb === "function") { - cb(throwReadOnly(newPath)); - return; - } - throw throwReadOnly(newPath); + callbackError(cb, throwReadOnly(newPath)); + return; + } + if (shouldBlockRead(existingPath)) { + S.state._currentCmdPreview = null; + callbackError(cb, throwReadBlocked(existingPath)); + return; } S.state._currentCmdPreview = null; return _link.apply(this, arguments); @@ -395,11 +468,8 @@ function install(fsMod) { S.state._currentCmdPreview = 'fs.chmod("' + p + '")'; if (shouldBlockWrite(p)) { S.state._currentCmdPreview = null; - if (typeof cb === "function") { - cb(throwReadOnly(p)); - return; - } - throw throwReadOnly(p); + callbackError(cb, throwReadOnly(p)); + return; } S.state._currentCmdPreview = null; return _chmod.apply(this, arguments); @@ -410,14 +480,14 @@ function install(fsMod) { if (fsMod.cpSync) { var _cpSync = fsMod.cpSync; - fsMod.cpSync = function (src, dest) { + fsMod.cpSync = function (src, dest, options) { S.state._currentCmdPreview = 'fs.cpSync("' + src + '", "' + dest + '")'; if (shouldBlockWrite(dest)) { S.state._currentCmdPreview = null; throw throwReadOnly(dest); } S.state._currentCmdPreview = null; - return _cpSync.apply(this, arguments); + return _cpSync.call(this, src, dest, withCopySourceFilter(options)); }; } @@ -425,17 +495,15 @@ function install(fsMod) { var _cp = fsMod.cp; fsMod.cp = function (src, dest, optsOrCb, cb) { S.state._currentCmdPreview = 'fs.cp("' + src + '", "' + dest + '")'; + var callback = getCb2(optsOrCb, cb); if (shouldBlockWrite(dest)) { S.state._currentCmdPreview = null; - var callback = getCb2(optsOrCb, cb); - if (typeof callback === "function") { - callback(throwReadOnly(dest)); - return; - } - throw throwReadOnly(dest); + callbackError(callback, throwReadOnly(dest)); + return; } S.state._currentCmdPreview = null; - return _cp.apply(this, arguments); + var options = typeof optsOrCb === "function" ? undefined : optsOrCb; + return _cp.call(this, src, dest, withCopySourceFilter(options), callback); }; } @@ -443,18 +511,16 @@ function install(fsMod) { var _openSync = fsMod.openSync; fsMod.openSync = function (file, flags) { - var f = String(flags || "r"); + var f = String(flags === undefined ? "r" : flags); + var access = classifyOpenFlags(flags); S.state._currentCmdPreview = 'fs.openSync("' + file + '", "' + f + '")'; - if (f === "r" || f === "rs" || f === "sr") { - if (shouldBlockRead(file)) { - S.state._currentCmdPreview = null; - throw throwReadBlocked(file); - } - } else { - if (shouldBlockWrite(file)) { - S.state._currentCmdPreview = null; - throw throwReadOnly(file); - } + if (access.read && shouldBlockRead(file)) { + S.state._currentCmdPreview = null; + throw throwReadBlocked(file); + } + if (access.write && shouldBlockWrite(file)) { + S.state._currentCmdPreview = null; + throw throwReadOnly(file); } S.state._currentCmdPreview = null; return _openSync.apply(this, arguments); @@ -462,28 +528,20 @@ function install(fsMod) { var _open = fsMod.open; fsMod.open = function (file, flags, modeOrCb, cb) { - var f = String(flags || "r"); + var effectiveFlags = typeof flags === "function" ? undefined : flags; + var f = String(effectiveFlags === undefined ? "r" : effectiveFlags); + var access = classifyOpenFlags(effectiveFlags); + var callback = typeof flags === "function" ? flags : getCb2(modeOrCb, cb); S.state._currentCmdPreview = 'fs.open("' + file + '", "' + f + '")'; - if (f === "r" || f === "rs" || f === "sr") { - if (shouldBlockRead(file)) { - S.state._currentCmdPreview = null; - var callback = getCb2(modeOrCb, cb); - if (typeof callback === "function") { - callback(throwReadBlocked(file)); - return; - } - throw throwReadBlocked(file); - } - } else { - if (shouldBlockWrite(file)) { - S.state._currentCmdPreview = null; - var callback = getCb2(modeOrCb, cb); - if (typeof callback === "function") { - callback(throwReadOnly(file)); - return; - } - throw throwReadOnly(file); - } + if (access.read && shouldBlockRead(file)) { + S.state._currentCmdPreview = null; + callbackError(callback, throwReadBlocked(file)); + return; + } + if (access.write && shouldBlockWrite(file)) { + S.state._currentCmdPreview = null; + callbackError(callback, throwReadOnly(file)); + return; } S.state._currentCmdPreview = null; return _open.apply(this, arguments); @@ -519,11 +577,8 @@ function install(fsMod) { if (shouldBlockRead(file)) { S.state._currentCmdPreview = null; var callback = getCb2(optsOrCb, cb); - if (typeof callback === "function") { - callback(throwReadBlocked(file)); - return; - } - throw throwReadBlocked(file); + callbackError(callback, throwReadBlocked(file)); + return; } S.state._currentCmdPreview = null; return _readFile.apply(this, arguments); @@ -546,11 +601,8 @@ function install(fsMod) { if (shouldBlockRead(dir)) { S.state._currentCmdPreview = null; var callback = getCb2(optsOrCb, cb); - if (typeof callback === "function") { - callback(throwReadBlocked(dir)); - return; - } - throw throwReadBlocked(dir); + callbackError(callback, throwReadBlocked(dir)); + return; } S.state._currentCmdPreview = null; return _readdir.apply(this, arguments); @@ -607,6 +659,10 @@ function install(fsMod) { S.state._currentCmdPreview = null; return Promise.reject(throwReadOnly(dest)); } + if (shouldBlockRead(src)) { + S.state._currentCmdPreview = null; + return Promise.reject(throwReadBlocked(src)); + } S.state._currentCmdPreview = null; return _pCopyFile.apply(this, arguments); }; @@ -614,10 +670,14 @@ function install(fsMod) { var _pRename = fsPromises.rename; fsPromises.rename = function (oldPath, newPath) { S.state._currentCmdPreview = 'fs.promises.rename("' + oldPath + '", "' + newPath + '")'; - if (shouldBlockWrite(oldPath) || shouldBlockWrite(newPath)) { + if (shouldBlockEntryWrite(oldPath) || shouldBlockEntryWrite(newPath)) { S.state._currentCmdPreview = null; return Promise.reject(throwReadOnly(oldPath)); } + if (shouldBlockRead(oldPath)) { + S.state._currentCmdPreview = null; + return Promise.reject(throwReadBlocked(oldPath)); + } S.state._currentCmdPreview = null; return _pRename.apply(this, arguments); }; @@ -625,7 +685,7 @@ function install(fsMod) { var _pUnlink = fsPromises.unlink; fsPromises.unlink = function (file) { S.state._currentCmdPreview = 'fs.promises.unlink("' + file + '")'; - if (shouldBlockWrite(file)) { + if (shouldBlockEntryWrite(file)) { S.state._currentCmdPreview = null; return Promise.reject(throwReadOnly(file)); } @@ -636,7 +696,7 @@ function install(fsMod) { var _pMkdir = fsPromises.mkdir; fsPromises.mkdir = function (dir) { S.state._currentCmdPreview = 'fs.promises.mkdir("' + dir + '")'; - if (shouldBlockWrite(dir)) { + if (shouldBlockEntryWrite(dir)) { S.state._currentCmdPreview = null; return Promise.reject(throwReadOnly(dir)); } @@ -647,7 +707,7 @@ function install(fsMod) { var _pRmdir = fsPromises.rmdir; fsPromises.rmdir = function (dir) { S.state._currentCmdPreview = 'fs.promises.rmdir("' + dir + '")'; - if (shouldBlockWrite(dir)) { + if (shouldBlockEntryWrite(dir)) { S.state._currentCmdPreview = null; return Promise.reject(throwReadOnly(dir)); } @@ -659,7 +719,7 @@ function install(fsMod) { var _pRm = fsPromises.rm; fsPromises.rm = function (p) { S.state._currentCmdPreview = 'fs.promises.rm("' + p + '")'; - if (shouldBlockWrite(p)) { + if (shouldBlockEntryWrite(p)) { S.state._currentCmdPreview = null; return Promise.reject(throwReadOnly(p)); } @@ -670,18 +730,16 @@ function install(fsMod) { var _pOpen = fsPromises.open; fsPromises.open = function (file, flags) { - var f = String(flags || "r"); + var f = String(flags === undefined ? "r" : flags); + var access = classifyOpenFlags(flags); S.state._currentCmdPreview = 'fs.promises.open("' + file + '", "' + f + '")'; - if (f === "r" || f === "rs" || f === "sr") { - if (shouldBlockRead(file)) { - S.state._currentCmdPreview = null; - return Promise.reject(throwReadBlocked(file)); - } - } else { - if (shouldBlockWrite(file)) { - S.state._currentCmdPreview = null; - return Promise.reject(throwReadOnly(file)); - } + if (access.read && shouldBlockRead(file)) { + S.state._currentCmdPreview = null; + return Promise.reject(throwReadBlocked(file)); + } + if (access.write && shouldBlockWrite(file)) { + S.state._currentCmdPreview = null; + return Promise.reject(throwReadOnly(file)); } S.state._currentCmdPreview = null; return _pOpen.apply(this, arguments); @@ -722,7 +780,10 @@ function install(fsMod) { if (fsPromises.symlink) { var _pSymlink = fsPromises.symlink; fsPromises.symlink = function (target, p) { - if (shouldBlockWrite(p)) return Promise.reject(throwReadOnly(p)); + if (shouldBlockEntryWrite(p)) return Promise.reject(throwReadOnly(p)); + if (shouldBlockRead(resolveSymlinkTarget(target, p))) { + return Promise.reject(throwReadBlocked(target)); + } return _pSymlink.apply(this, arguments); }; } @@ -730,7 +791,8 @@ function install(fsMod) { if (fsPromises.link) { var _pLink = fsPromises.link; fsPromises.link = function (existingPath, newPath) { - if (shouldBlockWrite(newPath)) return Promise.reject(throwReadOnly(newPath)); + if (shouldBlockEntryWrite(newPath)) return Promise.reject(throwReadOnly(newPath)); + if (shouldBlockRead(existingPath)) return Promise.reject(throwReadBlocked(existingPath)); return _pLink.apply(this, arguments); }; } @@ -745,9 +807,9 @@ function install(fsMod) { if (fsPromises.cp) { var _pCp = fsPromises.cp; - fsPromises.cp = function (src, dest) { + fsPromises.cp = function (src, dest, options) { if (shouldBlockWrite(dest)) return Promise.reject(throwReadOnly(dest)); - return _pCp.apply(this, arguments); + return _pCp.call(this, src, dest, withCopySourceFilter(options)); }; } @@ -766,4 +828,4 @@ function install(fsMod) { } } -module.exports = { install: install }; +module.exports = { install: install, classifyOpenFlags: classifyOpenFlags }; diff --git a/appcontainer/sandbox-permission.js b/appcontainer/sandbox-permission.js index 2a28514..980834e 100644 --- a/appcontainer/sandbox-permission.js +++ b/appcontainer/sandbox-permission.js @@ -10,6 +10,7 @@ var path = require("path"); var fs = require("fs"); var S = require(path.join(__dirname, "sandbox-state.js")); +var sensitive = require(path.join(__dirname, "sandbox-sensitive.js")); // ── Approval directory ── @@ -23,6 +24,8 @@ var _approvalDir = (function () { var _filePermReadAllowed = {}; // dir -> expiry (RO grants) var _filePermWriteAllowed = {}; // dir -> expiry (RW grants) var _filePermDenied = new Set(); // paths denied this session +var _sensitiveReadAllowed = {}; // exact file -> expiry +var _sensitiveReadDenied = new Set(); // exact files denied this session // Pending async permission requests: normalizedDir -> { resFile, timestamp }. var _pendingAsyncDirs = {}; @@ -32,6 +35,8 @@ function clearCaches() { _filePermReadAllowed = {}; _filePermWriteAllowed = {}; _filePermDenied.clear(); + _sensitiveReadAllowed = {}; + _sensitiveReadDenied.clear(); _pendingAsyncDirs = {}; } @@ -151,7 +156,7 @@ function handlePermissionDecision(decision, roDir, isWrite) { // ── File permission request (sync IPC) ── -function requestFilePermission(filePath, roDir, accessNeeded) { +function requestFilePermission(filePath, roDir, accessNeeded, requestKind) { if (!_approvalDir) return "deny"; try { fs.mkdirSync(_approvalDir, { recursive: true }); @@ -180,6 +185,7 @@ function requestFilePermission(filePath, roDir, accessNeeded) { command: S.state._currentCmdPreview ? S.state._currentCmdPreview.substring(0, 500) : null, callerStack: stackTrace.substring(0, 500) || null, responseFile: resFile, + requestKind: requestKind || "directory-access", }); process.stderr.write( "[sandbox] File permission requested via IPC for: " + @@ -211,11 +217,75 @@ function requestFilePermission(filePath, roDir, accessNeeded) { // ── shouldBlockWrite / shouldBlockRead ── -function shouldBlockWrite(filePath) { - if (!S.isBlockedPath(filePath)) return false; +function canonicalizeReadPath(filePath) { + var normalized = sensitive.normalizeFilePath(filePath); + if (!normalized) return ""; + try { + return fs.realpathSync(normalized); + } catch { + return normalized; + } +} + +function canonicalizeWritePath(filePath) { + var normalized = sensitive.normalizeFilePath(filePath); + if (!normalized) return ""; + var probe = normalized; + var missingParts = []; + while (true) { + try { + var canonical = fs.realpathSync(probe); + return missingParts.length > 0 ? path.join(canonical, ...missingParts) : canonical; + } catch { + var parent = path.dirname(probe); + if (parent === probe) return normalized; + missingParts.unshift(path.basename(probe)); + probe = parent; + } + } +} + +function canonicalizeEntryWritePath(filePath) { + var normalized = sensitive.normalizeFilePath(filePath); + if (!normalized) return ""; + var parent = path.dirname(normalized); + if (parent === normalized) return normalized; + return path.join(canonicalizeWritePath(parent), path.basename(normalized)); +} + +function shouldBlockSensitiveRead(filePath) { + if (S.state.privacyLevel === "basic") return false; + var normalized = sensitive.normalizeFilePath(filePath); + if (!normalized && filePath) return true; + var canonical = canonicalizeReadPath(normalized); + if (sensitive.isSensitivePath(canonical)) return true; + if (!sensitive.isSensitiveFile(normalized) && !sensitive.isSensitiveFile(canonical)) return false; + var resolved = path.resolve(canonical).toLowerCase(); + var now = Date.now(); + if (_sensitiveReadAllowed[resolved] && _sensitiveReadAllowed[resolved] > now) return false; + if (_sensitiveReadDenied.has(resolved)) return true; + + var decision = requestFilePermission( + path.resolve(normalized).toLowerCase(), + path.dirname(resolved), + "ro", + "sensitive-file", + ); + if (decision === "allow-once") { + _sensitiveReadAllowed[resolved] = Date.now() + 5000; + return false; + } + _sensitiveReadDenied.add(resolved); + return true; +} + +function shouldBlockCanonicalWrite(normalized, filePath) { + if (!normalized && filePath) return true; + if (sensitive.isSensitivePath(normalized)) return true; + if (!S.isBlockedPath(normalized)) return false; var resolved; try { - resolved = S.resolvePathLower(filePath); + resolved = S.resolvePathLower(normalized); } catch { return true; } @@ -245,11 +315,23 @@ function shouldBlockWrite(filePath) { } } +function shouldBlockWrite(filePath) { + return shouldBlockCanonicalWrite(canonicalizeWritePath(filePath), filePath); +} + +function shouldBlockEntryWrite(filePath) { + return shouldBlockCanonicalWrite(canonicalizeEntryWritePath(filePath), filePath); +} + function shouldBlockRead(filePath, shellContext) { - if (!S.isReadBlockedPath(filePath, shellContext)) return false; + var normalized = canonicalizeReadPath(filePath); + if (!normalized && filePath) return true; + if (sensitive.isSensitivePath(normalized)) return true; + if (shouldBlockSensitiveRead(filePath)) return true; + if (!S.isReadBlockedPath(normalized, shellContext)) return false; var resolved; try { - resolved = S.resolvePathLower(filePath); + resolved = S.resolvePathLower(normalized); } catch { return true; } @@ -479,7 +561,12 @@ module.exports = { cleanupPendingForAuthorizedDirs: cleanupPendingForAuthorizedDirs, // Permission checks shouldBlockWrite: shouldBlockWrite, + shouldBlockEntryWrite: shouldBlockEntryWrite, shouldBlockRead: shouldBlockRead, + shouldBlockSensitiveRead: shouldBlockSensitiveRead, + canonicalizeReadPath: canonicalizeReadPath, + canonicalizeWritePath: canonicalizeWritePath, + canonicalizeEntryWritePath: canonicalizeEntryWritePath, // Request functions requestFilePermission: requestFilePermission, requestShellPermission: requestShellPermission, diff --git a/appcontainer/sandbox-sensitive.js b/appcontainer/sandbox-sensitive.js index 4495e44..94e326b 100644 --- a/appcontainer/sandbox-sensitive.js +++ b/appcontainer/sandbox-sensitive.js @@ -8,10 +8,28 @@ var path = require("path"); var S = require(path.join(__dirname, "sandbox-state.js")); +var fileURLToPath = require("url").fileURLToPath; // ── Default sensitive directories (relative to user home) ── var SENSITIVE_DIRS = [".ssh", ".gnupg", ".aws", ".azure", path.join(".config", "gcloud")]; +var SENSITIVE_FILE_PATTERNS = [ + ".env", + ".env.*", + "*_key*", + "*.key", + "*.pem", + "*.crt", + "*.cer", + "*.p12", + "*.pfx", + "id_rsa", + "id_dsa", + "id_ecdsa", + "id_ed25519", + "credentials", + "credentials.*", +]; var _home = (process.env.USERPROFILE || process.env.HOME || "").toLowerCase(); var _resolvedSensitive = _home @@ -20,6 +38,31 @@ var _resolvedSensitive = _home }) : []; +function normalizeFilePath(filePath) { + if (!filePath) return ""; + var value = filePath; + if ( + typeof value === "object" && + value !== null && + String(value.protocol || "").toLowerCase() === "file:" + ) { + try { + return fileURLToPath(value); + } catch (e) { + return ""; + } + } + var raw = String(value); + if (/^file:/i.test(raw)) { + try { + return fileURLToPath(raw); + } catch (e) { + return ""; + } + } + return raw; +} + /** * Check if a file path is under a sensitive directory. * Pure function, no side effects, no IPC, no caching. @@ -28,7 +71,9 @@ function isSensitivePath(filePath) { if (!_home || !filePath) return false; var resolved; try { - resolved = S.resolvePathLower(filePath); + var normalized = normalizeFilePath(filePath); + if (!normalized) return false; + resolved = S.resolvePathLower(normalized); } catch (e) { return false; } @@ -39,6 +84,23 @@ function isSensitivePath(filePath) { return false; } +/** Check filename patterns that require confirmation before reads. */ +function isSensitiveFile(filePath) { + if (!filePath) return false; + var base; + try { + base = path.basename(normalizeFilePath(filePath)).split(":")[0].toLowerCase(); + } catch (e) { + return false; + } + if (!base) return false; + if (base === ".env" || base.indexOf(".env.") === 0) return true; + if (base.indexOf("_key") !== -1) return true; + if (/\.(?:key|pem|crt|cer|p12|pfx)$/.test(base)) return true; + if (/^id_(?:rsa|dsa|ecdsa|ed25519)$/.test(base)) return true; + return base === "credentials" || base.indexOf("credentials.") === 0; +} + /** * Create an Error for sensitive path access denial. * Message is distinct from normal permission denial to prevent @@ -66,7 +128,9 @@ function parentOfSensitive(dirPath) { if (!_home || !dirPath) return false; var resolved; try { - resolved = S.resolvePathLower(dirPath).replace(/[\\/]+$/, ""); + var normalized = normalizeFilePath(dirPath); + if (!normalized) return false; + resolved = S.resolvePathLower(normalized).replace(/[\\/]+$/, ""); } catch (e) { return false; } @@ -76,7 +140,10 @@ function parentOfSensitive(dirPath) { module.exports = { SENSITIVE_DIRS: SENSITIVE_DIRS, + SENSITIVE_FILE_PATTERNS: SENSITIVE_FILE_PATTERNS, + normalizeFilePath: normalizeFilePath, isSensitivePath: isSensitivePath, + isSensitiveFile: isSensitiveFile, parentOfSensitive: parentOfSensitive, throwSensitiveDenied: throwSensitiveDenied, }; diff --git a/appcontainer/sandbox-state.js b/appcontainer/sandbox-state.js index 737454f..f03c706 100644 --- a/appcontainer/sandbox-state.js +++ b/appcontainer/sandbox-state.js @@ -38,6 +38,9 @@ var state = { _roDirs: [], _rwDirs: [], _currentCmdPreview: null, + privacyLevel: /^(basic|balanced|strict)$/.test(process.env.OPENCLAW_PRIVACY_LEVEL || "") + ? process.env.OPENCLAW_PRIVACY_LEVEL + : "balanced", }; // ── Directory initialization ── @@ -281,27 +284,40 @@ function throwReadBlocked(filePath) { // ── IPC message handling for state updates ── +function handleMessage(msg) { + if (!msg) return; + if (msg.type === "sandbox-session-changed") { + var perm = require(path.join(__dirname, "sandbox-permission.js")); + perm.clearCaches(); + process.stderr.write("[sandbox] Session changed — cleared file permission caches\n"); + } else if (msg.type === "sandbox-dirs-updated") { + state._roDirs = normDirList((msg.ro || []).join(",")); + state._rwDirs = normDirList((msg.rw || []).join(",")); + process.env.OPENCLAW_SANDBOX_DIRS_RO = (msg.ro || []).join(","); + process.env.OPENCLAW_SANDBOX_DIRS_RW = (msg.rw || []).join(","); + var perm = require(path.join(__dirname, "sandbox-permission.js")); + perm.cleanupPendingForAuthorizedDirs(state._rwDirs, state._roDirs); + process.stderr.write( + "[sandbox] Dirs updated — RO: " + + state._roDirs.length + + ", RW: " + + state._rwDirs.length + + "\n", + ); + } else if ( + msg.type === "sandbox-privacy-updated" && + /^(basic|balanced|strict)$/.test(msg.privacyLevel || "") + ) { + state.privacyLevel = msg.privacyLevel; + process.env.OPENCLAW_PRIVACY_LEVEL = state.privacyLevel; + var perm = require(path.join(__dirname, "sandbox-permission.js")); + perm.clearCaches(); + process.stderr.write("[sandbox] Privacy level updated: " + state.privacyLevel + "\n"); + } +} + function setupMessageHandler() { - process.on("message", function (msg) { - if (!msg) return; - if (msg.type === "sandbox-session-changed") { - var perm = require(path.join(__dirname, "sandbox-permission.js")); - perm.clearCaches(); - process.stderr.write("[sandbox] Session changed — cleared file permission caches\n"); - } else if (msg.type === "sandbox-dirs-updated") { - state._roDirs = normDirList((msg.ro || []).join(",")); - state._rwDirs = normDirList((msg.rw || []).join(",")); - var perm = require(path.join(__dirname, "sandbox-permission.js")); - perm.cleanupPendingForAuthorizedDirs(state._rwDirs, state._roDirs); - process.stderr.write( - "[sandbox] Dirs updated — RO: " + - state._roDirs.length + - ", RW: " + - state._rwDirs.length + - "\n", - ); - } - }); + process.on("message", handleMessage); } // ── Exports ── @@ -329,5 +345,6 @@ module.exports = { throwReadBlocked: throwReadBlocked, _safePaths: _safePaths, // Setup + handleMessage: handleMessage, setupMessageHandler: setupMessageHandler, }; diff --git a/desktop/renderer/env.d.ts b/desktop/renderer/env.d.ts index 8c65b8e..e575c77 100644 --- a/desktop/renderer/env.d.ts +++ b/desktop/renderer/env.d.ts @@ -23,6 +23,13 @@ interface AppSettings { themeMode: string; accentColor: string; privacyLevel: string; + piiDetection: { + phone: boolean; + idCard: boolean; + bankCard: boolean; + email: boolean; + apiKey: boolean; + }; } interface SkillEntry { @@ -252,10 +259,12 @@ interface OpenClawAPI { onPermissionRequest( callback: (data: { requestId: string; - type: "file" | "shell" | "shell-async"; + type: "file" | "sensitive-file" | "shell" | "shell-async" | "app-approval"; targetPath: string; dirPath: string; command?: string; + callerStack?: string; + accessNeeded?: string; }) => void, ): () => void; respondPermission(requestId: string, decision: string): Promise; diff --git a/desktop/renderer/src/App.vue b/desktop/renderer/src/App.vue index e5ec171..1299062 100644 --- a/desktop/renderer/src/App.vue +++ b/desktop/renderer/src/App.vue @@ -211,7 +211,7 @@ const integrityLoading = ref(false); // ── Permission dialog state (queue of pending requests) ── interface PermissionRequestData { requestId: string; - type: "file" | "shell" | "shell-async" | "app-approval"; + type: "file" | "sensitive-file" | "shell" | "shell-async" | "app-approval"; targetPath: string; dirPath: string; command?: string; @@ -377,8 +377,10 @@ onMounted(async () => { // Add a pending entry to the exec panel so user sees what's waiting for permission if (chatStore.streaming) { const path = data.targetPath || data.dirPath; - const access = data.accessNeeded === "ro" ? "Read" : "Write"; - const label = `${access} permission: ${path}`; + const label = + data.type === "sensitive-file" + ? t("perm.sensitiveFilePending", { path }) + : `${data.accessNeeded === "ro" ? "Read" : "Write"} permission: ${path}`; chatStore.addPendingToolCall(data.requestId, label); } }); diff --git a/desktop/renderer/src/components/PermissionDialog.vue b/desktop/renderer/src/components/PermissionDialog.vue index 5e4462a..30f124b 100644 --- a/desktop/renderer/src/components/PermissionDialog.vue +++ b/desktop/renderer/src/components/PermissionDialog.vue @@ -10,7 +10,7 @@ const AUTO_DENY_SECONDS = 60; interface PermissionRequest { requestId: string; - type: "file" | "shell" | "shell-async" | "app-approval"; + type: "file" | "sensitive-file" | "shell" | "shell-async" | "app-approval"; targetPath?: string; dirPath?: string; command?: string; @@ -72,6 +72,10 @@ const descriptionHtml = computed(() => { const appName = `${escapeHtml(props.request.app || "")}`; return t("perm.appDesc", { app: appName }); } + if (props.request.type === "sensitive-file") { + const file = `${escapeHtml(props.request.targetPath || "")}`; + return t("perm.sensitiveFileDesc", { file }); + } const dir = `${escapeHtml(props.request.dirPath || "")}`; const access = props.request.accessNeeded; if (props.request.type === "file") { @@ -90,13 +94,15 @@ const commandHtml = computed(() => { }); const isAppApproval = computed(() => props.request?.type === "app-approval"); +const isSensitiveFile = computed(() => props.request?.type === "sensitive-file"); const isShellRequest = computed( () => props.request?.type === "shell" || props.request?.type === "shell-async", ); const isReadOnly = computed(() => props.request?.accessNeeded === "ro"); const riskLevel = computed<"low" | "medium" | "high">(() => { if (!props.request) return "low"; - if (props.request.type === "app-approval") return "high"; + if (props.request.type === "app-approval" || props.request.type === "sensitive-file") + return "high"; if (isShellRequest.value) return isReadOnly.value ? "medium" : "high"; return isReadOnly.value ? "low" : "medium"; }); @@ -104,18 +110,21 @@ const isModal = computed(() => riskLevel.value !== "low"); const dialogIcon = computed(() => { if (!props.request) return "📁"; if (props.request.type === "app-approval") return "🚀"; + if (props.request.type === "sensitive-file") return "🔐"; if (isShellRequest.value) return "⌨"; return isReadOnly.value ? "📁" : "✎"; }); const dialogTitle = computed(() => { if (!props.request) return t("perm.title"); if (props.request.type === "app-approval") return t("perm.titleApp"); + if (props.request.type === "sensitive-file") return t("perm.titleSensitiveFile"); if (isShellRequest.value) return t("perm.titleCommand"); return isReadOnly.value ? t("perm.titleRead") : t("perm.titleWrite"); }); const riskHint = computed(() => { if (!props.request) return ""; if (props.request.type === "app-approval") return t("perm.riskApp"); + if (props.request.type === "sensitive-file") return t("perm.riskSensitiveFile"); if (isShellRequest.value) return isReadOnly.value ? t("perm.riskCommandRO") : t("perm.riskCommandRW"); return isReadOnly.value ? t("perm.riskRead") : t("perm.riskWrite"); @@ -193,6 +202,11 @@ function respond(decision: string) { {{ t("perm.allowAlways") }} +