pi-codex-auto-review 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +201 -0
- package/NOTICE +10 -0
- package/README.md +77 -0
- package/dist/approvals.d.ts +72 -0
- package/dist/approvals.js +167 -0
- package/dist/approvals.js.map +1 -0
- package/dist/audit.d.ts +28 -0
- package/dist/audit.js +40 -0
- package/dist/audit.js.map +1 -0
- package/dist/cli.d.ts +2 -0
- package/dist/cli.js +70 -0
- package/dist/cli.js.map +1 -0
- package/dist/context-files.d.ts +14 -0
- package/dist/context-files.js +113 -0
- package/dist/context-files.js.map +1 -0
- package/dist/contracts.d.ts +258 -0
- package/dist/contracts.js +102 -0
- package/dist/contracts.js.map +1 -0
- package/dist/index.d.ts +28 -0
- package/dist/index.js +106 -0
- package/dist/index.js.map +1 -0
- package/dist/native/darwin-arm64/artifact.json +5 -0
- package/dist/native/darwin-arm64/pi-guard-execpolicy +0 -0
- package/dist/native/linux-x64/artifact.json +5 -0
- package/dist/native/linux-x64/pi-guard-execpolicy +0 -0
- package/dist/policy/domains.d.ts +5 -0
- package/dist/policy/domains.js +18 -0
- package/dist/policy/domains.js.map +1 -0
- package/dist/policy/index.d.ts +56 -0
- package/dist/policy/index.js +304 -0
- package/dist/policy/index.js.map +1 -0
- package/dist/policy/paths.d.ts +5 -0
- package/dist/policy/paths.js +54 -0
- package/dist/policy/paths.js.map +1 -0
- package/dist/policy/rules.d.ts +36 -0
- package/dist/policy/rules.js +61 -0
- package/dist/policy/rules.js.map +1 -0
- package/dist/policy/shell.d.ts +14 -0
- package/dist/policy/shell.js +122 -0
- package/dist/policy/shell.js.map +1 -0
- package/dist/reports.d.ts +21 -0
- package/dist/reports.js +42 -0
- package/dist/reports.js.map +1 -0
- package/dist/review/context.d.ts +25 -0
- package/dist/review/context.js +108 -0
- package/dist/review/context.js.map +1 -0
- package/dist/review/investigation.d.ts +39 -0
- package/dist/review/investigation.js +36 -0
- package/dist/review/investigation.js.map +1 -0
- package/dist/review/lifecycle.d.ts +29 -0
- package/dist/review/lifecycle.js +71 -0
- package/dist/review/lifecycle.js.map +1 -0
- package/dist/review/policy.d.ts +4 -0
- package/dist/review/policy.js +15 -0
- package/dist/review/policy.js.map +1 -0
- package/dist/review/upstream-policy.d.ts +2 -0
- package/dist/review/upstream-policy.js +210 -0
- package/dist/review/upstream-policy.js.map +1 -0
- package/dist/reviewer.d.ts +45 -0
- package/dist/reviewer.js +148 -0
- package/dist/reviewer.js.map +1 -0
- package/dist/sandbox/broker.d.ts +1 -0
- package/dist/sandbox/broker.js +135 -0
- package/dist/sandbox/broker.js.map +1 -0
- package/dist/sandbox/config.d.ts +5 -0
- package/dist/sandbox/config.js +91 -0
- package/dist/sandbox/config.js.map +1 -0
- package/dist/sandbox/executor.d.ts +20 -0
- package/dist/sandbox/executor.js +144 -0
- package/dist/sandbox/executor.js.map +1 -0
- package/dist/sandbox/hard-links.d.ts +4 -0
- package/dist/sandbox/hard-links.js +145 -0
- package/dist/sandbox/hard-links.js.map +1 -0
- package/dist/sandbox/linux-read-paths.d.ts +7 -0
- package/dist/sandbox/linux-read-paths.js +47 -0
- package/dist/sandbox/linux-read-paths.js.map +1 -0
- package/dist/sandbox/paths.d.ts +3 -0
- package/dist/sandbox/paths.js +10 -0
- package/dist/sandbox/paths.js.map +1 -0
- package/dist/sandbox/runtime-write-paths.d.ts +2 -0
- package/dist/sandbox/runtime-write-paths.js +8 -0
- package/dist/sandbox/runtime-write-paths.js.map +1 -0
- package/dist/sandbox/seccomp.d.ts +5 -0
- package/dist/sandbox/seccomp.js +16 -0
- package/dist/sandbox/seccomp.js.map +1 -0
- package/dist/sandbox/worker.d.ts +1 -0
- package/dist/sandbox/worker.js +73 -0
- package/dist/sandbox/worker.js.map +1 -0
- package/dist/signals.d.ts +10 -0
- package/dist/signals.js +21 -0
- package/dist/signals.js.map +1 -0
- package/dist/startup.d.ts +22 -0
- package/dist/startup.js +100 -0
- package/dist/startup.js.map +1 -0
- package/dist/tools/controller.d.ts +63 -0
- package/dist/tools/controller.js +286 -0
- package/dist/tools/controller.js.map +1 -0
- package/dist/tools/external.d.ts +30 -0
- package/dist/tools/external.js +24 -0
- package/dist/tools/external.js.map +1 -0
- package/dist/tools/mcp.d.ts +17 -0
- package/dist/tools/mcp.js +186 -0
- package/dist/tools/mcp.js.map +1 -0
- package/docs/publishing.md +73 -0
- package/docs/security/dependencies.md +43 -0
- package/docs/testing/auto-review-protection.md +130 -0
- package/docs/usage.md +217 -0
- package/native/execpolicy/LICENSE +201 -0
- package/native/execpolicy/NOTICE +6 -0
- package/node_modules/@anthropic-ai/sandbox-runtime/LICENSE +201 -0
- package/node_modules/@anthropic-ai/sandbox-runtime/README.md +901 -0
- package/node_modules/@anthropic-ai/sandbox-runtime/dist/cli.d.ts +3 -0
- package/node_modules/@anthropic-ai/sandbox-runtime/dist/cli.d.ts.map +1 -0
- package/node_modules/@anthropic-ai/sandbox-runtime/dist/cli.js +472 -0
- package/node_modules/@anthropic-ai/sandbox-runtime/dist/cli.js.map +1 -0
- package/node_modules/@anthropic-ai/sandbox-runtime/dist/index.d.ts +19 -0
- package/node_modules/@anthropic-ai/sandbox-runtime/dist/index.d.ts.map +1 -0
- package/node_modules/@anthropic-ai/sandbox-runtime/dist/index.js +17 -0
- package/node_modules/@anthropic-ai/sandbox-runtime/dist/index.js.map +1 -0
- package/node_modules/@anthropic-ai/sandbox-runtime/dist/sandbox/address.d.ts +52 -0
- package/node_modules/@anthropic-ai/sandbox-runtime/dist/sandbox/address.d.ts.map +1 -0
- package/node_modules/@anthropic-ai/sandbox-runtime/dist/sandbox/address.js +153 -0
- package/node_modules/@anthropic-ai/sandbox-runtime/dist/sandbox/address.js.map +1 -0
- package/node_modules/@anthropic-ai/sandbox-runtime/dist/sandbox/aws-sigv4.d.ts +164 -0
- package/node_modules/@anthropic-ai/sandbox-runtime/dist/sandbox/aws-sigv4.d.ts.map +1 -0
- package/node_modules/@anthropic-ai/sandbox-runtime/dist/sandbox/aws-sigv4.js +246 -0
- package/node_modules/@anthropic-ai/sandbox-runtime/dist/sandbox/aws-sigv4.js.map +1 -0
- package/node_modules/@anthropic-ai/sandbox-runtime/dist/sandbox/body-substitution.d.ts +58 -0
- package/node_modules/@anthropic-ai/sandbox-runtime/dist/sandbox/body-substitution.d.ts.map +1 -0
- package/node_modules/@anthropic-ai/sandbox-runtime/dist/sandbox/body-substitution.js +128 -0
- package/node_modules/@anthropic-ai/sandbox-runtime/dist/sandbox/body-substitution.js.map +1 -0
- package/node_modules/@anthropic-ai/sandbox-runtime/dist/sandbox/credential-aws-pairs.d.ts +124 -0
- package/node_modules/@anthropic-ai/sandbox-runtime/dist/sandbox/credential-aws-pairs.d.ts.map +1 -0
- package/node_modules/@anthropic-ai/sandbox-runtime/dist/sandbox/credential-aws-pairs.js +258 -0
- package/node_modules/@anthropic-ai/sandbox-runtime/dist/sandbox/credential-aws-pairs.js.map +1 -0
- package/node_modules/@anthropic-ai/sandbox-runtime/dist/sandbox/credential-decode.d.ts +87 -0
- package/node_modules/@anthropic-ai/sandbox-runtime/dist/sandbox/credential-decode.d.ts.map +1 -0
- package/node_modules/@anthropic-ai/sandbox-runtime/dist/sandbox/credential-decode.js +138 -0
- package/node_modules/@anthropic-ai/sandbox-runtime/dist/sandbox/credential-decode.js.map +1 -0
- package/node_modules/@anthropic-ai/sandbox-runtime/dist/sandbox/credential-extract.d.ts +67 -0
- package/node_modules/@anthropic-ai/sandbox-runtime/dist/sandbox/credential-extract.d.ts.map +1 -0
- package/node_modules/@anthropic-ai/sandbox-runtime/dist/sandbox/credential-extract.js +113 -0
- package/node_modules/@anthropic-ai/sandbox-runtime/dist/sandbox/credential-extract.js.map +1 -0
- package/node_modules/@anthropic-ai/sandbox-runtime/dist/sandbox/credential-mask-env.d.ts +70 -0
- package/node_modules/@anthropic-ai/sandbox-runtime/dist/sandbox/credential-mask-env.d.ts.map +1 -0
- package/node_modules/@anthropic-ai/sandbox-runtime/dist/sandbox/credential-mask-env.js +171 -0
- package/node_modules/@anthropic-ai/sandbox-runtime/dist/sandbox/credential-mask-env.js.map +1 -0
- package/node_modules/@anthropic-ai/sandbox-runtime/dist/sandbox/credential-mask-files.d.ts +120 -0
- package/node_modules/@anthropic-ai/sandbox-runtime/dist/sandbox/credential-mask-files.d.ts.map +1 -0
- package/node_modules/@anthropic-ai/sandbox-runtime/dist/sandbox/credential-mask-files.js +281 -0
- package/node_modules/@anthropic-ai/sandbox-runtime/dist/sandbox/credential-mask-files.js.map +1 -0
- package/node_modules/@anthropic-ai/sandbox-runtime/dist/sandbox/credential-sentinel.d.ts +108 -0
- package/node_modules/@anthropic-ai/sandbox-runtime/dist/sandbox/credential-sentinel.d.ts.map +1 -0
- package/node_modules/@anthropic-ai/sandbox-runtime/dist/sandbox/credential-sentinel.js +208 -0
- package/node_modules/@anthropic-ai/sandbox-runtime/dist/sandbox/credential-sentinel.js.map +1 -0
- package/node_modules/@anthropic-ai/sandbox-runtime/dist/sandbox/domain-pattern.d.ts +82 -0
- package/node_modules/@anthropic-ai/sandbox-runtime/dist/sandbox/domain-pattern.d.ts.map +1 -0
- package/node_modules/@anthropic-ai/sandbox-runtime/dist/sandbox/domain-pattern.js +155 -0
- package/node_modules/@anthropic-ai/sandbox-runtime/dist/sandbox/domain-pattern.js.map +1 -0
- package/node_modules/@anthropic-ai/sandbox-runtime/dist/sandbox/generate-seccomp-filter.d.ts +44 -0
- package/node_modules/@anthropic-ai/sandbox-runtime/dist/sandbox/generate-seccomp-filter.d.ts.map +1 -0
- package/node_modules/@anthropic-ai/sandbox-runtime/dist/sandbox/generate-seccomp-filter.js +220 -0
- package/node_modules/@anthropic-ai/sandbox-runtime/dist/sandbox/generate-seccomp-filter.js.map +1 -0
- package/node_modules/@anthropic-ai/sandbox-runtime/dist/sandbox/http-proxy.d.ts +150 -0
- package/node_modules/@anthropic-ai/sandbox-runtime/dist/sandbox/http-proxy.d.ts.map +1 -0
- package/node_modules/@anthropic-ai/sandbox-runtime/dist/sandbox/http-proxy.js +678 -0
- package/node_modules/@anthropic-ai/sandbox-runtime/dist/sandbox/http-proxy.js.map +1 -0
- package/node_modules/@anthropic-ai/sandbox-runtime/dist/sandbox/java-proxy-agent.d.ts +32 -0
- package/node_modules/@anthropic-ai/sandbox-runtime/dist/sandbox/java-proxy-agent.d.ts.map +1 -0
- package/node_modules/@anthropic-ai/sandbox-runtime/dist/sandbox/java-proxy-agent.js +111 -0
- package/node_modules/@anthropic-ai/sandbox-runtime/dist/sandbox/java-proxy-agent.js.map +1 -0
- package/node_modules/@anthropic-ai/sandbox-runtime/dist/sandbox/linux-sandbox-utils.d.ts +305 -0
- package/node_modules/@anthropic-ai/sandbox-runtime/dist/sandbox/linux-sandbox-utils.d.ts.map +1 -0
- package/node_modules/@anthropic-ai/sandbox-runtime/dist/sandbox/linux-sandbox-utils.js +2605 -0
- package/node_modules/@anthropic-ai/sandbox-runtime/dist/sandbox/linux-sandbox-utils.js.map +1 -0
- package/node_modules/@anthropic-ai/sandbox-runtime/dist/sandbox/linux-violation-monitor.d.ts +55 -0
- package/node_modules/@anthropic-ai/sandbox-runtime/dist/sandbox/linux-violation-monitor.d.ts.map +1 -0
- package/node_modules/@anthropic-ai/sandbox-runtime/dist/sandbox/linux-violation-monitor.js +152 -0
- package/node_modules/@anthropic-ai/sandbox-runtime/dist/sandbox/linux-violation-monitor.js.map +1 -0
- package/node_modules/@anthropic-ai/sandbox-runtime/dist/sandbox/listen-in-range.d.ts +12 -0
- package/node_modules/@anthropic-ai/sandbox-runtime/dist/sandbox/listen-in-range.d.ts.map +1 -0
- package/node_modules/@anthropic-ai/sandbox-runtime/dist/sandbox/listen-in-range.js +43 -0
- package/node_modules/@anthropic-ai/sandbox-runtime/dist/sandbox/listen-in-range.js.map +1 -0
- package/node_modules/@anthropic-ai/sandbox-runtime/dist/sandbox/macos-sandbox-utils.d.ts +116 -0
- package/node_modules/@anthropic-ai/sandbox-runtime/dist/sandbox/macos-sandbox-utils.d.ts.map +1 -0
- package/node_modules/@anthropic-ai/sandbox-runtime/dist/sandbox/macos-sandbox-utils.js +1195 -0
- package/node_modules/@anthropic-ai/sandbox-runtime/dist/sandbox/macos-sandbox-utils.js.map +1 -0
- package/node_modules/@anthropic-ai/sandbox-runtime/dist/sandbox/mitm-ca.d.ts +200 -0
- package/node_modules/@anthropic-ai/sandbox-runtime/dist/sandbox/mitm-ca.d.ts.map +1 -0
- package/node_modules/@anthropic-ai/sandbox-runtime/dist/sandbox/mitm-ca.js +443 -0
- package/node_modules/@anthropic-ai/sandbox-runtime/dist/sandbox/mitm-ca.js.map +1 -0
- package/node_modules/@anthropic-ai/sandbox-runtime/dist/sandbox/mitm-leaf.d.ts +28 -0
- package/node_modules/@anthropic-ai/sandbox-runtime/dist/sandbox/mitm-leaf.d.ts.map +1 -0
- package/node_modules/@anthropic-ai/sandbox-runtime/dist/sandbox/mitm-leaf.js +114 -0
- package/node_modules/@anthropic-ai/sandbox-runtime/dist/sandbox/mitm-leaf.js.map +1 -0
- package/node_modules/@anthropic-ai/sandbox-runtime/dist/sandbox/mux-proxy.d.ts +59 -0
- package/node_modules/@anthropic-ai/sandbox-runtime/dist/sandbox/mux-proxy.d.ts.map +1 -0
- package/node_modules/@anthropic-ai/sandbox-runtime/dist/sandbox/mux-proxy.js +159 -0
- package/node_modules/@anthropic-ai/sandbox-runtime/dist/sandbox/mux-proxy.js.map +1 -0
- package/node_modules/@anthropic-ai/sandbox-runtime/dist/sandbox/parent-proxy.d.ts +147 -0
- package/node_modules/@anthropic-ai/sandbox-runtime/dist/sandbox/parent-proxy.d.ts.map +1 -0
- package/node_modules/@anthropic-ai/sandbox-runtime/dist/sandbox/parent-proxy.js +445 -0
- package/node_modules/@anthropic-ai/sandbox-runtime/dist/sandbox/parent-proxy.js.map +1 -0
- package/node_modules/@anthropic-ai/sandbox-runtime/dist/sandbox/read-deny-glob.d.ts +17 -0
- package/node_modules/@anthropic-ai/sandbox-runtime/dist/sandbox/read-deny-glob.d.ts.map +1 -0
- package/node_modules/@anthropic-ai/sandbox-runtime/dist/sandbox/read-deny-glob.js +163 -0
- package/node_modules/@anthropic-ai/sandbox-runtime/dist/sandbox/read-deny-glob.js.map +1 -0
- package/node_modules/@anthropic-ai/sandbox-runtime/dist/sandbox/request-filter.d.ts +83 -0
- package/node_modules/@anthropic-ai/sandbox-runtime/dist/sandbox/request-filter.d.ts.map +1 -0
- package/node_modules/@anthropic-ai/sandbox-runtime/dist/sandbox/request-filter.js +212 -0
- package/node_modules/@anthropic-ai/sandbox-runtime/dist/sandbox/request-filter.js.map +1 -0
- package/node_modules/@anthropic-ai/sandbox-runtime/dist/sandbox/resolved-address-guard.d.ts +76 -0
- package/node_modules/@anthropic-ai/sandbox-runtime/dist/sandbox/resolved-address-guard.d.ts.map +1 -0
- package/node_modules/@anthropic-ai/sandbox-runtime/dist/sandbox/resolved-address-guard.js +221 -0
- package/node_modules/@anthropic-ai/sandbox-runtime/dist/sandbox/resolved-address-guard.js.map +1 -0
- package/node_modules/@anthropic-ai/sandbox-runtime/dist/sandbox/sandbox-config.d.ts +1691 -0
- package/node_modules/@anthropic-ai/sandbox-runtime/dist/sandbox/sandbox-config.d.ts.map +1 -0
- package/node_modules/@anthropic-ai/sandbox-runtime/dist/sandbox/sandbox-config.js +1233 -0
- package/node_modules/@anthropic-ai/sandbox-runtime/dist/sandbox/sandbox-config.js.map +1 -0
- package/node_modules/@anthropic-ai/sandbox-runtime/dist/sandbox/sandbox-manager.d.ts +97 -0
- package/node_modules/@anthropic-ai/sandbox-runtime/dist/sandbox/sandbox-manager.d.ts.map +1 -0
- package/node_modules/@anthropic-ai/sandbox-runtime/dist/sandbox/sandbox-manager.js +1937 -0
- package/node_modules/@anthropic-ai/sandbox-runtime/dist/sandbox/sandbox-manager.js.map +1 -0
- package/node_modules/@anthropic-ai/sandbox-runtime/dist/sandbox/sandbox-schemas.d.ts +113 -0
- package/node_modules/@anthropic-ai/sandbox-runtime/dist/sandbox/sandbox-schemas.d.ts.map +1 -0
- package/node_modules/@anthropic-ai/sandbox-runtime/dist/sandbox/sandbox-schemas.js +3 -0
- package/node_modules/@anthropic-ai/sandbox-runtime/dist/sandbox/sandbox-schemas.js.map +1 -0
- package/node_modules/@anthropic-ai/sandbox-runtime/dist/sandbox/sandbox-utils.d.ts +411 -0
- package/node_modules/@anthropic-ai/sandbox-runtime/dist/sandbox/sandbox-utils.d.ts.map +1 -0
- package/node_modules/@anthropic-ai/sandbox-runtime/dist/sandbox/sandbox-utils.js +1408 -0
- package/node_modules/@anthropic-ai/sandbox-runtime/dist/sandbox/sandbox-utils.js.map +1 -0
- package/node_modules/@anthropic-ai/sandbox-runtime/dist/sandbox/sandbox-violation-store.d.ts +37 -0
- package/node_modules/@anthropic-ai/sandbox-runtime/dist/sandbox/sandbox-violation-store.d.ts.map +1 -0
- package/node_modules/@anthropic-ai/sandbox-runtime/dist/sandbox/sandbox-violation-store.js +125 -0
- package/node_modules/@anthropic-ai/sandbox-runtime/dist/sandbox/sandbox-violation-store.js.map +1 -0
- package/node_modules/@anthropic-ai/sandbox-runtime/dist/sandbox/socks-proxy.d.ts +57 -0
- package/node_modules/@anthropic-ai/sandbox-runtime/dist/sandbox/socks-proxy.d.ts.map +1 -0
- package/node_modules/@anthropic-ai/sandbox-runtime/dist/sandbox/socks-proxy.js +332 -0
- package/node_modules/@anthropic-ai/sandbox-runtime/dist/sandbox/socks-proxy.js.map +1 -0
- package/node_modules/@anthropic-ai/sandbox-runtime/dist/sandbox/tls-terminate-proxy.d.ts +100 -0
- package/node_modules/@anthropic-ai/sandbox-runtime/dist/sandbox/tls-terminate-proxy.d.ts.map +1 -0
- package/node_modules/@anthropic-ai/sandbox-runtime/dist/sandbox/tls-terminate-proxy.js +546 -0
- package/node_modules/@anthropic-ai/sandbox-runtime/dist/sandbox/tls-terminate-proxy.js.map +1 -0
- package/node_modules/@anthropic-ai/sandbox-runtime/dist/sandbox/windows-sandbox-utils.d.ts +923 -0
- package/node_modules/@anthropic-ai/sandbox-runtime/dist/sandbox/windows-sandbox-utils.d.ts.map +1 -0
- package/node_modules/@anthropic-ai/sandbox-runtime/dist/sandbox/windows-sandbox-utils.js +1419 -0
- package/node_modules/@anthropic-ai/sandbox-runtime/dist/sandbox/windows-sandbox-utils.js.map +1 -0
- package/node_modules/@anthropic-ai/sandbox-runtime/dist/utils/config-loader.d.ts +31 -0
- package/node_modules/@anthropic-ai/sandbox-runtime/dist/utils/config-loader.d.ts.map +1 -0
- package/node_modules/@anthropic-ai/sandbox-runtime/dist/utils/config-loader.js +69 -0
- package/node_modules/@anthropic-ai/sandbox-runtime/dist/utils/config-loader.js.map +1 -0
- package/node_modules/@anthropic-ai/sandbox-runtime/dist/utils/debug.d.ts +7 -0
- package/node_modules/@anthropic-ai/sandbox-runtime/dist/utils/debug.d.ts.map +1 -0
- package/node_modules/@anthropic-ai/sandbox-runtime/dist/utils/debug.js +25 -0
- package/node_modules/@anthropic-ai/sandbox-runtime/dist/utils/debug.js.map +1 -0
- package/node_modules/@anthropic-ai/sandbox-runtime/dist/utils/platform.d.ts +15 -0
- package/node_modules/@anthropic-ai/sandbox-runtime/dist/utils/platform.d.ts.map +1 -0
- package/node_modules/@anthropic-ai/sandbox-runtime/dist/utils/platform.js +49 -0
- package/node_modules/@anthropic-ai/sandbox-runtime/dist/utils/platform.js.map +1 -0
- package/node_modules/@anthropic-ai/sandbox-runtime/dist/utils/ripgrep.d.ts +17 -0
- package/node_modules/@anthropic-ai/sandbox-runtime/dist/utils/ripgrep.d.ts.map +1 -0
- package/node_modules/@anthropic-ai/sandbox-runtime/dist/utils/ripgrep.js +37 -0
- package/node_modules/@anthropic-ai/sandbox-runtime/dist/utils/ripgrep.js.map +1 -0
- package/node_modules/@anthropic-ai/sandbox-runtime/dist/utils/shell-quote.d.ts +27 -0
- package/node_modules/@anthropic-ai/sandbox-runtime/dist/utils/shell-quote.d.ts.map +1 -0
- package/node_modules/@anthropic-ai/sandbox-runtime/dist/utils/shell-quote.js +47 -0
- package/node_modules/@anthropic-ai/sandbox-runtime/dist/utils/shell-quote.js.map +1 -0
- package/node_modules/@anthropic-ai/sandbox-runtime/dist/utils/which.d.ts +9 -0
- package/node_modules/@anthropic-ai/sandbox-runtime/dist/utils/which.d.ts.map +1 -0
- package/node_modules/@anthropic-ai/sandbox-runtime/dist/utils/which.js +25 -0
- package/node_modules/@anthropic-ai/sandbox-runtime/dist/utils/which.js.map +1 -0
- package/node_modules/@anthropic-ai/sandbox-runtime/package.json +88 -0
- package/node_modules/@anthropic-ai/sandbox-runtime/vendor/java-proxy-agent/build.ts +95 -0
- package/node_modules/@anthropic-ai/sandbox-runtime/vendor/java-proxy-agent/srt-proxy-agent.jar +0 -0
- package/node_modules/@anthropic-ai/sandbox-runtime/vendor/seccomp/arm64/apply-seccomp +0 -0
- package/node_modules/@anthropic-ai/sandbox-runtime/vendor/seccomp/build.ts +69 -0
- package/node_modules/@anthropic-ai/sandbox-runtime/vendor/seccomp/x64/apply-seccomp +0 -0
- package/node_modules/@anthropic-ai/sandbox-runtime/vendor/srt-win/arm64/srt-win.exe +0 -0
- package/node_modules/@anthropic-ai/sandbox-runtime/vendor/srt-win/build.ts +28 -0
- package/node_modules/@anthropic-ai/sandbox-runtime/vendor/srt-win/x64/srt-win.exe +0 -0
- package/node_modules/@pondwader/socks5-server/.github/workflows/ci.yml +27 -0
- package/node_modules/@pondwader/socks5-server/LICENSE +20 -0
- package/node_modules/@pondwader/socks5-server/README.md +151 -0
- package/node_modules/@pondwader/socks5-server/dist/index.d.mts +87 -0
- package/node_modules/@pondwader/socks5-server/dist/index.d.ts +87 -0
- package/node_modules/@pondwader/socks5-server/dist/index.js +346 -0
- package/node_modules/@pondwader/socks5-server/dist/index.mjs +307 -0
- package/node_modules/@pondwader/socks5-server/package.json +31 -0
- package/node_modules/commander/LICENSE +22 -0
- package/node_modules/commander/Readme.md +1157 -0
- package/node_modules/commander/esm.mjs +16 -0
- package/node_modules/commander/index.js +24 -0
- package/node_modules/commander/lib/argument.js +149 -0
- package/node_modules/commander/lib/command.js +2509 -0
- package/node_modules/commander/lib/error.js +39 -0
- package/node_modules/commander/lib/help.js +520 -0
- package/node_modules/commander/lib/option.js +330 -0
- package/node_modules/commander/lib/suggestSimilar.js +101 -0
- package/node_modules/commander/package-support.json +16 -0
- package/node_modules/commander/package.json +84 -0
- package/node_modules/commander/typings/esm.d.mts +3 -0
- package/node_modules/commander/typings/index.d.ts +969 -0
- package/node_modules/node-forge/.editorconfig +17 -0
- package/node_modules/node-forge/.eslintrc.js +24 -0
- package/node_modules/node-forge/.github/workflows/main.yaml +96 -0
- package/node_modules/node-forge/.istanbul.yml +2 -0
- package/node_modules/node-forge/.npmignore +15 -0
- package/node_modules/node-forge/CHANGELOG.md +555 -0
- package/node_modules/node-forge/CONTRIBUTING.md +18 -0
- package/node_modules/node-forge/LICENSE +331 -0
- package/node_modules/node-forge/README.md +2071 -0
- package/node_modules/node-forge/RELEASE.md +19 -0
- package/node_modules/node-forge/SECURITY.md +5 -0
- package/node_modules/node-forge/examples/create-cert.js +110 -0
- package/node_modules/node-forge/examples/create-csr.js +66 -0
- package/node_modules/node-forge/examples/create-pkcs12.js +160 -0
- package/node_modules/node-forge/examples/imap.js +46 -0
- package/node_modules/node-forge/examples/sign-p7.js +115 -0
- package/node_modules/node-forge/examples/tls.js +189 -0
- package/node_modules/node-forge/flash/PooledSocket.as +18 -0
- package/node_modules/node-forge/flash/README.md +48 -0
- package/node_modules/node-forge/flash/SocketEvent.as +36 -0
- package/node_modules/node-forge/flash/SocketPool.as +754 -0
- package/node_modules/node-forge/flash/mod_fsp/README +135 -0
- package/node_modules/node-forge/flash/mod_fsp/mod_fsp.c +415 -0
- package/node_modules/node-forge/flash/package.json +28 -0
- package/node_modules/node-forge/flash/policyserver.js +65 -0
- package/node_modules/node-forge/flash/policyserver.py +112 -0
- package/node_modules/node-forge/flash/swf/SocketPool.swf +0 -0
- package/node_modules/node-forge/karma-sauce.conf.js +79 -0
- package/node_modules/node-forge/karma.conf.js +133 -0
- package/node_modules/node-forge/lib/aes.js +1091 -0
- package/node_modules/node-forge/lib/aesCipherSuites.js +282 -0
- package/node_modules/node-forge/lib/asn1-validator.js +91 -0
- package/node_modules/node-forge/lib/asn1.js +1503 -0
- package/node_modules/node-forge/lib/baseN.js +186 -0
- package/node_modules/node-forge/lib/cipher.js +230 -0
- package/node_modules/node-forge/lib/cipherModes.js +999 -0
- package/node_modules/node-forge/lib/des.js +496 -0
- package/node_modules/node-forge/lib/ed25519.js +1091 -0
- package/node_modules/node-forge/lib/forge.js +13 -0
- package/node_modules/node-forge/lib/form.js +149 -0
- package/node_modules/node-forge/lib/hmac.js +146 -0
- package/node_modules/node-forge/lib/http.js +1346 -0
- package/node_modules/node-forge/lib/index.all.js +16 -0
- package/node_modules/node-forge/lib/index.js +33 -0
- package/node_modules/node-forge/lib/jsbn.js +1294 -0
- package/node_modules/node-forge/lib/kem.js +168 -0
- package/node_modules/node-forge/lib/log.js +319 -0
- package/node_modules/node-forge/lib/md.all.js +13 -0
- package/node_modules/node-forge/lib/md.js +11 -0
- package/node_modules/node-forge/lib/md5.js +289 -0
- package/node_modules/node-forge/lib/mgf.js +12 -0
- package/node_modules/node-forge/lib/mgf1.js +57 -0
- package/node_modules/node-forge/lib/oids.js +180 -0
- package/node_modules/node-forge/lib/pbe.js +1023 -0
- package/node_modules/node-forge/lib/pbkdf2.js +211 -0
- package/node_modules/node-forge/lib/pem.js +237 -0
- package/node_modules/node-forge/lib/pkcs1.js +276 -0
- package/node_modules/node-forge/lib/pkcs12.js +1078 -0
- package/node_modules/node-forge/lib/pkcs7.js +1260 -0
- package/node_modules/node-forge/lib/pkcs7asn1.js +410 -0
- package/node_modules/node-forge/lib/pki.js +102 -0
- package/node_modules/node-forge/lib/prime.js +297 -0
- package/node_modules/node-forge/lib/prime.worker.js +168 -0
- package/node_modules/node-forge/lib/prng.js +419 -0
- package/node_modules/node-forge/lib/pss.js +241 -0
- package/node_modules/node-forge/lib/random.js +191 -0
- package/node_modules/node-forge/lib/rc2.js +410 -0
- package/node_modules/node-forge/lib/rsa.js +1973 -0
- package/node_modules/node-forge/lib/sha1.js +319 -0
- package/node_modules/node-forge/lib/sha256.js +327 -0
- package/node_modules/node-forge/lib/sha512.js +561 -0
- package/node_modules/node-forge/lib/socket.js +287 -0
- package/node_modules/node-forge/lib/ssh.js +236 -0
- package/node_modules/node-forge/lib/tls.js +4282 -0
- package/node_modules/node-forge/lib/tlssocket.js +249 -0
- package/node_modules/node-forge/lib/util.js +2652 -0
- package/node_modules/node-forge/lib/x509.js +3251 -0
- package/node_modules/node-forge/lib/xhr.js +738 -0
- package/node_modules/node-forge/package.json +123 -0
- package/node_modules/node-forge/tests/.eslintrc.js +5 -0
- package/node_modules/node-forge/tests/README.md +4 -0
- package/node_modules/node-forge/tests/benchmarks/aes-speed.js +62 -0
- package/node_modules/node-forge/tests/benchmarks/so-44303784.js +246 -0
- package/node_modules/node-forge/tests/flash/README.md +7 -0
- package/node_modules/node-forge/tests/flash/Test.as +96 -0
- package/node_modules/node-forge/tests/flash/build-flash.xml +7 -0
- package/node_modules/node-forge/tests/flash/index.html +28 -0
- package/node_modules/node-forge/tests/index.html +33 -0
- package/node_modules/node-forge/tests/issues/heartbleed.js +55 -0
- package/node_modules/node-forge/tests/issues/issue-428.html +95 -0
- package/node_modules/node-forge/tests/issues/issue-428.js +375 -0
- package/node_modules/node-forge/tests/karma/index.js +8 -0
- package/node_modules/node-forge/tests/karma/testWorker.js +65 -0
- package/node_modules/node-forge/tests/karma/web-worker-rsa.js +55 -0
- package/node_modules/node-forge/tests/legacy/common.html +65 -0
- package/node_modules/node-forge/tests/legacy/common.js +2199 -0
- package/node_modules/node-forge/tests/legacy/form.html +148 -0
- package/node_modules/node-forge/tests/legacy/form.js +40 -0
- package/node_modules/node-forge/tests/legacy/http.html +235 -0
- package/node_modules/node-forge/tests/legacy/index.html +70 -0
- package/node_modules/node-forge/tests/legacy/keygen.html +113 -0
- package/node_modules/node-forge/tests/legacy/login.css +26 -0
- package/node_modules/node-forge/tests/legacy/loginDemo.html +32 -0
- package/node_modules/node-forge/tests/legacy/loginDemo.js +149 -0
- package/node_modules/node-forge/tests/legacy/performance.html +533 -0
- package/node_modules/node-forge/tests/legacy/result.txt +1 -0
- package/node_modules/node-forge/tests/legacy/socketPool.html +302 -0
- package/node_modules/node-forge/tests/legacy/tasks.html +54 -0
- package/node_modules/node-forge/tests/legacy/tasks.js +378 -0
- package/node_modules/node-forge/tests/legacy/tls.html +389 -0
- package/node_modules/node-forge/tests/legacy/webid.html +89 -0
- package/node_modules/node-forge/tests/legacy/webid.js +313 -0
- package/node_modules/node-forge/tests/legacy/ws-webid.js +120 -0
- package/node_modules/node-forge/tests/legacy/xhr.html +67 -0
- package/node_modules/node-forge/tests/legacy/xhr.js +590 -0
- package/node_modules/node-forge/tests/pocs/ghsa-2328-f5f3-gj25.js +85 -0
- package/node_modules/node-forge/tests/pocs/ghsa-ppp5-5v6c-4jwp.js +166 -0
- package/node_modules/node-forge/tests/pocs/ghsa-q67f-28xg-22rw.js +103 -0
- package/node_modules/node-forge/tests/security/cve-2025-12816.js +75 -0
- package/node_modules/node-forge/tests/security/ghsa-554w-wpv2-vw27.js +81 -0
- package/node_modules/node-forge/tests/security/index.js +3 -0
- package/node_modules/node-forge/tests/server.crt +26 -0
- package/node_modules/node-forge/tests/server.js +87 -0
- package/node_modules/node-forge/tests/server.key +27 -0
- package/node_modules/node-forge/tests/support/task.js +718 -0
- package/node_modules/node-forge/tests/tests.css +61 -0
- package/node_modules/node-forge/tests/unit/aes.js +1498 -0
- package/node_modules/node-forge/tests/unit/asn1.js +1337 -0
- package/node_modules/node-forge/tests/unit/csr.js +129 -0
- package/node_modules/node-forge/tests/unit/des.js +231 -0
- package/node_modules/node-forge/tests/unit/ed25519.js +395 -0
- package/node_modules/node-forge/tests/unit/forge.js +2 -0
- package/node_modules/node-forge/tests/unit/hmac.js +64 -0
- package/node_modules/node-forge/tests/unit/index.html +20 -0
- package/node_modules/node-forge/tests/unit/index.js +27 -0
- package/node_modules/node-forge/tests/unit/jsbn.js +40 -0
- package/node_modules/node-forge/tests/unit/kem.js +167 -0
- package/node_modules/node-forge/tests/unit/md5.js +114 -0
- package/node_modules/node-forge/tests/unit/mgf1.js +16 -0
- package/node_modules/node-forge/tests/unit/pbkdf2.js +182 -0
- package/node_modules/node-forge/tests/unit/pem.js +159 -0
- package/node_modules/node-forge/tests/unit/pkcs1.js +1077 -0
- package/node_modules/node-forge/tests/unit/pkcs12.js +739 -0
- package/node_modules/node-forge/tests/unit/pkcs7.js +792 -0
- package/node_modules/node-forge/tests/unit/random.js +48 -0
- package/node_modules/node-forge/tests/unit/rc2.js +88 -0
- package/node_modules/node-forge/tests/unit/rsa.js +1278 -0
- package/node_modules/node-forge/tests/unit/sha1.js +72 -0
- package/node_modules/node-forge/tests/unit/sha256.js +79 -0
- package/node_modules/node-forge/tests/unit/sha512.js +200 -0
- package/node_modules/node-forge/tests/unit/ssh.js +175 -0
- package/node_modules/node-forge/tests/unit/tls.js +175 -0
- package/node_modules/node-forge/tests/unit/util.js +595 -0
- package/node_modules/node-forge/tests/unit/x509.js +1986 -0
- package/node_modules/node-forge/tests/websockets/server-webid.js +495 -0
- package/node_modules/node-forge/tests/websockets/server-ws.js +158 -0
- package/node_modules/node-forge/webpack-tests.config.js +26 -0
- package/node_modules/node-forge/webpack.config.js +130 -0
- package/node_modules/zod/LICENSE +21 -0
- package/node_modules/zod/README.md +208 -0
- package/node_modules/zod/index.cjs +33 -0
- package/node_modules/zod/index.d.cts +4 -0
- package/node_modules/zod/index.d.ts +4 -0
- package/node_modules/zod/index.js +4 -0
- package/node_modules/zod/package.json +118 -0
- package/node_modules/zod/src/index.ts +4 -0
- package/node_modules/zod/src/v3/ZodError.ts +330 -0
- package/node_modules/zod/src/v3/benchmarks/datetime.ts +58 -0
- package/node_modules/zod/src/v3/benchmarks/discriminatedUnion.ts +80 -0
- package/node_modules/zod/src/v3/benchmarks/index.ts +59 -0
- package/node_modules/zod/src/v3/benchmarks/ipv4.ts +57 -0
- package/node_modules/zod/src/v3/benchmarks/object.ts +69 -0
- package/node_modules/zod/src/v3/benchmarks/primitives.ts +162 -0
- package/node_modules/zod/src/v3/benchmarks/realworld.ts +63 -0
- package/node_modules/zod/src/v3/benchmarks/string.ts +55 -0
- package/node_modules/zod/src/v3/benchmarks/union.ts +80 -0
- package/node_modules/zod/src/v3/errors.ts +13 -0
- package/node_modules/zod/src/v3/external.ts +6 -0
- package/node_modules/zod/src/v3/helpers/enumUtil.ts +17 -0
- package/node_modules/zod/src/v3/helpers/errorUtil.ts +8 -0
- package/node_modules/zod/src/v3/helpers/parseUtil.ts +176 -0
- package/node_modules/zod/src/v3/helpers/partialUtil.ts +34 -0
- package/node_modules/zod/src/v3/helpers/typeAliases.ts +2 -0
- package/node_modules/zod/src/v3/helpers/util.ts +224 -0
- package/node_modules/zod/src/v3/index.ts +4 -0
- package/node_modules/zod/src/v3/locales/en.ts +124 -0
- package/node_modules/zod/src/v3/standard-schema.ts +113 -0
- package/node_modules/zod/src/v3/tests/Mocker.ts +54 -0
- package/node_modules/zod/src/v3/tests/all-errors.test.ts +157 -0
- package/node_modules/zod/src/v3/tests/anyunknown.test.ts +28 -0
- package/node_modules/zod/src/v3/tests/array.test.ts +71 -0
- package/node_modules/zod/src/v3/tests/async-parsing.test.ts +388 -0
- package/node_modules/zod/src/v3/tests/async-refinements.test.ts +46 -0
- package/node_modules/zod/src/v3/tests/base.test.ts +29 -0
- package/node_modules/zod/src/v3/tests/bigint.test.ts +55 -0
- package/node_modules/zod/src/v3/tests/branded.test.ts +53 -0
- package/node_modules/zod/src/v3/tests/catch.test.ts +220 -0
- package/node_modules/zod/src/v3/tests/coerce.test.ts +133 -0
- package/node_modules/zod/src/v3/tests/complex.test.ts +56 -0
- package/node_modules/zod/src/v3/tests/custom.test.ts +31 -0
- package/node_modules/zod/src/v3/tests/date.test.ts +32 -0
- package/node_modules/zod/src/v3/tests/deepmasking.test.ts +186 -0
- package/node_modules/zod/src/v3/tests/default.test.ts +112 -0
- package/node_modules/zod/src/v3/tests/description.test.ts +33 -0
- package/node_modules/zod/src/v3/tests/discriminated-unions.test.ts +315 -0
- package/node_modules/zod/src/v3/tests/enum.test.ts +80 -0
- package/node_modules/zod/src/v3/tests/error.test.ts +551 -0
- package/node_modules/zod/src/v3/tests/firstparty.test.ts +87 -0
- package/node_modules/zod/src/v3/tests/firstpartyschematypes.test.ts +21 -0
- package/node_modules/zod/src/v3/tests/function.test.ts +257 -0
- package/node_modules/zod/src/v3/tests/generics.test.ts +48 -0
- package/node_modules/zod/src/v3/tests/instanceof.test.ts +37 -0
- package/node_modules/zod/src/v3/tests/intersection.test.ts +110 -0
- package/node_modules/zod/src/v3/tests/language-server.source.ts +76 -0
- package/node_modules/zod/src/v3/tests/language-server.test.ts +207 -0
- package/node_modules/zod/src/v3/tests/literal.test.ts +36 -0
- package/node_modules/zod/src/v3/tests/map.test.ts +110 -0
- package/node_modules/zod/src/v3/tests/masking.test.ts +4 -0
- package/node_modules/zod/src/v3/tests/mocker.test.ts +19 -0
- package/node_modules/zod/src/v3/tests/nan.test.ts +21 -0
- package/node_modules/zod/src/v3/tests/nativeEnum.test.ts +87 -0
- package/node_modules/zod/src/v3/tests/nullable.test.ts +42 -0
- package/node_modules/zod/src/v3/tests/number.test.ts +176 -0
- package/node_modules/zod/src/v3/tests/object-augmentation.test.ts +29 -0
- package/node_modules/zod/src/v3/tests/object-in-es5-env.test.ts +29 -0
- package/node_modules/zod/src/v3/tests/object.test.ts +434 -0
- package/node_modules/zod/src/v3/tests/optional.test.ts +42 -0
- package/node_modules/zod/src/v3/tests/parseUtil.test.ts +23 -0
- package/node_modules/zod/src/v3/tests/parser.test.ts +41 -0
- package/node_modules/zod/src/v3/tests/partials.test.ts +243 -0
- package/node_modules/zod/src/v3/tests/pickomit.test.ts +111 -0
- package/node_modules/zod/src/v3/tests/pipeline.test.ts +29 -0
- package/node_modules/zod/src/v3/tests/preprocess.test.ts +186 -0
- package/node_modules/zod/src/v3/tests/primitive.test.ts +440 -0
- package/node_modules/zod/src/v3/tests/promise.test.ts +90 -0
- package/node_modules/zod/src/v3/tests/readonly.test.ts +194 -0
- package/node_modules/zod/src/v3/tests/record.test.ts +171 -0
- package/node_modules/zod/src/v3/tests/recursive.test.ts +197 -0
- package/node_modules/zod/src/v3/tests/refine.test.ts +313 -0
- package/node_modules/zod/src/v3/tests/safeparse.test.ts +27 -0
- package/node_modules/zod/src/v3/tests/set.test.ts +142 -0
- package/node_modules/zod/src/v3/tests/standard-schema.test.ts +83 -0
- package/node_modules/zod/src/v3/tests/string.test.ts +916 -0
- package/node_modules/zod/src/v3/tests/transformer.test.ts +233 -0
- package/node_modules/zod/src/v3/tests/tuple.test.ts +90 -0
- package/node_modules/zod/src/v3/tests/unions.test.ts +57 -0
- package/node_modules/zod/src/v3/tests/validations.test.ts +133 -0
- package/node_modules/zod/src/v3/tests/void.test.ts +15 -0
- package/node_modules/zod/src/v3/types.ts +5136 -0
- package/node_modules/zod/src/v4/classic/checks.ts +30 -0
- package/node_modules/zod/src/v4/classic/coerce.ts +27 -0
- package/node_modules/zod/src/v4/classic/compat.ts +66 -0
- package/node_modules/zod/src/v4/classic/errors.ts +75 -0
- package/node_modules/zod/src/v4/classic/external.ts +50 -0
- package/node_modules/zod/src/v4/classic/index.ts +5 -0
- package/node_modules/zod/src/v4/classic/iso.ts +90 -0
- package/node_modules/zod/src/v4/classic/parse.ts +33 -0
- package/node_modules/zod/src/v4/classic/schemas.ts +2054 -0
- package/node_modules/zod/src/v4/classic/tests/anyunknown.test.ts +26 -0
- package/node_modules/zod/src/v4/classic/tests/array.test.ts +264 -0
- package/node_modules/zod/src/v4/classic/tests/assignability.test.ts +210 -0
- package/node_modules/zod/src/v4/classic/tests/async-parsing.test.ts +381 -0
- package/node_modules/zod/src/v4/classic/tests/async-refinements.test.ts +68 -0
- package/node_modules/zod/src/v4/classic/tests/base.test.ts +7 -0
- package/node_modules/zod/src/v4/classic/tests/bigint.test.ts +54 -0
- package/node_modules/zod/src/v4/classic/tests/brand.test.ts +63 -0
- package/node_modules/zod/src/v4/classic/tests/catch.test.ts +252 -0
- package/node_modules/zod/src/v4/classic/tests/coalesce.test.ts +20 -0
- package/node_modules/zod/src/v4/classic/tests/coerce.test.ts +160 -0
- package/node_modules/zod/src/v4/classic/tests/continuability.test.ts +352 -0
- package/node_modules/zod/src/v4/classic/tests/custom.test.ts +40 -0
- package/node_modules/zod/src/v4/classic/tests/date.test.ts +31 -0
- package/node_modules/zod/src/v4/classic/tests/datetime.test.ts +296 -0
- package/node_modules/zod/src/v4/classic/tests/default.test.ts +313 -0
- package/node_modules/zod/src/v4/classic/tests/description.test.ts +32 -0
- package/node_modules/zod/src/v4/classic/tests/discriminated-unions.test.ts +619 -0
- package/node_modules/zod/src/v4/classic/tests/enum.test.ts +285 -0
- package/node_modules/zod/src/v4/classic/tests/error-utils.test.ts +527 -0
- package/node_modules/zod/src/v4/classic/tests/error.test.ts +711 -0
- package/node_modules/zod/src/v4/classic/tests/file.test.ts +91 -0
- package/node_modules/zod/src/v4/classic/tests/firstparty.test.ts +175 -0
- package/node_modules/zod/src/v4/classic/tests/function.test.ts +268 -0
- package/node_modules/zod/src/v4/classic/tests/generics.test.ts +72 -0
- package/node_modules/zod/src/v4/classic/tests/index.test.ts +829 -0
- package/node_modules/zod/src/v4/classic/tests/instanceof.test.ts +34 -0
- package/node_modules/zod/src/v4/classic/tests/intersection.test.ts +171 -0
- package/node_modules/zod/src/v4/classic/tests/json.test.ts +108 -0
- package/node_modules/zod/src/v4/classic/tests/lazy.test.ts +227 -0
- package/node_modules/zod/src/v4/classic/tests/literal.test.ts +92 -0
- package/node_modules/zod/src/v4/classic/tests/map.test.ts +196 -0
- package/node_modules/zod/src/v4/classic/tests/nan.test.ts +21 -0
- package/node_modules/zod/src/v4/classic/tests/nested-refine.test.ts +168 -0
- package/node_modules/zod/src/v4/classic/tests/nonoptional.test.ts +86 -0
- package/node_modules/zod/src/v4/classic/tests/nullable.test.ts +22 -0
- package/node_modules/zod/src/v4/classic/tests/number.test.ts +247 -0
- package/node_modules/zod/src/v4/classic/tests/object.test.ts +563 -0
- package/node_modules/zod/src/v4/classic/tests/optional.test.ts +123 -0
- package/node_modules/zod/src/v4/classic/tests/partial.test.ts +147 -0
- package/node_modules/zod/src/v4/classic/tests/pickomit.test.ts +127 -0
- package/node_modules/zod/src/v4/classic/tests/pipe.test.ts +81 -0
- package/node_modules/zod/src/v4/classic/tests/prefault.test.ts +37 -0
- package/node_modules/zod/src/v4/classic/tests/preprocess.test.ts +298 -0
- package/node_modules/zod/src/v4/classic/tests/primitive.test.ts +175 -0
- package/node_modules/zod/src/v4/classic/tests/promise.test.ts +81 -0
- package/node_modules/zod/src/v4/classic/tests/prototypes.test.ts +23 -0
- package/node_modules/zod/src/v4/classic/tests/readonly.test.ts +252 -0
- package/node_modules/zod/src/v4/classic/tests/record.test.ts +342 -0
- package/node_modules/zod/src/v4/classic/tests/recursive-types.test.ts +356 -0
- package/node_modules/zod/src/v4/classic/tests/refine.test.ts +532 -0
- package/node_modules/zod/src/v4/classic/tests/registries.test.ts +204 -0
- package/node_modules/zod/src/v4/classic/tests/set.test.ts +179 -0
- package/node_modules/zod/src/v4/classic/tests/standard-schema.test.ts +57 -0
- package/node_modules/zod/src/v4/classic/tests/string-formats.test.ts +109 -0
- package/node_modules/zod/src/v4/classic/tests/string.test.ts +881 -0
- package/node_modules/zod/src/v4/classic/tests/stringbool.test.ts +66 -0
- package/node_modules/zod/src/v4/classic/tests/template-literal.test.ts +758 -0
- package/node_modules/zod/src/v4/classic/tests/to-json-schema.test.ts +2314 -0
- package/node_modules/zod/src/v4/classic/tests/transform.test.ts +250 -0
- package/node_modules/zod/src/v4/classic/tests/tuple.test.ts +163 -0
- package/node_modules/zod/src/v4/classic/tests/union.test.ts +94 -0
- package/node_modules/zod/src/v4/classic/tests/validations.test.ts +283 -0
- package/node_modules/zod/src/v4/classic/tests/void.test.ts +12 -0
- package/node_modules/zod/src/v4/core/api.ts +1594 -0
- package/node_modules/zod/src/v4/core/checks.ts +1283 -0
- package/node_modules/zod/src/v4/core/config.ts +15 -0
- package/node_modules/zod/src/v4/core/core.ts +134 -0
- package/node_modules/zod/src/v4/core/doc.ts +44 -0
- package/node_modules/zod/src/v4/core/errors.ts +424 -0
- package/node_modules/zod/src/v4/core/function.ts +176 -0
- package/node_modules/zod/src/v4/core/index.ts +15 -0
- package/node_modules/zod/src/v4/core/json-schema.ts +143 -0
- package/node_modules/zod/src/v4/core/parse.ts +94 -0
- package/node_modules/zod/src/v4/core/regexes.ts +135 -0
- package/node_modules/zod/src/v4/core/registries.ts +96 -0
- package/node_modules/zod/src/v4/core/schemas.ts +3842 -0
- package/node_modules/zod/src/v4/core/standard-schema.ts +64 -0
- package/node_modules/zod/src/v4/core/tests/index.test.ts +46 -0
- package/node_modules/zod/src/v4/core/tests/locales/be.test.ts +124 -0
- package/node_modules/zod/src/v4/core/tests/locales/en.test.ts +22 -0
- package/node_modules/zod/src/v4/core/tests/locales/ru.test.ts +128 -0
- package/node_modules/zod/src/v4/core/tests/locales/tr.test.ts +69 -0
- package/node_modules/zod/src/v4/core/to-json-schema.ts +977 -0
- package/node_modules/zod/src/v4/core/util.ts +775 -0
- package/node_modules/zod/src/v4/core/versions.ts +5 -0
- package/node_modules/zod/src/v4/core/zsf.ts +323 -0
- package/node_modules/zod/src/v4/index.ts +4 -0
- package/node_modules/zod/src/v4/locales/ar.ts +125 -0
- package/node_modules/zod/src/v4/locales/az.ts +121 -0
- package/node_modules/zod/src/v4/locales/be.ts +184 -0
- package/node_modules/zod/src/v4/locales/ca.ts +127 -0
- package/node_modules/zod/src/v4/locales/cs.ts +142 -0
- package/node_modules/zod/src/v4/locales/de.ts +124 -0
- package/node_modules/zod/src/v4/locales/en.ts +127 -0
- package/node_modules/zod/src/v4/locales/eo.ts +125 -0
- package/node_modules/zod/src/v4/locales/es.ts +125 -0
- package/node_modules/zod/src/v4/locales/fa.ts +134 -0
- package/node_modules/zod/src/v4/locales/fi.ts +131 -0
- package/node_modules/zod/src/v4/locales/fr-CA.ts +126 -0
- package/node_modules/zod/src/v4/locales/fr.ts +124 -0
- package/node_modules/zod/src/v4/locales/he.ts +125 -0
- package/node_modules/zod/src/v4/locales/hu.ts +126 -0
- package/node_modules/zod/src/v4/locales/id.ts +125 -0
- package/node_modules/zod/src/v4/locales/index.ts +39 -0
- package/node_modules/zod/src/v4/locales/it.ts +125 -0
- package/node_modules/zod/src/v4/locales/ja.ts +122 -0
- package/node_modules/zod/src/v4/locales/kh.ts +126 -0
- package/node_modules/zod/src/v4/locales/ko.ts +131 -0
- package/node_modules/zod/src/v4/locales/mk.ts +127 -0
- package/node_modules/zod/src/v4/locales/ms.ts +124 -0
- package/node_modules/zod/src/v4/locales/nl.ts +126 -0
- package/node_modules/zod/src/v4/locales/no.ts +124 -0
- package/node_modules/zod/src/v4/locales/ota.ts +125 -0
- package/node_modules/zod/src/v4/locales/pl.ts +126 -0
- package/node_modules/zod/src/v4/locales/ps.ts +133 -0
- package/node_modules/zod/src/v4/locales/pt.ts +123 -0
- package/node_modules/zod/src/v4/locales/ru.ts +184 -0
- package/node_modules/zod/src/v4/locales/sl.ts +126 -0
- package/node_modules/zod/src/v4/locales/sv.ts +127 -0
- package/node_modules/zod/src/v4/locales/ta.ts +125 -0
- package/node_modules/zod/src/v4/locales/th.ts +126 -0
- package/node_modules/zod/src/v4/locales/tr.ts +121 -0
- package/node_modules/zod/src/v4/locales/ua.ts +126 -0
- package/node_modules/zod/src/v4/locales/ur.ts +126 -0
- package/node_modules/zod/src/v4/locales/vi.ts +125 -0
- package/node_modules/zod/src/v4/locales/zh-CN.ts +123 -0
- package/node_modules/zod/src/v4/locales/zh-TW.ts +125 -0
- package/node_modules/zod/src/v4/mini/checks.ts +32 -0
- package/node_modules/zod/src/v4/mini/coerce.ts +22 -0
- package/node_modules/zod/src/v4/mini/external.ts +40 -0
- package/node_modules/zod/src/v4/mini/index.ts +3 -0
- package/node_modules/zod/src/v4/mini/iso.ts +62 -0
- package/node_modules/zod/src/v4/mini/parse.ts +1 -0
- package/node_modules/zod/src/v4/mini/schemas.ts +1579 -0
- package/node_modules/zod/src/v4/mini/tests/assignability.test.ts +129 -0
- package/node_modules/zod/src/v4/mini/tests/brand.test.ts +51 -0
- package/node_modules/zod/src/v4/mini/tests/checks.test.ts +144 -0
- package/node_modules/zod/src/v4/mini/tests/computed.test.ts +36 -0
- package/node_modules/zod/src/v4/mini/tests/error.test.ts +22 -0
- package/node_modules/zod/src/v4/mini/tests/functions.test.ts +43 -0
- package/node_modules/zod/src/v4/mini/tests/index.test.ts +871 -0
- package/node_modules/zod/src/v4/mini/tests/number.test.ts +95 -0
- package/node_modules/zod/src/v4/mini/tests/object.test.ts +185 -0
- package/node_modules/zod/src/v4/mini/tests/prototypes.test.ts +43 -0
- package/node_modules/zod/src/v4/mini/tests/recursive-types.test.ts +275 -0
- package/node_modules/zod/src/v4/mini/tests/string.test.ts +299 -0
- package/node_modules/zod/src/v4-mini/index.ts +1 -0
- package/node_modules/zod/v3/ZodError.cjs +138 -0
- package/node_modules/zod/v3/ZodError.d.cts +164 -0
- package/node_modules/zod/v3/ZodError.d.ts +164 -0
- package/node_modules/zod/v3/ZodError.js +133 -0
- package/node_modules/zod/v3/errors.cjs +17 -0
- package/node_modules/zod/v3/errors.d.cts +5 -0
- package/node_modules/zod/v3/errors.d.ts +5 -0
- package/node_modules/zod/v3/errors.js +9 -0
- package/node_modules/zod/v3/external.cjs +22 -0
- package/node_modules/zod/v3/external.d.cts +6 -0
- package/node_modules/zod/v3/external.d.ts +6 -0
- package/node_modules/zod/v3/external.js +6 -0
- package/node_modules/zod/v3/helpers/enumUtil.cjs +2 -0
- package/node_modules/zod/v3/helpers/enumUtil.d.cts +8 -0
- package/node_modules/zod/v3/helpers/enumUtil.d.ts +8 -0
- package/node_modules/zod/v3/helpers/enumUtil.js +1 -0
- package/node_modules/zod/v3/helpers/errorUtil.cjs +9 -0
- package/node_modules/zod/v3/helpers/errorUtil.d.cts +9 -0
- package/node_modules/zod/v3/helpers/errorUtil.d.ts +9 -0
- package/node_modules/zod/v3/helpers/errorUtil.js +6 -0
- package/node_modules/zod/v3/helpers/parseUtil.cjs +124 -0
- package/node_modules/zod/v3/helpers/parseUtil.d.cts +78 -0
- package/node_modules/zod/v3/helpers/parseUtil.d.ts +78 -0
- package/node_modules/zod/v3/helpers/parseUtil.js +109 -0
- package/node_modules/zod/v3/helpers/partialUtil.cjs +2 -0
- package/node_modules/zod/v3/helpers/partialUtil.d.cts +8 -0
- package/node_modules/zod/v3/helpers/partialUtil.d.ts +8 -0
- package/node_modules/zod/v3/helpers/partialUtil.js +1 -0
- package/node_modules/zod/v3/helpers/typeAliases.cjs +2 -0
- package/node_modules/zod/v3/helpers/typeAliases.d.cts +2 -0
- package/node_modules/zod/v3/helpers/typeAliases.d.ts +2 -0
- package/node_modules/zod/v3/helpers/typeAliases.js +1 -0
- package/node_modules/zod/v3/helpers/util.cjs +137 -0
- package/node_modules/zod/v3/helpers/util.d.cts +85 -0
- package/node_modules/zod/v3/helpers/util.d.ts +85 -0
- package/node_modules/zod/v3/helpers/util.js +133 -0
- package/node_modules/zod/v3/index.cjs +33 -0
- package/node_modules/zod/v3/index.d.cts +4 -0
- package/node_modules/zod/v3/index.d.ts +4 -0
- package/node_modules/zod/v3/index.js +4 -0
- package/node_modules/zod/v3/locales/en.cjs +111 -0
- package/node_modules/zod/v3/locales/en.d.cts +3 -0
- package/node_modules/zod/v3/locales/en.d.ts +3 -0
- package/node_modules/zod/v3/locales/en.js +109 -0
- package/node_modules/zod/v3/standard-schema.cjs +2 -0
- package/node_modules/zod/v3/standard-schema.d.cts +102 -0
- package/node_modules/zod/v3/standard-schema.d.ts +102 -0
- package/node_modules/zod/v3/standard-schema.js +1 -0
- package/node_modules/zod/v3/types.cjs +3775 -0
- package/node_modules/zod/v3/types.d.cts +1031 -0
- package/node_modules/zod/v3/types.d.ts +1031 -0
- package/node_modules/zod/v3/types.js +3693 -0
- package/node_modules/zod/v4/classic/checks.cjs +32 -0
- package/node_modules/zod/v4/classic/checks.d.cts +1 -0
- package/node_modules/zod/v4/classic/checks.d.ts +1 -0
- package/node_modules/zod/v4/classic/checks.js +1 -0
- package/node_modules/zod/v4/classic/coerce.cjs +47 -0
- package/node_modules/zod/v4/classic/coerce.d.cts +17 -0
- package/node_modules/zod/v4/classic/coerce.d.ts +17 -0
- package/node_modules/zod/v4/classic/coerce.js +17 -0
- package/node_modules/zod/v4/classic/compat.cjs +57 -0
- package/node_modules/zod/v4/classic/compat.d.cts +46 -0
- package/node_modules/zod/v4/classic/compat.d.ts +46 -0
- package/node_modules/zod/v4/classic/compat.js +27 -0
- package/node_modules/zod/v4/classic/errors.cjs +67 -0
- package/node_modules/zod/v4/classic/errors.d.cts +30 -0
- package/node_modules/zod/v4/classic/errors.d.ts +30 -0
- package/node_modules/zod/v4/classic/errors.js +41 -0
- package/node_modules/zod/v4/classic/external.cjs +70 -0
- package/node_modules/zod/v4/classic/external.d.cts +13 -0
- package/node_modules/zod/v4/classic/external.d.ts +13 -0
- package/node_modules/zod/v4/classic/external.js +18 -0
- package/node_modules/zod/v4/classic/index.cjs +33 -0
- package/node_modules/zod/v4/classic/index.d.cts +4 -0
- package/node_modules/zod/v4/classic/index.d.ts +4 -0
- package/node_modules/zod/v4/classic/index.js +4 -0
- package/node_modules/zod/v4/classic/iso.cjs +60 -0
- package/node_modules/zod/v4/classic/iso.d.cts +22 -0
- package/node_modules/zod/v4/classic/iso.d.ts +22 -0
- package/node_modules/zod/v4/classic/iso.js +30 -0
- package/node_modules/zod/v4/classic/parse.cjs +32 -0
- package/node_modules/zod/v4/classic/parse.d.cts +23 -0
- package/node_modules/zod/v4/classic/parse.d.ts +23 -0
- package/node_modules/zod/v4/classic/parse.js +6 -0
- package/node_modules/zod/v4/classic/schemas.cjs +1109 -0
- package/node_modules/zod/v4/classic/schemas.d.cts +630 -0
- package/node_modules/zod/v4/classic/schemas.d.ts +630 -0
- package/node_modules/zod/v4/classic/schemas.js +1006 -0
- package/node_modules/zod/v4/core/api.cjs +1039 -0
- package/node_modules/zod/v4/core/api.d.cts +284 -0
- package/node_modules/zod/v4/core/api.d.ts +284 -0
- package/node_modules/zod/v4/core/api.js +906 -0
- package/node_modules/zod/v4/core/checks.cjs +591 -0
- package/node_modules/zod/v4/core/checks.d.cts +278 -0
- package/node_modules/zod/v4/core/checks.d.ts +278 -0
- package/node_modules/zod/v4/core/checks.js +565 -0
- package/node_modules/zod/v4/core/core.cjs +67 -0
- package/node_modules/zod/v4/core/core.d.cts +49 -0
- package/node_modules/zod/v4/core/core.d.ts +49 -0
- package/node_modules/zod/v4/core/core.js +61 -0
- package/node_modules/zod/v4/core/doc.cjs +39 -0
- package/node_modules/zod/v4/core/doc.d.cts +14 -0
- package/node_modules/zod/v4/core/doc.d.ts +14 -0
- package/node_modules/zod/v4/core/doc.js +35 -0
- package/node_modules/zod/v4/core/errors.cjs +226 -0
- package/node_modules/zod/v4/core/errors.d.cts +208 -0
- package/node_modules/zod/v4/core/errors.d.ts +208 -0
- package/node_modules/zod/v4/core/errors.js +195 -0
- package/node_modules/zod/v4/core/function.cjs +102 -0
- package/node_modules/zod/v4/core/function.d.cts +52 -0
- package/node_modules/zod/v4/core/function.d.ts +52 -0
- package/node_modules/zod/v4/core/function.js +75 -0
- package/node_modules/zod/v4/core/index.cjs +44 -0
- package/node_modules/zod/v4/core/index.d.cts +15 -0
- package/node_modules/zod/v4/core/index.d.ts +15 -0
- package/node_modules/zod/v4/core/index.js +15 -0
- package/node_modules/zod/v4/core/json-schema.cjs +2 -0
- package/node_modules/zod/v4/core/json-schema.d.cts +87 -0
- package/node_modules/zod/v4/core/json-schema.d.ts +87 -0
- package/node_modules/zod/v4/core/json-schema.js +1 -0
- package/node_modules/zod/v4/core/parse.cjs +87 -0
- package/node_modules/zod/v4/core/parse.d.cts +25 -0
- package/node_modules/zod/v4/core/parse.d.ts +25 -0
- package/node_modules/zod/v4/core/parse.js +57 -0
- package/node_modules/zod/v4/core/regexes.cjs +103 -0
- package/node_modules/zod/v4/core/regexes.d.cts +62 -0
- package/node_modules/zod/v4/core/regexes.d.ts +62 -0
- package/node_modules/zod/v4/core/regexes.js +95 -0
- package/node_modules/zod/v4/core/registries.cjs +56 -0
- package/node_modules/zod/v4/core/registries.d.cts +35 -0
- package/node_modules/zod/v4/core/registries.d.ts +35 -0
- package/node_modules/zod/v4/core/registries.js +51 -0
- package/node_modules/zod/v4/core/schemas.cjs +1748 -0
- package/node_modules/zod/v4/core/schemas.d.cts +1041 -0
- package/node_modules/zod/v4/core/schemas.d.ts +1041 -0
- package/node_modules/zod/v4/core/schemas.js +1717 -0
- package/node_modules/zod/v4/core/standard-schema.cjs +2 -0
- package/node_modules/zod/v4/core/standard-schema.d.cts +55 -0
- package/node_modules/zod/v4/core/standard-schema.d.ts +55 -0
- package/node_modules/zod/v4/core/standard-schema.js +1 -0
- package/node_modules/zod/v4/core/to-json-schema.cjs +854 -0
- package/node_modules/zod/v4/core/to-json-schema.d.cts +88 -0
- package/node_modules/zod/v4/core/to-json-schema.d.ts +88 -0
- package/node_modules/zod/v4/core/to-json-schema.js +849 -0
- package/node_modules/zod/v4/core/util.cjs +539 -0
- package/node_modules/zod/v4/core/util.d.cts +183 -0
- package/node_modules/zod/v4/core/util.d.ts +183 -0
- package/node_modules/zod/v4/core/util.js +493 -0
- package/node_modules/zod/v4/core/versions.cjs +8 -0
- package/node_modules/zod/v4/core/versions.d.cts +5 -0
- package/node_modules/zod/v4/core/versions.d.ts +5 -0
- package/node_modules/zod/v4/core/versions.js +5 -0
- package/node_modules/zod/v4/index.cjs +22 -0
- package/node_modules/zod/v4/index.d.cts +3 -0
- package/node_modules/zod/v4/index.d.ts +3 -0
- package/node_modules/zod/v4/index.js +3 -0
- package/node_modules/zod/v4/locales/ar.cjs +142 -0
- package/node_modules/zod/v4/locales/ar.d.cts +4 -0
- package/node_modules/zod/v4/locales/ar.d.ts +4 -0
- package/node_modules/zod/v4/locales/ar.js +116 -0
- package/node_modules/zod/v4/locales/az.cjs +141 -0
- package/node_modules/zod/v4/locales/az.d.cts +4 -0
- package/node_modules/zod/v4/locales/az.d.ts +4 -0
- package/node_modules/zod/v4/locales/az.js +115 -0
- package/node_modules/zod/v4/locales/be.cjs +190 -0
- package/node_modules/zod/v4/locales/be.d.cts +4 -0
- package/node_modules/zod/v4/locales/be.d.ts +4 -0
- package/node_modules/zod/v4/locales/be.js +164 -0
- package/node_modules/zod/v4/locales/ca.cjs +144 -0
- package/node_modules/zod/v4/locales/ca.d.cts +4 -0
- package/node_modules/zod/v4/locales/ca.d.ts +4 -0
- package/node_modules/zod/v4/locales/ca.js +118 -0
- package/node_modules/zod/v4/locales/cs.cjs +161 -0
- package/node_modules/zod/v4/locales/cs.d.cts +4 -0
- package/node_modules/zod/v4/locales/cs.d.ts +4 -0
- package/node_modules/zod/v4/locales/cs.js +135 -0
- package/node_modules/zod/v4/locales/de.cjs +142 -0
- package/node_modules/zod/v4/locales/de.d.cts +4 -0
- package/node_modules/zod/v4/locales/de.d.ts +4 -0
- package/node_modules/zod/v4/locales/de.js +116 -0
- package/node_modules/zod/v4/locales/en.cjs +145 -0
- package/node_modules/zod/v4/locales/en.d.cts +5 -0
- package/node_modules/zod/v4/locales/en.d.ts +5 -0
- package/node_modules/zod/v4/locales/en.js +117 -0
- package/node_modules/zod/v4/locales/eo.cjs +144 -0
- package/node_modules/zod/v4/locales/eo.d.cts +5 -0
- package/node_modules/zod/v4/locales/eo.d.ts +5 -0
- package/node_modules/zod/v4/locales/eo.js +116 -0
- package/node_modules/zod/v4/locales/es.cjs +143 -0
- package/node_modules/zod/v4/locales/es.d.cts +4 -0
- package/node_modules/zod/v4/locales/es.d.ts +4 -0
- package/node_modules/zod/v4/locales/es.js +117 -0
- package/node_modules/zod/v4/locales/fa.cjs +148 -0
- package/node_modules/zod/v4/locales/fa.d.cts +4 -0
- package/node_modules/zod/v4/locales/fa.d.ts +4 -0
- package/node_modules/zod/v4/locales/fa.js +122 -0
- package/node_modules/zod/v4/locales/fi.cjs +148 -0
- package/node_modules/zod/v4/locales/fi.d.cts +4 -0
- package/node_modules/zod/v4/locales/fi.d.ts +4 -0
- package/node_modules/zod/v4/locales/fi.js +122 -0
- package/node_modules/zod/v4/locales/fr-CA.cjs +143 -0
- package/node_modules/zod/v4/locales/fr-CA.d.cts +4 -0
- package/node_modules/zod/v4/locales/fr-CA.d.ts +4 -0
- package/node_modules/zod/v4/locales/fr-CA.js +117 -0
- package/node_modules/zod/v4/locales/fr.cjs +142 -0
- package/node_modules/zod/v4/locales/fr.d.cts +4 -0
- package/node_modules/zod/v4/locales/fr.d.ts +4 -0
- package/node_modules/zod/v4/locales/fr.js +116 -0
- package/node_modules/zod/v4/locales/he.cjs +143 -0
- package/node_modules/zod/v4/locales/he.d.cts +4 -0
- package/node_modules/zod/v4/locales/he.d.ts +4 -0
- package/node_modules/zod/v4/locales/he.js +117 -0
- package/node_modules/zod/v4/locales/hu.cjs +143 -0
- package/node_modules/zod/v4/locales/hu.d.cts +4 -0
- package/node_modules/zod/v4/locales/hu.d.ts +4 -0
- package/node_modules/zod/v4/locales/hu.js +117 -0
- package/node_modules/zod/v4/locales/id.cjs +142 -0
- package/node_modules/zod/v4/locales/id.d.cts +4 -0
- package/node_modules/zod/v4/locales/id.d.ts +4 -0
- package/node_modules/zod/v4/locales/id.js +116 -0
- package/node_modules/zod/v4/locales/index.cjs +84 -0
- package/node_modules/zod/v4/locales/index.d.cts +39 -0
- package/node_modules/zod/v4/locales/index.d.ts +39 -0
- package/node_modules/zod/v4/locales/index.js +39 -0
- package/node_modules/zod/v4/locales/it.cjs +143 -0
- package/node_modules/zod/v4/locales/it.d.cts +4 -0
- package/node_modules/zod/v4/locales/it.d.ts +4 -0
- package/node_modules/zod/v4/locales/it.js +117 -0
- package/node_modules/zod/v4/locales/ja.cjs +141 -0
- package/node_modules/zod/v4/locales/ja.d.cts +4 -0
- package/node_modules/zod/v4/locales/ja.d.ts +4 -0
- package/node_modules/zod/v4/locales/ja.js +115 -0
- package/node_modules/zod/v4/locales/kh.cjs +143 -0
- package/node_modules/zod/v4/locales/kh.d.cts +4 -0
- package/node_modules/zod/v4/locales/kh.d.ts +4 -0
- package/node_modules/zod/v4/locales/kh.js +117 -0
- package/node_modules/zod/v4/locales/ko.cjs +147 -0
- package/node_modules/zod/v4/locales/ko.d.cts +4 -0
- package/node_modules/zod/v4/locales/ko.d.ts +4 -0
- package/node_modules/zod/v4/locales/ko.js +121 -0
- package/node_modules/zod/v4/locales/mk.cjs +144 -0
- package/node_modules/zod/v4/locales/mk.d.cts +4 -0
- package/node_modules/zod/v4/locales/mk.d.ts +4 -0
- package/node_modules/zod/v4/locales/mk.js +118 -0
- package/node_modules/zod/v4/locales/ms.cjs +142 -0
- package/node_modules/zod/v4/locales/ms.d.cts +4 -0
- package/node_modules/zod/v4/locales/ms.d.ts +4 -0
- package/node_modules/zod/v4/locales/ms.js +116 -0
- package/node_modules/zod/v4/locales/nl.cjs +143 -0
- package/node_modules/zod/v4/locales/nl.d.cts +4 -0
- package/node_modules/zod/v4/locales/nl.d.ts +4 -0
- package/node_modules/zod/v4/locales/nl.js +117 -0
- package/node_modules/zod/v4/locales/no.cjs +142 -0
- package/node_modules/zod/v4/locales/no.d.cts +4 -0
- package/node_modules/zod/v4/locales/no.d.ts +4 -0
- package/node_modules/zod/v4/locales/no.js +116 -0
- package/node_modules/zod/v4/locales/ota.cjs +143 -0
- package/node_modules/zod/v4/locales/ota.d.cts +4 -0
- package/node_modules/zod/v4/locales/ota.d.ts +4 -0
- package/node_modules/zod/v4/locales/ota.js +117 -0
- package/node_modules/zod/v4/locales/pl.cjs +143 -0
- package/node_modules/zod/v4/locales/pl.d.cts +4 -0
- package/node_modules/zod/v4/locales/pl.d.ts +4 -0
- package/node_modules/zod/v4/locales/pl.js +117 -0
- package/node_modules/zod/v4/locales/ps.cjs +148 -0
- package/node_modules/zod/v4/locales/ps.d.cts +4 -0
- package/node_modules/zod/v4/locales/ps.d.ts +4 -0
- package/node_modules/zod/v4/locales/ps.js +122 -0
- package/node_modules/zod/v4/locales/pt.cjs +142 -0
- package/node_modules/zod/v4/locales/pt.d.cts +4 -0
- package/node_modules/zod/v4/locales/pt.d.ts +4 -0
- package/node_modules/zod/v4/locales/pt.js +116 -0
- package/node_modules/zod/v4/locales/ru.cjs +190 -0
- package/node_modules/zod/v4/locales/ru.d.cts +4 -0
- package/node_modules/zod/v4/locales/ru.d.ts +4 -0
- package/node_modules/zod/v4/locales/ru.js +164 -0
- package/node_modules/zod/v4/locales/sl.cjs +143 -0
- package/node_modules/zod/v4/locales/sl.d.cts +4 -0
- package/node_modules/zod/v4/locales/sl.d.ts +4 -0
- package/node_modules/zod/v4/locales/sl.js +117 -0
- package/node_modules/zod/v4/locales/sv.cjs +144 -0
- package/node_modules/zod/v4/locales/sv.d.cts +4 -0
- package/node_modules/zod/v4/locales/sv.d.ts +4 -0
- package/node_modules/zod/v4/locales/sv.js +118 -0
- package/node_modules/zod/v4/locales/ta.cjs +143 -0
- package/node_modules/zod/v4/locales/ta.d.cts +4 -0
- package/node_modules/zod/v4/locales/ta.d.ts +4 -0
- package/node_modules/zod/v4/locales/ta.js +117 -0
- package/node_modules/zod/v4/locales/th.cjs +143 -0
- package/node_modules/zod/v4/locales/th.d.cts +4 -0
- package/node_modules/zod/v4/locales/th.d.ts +4 -0
- package/node_modules/zod/v4/locales/th.js +117 -0
- package/node_modules/zod/v4/locales/tr.cjs +143 -0
- package/node_modules/zod/v4/locales/tr.d.cts +5 -0
- package/node_modules/zod/v4/locales/tr.d.ts +5 -0
- package/node_modules/zod/v4/locales/tr.js +115 -0
- package/node_modules/zod/v4/locales/ua.cjs +143 -0
- package/node_modules/zod/v4/locales/ua.d.cts +4 -0
- package/node_modules/zod/v4/locales/ua.d.ts +4 -0
- package/node_modules/zod/v4/locales/ua.js +117 -0
- package/node_modules/zod/v4/locales/ur.cjs +143 -0
- package/node_modules/zod/v4/locales/ur.d.cts +4 -0
- package/node_modules/zod/v4/locales/ur.d.ts +4 -0
- package/node_modules/zod/v4/locales/ur.js +117 -0
- package/node_modules/zod/v4/locales/vi.cjs +142 -0
- package/node_modules/zod/v4/locales/vi.d.cts +4 -0
- package/node_modules/zod/v4/locales/vi.d.ts +4 -0
- package/node_modules/zod/v4/locales/vi.js +116 -0
- package/node_modules/zod/v4/locales/zh-CN.cjs +142 -0
- package/node_modules/zod/v4/locales/zh-CN.d.cts +4 -0
- package/node_modules/zod/v4/locales/zh-CN.d.ts +4 -0
- package/node_modules/zod/v4/locales/zh-CN.js +116 -0
- package/node_modules/zod/v4/locales/zh-TW.cjs +143 -0
- package/node_modules/zod/v4/locales/zh-TW.d.cts +4 -0
- package/node_modules/zod/v4/locales/zh-TW.d.ts +4 -0
- package/node_modules/zod/v4/locales/zh-TW.js +117 -0
- package/node_modules/zod/v4/mini/checks.cjs +34 -0
- package/node_modules/zod/v4/mini/checks.d.cts +1 -0
- package/node_modules/zod/v4/mini/checks.d.ts +1 -0
- package/node_modules/zod/v4/mini/checks.js +1 -0
- package/node_modules/zod/v4/mini/coerce.cjs +47 -0
- package/node_modules/zod/v4/mini/coerce.d.cts +7 -0
- package/node_modules/zod/v4/mini/coerce.d.ts +7 -0
- package/node_modules/zod/v4/mini/coerce.js +17 -0
- package/node_modules/zod/v4/mini/external.cjs +62 -0
- package/node_modules/zod/v4/mini/external.d.cts +11 -0
- package/node_modules/zod/v4/mini/external.d.ts +11 -0
- package/node_modules/zod/v4/mini/external.js +13 -0
- package/node_modules/zod/v4/mini/index.cjs +32 -0
- package/node_modules/zod/v4/mini/index.d.cts +3 -0
- package/node_modules/zod/v4/mini/index.d.ts +3 -0
- package/node_modules/zod/v4/mini/index.js +3 -0
- package/node_modules/zod/v4/mini/iso.cjs +60 -0
- package/node_modules/zod/v4/mini/iso.d.cts +22 -0
- package/node_modules/zod/v4/mini/iso.d.ts +22 -0
- package/node_modules/zod/v4/mini/iso.js +30 -0
- package/node_modules/zod/v4/mini/parse.cjs +8 -0
- package/node_modules/zod/v4/mini/parse.d.cts +1 -0
- package/node_modules/zod/v4/mini/parse.d.ts +1 -0
- package/node_modules/zod/v4/mini/parse.js +1 -0
- package/node_modules/zod/v4/mini/schemas.cjs +839 -0
- package/node_modules/zod/v4/mini/schemas.d.cts +356 -0
- package/node_modules/zod/v4/mini/schemas.d.ts +356 -0
- package/node_modules/zod/v4/mini/schemas.js +732 -0
- package/node_modules/zod/v4-mini/index.cjs +17 -0
- package/node_modules/zod/v4-mini/index.d.cts +1 -0
- package/node_modules/zod/v4-mini/index.d.ts +1 -0
- package/node_modules/zod/v4-mini/index.js +1 -0
- package/package.json +97 -0
|
@@ -0,0 +1,2605 @@
|
|
|
1
|
+
import { quote } from '../utils/shell-quote.js';
|
|
2
|
+
import { logForDebugging } from '../utils/debug.js';
|
|
3
|
+
import { whichSync } from '../utils/which.js';
|
|
4
|
+
import { randomBytes } from 'node:crypto';
|
|
5
|
+
import * as fs from 'fs';
|
|
6
|
+
import { spawn, spawnSync } from 'node:child_process';
|
|
7
|
+
import { endianness, tmpdir } from 'node:os';
|
|
8
|
+
import path, { join } from 'node:path';
|
|
9
|
+
import { ripGrep } from '../utils/ripgrep.js';
|
|
10
|
+
import { buildJavaToolOptions } from './java-proxy-agent.js';
|
|
11
|
+
import { generateProxyEnvVars, buildPosixGitSafeDirEnv, normalizePathForSandbox, normalizeCaseForComparison, isSymlinkOutsideBoundary, encodeSandboxedCommand, attributionKeyFor, DANGEROUS_FILES, isAbsenceErrno, isAtOrUnder, isStrictlyUnder, getDangerousDirectories, } from './sandbox-utils.js';
|
|
12
|
+
import { getApplySeccompBinaryPath } from './generate-seccomp-filter.js';
|
|
13
|
+
/** Default max depth for searching dangerous files */
|
|
14
|
+
const DEFAULT_MANDATORY_DENY_SEARCH_DEPTH = 3;
|
|
15
|
+
/**
|
|
16
|
+
* Find if any component of the path is a symlink within the allowed write paths.
|
|
17
|
+
* Returns the symlink path if found, or null if no symlinks.
|
|
18
|
+
*
|
|
19
|
+
* This is used to detect and block symlink replacement attacks where an attacker
|
|
20
|
+
* could delete a symlink and create a real directory with malicious content.
|
|
21
|
+
*/
|
|
22
|
+
function findSymlinkInPath(targetPath, allowedWritePaths) {
|
|
23
|
+
const parts = targetPath.split(path.sep);
|
|
24
|
+
let currentPath = '';
|
|
25
|
+
for (const part of parts) {
|
|
26
|
+
if (!part)
|
|
27
|
+
continue; // Skip empty parts (leading /)
|
|
28
|
+
const nextPath = currentPath + path.sep + part;
|
|
29
|
+
try {
|
|
30
|
+
const stats = fs.lstatSync(nextPath);
|
|
31
|
+
if (stats.isSymbolicLink()) {
|
|
32
|
+
// Check if this symlink is within an allowed write path
|
|
33
|
+
const isWithinAllowedPath = allowedWritePaths.some(allowedPath => isAtOrUnder(nextPath, allowedPath));
|
|
34
|
+
if (isWithinAllowedPath) {
|
|
35
|
+
return nextPath;
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
catch {
|
|
40
|
+
// Path doesn't exist - no symlink issue here
|
|
41
|
+
break;
|
|
42
|
+
}
|
|
43
|
+
currentPath = nextPath;
|
|
44
|
+
}
|
|
45
|
+
return null;
|
|
46
|
+
}
|
|
47
|
+
/** Bounded depth for chasing dangling symlink chains (kernel ELOOP limit). */
|
|
48
|
+
const MAX_SYMLINK_RESOLUTION_DEPTH = 40;
|
|
49
|
+
/**
|
|
50
|
+
* Canonicalize a deny path through symlinks before any mask or bind is
|
|
51
|
+
* computed (resolve-before-mask). Deny paths regularly contain symlinked
|
|
52
|
+
* ancestors — the common dotfiles setup symlinks .claude (or the parent of
|
|
53
|
+
* .mcp.json) to a real directory — and masking the raw symlink with
|
|
54
|
+
* `--ro-bind /dev/null <symlink>` makes bwrap abort at startup: "Is a
|
|
55
|
+
* directory" for relative link targets, ENOENT for absolute ones.
|
|
56
|
+
*
|
|
57
|
+
* Resolution: realpath when the full path exists; otherwise canonicalize the
|
|
58
|
+
* deepest existing ancestor and re-join the missing components. A dangling
|
|
59
|
+
* symlink along the way is chain-walked (bounded) so the deny lands where a
|
|
60
|
+
* write through the link would actually create the file. Returns null when
|
|
61
|
+
* the path cannot be canonicalized (e.g. a symlink cycle); callers should
|
|
62
|
+
* skip such paths rather than emit a mask bwrap will reject.
|
|
63
|
+
*
|
|
64
|
+
* Unlike normalizePathForSandbox, this intentionally applies no
|
|
65
|
+
* isSymlinkOutsideBoundary check: that check exists so allow paths can't
|
|
66
|
+
* silently widen write access through a symlink, but a deny path only ever
|
|
67
|
+
* removes access, and the mask must land on the inode that writes through
|
|
68
|
+
* the symlink actually reach.
|
|
69
|
+
*/
|
|
70
|
+
function resolveSymlinkedDenyPath(targetPath) {
|
|
71
|
+
let current = targetPath;
|
|
72
|
+
for (let i = 0; i < MAX_SYMLINK_RESOLUTION_DEPTH; i++) {
|
|
73
|
+
try {
|
|
74
|
+
return fs.realpathSync(current);
|
|
75
|
+
}
|
|
76
|
+
catch {
|
|
77
|
+
// Some component is missing or dangling — canonicalize manually below.
|
|
78
|
+
}
|
|
79
|
+
// Find the deepest ancestor that fully resolves, collecting the missing
|
|
80
|
+
// suffix components (leaf first, so unshift keeps them in path order).
|
|
81
|
+
let ancestor = current;
|
|
82
|
+
const remainder = [];
|
|
83
|
+
let resolvedAncestor = null;
|
|
84
|
+
while (resolvedAncestor === null) {
|
|
85
|
+
const parent = path.dirname(ancestor);
|
|
86
|
+
if (parent === ancestor) {
|
|
87
|
+
return null; // reached the root without resolving anything
|
|
88
|
+
}
|
|
89
|
+
remainder.unshift(path.basename(ancestor));
|
|
90
|
+
ancestor = parent;
|
|
91
|
+
try {
|
|
92
|
+
resolvedAncestor = fs.realpathSync(ancestor);
|
|
93
|
+
}
|
|
94
|
+
catch {
|
|
95
|
+
// Keep walking up.
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
// The first missing component may be a dangling symlink (lstat succeeds,
|
|
99
|
+
// realpath fails). Follow it and loop to re-canonicalize; otherwise the
|
|
100
|
+
// remainder is genuinely non-existent and the re-joined path is final.
|
|
101
|
+
const firstMissing = path.join(resolvedAncestor, remainder[0]);
|
|
102
|
+
let linkTarget = null;
|
|
103
|
+
try {
|
|
104
|
+
linkTarget = fs.readlinkSync(firstMissing);
|
|
105
|
+
}
|
|
106
|
+
catch {
|
|
107
|
+
// Not a symlink — nothing left to resolve.
|
|
108
|
+
}
|
|
109
|
+
if (linkTarget === null) {
|
|
110
|
+
return path.join(resolvedAncestor, ...remainder);
|
|
111
|
+
}
|
|
112
|
+
current = path.join(path.resolve(path.dirname(firstMissing), linkTarget), ...remainder.slice(1));
|
|
113
|
+
}
|
|
114
|
+
return null; // symlink chain too long or cyclic
|
|
115
|
+
}
|
|
116
|
+
/**
|
|
117
|
+
* Check if any existing component in the path is a file (not a directory).
|
|
118
|
+
* If so, the target path can never be created because you can't mkdir under a file.
|
|
119
|
+
*
|
|
120
|
+
* This handles the git worktree case: .git is a file, so .git/hooks can never
|
|
121
|
+
* exist and there's nothing to deny.
|
|
122
|
+
*/
|
|
123
|
+
function hasFileAncestor(targetPath) {
|
|
124
|
+
const parts = targetPath.split(path.sep);
|
|
125
|
+
let currentPath = '';
|
|
126
|
+
for (const part of parts) {
|
|
127
|
+
if (!part)
|
|
128
|
+
continue; // Skip empty parts (leading /)
|
|
129
|
+
const nextPath = currentPath + path.sep + part;
|
|
130
|
+
try {
|
|
131
|
+
const stat = fs.statSync(nextPath);
|
|
132
|
+
if (stat.isFile()) {
|
|
133
|
+
// This component exists as a file — nothing below it can be created
|
|
134
|
+
return true;
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
catch {
|
|
138
|
+
// Path doesn't exist — stop checking
|
|
139
|
+
break;
|
|
140
|
+
}
|
|
141
|
+
currentPath = nextPath;
|
|
142
|
+
}
|
|
143
|
+
return false;
|
|
144
|
+
}
|
|
145
|
+
/**
|
|
146
|
+
* Find the first non-existent path component.
|
|
147
|
+
* E.g., for "/existing/parent/nonexistent/child/file.txt" where /existing/parent exists,
|
|
148
|
+
* returns "/existing/parent/nonexistent"
|
|
149
|
+
*
|
|
150
|
+
* This is used to block creation of non-existent deny paths by mounting /dev/null
|
|
151
|
+
* at the first missing component, preventing mkdir from creating the parent directories.
|
|
152
|
+
*/
|
|
153
|
+
function findFirstNonExistentComponent(targetPath) {
|
|
154
|
+
const parts = targetPath.split(path.sep);
|
|
155
|
+
let currentPath = '';
|
|
156
|
+
for (const part of parts) {
|
|
157
|
+
if (!part)
|
|
158
|
+
continue; // Skip empty parts (leading /)
|
|
159
|
+
const nextPath = currentPath + path.sep + part;
|
|
160
|
+
if (!fs.existsSync(nextPath)) {
|
|
161
|
+
return nextPath;
|
|
162
|
+
}
|
|
163
|
+
currentPath = nextPath;
|
|
164
|
+
}
|
|
165
|
+
return targetPath; // Shouldn't reach here if called correctly
|
|
166
|
+
}
|
|
167
|
+
/**
|
|
168
|
+
* The part of the mandatory deny set that follows from the cwd alone: the
|
|
169
|
+
* dangerous files and directories resolved against it, plus `.git/hooks` and
|
|
170
|
+
* (unless the caller allows git config) `.git/config`.
|
|
171
|
+
* {@link linuxGetMandatoryDenyPaths} adds the nested matches its ripgrep scan
|
|
172
|
+
* finds on top of these. Split out so a consumer that must not scan — the
|
|
173
|
+
* violation monitor, which needs the same denies to judge a write bwrap
|
|
174
|
+
* refuses — reads the same definition rather than a copy of it.
|
|
175
|
+
*/
|
|
176
|
+
export function linuxGetCwdMandatoryDenyPaths(allowGitConfig = false) {
|
|
177
|
+
const cwd = process.cwd();
|
|
178
|
+
// Note: Settings files are added at the callsite in sandbox-manager.ts
|
|
179
|
+
const denyPaths = [
|
|
180
|
+
// Dangerous files in CWD
|
|
181
|
+
...DANGEROUS_FILES.map(f => path.resolve(cwd, f)),
|
|
182
|
+
// Dangerous directories in CWD
|
|
183
|
+
...getDangerousDirectories().map(d => path.resolve(cwd, d)),
|
|
184
|
+
];
|
|
185
|
+
// Git hooks and config are only denied when .git exists as a directory.
|
|
186
|
+
// In git worktrees, .git is a file (e.g., "gitdir: /path/..."), so
|
|
187
|
+
// .git/hooks can never exist — denying it would cause bwrap to fail.
|
|
188
|
+
// When .git doesn't exist at all, mounting at .git would block its
|
|
189
|
+
// creation and break git init.
|
|
190
|
+
const dotGitPath = path.resolve(cwd, '.git');
|
|
191
|
+
let dotGitIsDirectory = false;
|
|
192
|
+
try {
|
|
193
|
+
dotGitIsDirectory = fs.statSync(dotGitPath).isDirectory();
|
|
194
|
+
}
|
|
195
|
+
catch {
|
|
196
|
+
// .git doesn't exist
|
|
197
|
+
}
|
|
198
|
+
if (dotGitIsDirectory) {
|
|
199
|
+
// Git hooks always blocked for security
|
|
200
|
+
denyPaths.push(path.resolve(cwd, '.git/hooks'));
|
|
201
|
+
// Git config conditionally blocked based on allowGitConfig setting
|
|
202
|
+
if (!allowGitConfig) {
|
|
203
|
+
denyPaths.push(path.resolve(cwd, '.git/config'));
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
return denyPaths;
|
|
207
|
+
}
|
|
208
|
+
/**
|
|
209
|
+
* Get mandatory deny paths using ripgrep (Linux only).
|
|
210
|
+
* Uses a SINGLE ripgrep call with multiple glob patterns for efficiency.
|
|
211
|
+
* With --max-depth limiting, this is fast enough to run on each command without memoization.
|
|
212
|
+
*/
|
|
213
|
+
async function linuxGetMandatoryDenyPaths(ripgrepConfig = { command: 'rg' }, maxDepth = DEFAULT_MANDATORY_DENY_SEARCH_DEPTH, allowGitConfig = false, abortSignal) {
|
|
214
|
+
const cwd = process.cwd();
|
|
215
|
+
// Use provided signal or create a fallback controller
|
|
216
|
+
const fallbackController = new AbortController();
|
|
217
|
+
const signal = abortSignal ?? fallbackController.signal;
|
|
218
|
+
const dangerousDirectories = getDangerousDirectories();
|
|
219
|
+
const denyPaths = linuxGetCwdMandatoryDenyPaths(allowGitConfig);
|
|
220
|
+
// Build iglob args for all patterns in one ripgrep call
|
|
221
|
+
const iglobArgs = [];
|
|
222
|
+
for (const fileName of DANGEROUS_FILES) {
|
|
223
|
+
iglobArgs.push('--iglob', fileName);
|
|
224
|
+
}
|
|
225
|
+
for (const dirName of dangerousDirectories) {
|
|
226
|
+
iglobArgs.push('--iglob', `**/${dirName}/**`);
|
|
227
|
+
}
|
|
228
|
+
// Git hooks always blocked in nested repos
|
|
229
|
+
iglobArgs.push('--iglob', '**/.git/hooks/**');
|
|
230
|
+
// Git config conditionally blocked in nested repos
|
|
231
|
+
if (!allowGitConfig) {
|
|
232
|
+
iglobArgs.push('--iglob', '**/.git/config');
|
|
233
|
+
}
|
|
234
|
+
// Single ripgrep call to find all dangerous paths in subdirectories
|
|
235
|
+
// Limit depth for performance - deeply nested dangerous files are rare
|
|
236
|
+
// and the security benefit doesn't justify the traversal cost
|
|
237
|
+
let matches = [];
|
|
238
|
+
try {
|
|
239
|
+
matches = await ripGrep([
|
|
240
|
+
'--files',
|
|
241
|
+
'--hidden',
|
|
242
|
+
'--max-depth',
|
|
243
|
+
String(maxDepth),
|
|
244
|
+
...iglobArgs,
|
|
245
|
+
'-g',
|
|
246
|
+
'!**/node_modules/**',
|
|
247
|
+
], cwd, signal, ripgrepConfig);
|
|
248
|
+
}
|
|
249
|
+
catch (error) {
|
|
250
|
+
logForDebugging(`[Sandbox] ripgrep scan failed: ${error}`);
|
|
251
|
+
}
|
|
252
|
+
// Process matches
|
|
253
|
+
for (const match of matches) {
|
|
254
|
+
const absolutePath = path.resolve(cwd, match);
|
|
255
|
+
// File inside a dangerous directory -> add the directory path
|
|
256
|
+
let foundDir = false;
|
|
257
|
+
for (const dirName of [...dangerousDirectories, '.git']) {
|
|
258
|
+
const normalizedDirName = normalizeCaseForComparison(dirName);
|
|
259
|
+
const segments = absolutePath.split(path.sep);
|
|
260
|
+
const dirIndex = segments.findIndex(s => normalizeCaseForComparison(s) === normalizedDirName);
|
|
261
|
+
if (dirIndex !== -1) {
|
|
262
|
+
// For .git, we want hooks/ or config, not the whole .git dir
|
|
263
|
+
if (dirName === '.git') {
|
|
264
|
+
const gitDir = segments.slice(0, dirIndex + 1).join(path.sep);
|
|
265
|
+
if (match.includes('.git/hooks')) {
|
|
266
|
+
denyPaths.push(path.join(gitDir, 'hooks'));
|
|
267
|
+
}
|
|
268
|
+
else if (match.includes('.git/config')) {
|
|
269
|
+
denyPaths.push(path.join(gitDir, 'config'));
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
else {
|
|
273
|
+
denyPaths.push(segments.slice(0, dirIndex + 1).join(path.sep));
|
|
274
|
+
}
|
|
275
|
+
foundDir = true;
|
|
276
|
+
break;
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
// Dangerous file match
|
|
280
|
+
if (!foundDir) {
|
|
281
|
+
denyPaths.push(absolutePath);
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
return [...new Set(denyPaths)];
|
|
285
|
+
}
|
|
286
|
+
// Track mount points created by bwrap for non-existent deny paths.
|
|
287
|
+
// When bwrap does --ro-bind /dev/null /nonexistent/path, it creates an empty
|
|
288
|
+
// file on the host as a mount point. These persist after bwrap exits and must
|
|
289
|
+
// be cleaned up explicitly.
|
|
290
|
+
const bwrapMountPoints = new Set();
|
|
291
|
+
// The source of the empty-directory mount points: at most one at a time, made
|
|
292
|
+
// on first use, reused while a sandbox is running, and removed with the mount
|
|
293
|
+
// points — never before, because a live bind's source must stay.
|
|
294
|
+
// generateFilesystemArgs pins it read-only inside every sandbox that uses it.
|
|
295
|
+
let emptyMountSourceDir;
|
|
296
|
+
function ensureEmptyMountSourceDir() {
|
|
297
|
+
if (emptyMountSourceDir !== undefined) {
|
|
298
|
+
// Revalidated, not trusted: the path is under the system temp dir, which
|
|
299
|
+
// sandboxed commands commonly can write, and bwrap resolves a bind source
|
|
300
|
+
// on the HOST, so a directory swapped for a symlink between two wraps
|
|
301
|
+
// would publish whatever it points at. Anything that is not our own
|
|
302
|
+
// private empty directory is left where it is: it is not ours to delete.
|
|
303
|
+
try {
|
|
304
|
+
const stat = fs.lstatSync(emptyMountSourceDir);
|
|
305
|
+
if (stat.isDirectory() &&
|
|
306
|
+
stat.uid === process.getuid?.() &&
|
|
307
|
+
(stat.mode & 0o077) === 0 &&
|
|
308
|
+
fs.readdirSync(emptyMountSourceDir).length === 0) {
|
|
309
|
+
return emptyMountSourceDir;
|
|
310
|
+
}
|
|
311
|
+
}
|
|
312
|
+
catch {
|
|
313
|
+
// Gone, or no longer inspectable: make a new one below.
|
|
314
|
+
}
|
|
315
|
+
logForDebugging(`[Sandbox Linux] Not reusing the empty-directory mount source, it is gone or no longer our own empty directory: ${emptyMountSourceDir}`);
|
|
316
|
+
}
|
|
317
|
+
emptyMountSourceDir = fs.mkdtempSync(path.join(tmpdir(), 'claude-empty-'));
|
|
318
|
+
// mkdtemp asks for 0700 but the umask applies, so under `umask 0200` the
|
|
319
|
+
// directory lands on 0500. Set the mode here rather than let the check
|
|
320
|
+
// above reject the directory this call has just made.
|
|
321
|
+
fs.chmodSync(emptyMountSourceDir, 0o700);
|
|
322
|
+
return emptyMountSourceDir;
|
|
323
|
+
}
|
|
324
|
+
/**
|
|
325
|
+
* Is `p` a mount point an earlier sandbox left behind? bwrap makes the mount
|
|
326
|
+
* point for `--ro-bind /dev/null <absent path>` with ensure_file(dest, 0444):
|
|
327
|
+
* an empty regular file with no write bits. The set above lives in memory, so
|
|
328
|
+
* a process that dies without an 'exit' event (SIGKILL, OOM) leaves the file
|
|
329
|
+
* on the host with nothing left that knows what it is. Files that only look
|
|
330
|
+
* similar are written by their creators with write bits (a lockfile, an empty
|
|
331
|
+
* file materialised on purpose: 0666 & ~umask) or have content or a second
|
|
332
|
+
* link. An empty read-only DIRECTORY left the same way is not recognisable:
|
|
333
|
+
* it looks like anyone's empty directory.
|
|
334
|
+
*/
|
|
335
|
+
function isStaleBwrapMountPoint(p) {
|
|
336
|
+
try {
|
|
337
|
+
const stat = fs.lstatSync(p);
|
|
338
|
+
return (stat.isFile() &&
|
|
339
|
+
stat.size === 0 &&
|
|
340
|
+
(stat.mode & 0o222) === 0 &&
|
|
341
|
+
stat.nlink === 1);
|
|
342
|
+
}
|
|
343
|
+
catch {
|
|
344
|
+
return false;
|
|
345
|
+
}
|
|
346
|
+
}
|
|
347
|
+
export const CAP_SETFCAP = 31;
|
|
348
|
+
// This process's bounding set unioned with its inheritable set, read once —
|
|
349
|
+
// see processHasBoundingCapability for why those two. Only prctl(CAPBSET_DROP)
|
|
350
|
+
// and capset move them and this library calls neither, and without the memo
|
|
351
|
+
// the read is a synchronous open of /proc/self/status on the path of every
|
|
352
|
+
// wrapped command. Only a successful read is stored, so a transient failure
|
|
353
|
+
// does not latch "no capabilities" for the life of the process.
|
|
354
|
+
let boundingCapabilities;
|
|
355
|
+
let capabilityReadFailureLogged = false;
|
|
356
|
+
/**
|
|
357
|
+
* Whether this process holds `cap` in its bounding or inheritable set (Linux).
|
|
358
|
+
*
|
|
359
|
+
* bwrap is reached by execve, and for a euid-0 caller the kernel recomputes
|
|
360
|
+
* the new permitted set from the bounding and inheritable sets, discarding
|
|
361
|
+
* what the caller itself had permitted. Those two sets are therefore what
|
|
362
|
+
* decides which capabilities bwrap holds when it writes its uid map; CapPrm
|
|
363
|
+
* and CapEff do not, and for any process itself reached by exec as root they
|
|
364
|
+
* merely repeat the union.
|
|
365
|
+
*/
|
|
366
|
+
export function processHasBoundingCapability(cap) {
|
|
367
|
+
let bits = boundingCapabilities;
|
|
368
|
+
if (bits === undefined) {
|
|
369
|
+
let failure = 'has no CapBnd or CapInh line';
|
|
370
|
+
try {
|
|
371
|
+
bits = boundingCapabilitiesFromStatus(fs.readFileSync('/proc/self/status', 'utf8'));
|
|
372
|
+
boundingCapabilities = bits;
|
|
373
|
+
}
|
|
374
|
+
catch (e) {
|
|
375
|
+
failure = `could not be read (${String(e)})`;
|
|
376
|
+
}
|
|
377
|
+
if (bits === undefined) {
|
|
378
|
+
if (!capabilityReadFailureLogged) {
|
|
379
|
+
capabilityReadFailureLogged = true;
|
|
380
|
+
logForDebugging(`[Sandbox Linux] /proc/self/status ${failure} - assuming this process holds no capabilities`, { level: 'warn' });
|
|
381
|
+
}
|
|
382
|
+
return false;
|
|
383
|
+
}
|
|
384
|
+
}
|
|
385
|
+
return ((bits >> BigInt(cap)) & 1n) === 1n;
|
|
386
|
+
}
|
|
387
|
+
/**
|
|
388
|
+
* The capability set an execve gives a euid-0 caller's child, parsed out of a
|
|
389
|
+
* `/proc/<pid>/status`: CapBnd unioned with CapInh. `undefined` when either
|
|
390
|
+
* line is absent. CapPrm and CapEff are deliberately not consulted — see
|
|
391
|
+
* processHasBoundingCapability.
|
|
392
|
+
*/
|
|
393
|
+
export function boundingCapabilitiesFromStatus(status) {
|
|
394
|
+
const bounding = status.match(/^CapBnd:\s*([0-9a-fA-F]+)\s*$/m);
|
|
395
|
+
const inheritable = status.match(/^CapInh:\s*([0-9a-fA-F]+)\s*$/m);
|
|
396
|
+
return bounding && inheritable
|
|
397
|
+
? BigInt('0x' + bounding[1]) | BigInt('0x' + inheritable[1])
|
|
398
|
+
: undefined;
|
|
399
|
+
}
|
|
400
|
+
/**
|
|
401
|
+
* The bwrap capability list. Always `--cap-drop ALL`, which for a non-root
|
|
402
|
+
* caller is bwrap's default anyway. `--cap-add CAP_SETFCAP` for the whole
|
|
403
|
+
* bwrap invocation — the outer shell and the two socat relays hold it too —
|
|
404
|
+
* when the caller is uid 0, the seccomp helper is in use and the capability
|
|
405
|
+
* survives into bwrap: the helper's nested user namespace maps uid 0, which
|
|
406
|
+
* Linux 5.12 and the distribution kernels that backported it allow only from
|
|
407
|
+
* a creator holding CAP_SETFCAP. Under the helper a uid-0 caller's command
|
|
408
|
+
* therefore has a full set inside that nested namespace, which is
|
|
409
|
+
* identity-mapped to the caller's uid 0, and the filesystem policy there
|
|
410
|
+
* rests on the nested namespace's mount copies being locked. Without the
|
|
411
|
+
* helper (`allowAllUnixSockets`, or no usable helper binary) the command runs
|
|
412
|
+
* in bwrap's own namespaces and `--cap-drop ALL` is what stops it unmounting
|
|
413
|
+
* a deny.
|
|
414
|
+
*
|
|
415
|
+
* Pure: `hasSetfcap` is decided by the caller, which is also where the
|
|
416
|
+
* missing-capability case is reported once per process.
|
|
417
|
+
*/
|
|
418
|
+
export function capabilityArgs({ euid, hasSetfcap, usesSeccompHelper, }) {
|
|
419
|
+
const args = ['--cap-drop', 'ALL'];
|
|
420
|
+
if (euid !== 0)
|
|
421
|
+
return args;
|
|
422
|
+
if (!hasSetfcap)
|
|
423
|
+
return args;
|
|
424
|
+
if (usesSeccompHelper)
|
|
425
|
+
args.push('--cap-add', 'CAP_SETFCAP');
|
|
426
|
+
return args;
|
|
427
|
+
}
|
|
428
|
+
// Latch for the predicted case: once per process, not once per command.
|
|
429
|
+
let setfcapMissingLogged = false;
|
|
430
|
+
// A uid-0 caller whose bounding set lacks CAP_SETFCAP cannot sandbox
|
|
431
|
+
// anything: bwrap's own --unshare-user writes a uid map containing uid 0, and
|
|
432
|
+
// so does the seccomp helper's nested namespace, and a kernel that enforces
|
|
433
|
+
// the requirement refuses both unless the namespace's creator held the
|
|
434
|
+
// capability. allowAllUnixSockets does not avoid it — it only drops the
|
|
435
|
+
// helper, not bwrap's own user namespace. Worded for both the predicted case
|
|
436
|
+
// (the debug warning on the path of a wrapped command) and the confirmed one
|
|
437
|
+
// (checkLinuxDependencies, after bubblewrap has actually refused).
|
|
438
|
+
export const CAP_SETFCAP_MISSING_MESSAGE = "running as uid 0 without CAP_SETFCAP in this process's capability bounding set - on " +
|
|
439
|
+
'kernels that enforce the CAP_SETFCAP requirement for mapping uid 0 into a user namespace ' +
|
|
440
|
+
'(Linux 5.12 and distribution backports) every sandboxed command fails while writing a uid ' +
|
|
441
|
+
'map ("Operation not permitted"). Grant CAP_SETFCAP to this process, or run as a non-root user';
|
|
442
|
+
/**
|
|
443
|
+
* Linux's per-argument cap, MAX_ARG_STRLEN: 32 pages, so 128 KiB on most
|
|
444
|
+
* kernels and up to 2 MiB with 64 KiB pages. The page size is AT_PAGESZ in
|
|
445
|
+
* /proc/self/auxv (pairs of native words); 4 KiB, the smallest, if unreadable.
|
|
446
|
+
*/
|
|
447
|
+
let linuxMaxArgStrlen;
|
|
448
|
+
function maxArgStrlen() {
|
|
449
|
+
if (linuxMaxArgStrlen === undefined) {
|
|
450
|
+
const AT_PAGESZ = 6;
|
|
451
|
+
let pageSize = 4096;
|
|
452
|
+
try {
|
|
453
|
+
const auxv = fs.readFileSync('/proc/self/auxv');
|
|
454
|
+
const wordBytes = /64|s390x/.test(process.arch) ? 8 : 4;
|
|
455
|
+
// Buffer reads at most 6 bytes as a number; no value needed here is wider.
|
|
456
|
+
const low = Math.min(wordBytes, 6);
|
|
457
|
+
const word = (at) => endianness() === 'BE'
|
|
458
|
+
? auxv.readUIntBE(at + wordBytes - low, low)
|
|
459
|
+
: auxv.readUIntLE(at, low);
|
|
460
|
+
for (let at = 0; at + 2 * wordBytes <= auxv.length; at += 2 * wordBytes) {
|
|
461
|
+
if (word(at) === AT_PAGESZ) {
|
|
462
|
+
pageSize = word(at + wordBytes);
|
|
463
|
+
break;
|
|
464
|
+
}
|
|
465
|
+
}
|
|
466
|
+
}
|
|
467
|
+
catch {
|
|
468
|
+
// No /proc: the smallest page size only moves a profile to the file
|
|
469
|
+
// sooner than it had to.
|
|
470
|
+
}
|
|
471
|
+
linuxMaxArgStrlen = 32 * pageSize;
|
|
472
|
+
}
|
|
473
|
+
return linuxMaxArgStrlen;
|
|
474
|
+
}
|
|
475
|
+
/**
|
|
476
|
+
* Room left below the cap when deciding whether the profile stays on the
|
|
477
|
+
* command line: the embedder may put a prefix of its own (`exec`, `cd x &&`,
|
|
478
|
+
* an assignment) in the same argument.
|
|
479
|
+
*/
|
|
480
|
+
const ARG_HEADROOM_BYTES = 4096;
|
|
481
|
+
/** bwrap's cap on parsed words, the command line and `--args` file together. */
|
|
482
|
+
const BWRAP_MAX_ARGS = 9000;
|
|
483
|
+
/**
|
|
484
|
+
* The fd the `--args` file is opened on: a single digit, since dash rejects
|
|
485
|
+
* multi-digit redirections, and high, since embedders hand the command low
|
|
486
|
+
* fds of their own (an extra stdio pipe, a helper as `/proc/self/fd/3`).
|
|
487
|
+
*/
|
|
488
|
+
const BWRAP_ARGS_FD = 9;
|
|
489
|
+
/**
|
|
490
|
+
* Linux's O_TMPFILE, which neither Node nor Bun exposes in `fs.constants`:
|
|
491
|
+
* the asm-generic __O_TMPFILE, the value on every architecture they build
|
|
492
|
+
* for Linux, together with the O_DIRECTORY the flag is defined to carry — a
|
|
493
|
+
* kernel or filesystem that does not know it therefore fails the open on the
|
|
494
|
+
* directory rather than creating a named file.
|
|
495
|
+
*/
|
|
496
|
+
const O_TMPFILE = 0o20000000 | fs.constants.O_DIRECTORY;
|
|
497
|
+
/**
|
|
498
|
+
* The `--args` profiles this process holds open.
|
|
499
|
+
*
|
|
500
|
+
* A profile is an unnamed file (O_TMPFILE): written at wrap time, kept open
|
|
501
|
+
* here, and opened again by the string bwrap runs through
|
|
502
|
+
* `/proc/<pid>/fd/<n>`, which is a fresh read-only description of the same
|
|
503
|
+
* inode at offset 0. Nothing is named at any point, so there is nothing for
|
|
504
|
+
* anyone to substitute between the wrap and the execution — not a sandbox
|
|
505
|
+
* that renames an ancestor of tmpdir, not a sandbox another process of the
|
|
506
|
+
* same user launched with tmpdir writable. Every sandbox this library starts
|
|
507
|
+
* has its own PID namespace and a fresh /proc, so none of them can reach
|
|
508
|
+
* /proc/<this pid> either.
|
|
509
|
+
*
|
|
510
|
+
* Fds close when the sandboxes of a batch are cleaned up, and fd numbers are
|
|
511
|
+
* reused: a string kept past its cleanup and run later opens whatever the
|
|
512
|
+
* number means by then — nothing (the redirection fails and the command does
|
|
513
|
+
* not run), something bwrap refuses, or another profile of this process,
|
|
514
|
+
* never one from outside it.
|
|
515
|
+
*/
|
|
516
|
+
const bwrapArgsFds = new Set();
|
|
517
|
+
/** Where the string bwrap runs opens the profile held on `fd`. */
|
|
518
|
+
function bwrapArgsProfilePath(fd) {
|
|
519
|
+
return `/proc/${process.pid}/fd/${fd}`;
|
|
520
|
+
}
|
|
521
|
+
/**
|
|
522
|
+
* Writes `mountWords` NUL-separated to an unnamed file and returns the fd it
|
|
523
|
+
* stays open on. Throws when no directory takes an O_TMPFILE file, or when
|
|
524
|
+
* the profile cannot be opened again through /proc: there is no named-file
|
|
525
|
+
* fallback, and before any of this an over-long profile failed with E2BIG
|
|
526
|
+
* anyway. The check is this process's own open; a child's can still be
|
|
527
|
+
* refused (a process made non-dumpable owns its /proc entries as root), and
|
|
528
|
+
* the string then fails in the redirection and runs no command.
|
|
529
|
+
*/
|
|
530
|
+
function openBwrapArgsProfile(mountWords) {
|
|
531
|
+
const contents = mountWords.map(word => word + '\0').join('');
|
|
532
|
+
const failures = [];
|
|
533
|
+
for (const dir of new Set([tmpdir(), '/dev/shm'])) {
|
|
534
|
+
let fd;
|
|
535
|
+
try {
|
|
536
|
+
fd = fs.openSync(dir, O_TMPFILE | fs.constants.O_RDWR, 0o600);
|
|
537
|
+
}
|
|
538
|
+
catch (error) {
|
|
539
|
+
failures.push(`${dir}: ${errorText(error)}`);
|
|
540
|
+
continue;
|
|
541
|
+
}
|
|
542
|
+
try {
|
|
543
|
+
fs.writeFileSync(fd, contents);
|
|
544
|
+
// Read-only from here: this process is done writing it.
|
|
545
|
+
fs.fchmodSync(fd, 0o400);
|
|
546
|
+
// The string opens this path. Fail now, where the caller is told why,
|
|
547
|
+
// rather than when the command runs.
|
|
548
|
+
fs.closeSync(fs.openSync(bwrapArgsProfilePath(fd), fs.constants.O_RDONLY));
|
|
549
|
+
}
|
|
550
|
+
catch (error) {
|
|
551
|
+
failures.push(`${dir}: ${errorText(error)}`);
|
|
552
|
+
fs.closeSync(fd);
|
|
553
|
+
continue;
|
|
554
|
+
}
|
|
555
|
+
bwrapArgsFds.add(fd);
|
|
556
|
+
return fd;
|
|
557
|
+
}
|
|
558
|
+
throw new Error(`no unnamed file could be opened for it (${failures.join('; ')})`);
|
|
559
|
+
}
|
|
560
|
+
function closeBwrapArgsProfile(fd) {
|
|
561
|
+
bwrapArgsFds.delete(fd);
|
|
562
|
+
try {
|
|
563
|
+
fs.closeSync(fd);
|
|
564
|
+
}
|
|
565
|
+
catch {
|
|
566
|
+
// Already closed: nothing left to release.
|
|
567
|
+
}
|
|
568
|
+
}
|
|
569
|
+
function errorText(error) {
|
|
570
|
+
return error instanceof Error ? error.message : String(error);
|
|
571
|
+
}
|
|
572
|
+
/**
|
|
573
|
+
* Thrown when a Linux bubblewrap profile cannot be run on this host: what the
|
|
574
|
+
* configuration expands to is past a limit, or, for `command_too_long` and
|
|
575
|
+
* `nul_in_path`, what the caller passed in is. The command was not run and no
|
|
576
|
+
* profile file stays open, so do not run the per-command cleanup
|
|
577
|
+
* (`cleanupAfterCommand()`, `cleanupBwrapMountPoints()`) for a wrap that
|
|
578
|
+
* threw: it would release a second time, and a sandbox still running would
|
|
579
|
+
* lose its mount points. Branch on `.code`, never on `.message`, which
|
|
580
|
+
* carries the sizes of the moment. Other wrap-time failures (a shell that is
|
|
581
|
+
* not on PATH, a bridge socket that is gone) are plain Errors.
|
|
582
|
+
*/
|
|
583
|
+
export class LinuxSandboxProfileError extends Error {
|
|
584
|
+
constructor(code, message, cause) {
|
|
585
|
+
super(message);
|
|
586
|
+
this.name = 'LinuxSandboxProfileError';
|
|
587
|
+
this.code = code;
|
|
588
|
+
if (cause !== undefined) {
|
|
589
|
+
// Non-enumerable, as a native `cause` is. Once the compile target is
|
|
590
|
+
// ES2022 this is `super(message, { cause })`.
|
|
591
|
+
Object.defineProperty(this, 'cause', {
|
|
592
|
+
value: cause,
|
|
593
|
+
writable: true,
|
|
594
|
+
configurable: true,
|
|
595
|
+
enumerable: false,
|
|
596
|
+
});
|
|
597
|
+
}
|
|
598
|
+
}
|
|
599
|
+
}
|
|
600
|
+
/**
|
|
601
|
+
* The shell string that runs bwrap with `bwrapArgs`, which the caller runs
|
|
602
|
+
* as one argument of `sh -c`. When that would not fit the kernel's
|
|
603
|
+
* per-argument cap, the words in `mounts` (a slice of `bwrapArgs`) go to an
|
|
604
|
+
* unnamed file this process holds open (see `bwrapArgsFds`) and bwrap reads
|
|
605
|
+
* them through `--args` at the same position; the string opens that file
|
|
606
|
+
* again through /proc. Every other word, the per-command environment and the
|
|
607
|
+
* command among them, stays on the line. The result stays a simple command,
|
|
608
|
+
* so a prefix (`exec`, `timeout 30`) or a suffix (`&& next`) still composes.
|
|
609
|
+
* Throws {@link LinuxSandboxProfileError} when the profile cannot run.
|
|
610
|
+
*/
|
|
611
|
+
function renderBwrapInvocation(bwrapBinary, bwrapArgs, mounts) {
|
|
612
|
+
if (bwrapArgs.length > BWRAP_MAX_ARGS) {
|
|
613
|
+
throw new LinuxSandboxProfileError('too_many_arguments', `Sandbox profile has ${bwrapArgs.length} bwrap arguments and bwrap accepts at most ${BWRAP_MAX_ARGS} (about ${BWRAP_MAX_ARGS / 3} mounts); reduce what the configuration expands to: each path takes about three arguments, each environment variable two`);
|
|
614
|
+
}
|
|
615
|
+
const mountWords = bwrapArgs.slice(mounts.start, mounts.end);
|
|
616
|
+
// A command line ends at a NUL and bwrap splits an args file on one, so the
|
|
617
|
+
// word is cut short on the line and becomes several options in the file.
|
|
618
|
+
if (mountWords.some(word => word.includes('\0'))) {
|
|
619
|
+
throw new LinuxSandboxProfileError('nul_in_path', 'Sandbox profile contains a path with a NUL byte, which neither a command line nor a file of bwrap arguments can carry');
|
|
620
|
+
}
|
|
621
|
+
const inline = quote([bwrapBinary, ...bwrapArgs]);
|
|
622
|
+
const inlineBytes = Buffer.byteLength(inline, 'utf8');
|
|
623
|
+
const limit = maxArgStrlen() - 1;
|
|
624
|
+
if (inlineBytes <= limit - ARG_HEADROOM_BYTES) {
|
|
625
|
+
return inline;
|
|
626
|
+
}
|
|
627
|
+
const tooLong = `Sandbox profile is too long for the command line (${inlineBytes} bytes; past ${limit - ARG_HEADROOM_BYTES} it goes through a file)`;
|
|
628
|
+
// `--args <fd>` are two more words.
|
|
629
|
+
if (bwrapArgs.length + 2 > BWRAP_MAX_ARGS) {
|
|
630
|
+
throw new LinuxSandboxProfileError('too_many_arguments', `${tooLong} and, passed through a file, would exceed the ${BWRAP_MAX_ARGS} arguments bwrap accepts`);
|
|
631
|
+
}
|
|
632
|
+
let argsFd;
|
|
633
|
+
try {
|
|
634
|
+
argsFd = openBwrapArgsProfile(mountWords);
|
|
635
|
+
}
|
|
636
|
+
catch (error) {
|
|
637
|
+
throw new LinuxSandboxProfileError('args_file_unavailable', `${tooLong} and cannot be passed through a file: ${errorText(error)}`, error);
|
|
638
|
+
}
|
|
639
|
+
// /bin/sh opens the profile on the fd and execs bwrap, which reads it to
|
|
640
|
+
// EOF and closes it before running the command.
|
|
641
|
+
const viaArgsFile = quote([
|
|
642
|
+
'/bin/sh',
|
|
643
|
+
'-c',
|
|
644
|
+
`exec ${BWRAP_ARGS_FD}<"$1" && shift && exec "$@"`,
|
|
645
|
+
'srt-args',
|
|
646
|
+
bwrapArgsProfilePath(argsFd),
|
|
647
|
+
bwrapBinary,
|
|
648
|
+
...bwrapArgs.slice(0, mounts.start),
|
|
649
|
+
'--args',
|
|
650
|
+
String(BWRAP_ARGS_FD),
|
|
651
|
+
...bwrapArgs.slice(mounts.end),
|
|
652
|
+
]);
|
|
653
|
+
const viaArgsFileBytes = Buffer.byteLength(viaArgsFile, 'utf8');
|
|
654
|
+
if (viaArgsFileBytes > limit) {
|
|
655
|
+
closeBwrapArgsProfile(argsFd);
|
|
656
|
+
throw new LinuxSandboxProfileError('command_too_long', `Sandboxed command is too long for one shell argument even with the mounts passed through a file (${viaArgsFileBytes} bytes; the limit here is ${limit})`);
|
|
657
|
+
}
|
|
658
|
+
logForDebugging(`[Sandbox Linux] bwrap mounts moved to an unnamed file, read through ${bwrapArgsProfilePath(argsFd)} on bwrap's fd ${BWRAP_ARGS_FD}: the command line would be ${inlineBytes} bytes as one argument`);
|
|
659
|
+
return viaArgsFile;
|
|
660
|
+
}
|
|
661
|
+
// Number of wrapped commands that have been generated but whose cleanup has
|
|
662
|
+
// not yet run. cleanupBwrapMountPoints() defers file deletion while this is
|
|
663
|
+
// positive, because deleting a mount point file on the host while another
|
|
664
|
+
// bwrap instance is still running detaches that instance's bind mount and
|
|
665
|
+
// the deny rule stops applying inside it.
|
|
666
|
+
let activeSandboxCount = 0;
|
|
667
|
+
let exitHandlerRegistered = false;
|
|
668
|
+
/**
|
|
669
|
+
* Register cleanup handler for bwrap mount points
|
|
670
|
+
*/
|
|
671
|
+
function registerExitCleanupHandler() {
|
|
672
|
+
if (exitHandlerRegistered) {
|
|
673
|
+
return;
|
|
674
|
+
}
|
|
675
|
+
process.on('exit', () => {
|
|
676
|
+
cleanupBwrapMountPoints({ force: true });
|
|
677
|
+
});
|
|
678
|
+
exitHandlerRegistered = true;
|
|
679
|
+
}
|
|
680
|
+
/**
|
|
681
|
+
* Clean up mount point files created by bwrap for non-existent deny paths.
|
|
682
|
+
*
|
|
683
|
+
* When protecting non-existent deny paths, bwrap creates empty files on the
|
|
684
|
+
* host filesystem as mount points for --ro-bind. These files persist after
|
|
685
|
+
* bwrap exits. This function removes them.
|
|
686
|
+
*
|
|
687
|
+
* This should be called after each sandboxed command completes to prevent
|
|
688
|
+
* ghost dotfiles (e.g. .bashrc, .gitconfig) from appearing in the working
|
|
689
|
+
* directory. It is also called automatically on process exit as a safety net.
|
|
690
|
+
*
|
|
691
|
+
* Each call decrements the active-sandbox counter that was incremented by
|
|
692
|
+
* wrapCommandWithSandboxLinux(). File deletion is deferred until the counter
|
|
693
|
+
* reaches zero. Deleting a mount point file on the host while another bwrap
|
|
694
|
+
* instance is still running detaches that instance's bind mount (the dentry
|
|
695
|
+
* is unhashed, so path lookup no longer finds the mount) and the deny rule
|
|
696
|
+
* stops applying inside that sandbox.
|
|
697
|
+
*
|
|
698
|
+
* Pass `{ force: true }` to delete unconditionally — used by the process-exit
|
|
699
|
+
* handler and reset() where deferral is not meaningful.
|
|
700
|
+
*
|
|
701
|
+
* Also closes the `--args` profiles the wraps of this batch opened.
|
|
702
|
+
*/
|
|
703
|
+
export function cleanupBwrapMountPoints(opts) {
|
|
704
|
+
if (!opts?.force) {
|
|
705
|
+
if (activeSandboxCount > 0) {
|
|
706
|
+
activeSandboxCount--;
|
|
707
|
+
}
|
|
708
|
+
if (activeSandboxCount > 0) {
|
|
709
|
+
logForDebugging(`[Sandbox Linux] Deferring mount point cleanup — ${activeSandboxCount} sandbox(es) still active`);
|
|
710
|
+
return;
|
|
711
|
+
}
|
|
712
|
+
}
|
|
713
|
+
else {
|
|
714
|
+
activeSandboxCount = 0;
|
|
715
|
+
}
|
|
716
|
+
for (const mountPoint of bwrapMountPoints) {
|
|
717
|
+
try {
|
|
718
|
+
// Only remove if it's still the empty file/directory bwrap created.
|
|
719
|
+
// If something else has written real content, leave it alone.
|
|
720
|
+
const stat = fs.statSync(mountPoint);
|
|
721
|
+
if (stat.isFile() && stat.size === 0) {
|
|
722
|
+
fs.unlinkSync(mountPoint);
|
|
723
|
+
logForDebugging(`[Sandbox Linux] Cleaned up bwrap mount point (file): ${mountPoint}`);
|
|
724
|
+
}
|
|
725
|
+
else if (stat.isDirectory()) {
|
|
726
|
+
// Empty directory mount points are created for intermediate
|
|
727
|
+
// components (Fix 2). Only remove if still empty.
|
|
728
|
+
const entries = fs.readdirSync(mountPoint);
|
|
729
|
+
if (entries.length === 0) {
|
|
730
|
+
fs.rmdirSync(mountPoint);
|
|
731
|
+
logForDebugging(`[Sandbox Linux] Cleaned up bwrap mount point (dir): ${mountPoint}`);
|
|
732
|
+
}
|
|
733
|
+
}
|
|
734
|
+
}
|
|
735
|
+
catch {
|
|
736
|
+
// Ignore cleanup errors — the file may have already been removed
|
|
737
|
+
}
|
|
738
|
+
}
|
|
739
|
+
bwrapMountPoints.clear();
|
|
740
|
+
if (emptyMountSourceDir !== undefined) {
|
|
741
|
+
try {
|
|
742
|
+
// rmdirSync, not a recursive remove: it neither follows a symlink nor
|
|
743
|
+
// descends, so a path that is no longer our empty directory is left
|
|
744
|
+
// exactly as found.
|
|
745
|
+
fs.rmdirSync(emptyMountSourceDir);
|
|
746
|
+
logForDebugging(`[Sandbox Linux] Cleaned up the empty-directory mount source: ${emptyMountSourceDir}`);
|
|
747
|
+
emptyMountSourceDir = undefined;
|
|
748
|
+
}
|
|
749
|
+
catch (err) {
|
|
750
|
+
const code = err.code;
|
|
751
|
+
if (code === 'ENOTEMPTY') {
|
|
752
|
+
logForDebugging(`[Sandbox Linux] Left the empty-directory mount source behind, something has written into it: ${emptyMountSourceDir}`);
|
|
753
|
+
}
|
|
754
|
+
// Forget the path only when there is nothing left to remove. ENOTEMPTY,
|
|
755
|
+
// EACCES and EBUSY (a forced cleanup while a live sandbox still pins the
|
|
756
|
+
// source) leave it ours to try again; the revalidation on next use
|
|
757
|
+
// decides whether it can be reused.
|
|
758
|
+
if (code === 'ENOENT' || code === 'ENOTDIR') {
|
|
759
|
+
emptyMountSourceDir = undefined;
|
|
760
|
+
}
|
|
761
|
+
}
|
|
762
|
+
}
|
|
763
|
+
for (const argsFd of [...bwrapArgsFds]) {
|
|
764
|
+
closeBwrapArgsProfile(argsFd);
|
|
765
|
+
}
|
|
766
|
+
}
|
|
767
|
+
function isExecutable(p) {
|
|
768
|
+
try {
|
|
769
|
+
fs.accessSync(p, fs.constants.X_OK);
|
|
770
|
+
return true;
|
|
771
|
+
}
|
|
772
|
+
catch {
|
|
773
|
+
return false;
|
|
774
|
+
}
|
|
775
|
+
}
|
|
776
|
+
/**
|
|
777
|
+
* Get detailed status of Linux sandbox dependencies
|
|
778
|
+
*/
|
|
779
|
+
export function getLinuxDependencyStatus(opts) {
|
|
780
|
+
const { seccompConfig, bwrapPath, socatPath } = opts ?? {};
|
|
781
|
+
// argv0 mode: apply-seccomp is compiled into the caller's binary — skip
|
|
782
|
+
// the on-disk lookup and trust that applyPath resolves inside bwrap.
|
|
783
|
+
return {
|
|
784
|
+
hasBwrap: bwrapPath ? isExecutable(bwrapPath) : whichSync('bwrap') !== null,
|
|
785
|
+
hasSocat: socatPath ? isExecutable(socatPath) : whichSync('socat') !== null,
|
|
786
|
+
hasSeccompApply: seccompConfig?.argv0
|
|
787
|
+
? true
|
|
788
|
+
: getApplySeccompBinaryPath(seccompConfig?.applyPath) !== null,
|
|
789
|
+
};
|
|
790
|
+
}
|
|
791
|
+
/**
|
|
792
|
+
* Check sandbox dependencies and return structured result
|
|
793
|
+
*/
|
|
794
|
+
export function checkLinuxDependencies(opts) {
|
|
795
|
+
const { seccompConfig, bwrapPath, socatPath } = opts ?? {};
|
|
796
|
+
const errors = [];
|
|
797
|
+
const warnings = [];
|
|
798
|
+
// An explicit override is a directive, not a hint — if it doesn't exist,
|
|
799
|
+
// surface that rather than silently falling back to PATH.
|
|
800
|
+
let usableBwrap = null;
|
|
801
|
+
if (bwrapPath) {
|
|
802
|
+
if (isExecutable(bwrapPath))
|
|
803
|
+
usableBwrap = bwrapPath;
|
|
804
|
+
else
|
|
805
|
+
errors.push(`bubblewrap (bwrap) not executable at ${bwrapPath}`);
|
|
806
|
+
}
|
|
807
|
+
else {
|
|
808
|
+
usableBwrap = whichSync('bwrap');
|
|
809
|
+
if (usableBwrap === null)
|
|
810
|
+
errors.push('bubblewrap (bwrap) not installed');
|
|
811
|
+
}
|
|
812
|
+
if (socatPath) {
|
|
813
|
+
if (!isExecutable(socatPath))
|
|
814
|
+
errors.push(`socat not executable at ${socatPath}`);
|
|
815
|
+
}
|
|
816
|
+
else if (whichSync('socat') === null) {
|
|
817
|
+
errors.push('socat not installed');
|
|
818
|
+
}
|
|
819
|
+
if (!seccompConfig?.argv0 &&
|
|
820
|
+
getApplySeccompBinaryPath(seccompConfig?.applyPath) === null) {
|
|
821
|
+
warnings.push('seccomp not available - unix socket access not restricted');
|
|
822
|
+
}
|
|
823
|
+
const uid0Error = uid0SandboxError({
|
|
824
|
+
euid: process.geteuid?.(),
|
|
825
|
+
hasSetfcap: processHasBoundingCapability(CAP_SETFCAP),
|
|
826
|
+
bwrap: usableBwrap,
|
|
827
|
+
});
|
|
828
|
+
if (uid0Error !== null)
|
|
829
|
+
errors.push(uid0Error);
|
|
830
|
+
return { warnings, errors };
|
|
831
|
+
}
|
|
832
|
+
/**
|
|
833
|
+
* Predict, then confirm. The prediction — uid 0 with no CAP_SETFCAP to hand
|
|
834
|
+
* to bwrap — is exact about the capability but not about the kernel, so ask
|
|
835
|
+
* bubblewrap itself before failing the caller's initialize(). An error, not a
|
|
836
|
+
* warning: if it holds, every sandboxed command fails.
|
|
837
|
+
*
|
|
838
|
+
* `bwrap === null` skips the probe: a missing or unusable bubblewrap is
|
|
839
|
+
* already its own dependency error, and a second one about capabilities would
|
|
840
|
+
* only misdirect.
|
|
841
|
+
*/
|
|
842
|
+
export function uid0SandboxError({ euid, hasSetfcap, bwrap, }) {
|
|
843
|
+
if (euid !== 0 || hasSetfcap || bwrap === null)
|
|
844
|
+
return null;
|
|
845
|
+
const refusal = probeUid0UserNamespace(bwrap);
|
|
846
|
+
if (refusal === null)
|
|
847
|
+
return null;
|
|
848
|
+
return refusal === ''
|
|
849
|
+
? CAP_SETFCAP_MISSING_MESSAGE
|
|
850
|
+
: `${CAP_SETFCAP_MISSING_MESSAGE} (bubblewrap: ${refusal})`;
|
|
851
|
+
}
|
|
852
|
+
// One probe result per bwrap binary: whether a uid-0 map is refused is a
|
|
853
|
+
// property of the kernel and the binary, not of the moment. Keyed by path so
|
|
854
|
+
// a caller that passes an explicit bwrapPath is not answered for another one.
|
|
855
|
+
const uid0UserNamespaceProbes = new Map();
|
|
856
|
+
/**
|
|
857
|
+
* Run `bwrap --unshare-user --dev-bind / / true` once and report whether this
|
|
858
|
+
* kernel actually refuses to map uid 0. `null` means it does not, so there is
|
|
859
|
+
* nothing to report; otherwise bubblewrap's own first stderr line, or the
|
|
860
|
+
* empty string when the probe could not be run at all and the prediction
|
|
861
|
+
* stands unaided.
|
|
862
|
+
*/
|
|
863
|
+
function probeUid0UserNamespace(bwrap) {
|
|
864
|
+
const cached = uid0UserNamespaceProbes.get(bwrap);
|
|
865
|
+
if (cached !== undefined)
|
|
866
|
+
return cached;
|
|
867
|
+
const probe = spawnSync(bwrap, ['--unshare-user', '--dev-bind', '/', '/', 'true'], {
|
|
868
|
+
timeout: 5000,
|
|
869
|
+
stdio: ['ignore', 'ignore', 'pipe'],
|
|
870
|
+
encoding: 'utf8',
|
|
871
|
+
});
|
|
872
|
+
const refusal = probe.error === undefined && probe.status === 0
|
|
873
|
+
? null
|
|
874
|
+
: ((probe.stderr ?? '')
|
|
875
|
+
.split('\n')
|
|
876
|
+
.map(line => line.trim())
|
|
877
|
+
.find(line => line.length > 0)
|
|
878
|
+
?.slice(0, 200) ?? '');
|
|
879
|
+
uid0UserNamespaceProbes.set(bwrap, refusal);
|
|
880
|
+
return refusal;
|
|
881
|
+
}
|
|
882
|
+
/**
|
|
883
|
+
* Initialize the Linux network bridge for sandbox networking
|
|
884
|
+
*
|
|
885
|
+
* ARCHITECTURE NOTE:
|
|
886
|
+
* Linux network sandboxing uses bwrap --unshare-net which creates a completely isolated
|
|
887
|
+
* network namespace with NO network access. To enable network access, we:
|
|
888
|
+
*
|
|
889
|
+
* 1. Host side: Run socat bridges that listen on Unix sockets and forward to host proxy servers
|
|
890
|
+
* - HTTP bridge: Unix socket -> host HTTP proxy (for HTTP/HTTPS traffic)
|
|
891
|
+
* - SOCKS bridge: Unix socket -> host SOCKS5 proxy (for SSH/git traffic)
|
|
892
|
+
*
|
|
893
|
+
* 2. Sandbox side: Bind the Unix sockets into the isolated namespace and run socat listeners
|
|
894
|
+
* - HTTP listener on port 3128 -> HTTP Unix socket -> host HTTP proxy
|
|
895
|
+
* - SOCKS listener on port 1080 -> SOCKS Unix socket -> host SOCKS5 proxy
|
|
896
|
+
*
|
|
897
|
+
* 3. Configure environment:
|
|
898
|
+
* - HTTP_PROXY=http://localhost:3128 for HTTP/HTTPS tools
|
|
899
|
+
* - GIT_SSH_COMMAND with socat for SSH through SOCKS5
|
|
900
|
+
*
|
|
901
|
+
* LIMITATION: Unlike macOS sandbox which can enforce domain-based allowlists at the kernel level,
|
|
902
|
+
* Linux's --unshare-net provides only all-or-nothing network isolation. Domain filtering happens
|
|
903
|
+
* at the host proxy level, not the sandbox boundary. This means network restrictions on Linux
|
|
904
|
+
* depend on the proxy's filtering capabilities.
|
|
905
|
+
*
|
|
906
|
+
* DEPENDENCIES: Requires bwrap (bubblewrap) and socat
|
|
907
|
+
*/
|
|
908
|
+
export async function initializeLinuxNetworkBridge(httpProxyPort, socksProxyPort, socatPath) {
|
|
909
|
+
const socat = socatPath ?? 'socat';
|
|
910
|
+
const socketId = randomBytes(8).toString('hex');
|
|
911
|
+
const httpSocketPath = join(tmpdir(), `claude-http-${socketId}.sock`);
|
|
912
|
+
// Only allocated when ports differ; in the mux case the SOCKS side
|
|
913
|
+
// reuses httpSocketPath.
|
|
914
|
+
const socksSocketPath = join(tmpdir(), `claude-socks-${socketId}.sock`);
|
|
915
|
+
// Start HTTP bridge
|
|
916
|
+
const httpSocatArgs = [
|
|
917
|
+
`UNIX-LISTEN:${httpSocketPath},fork,reuseaddr`,
|
|
918
|
+
`TCP:localhost:${httpProxyPort},keepalive,keepidle=10,keepintvl=5,keepcnt=3`,
|
|
919
|
+
];
|
|
920
|
+
logForDebugging(`Starting HTTP bridge: ${socat} ${httpSocatArgs.join(' ')}`);
|
|
921
|
+
const httpBridgeProcess = spawn(socat, httpSocatArgs, {
|
|
922
|
+
stdio: 'ignore',
|
|
923
|
+
});
|
|
924
|
+
// Add error and exit handlers to monitor bridge health. These must be
|
|
925
|
+
// registered before the !pid check: when spawn fails (e.g. socat is
|
|
926
|
+
// missing or not executable), the ChildProcess emits an asynchronous
|
|
927
|
+
// 'error' event, and throwing first would leave that event without a
|
|
928
|
+
// listener — surfacing as an uncaughtException instead of the rejection
|
|
929
|
+
// below.
|
|
930
|
+
httpBridgeProcess.on('error', err => {
|
|
931
|
+
logForDebugging(`HTTP bridge process error: ${err}`, { level: 'error' });
|
|
932
|
+
});
|
|
933
|
+
httpBridgeProcess.on('exit', (code, signal) => {
|
|
934
|
+
logForDebugging(`HTTP bridge process exited with code ${code}, signal ${signal}`, { level: code === 0 ? 'info' : 'error' });
|
|
935
|
+
});
|
|
936
|
+
if (!httpBridgeProcess.pid) {
|
|
937
|
+
throw new Error('Failed to start HTTP bridge process');
|
|
938
|
+
}
|
|
939
|
+
// SOCKS bridge: when the host serves both protocols on one port (the mux),
|
|
940
|
+
// a second socat to the same TCP target is redundant — reuse the HTTP
|
|
941
|
+
// bridge's process and socket path. Downstream consumers
|
|
942
|
+
// (LinuxNetworkBridgeContext, in-sandbox socat, cleanup) treat duplicate
|
|
943
|
+
// refs idempotently. A separate bridge is only spawned when the ports
|
|
944
|
+
// differ (external proxy override).
|
|
945
|
+
let socksBridgeProcess;
|
|
946
|
+
let socksSockPath;
|
|
947
|
+
if (socksProxyPort === httpProxyPort) {
|
|
948
|
+
socksBridgeProcess = httpBridgeProcess;
|
|
949
|
+
socksSockPath = httpSocketPath;
|
|
950
|
+
}
|
|
951
|
+
else {
|
|
952
|
+
socksSockPath = socksSocketPath;
|
|
953
|
+
const socksSocatArgs = [
|
|
954
|
+
`UNIX-LISTEN:${socksSocketPath},fork,reuseaddr`,
|
|
955
|
+
`TCP:localhost:${socksProxyPort},keepalive,keepidle=10,keepintvl=5,keepcnt=3`,
|
|
956
|
+
];
|
|
957
|
+
logForDebugging(`Starting SOCKS bridge: ${socat} ${socksSocatArgs.join(' ')}`);
|
|
958
|
+
socksBridgeProcess = spawn(socat, socksSocatArgs, {
|
|
959
|
+
stdio: 'ignore',
|
|
960
|
+
});
|
|
961
|
+
// Add error and exit handlers to monitor bridge health — registered
|
|
962
|
+
// before the !pid check for the same reason as the HTTP bridge above.
|
|
963
|
+
socksBridgeProcess.on('error', err => {
|
|
964
|
+
logForDebugging(`SOCKS bridge process error: ${err}`, { level: 'error' });
|
|
965
|
+
});
|
|
966
|
+
socksBridgeProcess.on('exit', (code, signal) => {
|
|
967
|
+
logForDebugging(`SOCKS bridge process exited with code ${code}, signal ${signal}`, { level: code === 0 ? 'info' : 'error' });
|
|
968
|
+
});
|
|
969
|
+
if (!socksBridgeProcess.pid) {
|
|
970
|
+
// Clean up HTTP bridge
|
|
971
|
+
if (httpBridgeProcess.pid) {
|
|
972
|
+
try {
|
|
973
|
+
process.kill(httpBridgeProcess.pid, 'SIGTERM');
|
|
974
|
+
}
|
|
975
|
+
catch {
|
|
976
|
+
// Ignore errors
|
|
977
|
+
}
|
|
978
|
+
}
|
|
979
|
+
throw new Error('Failed to start SOCKS bridge process');
|
|
980
|
+
}
|
|
981
|
+
}
|
|
982
|
+
// Wait for both sockets to be ready
|
|
983
|
+
const maxAttempts = 5;
|
|
984
|
+
for (let i = 0; i < maxAttempts; i++) {
|
|
985
|
+
if (!httpBridgeProcess.pid ||
|
|
986
|
+
httpBridgeProcess.killed ||
|
|
987
|
+
!socksBridgeProcess.pid ||
|
|
988
|
+
socksBridgeProcess.killed) {
|
|
989
|
+
throw new Error('Linux bridge process died unexpectedly');
|
|
990
|
+
}
|
|
991
|
+
try {
|
|
992
|
+
// fs already imported
|
|
993
|
+
if (fs.existsSync(httpSocketPath) && fs.existsSync(socksSockPath)) {
|
|
994
|
+
logForDebugging(`Linux bridges ready after ${i + 1} attempts`);
|
|
995
|
+
break;
|
|
996
|
+
}
|
|
997
|
+
}
|
|
998
|
+
catch (err) {
|
|
999
|
+
logForDebugging(`Error checking sockets (attempt ${i + 1}): ${err}`, {
|
|
1000
|
+
level: 'error',
|
|
1001
|
+
});
|
|
1002
|
+
}
|
|
1003
|
+
if (i === maxAttempts - 1) {
|
|
1004
|
+
// Clean up both processes
|
|
1005
|
+
if (httpBridgeProcess.pid) {
|
|
1006
|
+
try {
|
|
1007
|
+
process.kill(httpBridgeProcess.pid, 'SIGTERM');
|
|
1008
|
+
}
|
|
1009
|
+
catch {
|
|
1010
|
+
// Ignore errors
|
|
1011
|
+
}
|
|
1012
|
+
}
|
|
1013
|
+
if (socksBridgeProcess.pid) {
|
|
1014
|
+
try {
|
|
1015
|
+
process.kill(socksBridgeProcess.pid, 'SIGTERM');
|
|
1016
|
+
}
|
|
1017
|
+
catch {
|
|
1018
|
+
// Ignore errors
|
|
1019
|
+
}
|
|
1020
|
+
}
|
|
1021
|
+
throw new Error(`Failed to create bridge sockets after ${maxAttempts} attempts`);
|
|
1022
|
+
}
|
|
1023
|
+
await new Promise(resolve => setTimeout(resolve, i * 100));
|
|
1024
|
+
}
|
|
1025
|
+
return {
|
|
1026
|
+
httpSocketPath,
|
|
1027
|
+
socksSocketPath: socksSockPath,
|
|
1028
|
+
httpBridgeProcess,
|
|
1029
|
+
socksBridgeProcess,
|
|
1030
|
+
httpProxyPort,
|
|
1031
|
+
socksProxyPort,
|
|
1032
|
+
};
|
|
1033
|
+
}
|
|
1034
|
+
/**
|
|
1035
|
+
* Resolve how to invoke apply-seccomp: either a standalone binary path, or a
|
|
1036
|
+
* multicall-binary prefix that dispatches on the ARGV0 env var.
|
|
1037
|
+
*
|
|
1038
|
+
* Returns a shell-ready string ending in a trailing space — callers append
|
|
1039
|
+
* quote([shell, '-c', cmd]). Returns undefined when seccomp is
|
|
1040
|
+
* unavailable (no argv0, no binary found).
|
|
1041
|
+
*
|
|
1042
|
+
* When argv0 is set, applyPath is used verbatim (no existence check); the
|
|
1043
|
+
* caller is responsible for ensuring it resolves inside the bwrap namespace.
|
|
1044
|
+
*/
|
|
1045
|
+
function resolveApplySeccompPrefix(applyPath, argv0) {
|
|
1046
|
+
if (argv0) {
|
|
1047
|
+
if (!applyPath) {
|
|
1048
|
+
throw new Error('seccompConfig.argv0 requires seccompConfig.applyPath');
|
|
1049
|
+
}
|
|
1050
|
+
return `ARGV0=${quote([argv0])} ${quote([applyPath])} `;
|
|
1051
|
+
}
|
|
1052
|
+
const binary = getApplySeccompBinaryPath(applyPath);
|
|
1053
|
+
return binary ? `${quote([binary])} ` : undefined;
|
|
1054
|
+
}
|
|
1055
|
+
/**
|
|
1056
|
+
* Build the command that runs inside the sandbox.
|
|
1057
|
+
* Sets up HTTP proxy on port 3128 and SOCKS proxy on port 1080
|
|
1058
|
+
*/
|
|
1059
|
+
function buildSandboxCommand(httpSocketPath, socksSocketPath, userCommand, applySeccompPrefix, shell, socatPath) {
|
|
1060
|
+
// Default to bash for backward compatibility
|
|
1061
|
+
const shellPath = shell || 'bash';
|
|
1062
|
+
// Host filesystem is bind-mounted into the sandbox, so an explicit
|
|
1063
|
+
// socatPath resolves to the same binary inside bwrap.
|
|
1064
|
+
const socat = quote([socatPath ?? 'socat']);
|
|
1065
|
+
const socatCommands = [
|
|
1066
|
+
`${socat} TCP-LISTEN:3128,fork,reuseaddr UNIX-CONNECT:${httpSocketPath} >/dev/null 2>&1 &`,
|
|
1067
|
+
`${socat} TCP-LISTEN:1080,fork,reuseaddr UNIX-CONNECT:${socksSocketPath} >/dev/null 2>&1 &`,
|
|
1068
|
+
// The trap saves the status the script is exiting with and exits with
|
|
1069
|
+
// it. A bare `exit` inside an EXIT trap is not portable: bash and dash
|
|
1070
|
+
// keep the script's status, zsh takes the status of the trap's own last
|
|
1071
|
+
// command (the kill), so under zsh a failing command reported 0. Single
|
|
1072
|
+
// quotes, so $? and $rc are read when the trap runs, not when it is set.
|
|
1073
|
+
"trap 'rc=$?; kill %1 %2 2>/dev/null; exit $rc' EXIT",
|
|
1074
|
+
];
|
|
1075
|
+
// apply-seccomp runs after socat so socat can still create Unix sockets.
|
|
1076
|
+
if (applySeccompPrefix) {
|
|
1077
|
+
const applySeccompCmd = applySeccompPrefix + quote([shellPath, '-c', userCommand]);
|
|
1078
|
+
const innerScript = [...socatCommands, applySeccompCmd].join('\n');
|
|
1079
|
+
return `${shellPath} -c ${quote([innerScript])}`;
|
|
1080
|
+
}
|
|
1081
|
+
else {
|
|
1082
|
+
const innerScript = [...socatCommands, `eval ${quote([userCommand])}`].join('\n');
|
|
1083
|
+
return `${shellPath} -c ${quote([innerScript])}`;
|
|
1084
|
+
}
|
|
1085
|
+
}
|
|
1086
|
+
/**
|
|
1087
|
+
* The three classes an fs errno from a path lookup falls into, enumerated
|
|
1088
|
+
* once because each leads somewhere different: ABSENCE, where the name
|
|
1089
|
+
* resolves to no file and every consumer already treats the path as absent
|
|
1090
|
+
* (isAbsenceErrno, in sandbox-utils.ts, where the glob walk classifies the
|
|
1091
|
+
* same failures); TRANSIENT, a busy, unlucky or re-exporting host rather
|
|
1092
|
+
* than a fact about the path, worth asking once more before the failure is
|
|
1093
|
+
* taken for an answer; and SETTLED, a file that is there and cannot be
|
|
1094
|
+
* looked at, and will not become readable while this wrap runs. Every code
|
|
1095
|
+
* neither predicate names is settled, because nothing says it clears.
|
|
1096
|
+
*/
|
|
1097
|
+
function isTransientErrno(err) {
|
|
1098
|
+
const code = err?.code;
|
|
1099
|
+
return (code === 'EIO' ||
|
|
1100
|
+
code === 'ESTALE' ||
|
|
1101
|
+
code === 'EMFILE' ||
|
|
1102
|
+
code === 'ENFILE' ||
|
|
1103
|
+
code === 'EAGAIN');
|
|
1104
|
+
}
|
|
1105
|
+
/**
|
|
1106
|
+
* `attempt`, asked a second time when the first failure was transient and
|
|
1107
|
+
* rethrown otherwise. Two caveats come with the second ask: a same-tick retry
|
|
1108
|
+
* cannot clear descriptor exhaustion (EMFILE, ENFILE), so for those it is
|
|
1109
|
+
* best effort. And on a soft-mounted network filesystem that is down, a
|
|
1110
|
+
* transient-class errno arrives only after the mount's own timeout, so the
|
|
1111
|
+
* retry can double that stall — a hard mount never returns at all, so the
|
|
1112
|
+
* retry never runs there.
|
|
1113
|
+
*/
|
|
1114
|
+
function retryingTransient(attempt) {
|
|
1115
|
+
try {
|
|
1116
|
+
return attempt();
|
|
1117
|
+
}
|
|
1118
|
+
catch (err) {
|
|
1119
|
+
if (!isTransientErrno(err))
|
|
1120
|
+
throw err;
|
|
1121
|
+
}
|
|
1122
|
+
return attempt();
|
|
1123
|
+
}
|
|
1124
|
+
/** The top-level directories this wrap leaves to the kernel and to the
|
|
1125
|
+
* caller's own remounts. A cover must never take one — --proc and --dev
|
|
1126
|
+
* replace two of them after the pins are spliced in, and /sys is kernel
|
|
1127
|
+
* state — and the '/' read-deny expansion must not hide one either. */
|
|
1128
|
+
const KERNEL_TOP_LEVEL_DIRS = ['/proc', '/dev', '/sys'];
|
|
1129
|
+
/**
|
|
1130
|
+
* A self-bind ("pin") for every directory between a seed (where a deny bind,
|
|
1131
|
+
* file mask or read-deny tmpfs lands) and the allowed write root covering it,
|
|
1132
|
+
* shallow-first, which makes each one a mountpoint the kernel refuses to
|
|
1133
|
+
* rename or remove. A directory that is not there is skipped: bwrap cannot
|
|
1134
|
+
* bind a missing source and there is nothing to rename. One that exists but
|
|
1135
|
+
* cannot be inspected is still pinned, and if bwrap cannot bind it either the
|
|
1136
|
+
* sandbox does not start, which is where the deny loop lands too.
|
|
1137
|
+
*
|
|
1138
|
+
* Pins are read-only and spliced in beneath every other mount, so that one
|
|
1139
|
+
* landing on top of an allowed write root cannot make it read-only; a write
|
|
1140
|
+
* root needs no pin of its own there, its allow bind already being a live
|
|
1141
|
+
* mountpoint.
|
|
1142
|
+
*
|
|
1143
|
+
* A '/' write root defeats that: its own recursive `--bind / /` is one of the
|
|
1144
|
+
* mounts above the pins and buries them, leaving those directories renamable.
|
|
1145
|
+
* Under one the pins therefore go AFTER the allow binds and take every
|
|
1146
|
+
* ancestor, the other allowed write roots included, stopping below the
|
|
1147
|
+
* top-level directory D on the chain; a writable `--bind D D` — the cover —
|
|
1148
|
+
* takes D itself and buries them again. Lookups enter the cover and never meet
|
|
1149
|
+
* a pin, so no filesystem boundary lands inside D, while the kernel's rename
|
|
1150
|
+
* and rmdir checks scan mounts by dentry and still find one. The only new
|
|
1151
|
+
* boundary is at D. /proc, /dev and /sys are never covered, and a seed under
|
|
1152
|
+
* one of them gets no pins.
|
|
1153
|
+
*/
|
|
1154
|
+
function ancestorPinArgs(seeds, writeRoots) {
|
|
1155
|
+
const { rootIsWriteRoot } = writeRoots;
|
|
1156
|
+
const pinDirs = new Set();
|
|
1157
|
+
const coverDirs = new Set();
|
|
1158
|
+
// Verdicts are seed-independent: a visited ancestor's chain is done.
|
|
1159
|
+
const visited = new Set();
|
|
1160
|
+
for (const seed of seeds) {
|
|
1161
|
+
if (rootIsWriteRoot &&
|
|
1162
|
+
KERNEL_TOP_LEVEL_DIRS.some(dir => isAtOrUnder(seed, dir))) {
|
|
1163
|
+
continue;
|
|
1164
|
+
}
|
|
1165
|
+
for (let dir = path.dirname(seed); dir !== '/' && !visited.has(dir) && writeRoots.isWithinAllowedWrite(dir); dir = path.dirname(dir)) {
|
|
1166
|
+
visited.add(dir);
|
|
1167
|
+
if (rootIsWriteRoot) {
|
|
1168
|
+
if (path.dirname(dir) === '/') {
|
|
1169
|
+
coverDirs.add(dir);
|
|
1170
|
+
continue;
|
|
1171
|
+
}
|
|
1172
|
+
}
|
|
1173
|
+
else if (writeRoots.isAllowedWriteRoot(dir)) {
|
|
1174
|
+
continue;
|
|
1175
|
+
}
|
|
1176
|
+
try {
|
|
1177
|
+
fs.statSync(dir);
|
|
1178
|
+
}
|
|
1179
|
+
catch (err) {
|
|
1180
|
+
if (isAbsenceErrno(err))
|
|
1181
|
+
continue;
|
|
1182
|
+
}
|
|
1183
|
+
pinDirs.add(dir);
|
|
1184
|
+
}
|
|
1185
|
+
}
|
|
1186
|
+
const covers = [...coverDirs];
|
|
1187
|
+
if (covers.length > 0) {
|
|
1188
|
+
logForDebugging(`[Sandbox Linux] Covering the pinned ancestors under: ${covers.join(', ')}`);
|
|
1189
|
+
}
|
|
1190
|
+
return [
|
|
1191
|
+
...[...pinDirs]
|
|
1192
|
+
.sort((a, b) => a.split('/').length - b.split('/').length)
|
|
1193
|
+
.flatMap(dir => ['--ro-bind', dir, dir]),
|
|
1194
|
+
...covers.flatMap(dir => ['--bind', dir, dir]),
|
|
1195
|
+
];
|
|
1196
|
+
}
|
|
1197
|
+
/**
|
|
1198
|
+
* Mount a tmpfs over a read-denied directory, then restore the allowed write
|
|
1199
|
+
* paths and allowRead paths the tmpfs just wiped. Returns the mounts it
|
|
1200
|
+
* re-bound, writable and read-only, which is what a later pass re-emits when
|
|
1201
|
+
* a denyWrite bind re-exposes this directory.
|
|
1202
|
+
*/
|
|
1203
|
+
function pushReadDenyDirMounts(args, unit) {
|
|
1204
|
+
const { landing, allowedWritePaths, readAllowPaths } = unit;
|
|
1205
|
+
args.push('--tmpfs', landing);
|
|
1206
|
+
// How to bind allowed path `p` back, if it is a carve-out of this unit:
|
|
1207
|
+
// its name must live inside the denied directory, and so must what it
|
|
1208
|
+
// resolves to. Parent directories may be symlinks (/lib/x is a carve-out
|
|
1209
|
+
// of a tmpfs on /usr, where /lib is a link to /usr/lib). A path that is
|
|
1210
|
+
// itself a symlink INTO the directory is not a carve-out: it names the
|
|
1211
|
+
// link, which this tmpfs did not hide, and re-allows nothing the link
|
|
1212
|
+
// points at, or a link planted at an allowed path (docs -> ~/.ssh) would
|
|
1213
|
+
// cancel the deny of its target.
|
|
1214
|
+
//
|
|
1215
|
+
// The mount goes back at the NAME (the entry re-allows the name it is, and
|
|
1216
|
+
// an entry whose last component is a symlink is ENOENT inside the sandbox
|
|
1217
|
+
// if restored only at its target), from the RESOLVED source: that is the
|
|
1218
|
+
// path both containment checks were made against, and naming it leaves
|
|
1219
|
+
// bwrap nothing to re-resolve at mount time.
|
|
1220
|
+
const restorePlacementOf = (p) => {
|
|
1221
|
+
const dest = unit.nameLocation(p);
|
|
1222
|
+
if (!isAtOrUnder(dest, landing))
|
|
1223
|
+
return undefined;
|
|
1224
|
+
const { canonical: source, resolved } = unit.resolve(p);
|
|
1225
|
+
if (!resolved) {
|
|
1226
|
+
logForDebugging(`[Sandbox Linux] Not restoring ${p} over denyRead tmpfs ${landing}: nothing there resolves (absent, dangling or unreadable), so the only source available is the name itself`, { level: 'warn' });
|
|
1227
|
+
return undefined;
|
|
1228
|
+
}
|
|
1229
|
+
if (!isAtOrUnder(source, landing)) {
|
|
1230
|
+
logForDebugging(`[Sandbox Linux] Not restoring ${p} over denyRead tmpfs ${landing}: it resolves outside it, to ${source}`, { level: 'warn' });
|
|
1231
|
+
return undefined;
|
|
1232
|
+
}
|
|
1233
|
+
// Restoring at the name from a DIFFERENT path is a second mount of the
|
|
1234
|
+
// target's inode, which nothing landing on the target's own path covers,
|
|
1235
|
+
// so such a restore is dropped whenever the read section hides anything
|
|
1236
|
+
// at, inside or around that target: the deny wins, and the follow-up that
|
|
1237
|
+
// gives the carve-out back has to bind the target at the target.
|
|
1238
|
+
if (source !== dest) {
|
|
1239
|
+
const denied = unit.readDenialAround(source);
|
|
1240
|
+
if (denied !== undefined) {
|
|
1241
|
+
logForDebugging(`[Sandbox Linux] Not restoring ${p} over denyRead tmpfs ${landing}: it resolves to ${source}, and the read section denies or masks ${denied}`, { level: 'warn' });
|
|
1242
|
+
return undefined;
|
|
1243
|
+
}
|
|
1244
|
+
}
|
|
1245
|
+
return { source, dest };
|
|
1246
|
+
};
|
|
1247
|
+
// tmpfs wiped any earlier write binds under this path — restore them.
|
|
1248
|
+
const restoredWrites = [];
|
|
1249
|
+
for (const writePath of allowedWritePaths) {
|
|
1250
|
+
const placement = restorePlacementOf(writePath);
|
|
1251
|
+
if (placement === undefined)
|
|
1252
|
+
continue;
|
|
1253
|
+
args.push('--bind', placement.source, placement.dest);
|
|
1254
|
+
restoredWrites.push(placement);
|
|
1255
|
+
logForDebugging(`[Sandbox Linux] Re-bound write path wiped by denyRead tmpfs: ${writePath}`);
|
|
1256
|
+
}
|
|
1257
|
+
// Re-allow specific paths within the denied directory (allowRead overrides denyRead).
|
|
1258
|
+
// After mounting tmpfs over the denied dir, bind back the allowed subdirectories
|
|
1259
|
+
// so they are readable again.
|
|
1260
|
+
const restoredReads = [];
|
|
1261
|
+
for (const allowPath of readAllowPaths) {
|
|
1262
|
+
const placement = restorePlacementOf(allowPath);
|
|
1263
|
+
if (placement === undefined)
|
|
1264
|
+
continue;
|
|
1265
|
+
// Skip only if a write path was re-bound just above AND covers
|
|
1266
|
+
// allowPath. A write path that's an ancestor of the deny dir isn't
|
|
1267
|
+
// re-bound (it wasn't wiped), so allowPath under it still needs
|
|
1268
|
+
// its own ro-bind here.
|
|
1269
|
+
if (restoredWrites.some(w => isAtOrUnder(placement.dest, w.dest)))
|
|
1270
|
+
continue;
|
|
1271
|
+
// Bind the allowed path back over the tmpfs so it's readable
|
|
1272
|
+
args.push('--ro-bind', placement.source, placement.dest);
|
|
1273
|
+
restoredReads.push(placement);
|
|
1274
|
+
logForDebugging(`[Sandbox Linux] Re-allowed read access within denied region: ${allowPath}`);
|
|
1275
|
+
}
|
|
1276
|
+
return { restoredWrites, restoredReads };
|
|
1277
|
+
}
|
|
1278
|
+
/**
|
|
1279
|
+
* Generate filesystem bind mount arguments for bwrap
|
|
1280
|
+
*/
|
|
1281
|
+
async function generateFilesystemArgs(readConfig, writeConfig, maskedFileBinds, maskedFileStoreDir, ripgrepConfig = { command: 'rg' }, mandatoryDenySearchDepth = DEFAULT_MANDATORY_DENY_SEARCH_DEPTH, allowGitConfig = false, abortSignal) {
|
|
1282
|
+
const args = [];
|
|
1283
|
+
// fs already imported
|
|
1284
|
+
// Collect normalized allowed write paths. Populated in the writeConfig
|
|
1285
|
+
// block, read again in the denyRead loop to re-bind writes under tmpfs.
|
|
1286
|
+
const allowedWritePaths = [];
|
|
1287
|
+
// denyWrite binds are buffered and emitted after denyRead processing so that
|
|
1288
|
+
// a denyRead tmpfs over an ancestor directory doesn't wipe them out.
|
|
1289
|
+
const denyWriteArgs = [];
|
|
1290
|
+
// Directories that a deny entry re-binds read-only inside the sandbox
|
|
1291
|
+
// (--ro-bind <dir> <dir>), keyed by resolved dest, with every raw
|
|
1292
|
+
// (pre-resolution) spelling each was reached through. A non-existent deny
|
|
1293
|
+
// path whose deepest existing ancestor lies within one of these is already
|
|
1294
|
+
// uncreatable, and must not get a /dev/null stub: bwrap would have to
|
|
1295
|
+
// creat() the mount point inside that read-only mount and abort ("Can't
|
|
1296
|
+
// create file at <path>: Read-only file system"). An EXISTING deny path
|
|
1297
|
+
// strictly beneath one is likewise already unwritable and its own
|
|
1298
|
+
// --ro-bind <p> <p> is skipped as redundant. The spellings matter
|
|
1299
|
+
// because the re-application passes record a deny bind's raw spelling
|
|
1300
|
+
// beside its resolved dest, so the stub-skip guard tests a covering
|
|
1301
|
+
// directory in its canonical form AND every recorded spelling.
|
|
1302
|
+
const readOnlyDenyDirSpellings = new Map();
|
|
1303
|
+
// dest → the pre-resolution deny path it came from. A bind at the resolved
|
|
1304
|
+
// dest also re-exposes whatever the symlinked spelling leads to, so the
|
|
1305
|
+
// re-application passes below compare a read deny's landing against both
|
|
1306
|
+
// spellings. Landings and allowed write paths are canonical, so the extra
|
|
1307
|
+
// spelling can only match more of them, never fewer.
|
|
1308
|
+
const denyWriteRawDests = new Map();
|
|
1309
|
+
// The shared empty directory this call's placeholders bind from, resolved at
|
|
1310
|
+
// most once per wrap: resolving again mid-wrap (the cached path having been
|
|
1311
|
+
// tampered with in between) would leave the binds already emitted pointing
|
|
1312
|
+
// at a directory this same call has just judged not ours.
|
|
1313
|
+
let emptySource;
|
|
1314
|
+
// Where a mount given `p` lands: `p` fully resolved, every symlink on the
|
|
1315
|
+
// way and not one hop. One resolution per path per wrap, so every predicate
|
|
1316
|
+
// below sees the same answer, and none before the mandatory-deny scan's
|
|
1317
|
+
// await: that scan can run arbitrarily long, and a realpath taken ahead of
|
|
1318
|
+
// it would miss a symlink retargeted meanwhile.
|
|
1319
|
+
const canonicalFormCache = new Map();
|
|
1320
|
+
// Paths whose canonical location could not be LOOKED AT — a settled errno,
|
|
1321
|
+
// or a transient one that outlived the retry — so the recorded spelling
|
|
1322
|
+
// stands in for it. That is a guess, and the
|
|
1323
|
+
// stub-skip prediction below refuses to conclude anything from a guess made
|
|
1324
|
+
// about its own inputs. Plain absence is not a guess: nothing is there to
|
|
1325
|
+
// resolve, and every consumer already treats such a path as absent (a
|
|
1326
|
+
// dangling symlink under '/' is ordinary, and counting it would keep every
|
|
1327
|
+
// deny placeholder on the whole host).
|
|
1328
|
+
const canonicalFormGuesses = new Set();
|
|
1329
|
+
// Paths canonicalForm answered with the spelling it was given because
|
|
1330
|
+
// nothing could be resolved. A mount SOURCE must never be one of them:
|
|
1331
|
+
// bwrap would resolve whatever sits at that name at mount time, after the
|
|
1332
|
+
// containment checks ran against the name itself.
|
|
1333
|
+
const canonicalFormUnresolved = new Set();
|
|
1334
|
+
const canonicalForm = (p) => {
|
|
1335
|
+
let canonical = canonicalFormCache.get(p);
|
|
1336
|
+
if (canonical === undefined) {
|
|
1337
|
+
try {
|
|
1338
|
+
canonical = retryingTransient(() => fs.realpathSync(p));
|
|
1339
|
+
}
|
|
1340
|
+
catch (err) {
|
|
1341
|
+
canonical = p; // vanished or unresolvable: the recorded form stands
|
|
1342
|
+
canonicalFormUnresolved.add(p);
|
|
1343
|
+
if (!isAbsenceErrno(err))
|
|
1344
|
+
canonicalFormGuesses.add(p);
|
|
1345
|
+
}
|
|
1346
|
+
canonicalFormCache.set(p, canonical);
|
|
1347
|
+
}
|
|
1348
|
+
return canonical;
|
|
1349
|
+
};
|
|
1350
|
+
/** Where `p` resolves to, beside whether anything was resolved at all.
|
|
1351
|
+
* Answered together because the answer is the spelling itself when nothing
|
|
1352
|
+
* was, and a mount source must never be one of those. */
|
|
1353
|
+
const canonicalLocationOf = (p) => ({
|
|
1354
|
+
canonical: canonicalForm(p),
|
|
1355
|
+
resolved: !canonicalFormUnresolved.has(p),
|
|
1356
|
+
});
|
|
1357
|
+
/** `p` as recorded, plus its canonical location when that differs. */
|
|
1358
|
+
const mountForms = (p) => {
|
|
1359
|
+
const canonical = canonicalForm(p);
|
|
1360
|
+
return canonical === p ? [p] : [p, canonical];
|
|
1361
|
+
};
|
|
1362
|
+
// Where the name `p` lives: its parent directories resolved, its last
|
|
1363
|
+
// component as written. This, not what `p` resolves to, decides which read
|
|
1364
|
+
// deny an allowRead entry is an exception to: an entry that is a symlink
|
|
1365
|
+
// names the link, and re-allows nothing the link points at.
|
|
1366
|
+
const nameLocationOf = (p) => {
|
|
1367
|
+
if (p === '/')
|
|
1368
|
+
return p;
|
|
1369
|
+
const parent = canonicalForm(path.dirname(p));
|
|
1370
|
+
return `${parent === '/' ? '' : parent}/${path.basename(p)}`;
|
|
1371
|
+
};
|
|
1372
|
+
// Whether a canonical path lies inside the write allowlist, and so
|
|
1373
|
+
// whether it is denied, stubbed and pinned at all: a path outside it is
|
|
1374
|
+
// left read-only by the initial --ro-bind / /. The deny pre-pass, the deny
|
|
1375
|
+
// loop's --ro-bind gate and the ancestor-pin walk MUST share it: the
|
|
1376
|
+
// pre-pass is only sound if it records exactly the directories the loop
|
|
1377
|
+
// re-binds read-only (a recorded directory that is never re-bound read-only
|
|
1378
|
+
// would suppress stubs unsafely; a re-bound directory missing from the
|
|
1379
|
+
// record only costs an abort). allowedWritePaths entries are canonical (the
|
|
1380
|
+
// allow loop drops a symlink-spelled one) and recorded with trailing
|
|
1381
|
+
// slashes stripped.
|
|
1382
|
+
//
|
|
1383
|
+
// Containment is root-aware (isAtOrUnder) because '/' is a legal allowOnly
|
|
1384
|
+
// entry that the allow loop binds writable: an `allowedPath + '/'` prefix
|
|
1385
|
+
// test spells it '//' and matches nothing, so every deny no other allow
|
|
1386
|
+
// entry covers would be judged outside the allowlist and silently lose its
|
|
1387
|
+
// bind over a writable root. The ancestor pins are the one consumer that
|
|
1388
|
+
// treats a '/' write root specially: see ancestorPinArgs.
|
|
1389
|
+
const isWithinAnyAllowedWritePath = (candidatePath) => allowedWritePaths.some(allowedPath => isAtOrUnder(candidatePath, allowedPath));
|
|
1390
|
+
const isAllowedWriteRoot = (candidatePath) => allowedWritePaths.includes(candidatePath);
|
|
1391
|
+
// Resolves symlinks, so computed on first use, which is after the
|
|
1392
|
+
// mandatory-deny scan.
|
|
1393
|
+
let readAllowPathsMemo;
|
|
1394
|
+
const readAllowPaths = () => (readAllowPathsMemo ?? (readAllowPathsMemo = (readConfig?.allowWithinDeny || []).map(p => normalizePathForSandbox(p))));
|
|
1395
|
+
// What the read section mounts for one denyRead entry: a tmpfs (directory)
|
|
1396
|
+
// or a /dev/null mask (anything else) on the entry itself; nothing when it
|
|
1397
|
+
// is not there; and, when it cannot be inspected, a tmpfs on the deepest
|
|
1398
|
+
// directory above it that can. That hides more than was asked, never less:
|
|
1399
|
+
// a same-uid command can make a parent unsearchable (chmod 000) and undo it
|
|
1400
|
+
// again from inside the next sandbox, so "unreadable now" is not "absent".
|
|
1401
|
+
// This is the widest blast radius a transient failure has, hence the retry:
|
|
1402
|
+
// one unlucky EIO turns a file deny into a stand-in over its whole
|
|
1403
|
+
// directory, and every carve-out beneath that is then refused.
|
|
1404
|
+
const readDenyTargetOf = (entry) => {
|
|
1405
|
+
for (let candidate = entry;; candidate = path.dirname(candidate)) {
|
|
1406
|
+
if (candidate === '/' && candidate !== entry)
|
|
1407
|
+
return undefined;
|
|
1408
|
+
try {
|
|
1409
|
+
const stats = retryingTransient(() => fs.statSync(candidate));
|
|
1410
|
+
return {
|
|
1411
|
+
path: candidate,
|
|
1412
|
+
isDirectory: stats.isDirectory(),
|
|
1413
|
+
isStandIn: candidate !== entry,
|
|
1414
|
+
};
|
|
1415
|
+
}
|
|
1416
|
+
catch (err) {
|
|
1417
|
+
if (isAbsenceErrno(err) || candidate === '/')
|
|
1418
|
+
return undefined;
|
|
1419
|
+
}
|
|
1420
|
+
}
|
|
1421
|
+
};
|
|
1422
|
+
// Where that mount lands, which is where the entry really is: one mount
|
|
1423
|
+
// then covers every spelling of that place; the real contents are hidden
|
|
1424
|
+
// wherever they are reachable, even when the route that named them is
|
|
1425
|
+
// itself inside an earlier tmpfs; and no mount is ever asked to land on a
|
|
1426
|
+
// symlink, which bubblewrap refuses outright from 0.12 on ("Can't mount on
|
|
1427
|
+
// symlink destination").
|
|
1428
|
+
//
|
|
1429
|
+
// '/' is never a landing. Only a symlink to the root resolves there (a '/'
|
|
1430
|
+
// entry itself is expanded into the root's children), and a --tmpfs /
|
|
1431
|
+
// would wipe every mount placed before it while the pivot promoted it,
|
|
1432
|
+
// booting the command on an empty tree. The directory holding the link
|
|
1433
|
+
// stands in for it, on the same rule as an entry that cannot be inspected,
|
|
1434
|
+
// and `undefined` means nothing at all is mounted for this entry.
|
|
1435
|
+
const readDenyMountOf = (entry) => {
|
|
1436
|
+
const target = readDenyTargetOf(entry);
|
|
1437
|
+
if (target === undefined)
|
|
1438
|
+
return undefined;
|
|
1439
|
+
const landing = canonicalForm(target.path);
|
|
1440
|
+
if (landing !== '/') {
|
|
1441
|
+
return {
|
|
1442
|
+
landing,
|
|
1443
|
+
named: target.path,
|
|
1444
|
+
isDirectory: target.isDirectory,
|
|
1445
|
+
isStandIn: target.isStandIn,
|
|
1446
|
+
};
|
|
1447
|
+
}
|
|
1448
|
+
const holder = canonicalForm(path.dirname(target.path));
|
|
1449
|
+
if (holder === '/')
|
|
1450
|
+
return undefined;
|
|
1451
|
+
return {
|
|
1452
|
+
landing: holder,
|
|
1453
|
+
named: target.path,
|
|
1454
|
+
isDirectory: true,
|
|
1455
|
+
isStandIn: true,
|
|
1456
|
+
};
|
|
1457
|
+
};
|
|
1458
|
+
// The directories and files the read section denies, as configured. A '/'
|
|
1459
|
+
// entry is expanded into the root's children, because --tmpfs / would wipe
|
|
1460
|
+
// every prior mount (ro-bind /, write binds, deny binds). /proc and /dev are
|
|
1461
|
+
// skipped (the caller remounts them after this function returns) and so is
|
|
1462
|
+
// /sys (kernel interface; the host's is already read-only via ro-bind). A
|
|
1463
|
+
// child an allowRead entry covers (its name is the child, or an ancestor
|
|
1464
|
+
// of where the child lands) is skipped too: the deny is synthetic and the
|
|
1465
|
+
// allow is the caller's, and a tmpfs at the child's canonical location
|
|
1466
|
+
// (/bin lands at /usr/bin) would only be restored over again. An entry
|
|
1467
|
+
// that is a symlink to the child covers nothing: its name lives elsewhere. /etc/ssh/ssh_config.d is
|
|
1468
|
+
// always hidden when there is a read policy: ssh is strict about config
|
|
1469
|
+
// file ownership and permissions, which can look wrong inside the sandbox
|
|
1470
|
+
// ("Bad owner or permissions" under OrbStack). Throws if '/' cannot be
|
|
1471
|
+
// listed, a transient failure having been retried once. Computed on first
|
|
1472
|
+
// use, like readAllowPaths.
|
|
1473
|
+
let readDenyEntriesMemo;
|
|
1474
|
+
const readDenyEntries = () => {
|
|
1475
|
+
if (readDenyEntriesMemo !== undefined)
|
|
1476
|
+
return readDenyEntriesMemo;
|
|
1477
|
+
if (!readConfig)
|
|
1478
|
+
return [];
|
|
1479
|
+
const entries = [];
|
|
1480
|
+
for (const p of readConfig.denyOnly || []) {
|
|
1481
|
+
if (normalizePathForSandbox(p) !== '/') {
|
|
1482
|
+
entries.push(p);
|
|
1483
|
+
continue;
|
|
1484
|
+
}
|
|
1485
|
+
// The only call here that throws, and the list is memoised on success
|
|
1486
|
+
// alone, so the retry belongs to it rather than to one of its callers:
|
|
1487
|
+
// the stub-skip derivation catches a throw and gives up on the
|
|
1488
|
+
// prediction, and the denyRead loop below does not catch one at all.
|
|
1489
|
+
const children = retryingTransient(() => fs.readdirSync('/'));
|
|
1490
|
+
for (const child of children) {
|
|
1491
|
+
if (KERNEL_TOP_LEVEL_DIRS.includes(`/${child}`))
|
|
1492
|
+
continue;
|
|
1493
|
+
const childLocation = canonicalForm('/' + child);
|
|
1494
|
+
const covered = readAllowPaths().some(allowPath => isAtOrUnder(childLocation, nameLocationOf(allowPath)));
|
|
1495
|
+
if (covered) {
|
|
1496
|
+
logForDebugging(`[Sandbox Linux] Root deny expansion skips /${child}: covered by allowRead`);
|
|
1497
|
+
continue;
|
|
1498
|
+
}
|
|
1499
|
+
entries.push('/' + child);
|
|
1500
|
+
}
|
|
1501
|
+
}
|
|
1502
|
+
if (fs.existsSync('/etc/ssh/ssh_config.d')) {
|
|
1503
|
+
entries.push('/etc/ssh/ssh_config.d');
|
|
1504
|
+
}
|
|
1505
|
+
readDenyEntriesMemo = entries;
|
|
1506
|
+
return entries;
|
|
1507
|
+
};
|
|
1508
|
+
// Shallow-first by canonical depth, so a tmpfs over a directory lands
|
|
1509
|
+
// before the tmpfs or /dev/null mask on anything inside it however either
|
|
1510
|
+
// was spelled. That order is what lets a unit restore its write and
|
|
1511
|
+
// allowRead paths unconditionally: nothing emitted so far can lie inside
|
|
1512
|
+
// one. Sorted by spelling, a symlink-spelled entry could mount first inside
|
|
1513
|
+
// a directory listed after it, whose restores would then bury it.
|
|
1514
|
+
const canonicalDepth = (p) => canonicalForm(p).split('/').length;
|
|
1515
|
+
// What every denyRead entry mounts and where: ONE walk, whose answers the
|
|
1516
|
+
// deny loop, the locations the carve-out gate below reads, and the
|
|
1517
|
+
// stub-skip prediction all take, so none of them can disagree about where
|
|
1518
|
+
// a deny lands. `mount` is undefined where nothing is mounted at all;
|
|
1519
|
+
// `liftedFile` marks a file deny an allowRead entry naming that very file
|
|
1520
|
+
// cancels — the entry mounts nothing in that case either. Lazy and
|
|
1521
|
+
// memoised like readDenyEntries(): it resolves symlinks, so it must not run
|
|
1522
|
+
// before the mandatory-deny scan's await.
|
|
1523
|
+
let readDenyPlanMemo;
|
|
1524
|
+
const readDenyPlan = () => (readDenyPlanMemo ?? (readDenyPlanMemo = readDenyEntries()
|
|
1525
|
+
.map(p => normalizePathForSandbox(p))
|
|
1526
|
+
.sort((a, b) => canonicalDepth(a) - canonicalDepth(b))
|
|
1527
|
+
.map(normalizedPath => {
|
|
1528
|
+
const mount = readDenyMountOf(normalizedPath);
|
|
1529
|
+
return {
|
|
1530
|
+
normalizedPath,
|
|
1531
|
+
mount,
|
|
1532
|
+
liftedFile: mount !== undefined &&
|
|
1533
|
+
!mount.isDirectory &&
|
|
1534
|
+
readAllowPaths().some(allowPath => nameLocationOf(allowPath) === mount.landing),
|
|
1535
|
+
};
|
|
1536
|
+
})));
|
|
1537
|
+
// Where the ancestor pins are spliced in once every mount is known:
|
|
1538
|
+
// beneath the allow binds, or after them under a '/' write root that would
|
|
1539
|
+
// otherwise bury them (see ancestorPinArgs).
|
|
1540
|
+
let ancestorPinInsertAt;
|
|
1541
|
+
// Determine initial root mount based on write restrictions
|
|
1542
|
+
if (writeConfig) {
|
|
1543
|
+
// Write restrictions: Start with read-only root, then allow writes to specific paths
|
|
1544
|
+
args.push('--ro-bind', '/', '/');
|
|
1545
|
+
const beneathAllowBinds = args.length;
|
|
1546
|
+
// Allow writes to specific paths
|
|
1547
|
+
for (const pathPattern of writeConfig.allowOnly || []) {
|
|
1548
|
+
// normalizePathForSandbox already strips a trailing slash from every
|
|
1549
|
+
// spelling it does not take for a glob; this strip covers the ones it
|
|
1550
|
+
// exempts — a literal directory named with glob characters, spelled
|
|
1551
|
+
// '<dir>/[id]/'. Allow paths are recorded slash-free because every
|
|
1552
|
+
// downstream comparison — the deny loop's within-allowlist gate,
|
|
1553
|
+
// findSymlinkInPath's mask scoping, the emission filter's re-expose
|
|
1554
|
+
// check, the denyRead re-bind and its allowRead skip, and the stub-skip
|
|
1555
|
+
// vetoes — matches by `allowedPath + '/'` prefix, which '<dir>//'
|
|
1556
|
+
// silently defeats. bwrap binds 'dir' and 'dir/' identically, so
|
|
1557
|
+
// normalizing the recorded spelling fixes every consumer at once
|
|
1558
|
+
// instead of per-predicate. ('/' itself is kept; the empty spelling an
|
|
1559
|
+
// empty $HOME expands '~' to is NOT the root, and falls out at the
|
|
1560
|
+
// existence check below.)
|
|
1561
|
+
const normalized = normalizePathForSandbox(pathPattern);
|
|
1562
|
+
const normalizedPath = normalized === '' ? '' : normalized.replace(/\/+$/, '') || '/';
|
|
1563
|
+
logForDebugging(`[Sandbox Linux] Processing write path: ${pathPattern} -> ${normalizedPath}`);
|
|
1564
|
+
// Skip /dev/* paths since --dev /dev already handles them
|
|
1565
|
+
if (normalizedPath.startsWith('/dev/')) {
|
|
1566
|
+
logForDebugging(`[Sandbox Linux] Skipping /dev path: ${normalizedPath}`);
|
|
1567
|
+
continue;
|
|
1568
|
+
}
|
|
1569
|
+
if (!fs.existsSync(normalizedPath)) {
|
|
1570
|
+
logForDebugging(`[Sandbox Linux] Skipping non-existent write path: ${normalizedPath}`);
|
|
1571
|
+
continue;
|
|
1572
|
+
}
|
|
1573
|
+
// Check if path is a symlink pointing outside expected boundaries
|
|
1574
|
+
// bwrap follows symlinks, so --bind on a symlink makes the target writable
|
|
1575
|
+
// This could unexpectedly expose paths the user didn't intend to allow
|
|
1576
|
+
try {
|
|
1577
|
+
const resolvedPath = fs.realpathSync(normalizedPath);
|
|
1578
|
+
// Trim trailing slashes before comparing: realpathSync never returns
|
|
1579
|
+
// a trailing slash, but normalizedPath may have one, which would cause
|
|
1580
|
+
// a false mismatch and incorrectly treat the path as a symlink.
|
|
1581
|
+
const normalizedForComparison = normalizedPath.replace(/\/+$/, '');
|
|
1582
|
+
if (resolvedPath !== normalizedForComparison &&
|
|
1583
|
+
isSymlinkOutsideBoundary(normalizedPath, resolvedPath)) {
|
|
1584
|
+
logForDebugging(`[Sandbox Linux] Skipping symlink write path pointing outside expected location: ${pathPattern} -> ${resolvedPath}`);
|
|
1585
|
+
continue;
|
|
1586
|
+
}
|
|
1587
|
+
}
|
|
1588
|
+
catch {
|
|
1589
|
+
// realpathSync failed - path might not exist or be accessible, skip it
|
|
1590
|
+
logForDebugging(`[Sandbox Linux] Skipping write path that could not be resolved: ${normalizedPath}`);
|
|
1591
|
+
continue;
|
|
1592
|
+
}
|
|
1593
|
+
args.push('--bind', normalizedPath, normalizedPath);
|
|
1594
|
+
allowedWritePaths.push(normalizedPath);
|
|
1595
|
+
}
|
|
1596
|
+
ancestorPinInsertAt = isAllowedWriteRoot('/')
|
|
1597
|
+
? args.length
|
|
1598
|
+
: beneathAllowBinds;
|
|
1599
|
+
let stubSkipVetoInputs;
|
|
1600
|
+
const getStubSkipVetoInputs = () => {
|
|
1601
|
+
if (stubSkipVetoInputs !== undefined) {
|
|
1602
|
+
return stubSkipVetoInputs;
|
|
1603
|
+
}
|
|
1604
|
+
const unusable = (cause) => {
|
|
1605
|
+
logForDebugging(`[Sandbox Linux] Read-deny prediction unusable (${cause}); keeping every deny placeholder, which refuses to start wherever a covering deny directory is read-only: bubblewrap cannot create a placeholder's mount point there`, { level: 'warn' });
|
|
1606
|
+
return { usable: false };
|
|
1607
|
+
};
|
|
1608
|
+
// Single-shot: readDenyEntries() owns the retry for the one thing that
|
|
1609
|
+
// throws here, its listing of '/'. A guess would survive a retry
|
|
1610
|
+
// anyway, being a settled verdict about a path that cannot be looked at.
|
|
1611
|
+
try {
|
|
1612
|
+
const allowedWritePathsBothForms = allowedWritePaths.flatMap(mountForms);
|
|
1613
|
+
const prospectiveReadDenyTmpfsDirsBothForms = readDenyPlan().flatMap(({ mount }) => {
|
|
1614
|
+
if (!mount?.isDirectory)
|
|
1615
|
+
return [];
|
|
1616
|
+
// The tmpfs hides its landing, and the spelling the entry
|
|
1617
|
+
// resolved to when that differs. '/' is never a landing, and
|
|
1618
|
+
// must not enter the prediction as a spelling either (a link
|
|
1619
|
+
// to the root resolves there, and its holder is the landing):
|
|
1620
|
+
// every covering directory lies under '/', so predicting a
|
|
1621
|
+
// tmpfs there would veto every skip on the whole host.
|
|
1622
|
+
return [
|
|
1623
|
+
...new Set([...mountForms(mount.named), mount.landing]),
|
|
1624
|
+
].filter(form => form !== '/');
|
|
1625
|
+
});
|
|
1626
|
+
// Every canonicalForm call site runs inside this derivation or after
|
|
1627
|
+
// the deny loop that triggers it, so a guess recorded by now was made
|
|
1628
|
+
// about the derivation's own inputs. A future caller that resolves
|
|
1629
|
+
// earlier can only add guesses here, which vetoes more skips, never
|
|
1630
|
+
// fewer.
|
|
1631
|
+
stubSkipVetoInputs =
|
|
1632
|
+
canonicalFormGuesses.size > 0
|
|
1633
|
+
? unusable(`no canonical location for ${[...canonicalFormGuesses].join(', ')}`)
|
|
1634
|
+
: {
|
|
1635
|
+
usable: true,
|
|
1636
|
+
allowedWritePathsBothForms,
|
|
1637
|
+
prospectiveReadDenyTmpfsDirsBothForms,
|
|
1638
|
+
};
|
|
1639
|
+
}
|
|
1640
|
+
catch (err) {
|
|
1641
|
+
stubSkipVetoInputs = unusable(`deriving it threw: ${err}`);
|
|
1642
|
+
}
|
|
1643
|
+
return stubSkipVetoInputs;
|
|
1644
|
+
};
|
|
1645
|
+
// Deny writes within allowed paths (user-specified + mandatory denies)
|
|
1646
|
+
const denyPaths = [
|
|
1647
|
+
...(writeConfig.denyWithinAllow || []),
|
|
1648
|
+
...(await linuxGetMandatoryDenyPaths(ripgrepConfig, mandatoryDenySearchDepth, allowGitConfig, abortSignal)),
|
|
1649
|
+
];
|
|
1650
|
+
// Duplicate deny entries must be collapsed: a duplicate
|
|
1651
|
+
// --ro-bind /dev/null <dest> hits a char device on the second pass and
|
|
1652
|
+
// bwrap's ensure_file() falls through to creat() on a read-only mount.
|
|
1653
|
+
const seenDenyWrite = new Set();
|
|
1654
|
+
// Placeholder destination -> the index of its source in denyWriteArgs, so
|
|
1655
|
+
// a later deny reaching the same destination can upgrade a /dev/null
|
|
1656
|
+
// placeholder to the directory form in place (see the emission below).
|
|
1657
|
+
const placeholderSourceArgIndex = new Map();
|
|
1658
|
+
// PRE-PASS (order-independent): record the directories the loop below
|
|
1659
|
+
// re-binds read-only, BEFORE any stub decision, so the read-only
|
|
1660
|
+
// conclusion does not depend on where an enclosing directory appears in
|
|
1661
|
+
// the caller's denyWrite ordering. It applies the loop's own resolution,
|
|
1662
|
+
// the same symlink re-check, and the same isWithinAnyAllowedWritePath
|
|
1663
|
+
// gate as the --ro-bind emission, and it records every raw spelling each
|
|
1664
|
+
// directory is reached through. A recorded directory is EVIDENCE for
|
|
1665
|
+
// skipping a stub only if it also passes the guard's vetoes below (no
|
|
1666
|
+
// read-deny tmpfs contains it in any spelling; no allowed write path is
|
|
1667
|
+
// both beneath it and under such a tmpfs), which exclude every way its
|
|
1668
|
+
// subtree could be writable in the sandbox. Keep the two passes in lockstep: a
|
|
1669
|
+
// directory recorded here is either re-bound read-only by the loop or
|
|
1670
|
+
// skipped because a recorded directory above it survived the vetoes and
|
|
1671
|
+
// is bound in its place, so every record still stands for a bind that
|
|
1672
|
+
// lands — unless a symlink appears in its path between the two passes,
|
|
1673
|
+
// where the loop masks that component and emits no bind for the
|
|
1674
|
+
// directory (the re-check below); an emitted one missing from the record
|
|
1675
|
+
// only costs a spurious abort.
|
|
1676
|
+
for (const pathPattern of denyPaths) {
|
|
1677
|
+
const rawPath = normalizePathForSandbox(pathPattern);
|
|
1678
|
+
if (rawPath.startsWith('/dev/')) {
|
|
1679
|
+
continue;
|
|
1680
|
+
}
|
|
1681
|
+
const resolvedPath = resolveSymlinkedDenyPath(rawPath);
|
|
1682
|
+
if (resolvedPath === null || resolvedPath.startsWith('/dev/')) {
|
|
1683
|
+
continue;
|
|
1684
|
+
}
|
|
1685
|
+
// Same defense-in-depth re-check as the loop: there the --ro-bind is
|
|
1686
|
+
// replaced by a symlink mask, so such a directory must not be recorded
|
|
1687
|
+
// as re-bound here.
|
|
1688
|
+
if (findSymlinkInPath(resolvedPath, allowedWritePaths)) {
|
|
1689
|
+
continue;
|
|
1690
|
+
}
|
|
1691
|
+
let isDirectory = false;
|
|
1692
|
+
try {
|
|
1693
|
+
isDirectory = fs.statSync(resolvedPath).isDirectory();
|
|
1694
|
+
}
|
|
1695
|
+
catch {
|
|
1696
|
+
continue; // absent (or vanished): not a read-only re-bound directory
|
|
1697
|
+
}
|
|
1698
|
+
if (!isDirectory) {
|
|
1699
|
+
continue;
|
|
1700
|
+
}
|
|
1701
|
+
if (isWithinAnyAllowedWritePath(resolvedPath)) {
|
|
1702
|
+
// Keep every spelling this dest is reached through (the
|
|
1703
|
+
// re-application passes record the first-seen raw spelling beside
|
|
1704
|
+
// the dest, which this pass cannot assume): the guard below treats
|
|
1705
|
+
// the directory as unsafe if a read-deny tmpfs contains ANY of them.
|
|
1706
|
+
let spellings = readOnlyDenyDirSpellings.get(resolvedPath);
|
|
1707
|
+
if (spellings === undefined) {
|
|
1708
|
+
spellings = new Set();
|
|
1709
|
+
readOnlyDenyDirSpellings.set(resolvedPath, spellings);
|
|
1710
|
+
}
|
|
1711
|
+
spellings.add(rawPath);
|
|
1712
|
+
}
|
|
1713
|
+
}
|
|
1714
|
+
// Per-covering-dir veto verdict, computed once per recorded directory
|
|
1715
|
+
// (the inputs never change during the deny loop) instead of per absent
|
|
1716
|
+
// deny entry.
|
|
1717
|
+
// INVARIANT: a stub, or an existing deny path's own bind, is skipped
|
|
1718
|
+
// only under a recorded covering deny directory that was judged against
|
|
1719
|
+
// a prediction that could be derived, that no read-deny tmpfs contains
|
|
1720
|
+
// (it, or any spelling it was reached through), and beneath which no
|
|
1721
|
+
// allowed write path sits that a read-deny tmpfs also covers. '/' is the
|
|
1722
|
+
// exception: it contains every allowed write path, so
|
|
1723
|
+
// coveredBySafeReadOnlyDenyDir judges a vetoed '/' against the candidate
|
|
1724
|
+
// instead — see the branch there.
|
|
1725
|
+
// The skip rests on the emission order: nothing host-backed and writable
|
|
1726
|
+
// lands after the buffered read-only binds, so a covering bind is the
|
|
1727
|
+
// last word on its subtree unless a tmpfs above it drops that bind at
|
|
1728
|
+
// emission. Where a veto could apply, keep the stub: the pre-existing
|
|
1729
|
+
// abort is preferable to a silently creatable deny path.
|
|
1730
|
+
const coveringDirUnsafeVerdicts = new Map();
|
|
1731
|
+
const coveringDirIsUnsafe = (denyDir) => {
|
|
1732
|
+
const cached = coveringDirUnsafeVerdicts.get(denyDir);
|
|
1733
|
+
if (cached !== undefined) {
|
|
1734
|
+
return cached;
|
|
1735
|
+
}
|
|
1736
|
+
const vetoInputs = getStubSkipVetoInputs();
|
|
1737
|
+
const unsafe =
|
|
1738
|
+
// (0) the prediction of what the denyRead loop will mount is
|
|
1739
|
+
// unusable, so no veto below can be trusted to fire. Veto
|
|
1740
|
+
// everything rather than nothing: a prediction that failed is no
|
|
1741
|
+
// evidence that this directory is reliably read-only.
|
|
1742
|
+
!vetoInputs.usable ||
|
|
1743
|
+
// (i) an allowed write path both strictly beneath the dir and at or
|
|
1744
|
+
// under a prospective read-deny tmpfs. Re-applying that tmpfs
|
|
1745
|
+
// after the dir's read-only bind is the one pass that mounts
|
|
1746
|
+
// anything inside such a path at all, and it restores read-only,
|
|
1747
|
+
// so this names no live re-opening route today and no
|
|
1748
|
+
// configuration is known that needs it. It is the only veto that
|
|
1749
|
+
// speaks about a writable path under the dir: should a pass after
|
|
1750
|
+
// the deny binds ever restore one writable again, the skip fails
|
|
1751
|
+
// closed here.
|
|
1752
|
+
vetoInputs.allowedWritePathsBothForms.some(writePath => isStrictlyUnder(writePath, denyDir) &&
|
|
1753
|
+
vetoInputs.prospectiveReadDenyTmpfsDirsBothForms.some(tmpfsDir => isAtOrUnder(writePath, tmpfsDir))) ||
|
|
1754
|
+
// (ii) a read-deny tmpfs CONTAINING the dir: its own --ro-bind is
|
|
1755
|
+
// dropped as hidden-by-the-tmpfs at emission, and a tmpfs above
|
|
1756
|
+
// it restores allowed paths around it — either way the dir's own
|
|
1757
|
+
// bind is not the last word on that subtree. A tmpfs containing
|
|
1758
|
+
// only a raw spelling it was reached through counts too; that
|
|
1759
|
+
// over-predicts, which only keeps a stub.
|
|
1760
|
+
vetoInputs.prospectiveReadDenyTmpfsDirsBothForms.some(tmpfsDir => [denyDir, ...(readOnlyDenyDirSpellings.get(denyDir) ?? [])].some(spelling => isAtOrUnder(spelling, tmpfsDir)));
|
|
1761
|
+
coveringDirUnsafeVerdicts.set(denyDir, unsafe);
|
|
1762
|
+
return unsafe;
|
|
1763
|
+
};
|
|
1764
|
+
// Materialized once: the pre-pass above fully populates the map and the
|
|
1765
|
+
// deny loop never mutates it.
|
|
1766
|
+
const readOnlyDenyDirs = [...readOnlyDenyDirSpellings.keys()];
|
|
1767
|
+
// Is `candidate` already unwritable in the sandbox: strictly under a
|
|
1768
|
+
// recorded read-only deny directory that survives every
|
|
1769
|
+
// coveringDirIsUnsafe veto? Strictly, because a deny equal to a recorded
|
|
1770
|
+
// directory IS that covering bind and must be emitted (an absent path
|
|
1771
|
+
// never equals one). Stops at the first vetoed covering directory.
|
|
1772
|
+
const coveredBySafeReadOnlyDenyDir = (candidate) => {
|
|
1773
|
+
let covered = false;
|
|
1774
|
+
for (const denyDir of readOnlyDenyDirs) {
|
|
1775
|
+
if (!isStrictlyUnder(candidate, denyDir))
|
|
1776
|
+
continue;
|
|
1777
|
+
if (coveringDirIsUnsafe(denyDir)) {
|
|
1778
|
+
if (denyDir === '/') {
|
|
1779
|
+
// A vetoed '/' does not disqualify an inner recorded directory:
|
|
1780
|
+
// everything lies beneath it, so it would veto every skip and
|
|
1781
|
+
// stub each absent mandatory-deny path of a write-denied cwd
|
|
1782
|
+
// after that cwd's own bind — the startup abort. Its descendants
|
|
1783
|
+
// are decided by their own recorded directories.
|
|
1784
|
+
//
|
|
1785
|
+
// It does still COVER a candidate outside every predicted
|
|
1786
|
+
// read-deny tmpfs. Its own --ro-bind / / holds the whole tree
|
|
1787
|
+
// read-only from where it is emitted, burying the allow loop's
|
|
1788
|
+
// binds, and the only writable surface after it is a re-applied
|
|
1789
|
+
// tmpfs itself: the re-application passes no allowed write
|
|
1790
|
+
// paths, so everything it restores comes back read-only. An
|
|
1791
|
+
// allowed write path beneath the root is therefore not a vector
|
|
1792
|
+
// here, and vetoing on one only brings the abort back for
|
|
1793
|
+
// `allowOnly: ['/', <dir>]`. An underivable prediction proves
|
|
1794
|
+
// nothing and keeps the stub. Without this branch, `allowOnly:
|
|
1795
|
+
// ['/']` with `denyWithinAllow: ['/']` stubs each absent cwd
|
|
1796
|
+
// dotfile on the read-only root it just mounted — the startup
|
|
1797
|
+
// abort — whenever anything vetoes '/', which an unusable
|
|
1798
|
+
// prediction or an allowed write path under a read-deny tmpfs
|
|
1799
|
+
// does. The branch is strictly stricter than covering on the
|
|
1800
|
+
// root's own bind, and it is what decides those two cases.
|
|
1801
|
+
const vetoInputs = getStubSkipVetoInputs();
|
|
1802
|
+
if (vetoInputs.usable &&
|
|
1803
|
+
!vetoInputs.prospectiveReadDenyTmpfsDirsBothForms.some(tmpfsDir => isAtOrUnder(candidate, tmpfsDir))) {
|
|
1804
|
+
covered = true;
|
|
1805
|
+
}
|
|
1806
|
+
continue;
|
|
1807
|
+
}
|
|
1808
|
+
return false;
|
|
1809
|
+
}
|
|
1810
|
+
covered = true;
|
|
1811
|
+
}
|
|
1812
|
+
return covered;
|
|
1813
|
+
};
|
|
1814
|
+
for (const pathPattern of denyPaths) {
|
|
1815
|
+
const rawPath = normalizePathForSandbox(pathPattern);
|
|
1816
|
+
// Skip /dev/* paths since --dev /dev already handles them
|
|
1817
|
+
if (rawPath.startsWith('/dev/')) {
|
|
1818
|
+
continue;
|
|
1819
|
+
}
|
|
1820
|
+
// Resolve-before-mask: normalizePathForSandbox keeps the raw symlink
|
|
1821
|
+
// path whenever resolution crosses isSymlinkOutsideBoundary (the
|
|
1822
|
+
// common dotfiles case), so canonicalize here. Every mask/bind below
|
|
1823
|
+
// must be computed against the resolved path — bwrap dest-resolves
|
|
1824
|
+
// symlinks, so a mask on the raw path either aborts startup (dir
|
|
1825
|
+
// symlink) or lands on the target inode anyway (file symlink). Writes
|
|
1826
|
+
// through the original symlinked path resolve to the same denied
|
|
1827
|
+
// target inside the mount namespace.
|
|
1828
|
+
const normalizedPath = resolveSymlinkedDenyPath(rawPath);
|
|
1829
|
+
if (normalizedPath === null) {
|
|
1830
|
+
// Unresolvable: a symlink cycle, or a chain past the ELOOP bound.
|
|
1831
|
+
// Fail closed. Dropping the deny here would sandbox the command with
|
|
1832
|
+
// the path unprotected, so instead mask the symlink component and let
|
|
1833
|
+
// bwrap refuse to start. When no component is a symlink inside an
|
|
1834
|
+
// allowed write path there is nothing to protect: the path is already
|
|
1835
|
+
// read-only from the initial --ro-bind / /.
|
|
1836
|
+
const unresolvableSymlink = findSymlinkInPath(rawPath, allowedWritePaths);
|
|
1837
|
+
if (unresolvableSymlink && !seenDenyWrite.has(unresolvableSymlink)) {
|
|
1838
|
+
seenDenyWrite.add(unresolvableSymlink);
|
|
1839
|
+
denyWriteArgs.push('--ro-bind', '/dev/null', unresolvableSymlink);
|
|
1840
|
+
denyWriteRawDests.set(unresolvableSymlink, rawPath);
|
|
1841
|
+
}
|
|
1842
|
+
logForDebugging(`[Sandbox Linux] Deny path could not be resolved through symlinks, failing closed: ${rawPath}`);
|
|
1843
|
+
continue;
|
|
1844
|
+
}
|
|
1845
|
+
if (normalizedPath !== rawPath) {
|
|
1846
|
+
logForDebugging(`[Sandbox Linux] Resolved symlinked deny path: ${rawPath} -> ${normalizedPath}`);
|
|
1847
|
+
}
|
|
1848
|
+
// Re-check after resolution: a deny path can only now land in /dev (e.g.
|
|
1849
|
+
// a symlink into it), where --dev /dev has already replaced the tree and
|
|
1850
|
+
// a bind would either miss or fight the new devtmpfs.
|
|
1851
|
+
if (normalizedPath.startsWith('/dev/')) {
|
|
1852
|
+
continue;
|
|
1853
|
+
}
|
|
1854
|
+
// Dedup post-resolution: distinct spellings (tilde vs absolute, via
|
|
1855
|
+
// symlink vs direct) converge to the same real path here.
|
|
1856
|
+
if (seenDenyWrite.has(normalizedPath))
|
|
1857
|
+
continue;
|
|
1858
|
+
seenDenyWrite.add(normalizedPath);
|
|
1859
|
+
// Defense-in-depth: the resolved path should be symlink-free for its
|
|
1860
|
+
// existing prefix, but the tree may change between resolution and this
|
|
1861
|
+
// check. A hit still fails closed — bwrap refuses to start rather than
|
|
1862
|
+
// sandboxing with an unprotected deny path.
|
|
1863
|
+
const symlinkInPath = findSymlinkInPath(normalizedPath, allowedWritePaths);
|
|
1864
|
+
if (symlinkInPath) {
|
|
1865
|
+
if (!seenDenyWrite.has(symlinkInPath)) {
|
|
1866
|
+
seenDenyWrite.add(symlinkInPath);
|
|
1867
|
+
denyWriteArgs.push('--ro-bind', '/dev/null', symlinkInPath);
|
|
1868
|
+
denyWriteRawDests.set(symlinkInPath, rawPath);
|
|
1869
|
+
}
|
|
1870
|
+
logForDebugging(`[Sandbox Linux] Mounted /dev/null at symlink ${symlinkInPath} to prevent symlink replacement attack`);
|
|
1871
|
+
continue;
|
|
1872
|
+
}
|
|
1873
|
+
// A mount point an earlier sandbox left on the host (see
|
|
1874
|
+
// isStaleBwrapMountPoint) is an absent deny path in all but name. Bound
|
|
1875
|
+
// onto itself as an existing file it would never be tracked, so never
|
|
1876
|
+
// removed, and on the host its existence can be the whole meaning (a
|
|
1877
|
+
// lockfile's). Cover it with /dev/null like the absent leaf below and
|
|
1878
|
+
// track it, so cleanupBwrapMountPoints() takes it away. Same gate as
|
|
1879
|
+
// that branch. Under a read-only denied directory nothing needs
|
|
1880
|
+
// covering (the file is already unwritable there), but it is still
|
|
1881
|
+
// tracked: it is no more the caller's file for being there.
|
|
1882
|
+
if ((isWithinAnyAllowedWritePath(path.dirname(normalizedPath)) ||
|
|
1883
|
+
isWithinAnyAllowedWritePath(normalizedPath)) &&
|
|
1884
|
+
isStaleBwrapMountPoint(normalizedPath)) {
|
|
1885
|
+
if (!coveredBySafeReadOnlyDenyDir(normalizedPath)) {
|
|
1886
|
+
denyWriteArgs.push('--ro-bind', '/dev/null', normalizedPath);
|
|
1887
|
+
denyWriteRawDests.set(normalizedPath, rawPath);
|
|
1888
|
+
}
|
|
1889
|
+
bwrapMountPoints.add(normalizedPath);
|
|
1890
|
+
registerExitCleanupHandler();
|
|
1891
|
+
logForDebugging(`[Sandbox Linux] Re-covering a mount point an earlier sandbox left behind: ${normalizedPath}`);
|
|
1892
|
+
continue;
|
|
1893
|
+
}
|
|
1894
|
+
// Handle non-existent paths by mounting /dev/null to block creation.
|
|
1895
|
+
// Without this, a sandboxed process could mkdir+write a denied path that
|
|
1896
|
+
// doesn't exist yet, bypassing the deny rule entirely.
|
|
1897
|
+
//
|
|
1898
|
+
// bwrap creates empty files on the host as mount points for these binds.
|
|
1899
|
+
// We track them in bwrapMountPoints so cleanupBwrapMountPoints() can
|
|
1900
|
+
// remove them after the command exits.
|
|
1901
|
+
if (!fs.existsSync(normalizedPath)) {
|
|
1902
|
+
// Fix 1 (worktree): If any existing component above the deny path is
|
|
1903
|
+
// a file (not a directory), skip the deny entirely. You can't mkdir
|
|
1904
|
+
// under a file, so the deny path can never be created. This handles
|
|
1905
|
+
// git worktrees where .git is a file. Asked of what lies above the
|
|
1906
|
+
// path, not of the path: another sandbox's bubblewrap may have made a
|
|
1907
|
+
// mount point at it since the look above, and a file there is no
|
|
1908
|
+
// reason to leave it unbound.
|
|
1909
|
+
if (hasFileAncestor(path.dirname(normalizedPath))) {
|
|
1910
|
+
logForDebugging(`[Sandbox Linux] Skipping deny path with file ancestor (cannot create paths under a file): ${normalizedPath}`);
|
|
1911
|
+
continue;
|
|
1912
|
+
}
|
|
1913
|
+
// Find the deepest existing ancestor directory
|
|
1914
|
+
let ancestorPath = path.dirname(normalizedPath);
|
|
1915
|
+
while (ancestorPath !== '/' && !fs.existsSync(ancestorPath)) {
|
|
1916
|
+
ancestorPath = path.dirname(ancestorPath);
|
|
1917
|
+
}
|
|
1918
|
+
// Only protect if the existing ancestor is within an allowed write path.
|
|
1919
|
+
// If not, the path is already read-only from --ro-bind / /.
|
|
1920
|
+
// Same predicate as the pre-pass and the --ro-bind gate (equality on
|
|
1921
|
+
// the absent normalizedPath itself is unreachable — allow entries
|
|
1922
|
+
// exist, the deny path does not).
|
|
1923
|
+
const ancestorIsWithinAllowedPath = isWithinAnyAllowedWritePath(ancestorPath) ||
|
|
1924
|
+
isWithinAnyAllowedWritePath(normalizedPath);
|
|
1925
|
+
// An ancestor inside a directory that an earlier deny re-bound
|
|
1926
|
+
// read-only (e.g. an explicit denyWrite on the project dir) is
|
|
1927
|
+
// already read-only in the sandbox: the deny path cannot be created
|
|
1928
|
+
// there, and stubbing it would make bwrap creat() a mount point
|
|
1929
|
+
// inside that read-only mount and abort. The order-independent
|
|
1930
|
+
// pre-pass above has already recorded every directory the loop
|
|
1931
|
+
// re-binds read-only, so a covering directory deny is visible here
|
|
1932
|
+
// regardless of where it appears in denyPaths. A recorded covering
|
|
1933
|
+
// directory is evidence for skipping only if it survives the
|
|
1934
|
+
// coveringDirIsUnsafe vetoes (see the INVARIANT at its definition).
|
|
1935
|
+
// (Tested on the absent path itself: a recorded directory that
|
|
1936
|
+
// covers it is at-or-above its deepest existing ancestor, since
|
|
1937
|
+
// recorded directories exist.)
|
|
1938
|
+
const ancestorIsWithinReadOnlyDeny = coveredBySafeReadOnlyDenyDir(normalizedPath);
|
|
1939
|
+
if (ancestorIsWithinAllowedPath && !ancestorIsWithinReadOnlyDeny) {
|
|
1940
|
+
const firstNonExistent = findFirstNonExistentComponent(normalizedPath);
|
|
1941
|
+
// Fix 2: If firstNonExistent is an intermediate component (not the
|
|
1942
|
+
// leaf deny path itself), mount a read-only empty directory instead
|
|
1943
|
+
// of /dev/null. This prevents the component from appearing as a file
|
|
1944
|
+
// which breaks tools that expect to traverse it as a directory.
|
|
1945
|
+
const isIntermediate = firstNonExistent !== normalizedPath;
|
|
1946
|
+
const source = isIntermediate
|
|
1947
|
+
? (emptySource ?? (emptySource = ensureEmptyMountSourceDir()))
|
|
1948
|
+
: '/dev/null';
|
|
1949
|
+
// One mount point per destination. Deny paths are deduplicated on
|
|
1950
|
+
// the deny path, but a placeholder lands on the first MISSING
|
|
1951
|
+
// component, so two denies sharing one arrive here with a single
|
|
1952
|
+
// destination — denyWrite '<cwd>/.claude' together with the
|
|
1953
|
+
// mandatory '<cwd>/.claude/commands', in a project with no
|
|
1954
|
+
// `.claude/`. Two binds there make bwrap refuse to start when they
|
|
1955
|
+
// disagree about the destination's kind ("Can't mkdir <dest>: Not a
|
|
1956
|
+
// directory"). The directory form wins the disagreement: an empty
|
|
1957
|
+
// read-only directory blocks creating the destination and everything
|
|
1958
|
+
// below it exactly as /dev/null does, and stays traversable for the
|
|
1959
|
+
// deeper deny that asked for a directory.
|
|
1960
|
+
const placeholderAt = placeholderSourceArgIndex.get(firstNonExistent);
|
|
1961
|
+
if (placeholderAt !== undefined) {
|
|
1962
|
+
if (isIntermediate)
|
|
1963
|
+
denyWriteArgs[placeholderAt] = source;
|
|
1964
|
+
logForDebugging(`[Sandbox Linux] Reusing the mount point at ${firstNonExistent} to block creation of ${normalizedPath}`);
|
|
1965
|
+
continue;
|
|
1966
|
+
}
|
|
1967
|
+
denyWriteArgs.push('--ro-bind', source, firstNonExistent);
|
|
1968
|
+
placeholderSourceArgIndex.set(firstNonExistent, denyWriteArgs.length - 2);
|
|
1969
|
+
// First writer wins for a destination several denies share (the
|
|
1970
|
+
// reuse branch above returns before reaching this), and the record
|
|
1971
|
+
// is purely additive: it only gives the tmpfs and mask comparisons
|
|
1972
|
+
// below a second spelling to test, and `dest` itself is always
|
|
1973
|
+
// tested.
|
|
1974
|
+
denyWriteRawDests.set(firstNonExistent, rawPath);
|
|
1975
|
+
bwrapMountPoints.add(firstNonExistent);
|
|
1976
|
+
registerExitCleanupHandler();
|
|
1977
|
+
logForDebugging(`[Sandbox Linux] Mounted ${isIntermediate ? 'empty dir' : '/dev/null'} at ${firstNonExistent} to block creation of ${normalizedPath}`);
|
|
1978
|
+
}
|
|
1979
|
+
else if (ancestorIsWithinReadOnlyDeny) {
|
|
1980
|
+
logForDebugging(`[Sandbox Linux] Skipping non-existent deny path inside a read-only denied directory (already uncreatable): ${normalizedPath}`);
|
|
1981
|
+
}
|
|
1982
|
+
else {
|
|
1983
|
+
logForDebugging(`[Sandbox Linux] Skipping non-existent deny path not within allowed paths: ${normalizedPath}`);
|
|
1984
|
+
}
|
|
1985
|
+
continue;
|
|
1986
|
+
}
|
|
1987
|
+
// Only add deny binding if this path is within an allowed write path
|
|
1988
|
+
// Otherwise it's already read-only from the initial --ro-bind / /
|
|
1989
|
+
const isWithinAllowedPath = isWithinAnyAllowedWritePath(normalizedPath);
|
|
1990
|
+
if (isWithinAllowedPath) {
|
|
1991
|
+
// Already unwritable under a read-only denied directory (the
|
|
1992
|
+
// existing-path twin of the stub skip above). Veto (ii) keeps the
|
|
1993
|
+
// covering bind through the emission filter; a symlinked spelling
|
|
1994
|
+
// keeps its own bind because the re-application passes below key
|
|
1995
|
+
// off emitted raw spellings.
|
|
1996
|
+
if (rawPath === normalizedPath &&
|
|
1997
|
+
coveredBySafeReadOnlyDenyDir(normalizedPath)) {
|
|
1998
|
+
logForDebugging(`[Sandbox Linux] Skipping deny path already under read-only denied directory: ${normalizedPath}`);
|
|
1999
|
+
continue;
|
|
2000
|
+
}
|
|
2001
|
+
// A deny's read-only bind is emitted after every allow bind, so an
|
|
2002
|
+
// allowed write path beneath it comes back read-only instead of
|
|
2003
|
+
// stopping the sandbox from starting. Say so: the config asked for
|
|
2004
|
+
// both and only the deny takes effect.
|
|
2005
|
+
for (const buried of allowedWritePaths) {
|
|
2006
|
+
if (isStrictlyUnder(buried, normalizedPath)) {
|
|
2007
|
+
logForDebugging(`[Sandbox Linux] Write deny ${normalizedPath} covers allowed write path ${buried}; ${buried} will be read-only`, { level: 'warn' });
|
|
2008
|
+
}
|
|
2009
|
+
}
|
|
2010
|
+
denyWriteArgs.push('--ro-bind', normalizedPath, normalizedPath);
|
|
2011
|
+
denyWriteRawDests.set(normalizedPath, rawPath);
|
|
2012
|
+
}
|
|
2013
|
+
else {
|
|
2014
|
+
logForDebugging(`[Sandbox Linux] Skipping deny path not within allowed paths: ${normalizedPath}`);
|
|
2015
|
+
}
|
|
2016
|
+
}
|
|
2017
|
+
}
|
|
2018
|
+
else {
|
|
2019
|
+
// No write restrictions: Allow all writes
|
|
2020
|
+
args.push('--bind', '/', '/');
|
|
2021
|
+
// Recording '/' makes isWithinAnyAllowedWritePath and isAllowedWriteRoot
|
|
2022
|
+
// say so, which is the '/'-write-root shape ancestorPinArgs treats
|
|
2023
|
+
// specially. Nothing is ever restored for it: no read-deny tmpfs lands
|
|
2024
|
+
// at '/'.
|
|
2025
|
+
allowedWritePaths.push('/');
|
|
2026
|
+
ancestorPinInsertAt = args.length;
|
|
2027
|
+
}
|
|
2028
|
+
// denyWriteArgs is emitted after the denyRead loop below.
|
|
2029
|
+
// Handle read restrictions by mounting tmpfs over denied paths.
|
|
2030
|
+
// Non-glob spellings arrive slash-free from normalizePathForSandbox — the
|
|
2031
|
+
// comparisons below (hidden-by-tmpfs prefixes, the exact-match allowRead
|
|
2032
|
+
// skip, pushReadDenyDirMounts' re-bind checks) depend on that.
|
|
2033
|
+
// The read-deny mounts emitted so far, in emission order: directories
|
|
2034
|
+
// masked by --tmpfs with what their restore loops re-bound, and files
|
|
2035
|
+
// masked by --ro-bind <source> <dest> (/dev/null for read-deny, the
|
|
2036
|
+
// sentinel fake for a credential mask). `landing` is where each mount sits
|
|
2037
|
+
// in the sandbox. Every later decision reads these records: the ancestor
|
|
2038
|
+
// pins, the deny-bind emission filter and the re-application passes.
|
|
2039
|
+
const readDenyTmpfsUnits = [];
|
|
2040
|
+
const fileMasks = [];
|
|
2041
|
+
// Replay the tmpfs units in order (bwrap is last-mount-wins): is `location`
|
|
2042
|
+
// beneath a unit's tmpfs and not brought back by a restore since? A restore
|
|
2043
|
+
// counts only where it lands. One reached through a symlink puts the
|
|
2044
|
+
// target's inode at the NAME; the target's own path is still under the
|
|
2045
|
+
// tmpfs, and asking there must not answer "brought back".
|
|
2046
|
+
const isHiddenByTmpfs = (location, broughtBackBy) => {
|
|
2047
|
+
const broughtBack = (restored) => restored.some(r => isAtOrUnder(location, r.dest));
|
|
2048
|
+
let hidden = false;
|
|
2049
|
+
for (const unit of readDenyTmpfsUnits) {
|
|
2050
|
+
if (isAtOrUnder(location, unit.landing)) {
|
|
2051
|
+
hidden = true;
|
|
2052
|
+
}
|
|
2053
|
+
if (broughtBack(unit.restoredWrites) ||
|
|
2054
|
+
(broughtBackBy === 'writes and reads' &&
|
|
2055
|
+
broughtBack(unit.restoredReads))) {
|
|
2056
|
+
hidden = false;
|
|
2057
|
+
}
|
|
2058
|
+
}
|
|
2059
|
+
return hidden;
|
|
2060
|
+
};
|
|
2061
|
+
// Entries a glob expansion produced for a directory it could not list.
|
|
2062
|
+
// What the pattern matches beneath an allowed path in there was never
|
|
2063
|
+
// found, so binding that path back over the tmpfs would show every one of
|
|
2064
|
+
// those matches unmasked.
|
|
2065
|
+
const unlistableDenyDirs = new Set(readConfig?.unlistableDenyDirs ?? []);
|
|
2066
|
+
// Every location the read section hides, each one where its mount lands:
|
|
2067
|
+
// one per entry that mounts something — a directory's tmpfs, the stand-in
|
|
2068
|
+
// tmpfs of an entry that could not be inspected or that resolves to '/', a
|
|
2069
|
+
// file's /dev/null mask — and one per masked credential file. An entry that
|
|
2070
|
+
// mounts nothing hides nothing, and must not cost a carve-out; a stand-in
|
|
2071
|
+
// hides where it lands, which is above the entry that asked for it. A
|
|
2072
|
+
// landing the loop below then skips as already hidden stays in this list:
|
|
2073
|
+
// it refuses a carve-out the mounts alone would have allowed, which is the
|
|
2074
|
+
// safe direction and one comparison fewer.
|
|
2075
|
+
const readDeniedLocations = [
|
|
2076
|
+
...readDenyPlan().flatMap(({ mount, liftedFile }) => mount === undefined || liftedFile ? [] : [mount.landing]),
|
|
2077
|
+
...(maskedFileBinds ?? []).map(mask => canonicalForm(mask.realPath)),
|
|
2078
|
+
];
|
|
2079
|
+
// What the read section hides at, inside, or around `target`, ignoring the
|
|
2080
|
+
// tmpfs landing at `landing` and every deny above it — those are what a
|
|
2081
|
+
// carve-out restored into that tmpfs is the exception to. Everything else
|
|
2082
|
+
// that overlaps the target wins over the carve-out. Both sides are resolved
|
|
2083
|
+
// locations: every read deny mounts where its entry resolves, and `target`
|
|
2084
|
+
// is what the carve-out resolves to, so no spelling has to be guessed at.
|
|
2085
|
+
// Returns the offending location, for the debug line.
|
|
2086
|
+
const readDenialAround = (target, landing) => readDeniedLocations.find(denied => !isAtOrUnder(landing, denied) &&
|
|
2087
|
+
(isAtOrUnder(denied, target) || isAtOrUnder(target, denied)));
|
|
2088
|
+
for (const { normalizedPath, mount, liftedFile } of readDenyPlan()) {
|
|
2089
|
+
if (mount === undefined) {
|
|
2090
|
+
logForDebugging(`[Sandbox Linux] Read deny path mounts nothing this wrap can place (absent, or uninspectable, or a link to '/' with nothing but '/' holding it): ${normalizedPath}`);
|
|
2091
|
+
continue;
|
|
2092
|
+
}
|
|
2093
|
+
const { landing, isDirectory, isStandIn } = mount;
|
|
2094
|
+
// One mount per location. A tmpfs emitted so far already hides this
|
|
2095
|
+
// place, and nothing bound back over it shows the host there again, so a
|
|
2096
|
+
// mount here would be created inside that tmpfs and change nothing. This
|
|
2097
|
+
// also collapses two spellings of one directory, and the entries a
|
|
2098
|
+
// collapsed read-deny glob leaves beneath a directory it already denies.
|
|
2099
|
+
if (isHiddenByTmpfs(landing, 'writes and reads')) {
|
|
2100
|
+
logForDebugging(`[Sandbox Linux] Skipping read deny already hidden by a denyRead tmpfs: ${landing}`);
|
|
2101
|
+
continue;
|
|
2102
|
+
}
|
|
2103
|
+
if (isDirectory) {
|
|
2104
|
+
// A stand-in for an entry this wrap cannot vouch for hides everything
|
|
2105
|
+
// beneath it: nothing under it can be vouched for either. So does a
|
|
2106
|
+
// directory a glob expansion could not enumerate.
|
|
2107
|
+
if (isStandIn) {
|
|
2108
|
+
logForDebugging(`[Sandbox Linux] Read deny path ${normalizedPath} cannot be mounted where it names; hiding ${landing} instead`, { level: 'warn' });
|
|
2109
|
+
}
|
|
2110
|
+
const unlistable = unlistableDenyDirs.has(normalizedPath) ||
|
|
2111
|
+
unlistableDenyDirs.has(landing);
|
|
2112
|
+
if (unlistable) {
|
|
2113
|
+
logForDebugging(`[Sandbox Linux] Read-denied directory could not be listed when the glob was expanded; restoring nothing beneath it: ${landing}`, { level: 'warn' });
|
|
2114
|
+
}
|
|
2115
|
+
const restoresNothing = isStandIn || unlistable;
|
|
2116
|
+
const restored = pushReadDenyDirMounts(args, {
|
|
2117
|
+
landing,
|
|
2118
|
+
allowedWritePaths: restoresNothing ? [] : allowedWritePaths,
|
|
2119
|
+
readAllowPaths: restoresNothing ? [] : readAllowPaths(),
|
|
2120
|
+
resolve: canonicalLocationOf,
|
|
2121
|
+
readDenialAround: restoreTarget => readDenialAround(restoreTarget, landing),
|
|
2122
|
+
nameLocation: nameLocationOf,
|
|
2123
|
+
});
|
|
2124
|
+
readDenyTmpfsUnits.push({ landing, ...restored });
|
|
2125
|
+
}
|
|
2126
|
+
else {
|
|
2127
|
+
// For files, only an exact allowRead match overrides the deny: an
|
|
2128
|
+
// entry that names this very file, through whatever symlinked
|
|
2129
|
+
// directories. One that is a symlink to it names the link and lifts
|
|
2130
|
+
// nothing, or a link planted at an allowRead path would cancel the deny
|
|
2131
|
+
// of the file it points at. A directory allowRead does not un-deny a
|
|
2132
|
+
// file specifically listed in denyRead — otherwise denyRead: ['.env']
|
|
2133
|
+
// + allowRead: ['.'] silently drops the .env deny.
|
|
2134
|
+
if (liftedFile) {
|
|
2135
|
+
logForDebugging(`[Sandbox Linux] Skipping read deny for re-allowed path: ${normalizedPath}`);
|
|
2136
|
+
continue;
|
|
2137
|
+
}
|
|
2138
|
+
// For files, bind /dev/null instead of tmpfs, where the path resolves
|
|
2139
|
+
// like every other read-deny mount.
|
|
2140
|
+
args.push('--ro-bind', '/dev/null', landing);
|
|
2141
|
+
fileMasks.push({ source: '/dev/null', landing });
|
|
2142
|
+
}
|
|
2143
|
+
}
|
|
2144
|
+
// Whole-file credential masks: same bind shape as a file read-deny,
|
|
2145
|
+
// at the same place — where the path really resolves, which is what
|
|
2146
|
+
// covers a credential dotfile that is a symlink into a dotfile manager's
|
|
2147
|
+
// directory (~/.netrc, ~/.npmrc). realPath was already normalized
|
|
2148
|
+
// (tilde-expanded, realpath'd) by the caller. The fake's parent dir is
|
|
2149
|
+
// explicitly ro-bound at the end of this function, so the bind source is
|
|
2150
|
+
// never writable from inside the sandbox.
|
|
2151
|
+
for (const { realPath, fakePath } of maskedFileBinds ?? []) {
|
|
2152
|
+
const landing = canonicalForm(realPath);
|
|
2153
|
+
args.push('--ro-bind', fakePath, landing);
|
|
2154
|
+
fileMasks.push({ source: fakePath, landing });
|
|
2155
|
+
}
|
|
2156
|
+
// Ancestor pinning: the directories between a deny bind, file mask or
|
|
2157
|
+
// read-deny tmpfs and its covering allowed write root carry no mount, so
|
|
2158
|
+
// rename(2) on one moves the mount along with it and the path can be
|
|
2159
|
+
// recreated unprotected (mv .git aside; mkdir .git; write .git/hooks/x),
|
|
2160
|
+
// or, for a read-denied path, is simply not found by the next command's
|
|
2161
|
+
// wrap and left readable under its new name. ancestorPinArgs says what is
|
|
2162
|
+
// pinned and where. Seeds are landings because the walk takes dirname() of
|
|
2163
|
+
// each: a symlink spelling would pin the chain of the link, not of where
|
|
2164
|
+
// the mount sits. Deny binds, tmpfs units and masks are emitted later and
|
|
2165
|
+
// land on top of both pins and covers.
|
|
2166
|
+
const pinArgs = ancestorPinArgs([
|
|
2167
|
+
...denyWriteRawDests.keys(),
|
|
2168
|
+
...fileMasks.map(mask => mask.landing),
|
|
2169
|
+
...readDenyTmpfsUnits.map(unit => unit.landing),
|
|
2170
|
+
], {
|
|
2171
|
+
rootIsWriteRoot: isAllowedWriteRoot('/'),
|
|
2172
|
+
isWithinAllowedWrite: isWithinAnyAllowedWritePath,
|
|
2173
|
+
isAllowedWriteRoot,
|
|
2174
|
+
});
|
|
2175
|
+
args.splice(ancestorPinInsertAt, 0, ...pinArgs);
|
|
2176
|
+
// Emitting denyWrite last means these ro-binds layer on top of any write
|
|
2177
|
+
// paths the denyRead loop just re-bound. Before this ordering, tmpfs over
|
|
2178
|
+
// an ancestor of cwd would wipe the .git/hooks protection. But skip any
|
|
2179
|
+
// dest already masked by denyRead:
|
|
2180
|
+
//
|
|
2181
|
+
// - file masks: --ro-bind <host> <host> for denyWrite would undo
|
|
2182
|
+
// --ro-bind /dev/null <host> from denyRead, which landed first.
|
|
2183
|
+
// - tmpfs dirs: a dest at or under a denyRead tmpfs is already hidden, and
|
|
2184
|
+
// re-binding the host path on top of the tmpfs would expose the real
|
|
2185
|
+
// (read-denied) contents read-only. Writes inside the tmpfs never reach
|
|
2186
|
+
// the host, so the write-deny stays enforced without the bind. Exception:
|
|
2187
|
+
// if an allowed write path at-or-under that tmpfs covers the dest, the
|
|
2188
|
+
// denyRead loop re-bound it (the .git/hooks case) and the write-deny bind
|
|
2189
|
+
// is still required on top.
|
|
2190
|
+
// Both are decided by where the mask or tmpfs landed against the deny's
|
|
2191
|
+
// canonical dest, which is where its bind lands.
|
|
2192
|
+
const emittedDenyWriteDests = [];
|
|
2193
|
+
// Write paths already restored read-only by a dropped deny bind, so two
|
|
2194
|
+
// denies covering the same path emit one --ro-bind.
|
|
2195
|
+
const restoredReadOnlyWritePaths = new Set();
|
|
2196
|
+
for (let i = 0; i < denyWriteArgs.length; i += 3) {
|
|
2197
|
+
const dest = denyWriteArgs[i + 2];
|
|
2198
|
+
const rawDest = denyWriteRawDests.get(dest) ?? dest;
|
|
2199
|
+
// A mask's landing, not its dest: the landing is where the mask's bind
|
|
2200
|
+
// actually sits, and this deny's dest is canonical, so the two are
|
|
2201
|
+
// comparable as written.
|
|
2202
|
+
if (fileMasks.some(mask => mask.landing === dest))
|
|
2203
|
+
continue;
|
|
2204
|
+
if (isHiddenByTmpfs(dest, 'writes')) {
|
|
2205
|
+
logForDebugging(`[Sandbox Linux] Skipping denyWrite bind already hidden by denyRead tmpfs: ${dest}`);
|
|
2206
|
+
// The tmpfs hides this dest but not a write path one of the units
|
|
2207
|
+
// restored writable beneath it: that path is inside this write deny, so
|
|
2208
|
+
// restore it read-only, where the unit left it. Emitting the dest's own
|
|
2209
|
+
// bind instead would expose the read-denied directory around it.
|
|
2210
|
+
for (const unit of readDenyTmpfsUnits) {
|
|
2211
|
+
for (const writeMount of unit.restoredWrites) {
|
|
2212
|
+
const writePath = writeMount.dest;
|
|
2213
|
+
if (!isAtOrUnder(writePath, dest) &&
|
|
2214
|
+
!isAtOrUnder(writePath, rawDest)) {
|
|
2215
|
+
continue;
|
|
2216
|
+
}
|
|
2217
|
+
// A masked file needs no restore: its mask already holds it
|
|
2218
|
+
// unreadable and unwritable. A read-only bind of the real file here
|
|
2219
|
+
// would land ABOVE that mask, and the re-application below would
|
|
2220
|
+
// then have to put the mask back over a bind this loop only just
|
|
2221
|
+
// emitted — so skipping saves both that bind and its emitted-dest
|
|
2222
|
+
// entry. A masked file strictly BENEATH a restored directory is the
|
|
2223
|
+
// other case and stays: recording that directory is what puts its
|
|
2224
|
+
// mask back on top. Masks and restores are both keyed by where they
|
|
2225
|
+
// land, so one comparison answers for every spelling.
|
|
2226
|
+
if (fileMasks.some(mask => mask.landing === writePath)) {
|
|
2227
|
+
logForDebugging(`[Sandbox Linux] Leaving a masked file to its mask inside dropped denyWrite bind ${dest}: ${writePath}`);
|
|
2228
|
+
continue;
|
|
2229
|
+
}
|
|
2230
|
+
if (restoredReadOnlyWritePaths.has(writePath))
|
|
2231
|
+
continue;
|
|
2232
|
+
restoredReadOnlyWritePaths.add(writePath);
|
|
2233
|
+
args.push('--ro-bind', writeMount.source, writePath);
|
|
2234
|
+
// Like a deny bind, this one lands above whatever the read section
|
|
2235
|
+
// mounted inside the write path, so the re-application passes below
|
|
2236
|
+
// have to see it and put those mounts back.
|
|
2237
|
+
emittedDenyWriteDests.push(writePath);
|
|
2238
|
+
logForDebugging(`[Sandbox Linux] Restoring write path read-only inside dropped denyWrite bind ${dest}: ${writePath}`);
|
|
2239
|
+
}
|
|
2240
|
+
}
|
|
2241
|
+
continue;
|
|
2242
|
+
}
|
|
2243
|
+
args.push(denyWriteArgs[i], denyWriteArgs[i + 1], dest);
|
|
2244
|
+
emittedDenyWriteDests.push(dest);
|
|
2245
|
+
// The tmpfs / mask re-application passes below ask "does this bind sit
|
|
2246
|
+
// above a read-denied path?". A bind at the resolved dest also re-exposes
|
|
2247
|
+
// anything under the symlinked spelling, so record both.
|
|
2248
|
+
if (rawDest !== dest)
|
|
2249
|
+
emittedDenyWriteDests.push(rawDest);
|
|
2250
|
+
}
|
|
2251
|
+
// The inverse stacking problem: a denyWrite ro-bind whose dest strictly
|
|
2252
|
+
// contains a read-denied dir re-exposes that dir's real contents (the bind
|
|
2253
|
+
// landed after the tmpfs). Re-apply the tmpfs on top, with the allowRead
|
|
2254
|
+
// re-binds the denyRead loop emitted. A bind of '/' itself (allowOnly and
|
|
2255
|
+
// denyWithinAllow both naming it) contains every one of them, so
|
|
2256
|
+
// containment is root-aware. An allowed write path beneath such a tmpfs
|
|
2257
|
+
// lies inside the emitted deny dest too, so the deny wins: it comes back
|
|
2258
|
+
// read-only, visible as it was after the first pass but not writable.
|
|
2259
|
+
// Deeper units and masks under the same dest follow in the same order as
|
|
2260
|
+
// the first pass, so nothing restored here stays on top of one.
|
|
2261
|
+
for (const unit of readDenyTmpfsUnits) {
|
|
2262
|
+
const reExposingDest = emittedDenyWriteDests.find(dest => isStrictlyUnder(unit.landing, dest));
|
|
2263
|
+
if (reExposingDest !== undefined) {
|
|
2264
|
+
logForDebugging(`[Sandbox Linux] Re-applying denyRead tmpfs re-exposed by denyWrite bind: ${unit.landing}`);
|
|
2265
|
+
// Name the paths that lose their write access here, and to which deny.
|
|
2266
|
+
if (unit.restoredWrites.length > 0) {
|
|
2267
|
+
logForDebugging(`[Sandbox Linux] Restoring write paths read-only inside denyWrite bind ${reExposingDest}: ${unit.restoredWrites.map(w => w.dest).join(', ')}`);
|
|
2268
|
+
}
|
|
2269
|
+
// The recorded pairs go back as they are: each restore returns to where
|
|
2270
|
+
// the first pass put it, from the source it vetted then. Write paths
|
|
2271
|
+
// come back read-only, being inside the deny that re-exposed the tmpfs.
|
|
2272
|
+
// The tmpfs goes back at the unit's landing, which is the destination
|
|
2273
|
+
// the first pass gave bwrap for it.
|
|
2274
|
+
args.push('--tmpfs', unit.landing);
|
|
2275
|
+
for (const restored of [...unit.restoredWrites, ...unit.restoredReads]) {
|
|
2276
|
+
args.push('--ro-bind', restored.source, restored.dest);
|
|
2277
|
+
}
|
|
2278
|
+
}
|
|
2279
|
+
}
|
|
2280
|
+
// Same problem for masked files: the mask landed before the denyWrite
|
|
2281
|
+
// ancestor bind, so the real file is back. Re-apply the mask with its
|
|
2282
|
+
// original source (/dev/null for read-deny, the fake for credential mask).
|
|
2283
|
+
for (const mask of fileMasks) {
|
|
2284
|
+
if (emittedDenyWriteDests.some(dest => isAtOrUnder(mask.landing, dest))) {
|
|
2285
|
+
logForDebugging(`[Sandbox Linux] Re-applying file mask re-exposed by denyWrite bind: ${mask.landing}`);
|
|
2286
|
+
args.push('--ro-bind', mask.source, mask.landing);
|
|
2287
|
+
}
|
|
2288
|
+
}
|
|
2289
|
+
// INVARIANT: the fake-file store directory must never be writable from
|
|
2290
|
+
// inside the sandbox. If it were, a sandboxed process could plant a
|
|
2291
|
+
// symlink at a fake path and a later host-side write() would follow it,
|
|
2292
|
+
// or replace a fake's content so the bind exposes attacker bytes. Emit
|
|
2293
|
+
// last so it overlays any earlier --bind that covers the store dir
|
|
2294
|
+
// (e.g. allowWrite: ['/tmp'] when the store is under os.tmpdir()).
|
|
2295
|
+
if (maskedFileStoreDir !== undefined) {
|
|
2296
|
+
args.push('--ro-bind', maskedFileStoreDir, maskedFileStoreDir);
|
|
2297
|
+
}
|
|
2298
|
+
// INVARIANT, for the same reason: the empty directory the placeholders above
|
|
2299
|
+
// bind from must never be writable from inside the sandbox. It lives under
|
|
2300
|
+
// the system temp dir, so a caller's allowWrite or a host $TMPDIR under a
|
|
2301
|
+
// default write path makes it writable — and a placeholder binds it onto a
|
|
2302
|
+
// DENIED destination, so a write to the source lands at the deny. Emit last,
|
|
2303
|
+
// like the store, to overlay any earlier --bind that covers it. A same-uid
|
|
2304
|
+
// process on the host can still swap the source between wrap and run, which
|
|
2305
|
+
// is outside what this library defends against.
|
|
2306
|
+
if (emptySource !== undefined) {
|
|
2307
|
+
args.push('--ro-bind', emptySource, emptySource);
|
|
2308
|
+
}
|
|
2309
|
+
return args;
|
|
2310
|
+
}
|
|
2311
|
+
/**
|
|
2312
|
+
* Wrap a command with sandbox restrictions on Linux
|
|
2313
|
+
*
|
|
2314
|
+
* UNIX SOCKET BLOCKING (APPLY-SECCOMP):
|
|
2315
|
+
* This implementation uses a custom apply-seccomp binary to block Unix domain socket
|
|
2316
|
+
* creation for user commands while allowing network infrastructure:
|
|
2317
|
+
*
|
|
2318
|
+
* The two stages are described in README.md, "Unix Socket Restrictions
|
|
2319
|
+
* (Linux)".
|
|
2320
|
+
*
|
|
2321
|
+
* This solves the conflict between:
|
|
2322
|
+
* - Security: Blocking arbitrary Unix socket creation in user commands
|
|
2323
|
+
* - Functionality: Network sandboxing requires socat to call socket(AF_UNIX, ...) for bridge connections
|
|
2324
|
+
*
|
|
2325
|
+
* The seccomp-bpf filter blocks socket(AF_UNIX, ...) syscalls, preventing:
|
|
2326
|
+
* - Creating new Unix domain socket file descriptors
|
|
2327
|
+
*
|
|
2328
|
+
* Security limitations:
|
|
2329
|
+
* - Does NOT block operations (bind, connect, sendto, etc.) on inherited Unix socket FDs
|
|
2330
|
+
* - Does NOT prevent passing Unix socket FDs via SCM_RIGHTS
|
|
2331
|
+
* - For most sandboxing use cases, blocking socket creation is sufficient
|
|
2332
|
+
*
|
|
2333
|
+
* The filter allows:
|
|
2334
|
+
* - All TCP/UDP sockets (AF_INET, AF_INET6) for normal network operations
|
|
2335
|
+
* - All other syscalls
|
|
2336
|
+
*
|
|
2337
|
+
* PLATFORM NOTE:
|
|
2338
|
+
* The allowUnixSockets configuration is not path-based on Linux (unlike macOS)
|
|
2339
|
+
* because seccomp-bpf cannot inspect user-space memory to read socket paths.
|
|
2340
|
+
*
|
|
2341
|
+
* Requirements for seccomp filtering:
|
|
2342
|
+
* - Pre-built apply-seccomp binaries are included for x64 and ARM64
|
|
2343
|
+
* - Pre-generated BPF filters are included for x64 and ARM64
|
|
2344
|
+
* - Other architectures are not currently supported (no apply-seccomp binary available)
|
|
2345
|
+
* - To use sandboxing without Unix socket blocking on unsupported architectures,
|
|
2346
|
+
* set allowAllUnixSockets: true in your configuration
|
|
2347
|
+
* Dependencies are checked by checkLinuxDependencies() before enabling the sandbox.
|
|
2348
|
+
*
|
|
2349
|
+
* CALLER OBLIGATION: the euid and capability decisions behind the returned
|
|
2350
|
+
* string are made here, in the process that builds it, so the string must be
|
|
2351
|
+
* run by a process with the same euid and the same capability bounding and
|
|
2352
|
+
* inheritable sets.
|
|
2353
|
+
*/
|
|
2354
|
+
export async function wrapCommandWithSandboxLinux(params) {
|
|
2355
|
+
const { command, commandId, needsNetworkRestriction, httpSocketPath, socksSocketPath, httpProxyPort, socksProxyPort, proxyAuthToken, caCertPath, javaAgentJarPath, readConfig, writeConfig, unsetEnvVars, setEnvVars, maskedFileBinds, maskedFileStoreDir, enableWeakerNestedSandbox, allowAllUnixSockets, binShell, ripgrepConfig = { command: 'rg' }, mandatoryDenySearchDepth = DEFAULT_MANDATORY_DENY_SEARCH_DEPTH, allowGitConfig = false, gitSafeDirectories, seccompConfig, bwrapPath, socatPath, observeSocketPath, abortSignal, } = params;
|
|
2356
|
+
// Determine if we have restrictions to apply
|
|
2357
|
+
// Read: denyOnly pattern - empty array means no restrictions
|
|
2358
|
+
// Write: allowOnly pattern - undefined means no restrictions, any config means restrictions
|
|
2359
|
+
const hasReadRestrictions = (readConfig && readConfig.denyOnly.length > 0) ||
|
|
2360
|
+
(maskedFileBinds !== undefined && maskedFileBinds.length > 0);
|
|
2361
|
+
const hasWriteRestrictions = writeConfig !== undefined;
|
|
2362
|
+
const hasEnvRestrictions = (unsetEnvVars !== undefined && unsetEnvVars.length > 0) ||
|
|
2363
|
+
(setEnvVars !== undefined && Object.keys(setEnvVars).length > 0);
|
|
2364
|
+
const hasGitConfig = (gitSafeDirectories?.length ?? 0) > 0;
|
|
2365
|
+
// Check if we need any sandboxing
|
|
2366
|
+
if (!needsNetworkRestriction &&
|
|
2367
|
+
!hasReadRestrictions &&
|
|
2368
|
+
!hasWriteRestrictions &&
|
|
2369
|
+
!hasEnvRestrictions &&
|
|
2370
|
+
!hasGitConfig) {
|
|
2371
|
+
return command;
|
|
2372
|
+
}
|
|
2373
|
+
// Mark this sandbox invocation as active. cleanupBwrapMountPoints() will
|
|
2374
|
+
// defer file deletion until this (and every other concurrent) invocation
|
|
2375
|
+
// has been cleaned up. The matching decrement happens in
|
|
2376
|
+
// cleanupBwrapMountPoints(), which the caller must invoke after the
|
|
2377
|
+
// spawned command exits. If wrapping fails below, the catch block
|
|
2378
|
+
// decrements so the count does not leak.
|
|
2379
|
+
activeSandboxCount++;
|
|
2380
|
+
// One encoded key for both carriers below (SRT_ENCODED_CMD for the seccomp
|
|
2381
|
+
// observer, the proxy username for network denies), so a violation seen
|
|
2382
|
+
// through either resolves to the same registry entry. macOS derives its log
|
|
2383
|
+
// tag from a single key the same way.
|
|
2384
|
+
const attributionKey = encodeSandboxedCommand(attributionKeyFor(command, commandId));
|
|
2385
|
+
const bwrapArgs = ['--new-session', '--die-with-parent'];
|
|
2386
|
+
let applySeccompPrefix;
|
|
2387
|
+
try {
|
|
2388
|
+
// ========== SECCOMP FILTER (Unix Socket Blocking) ==========
|
|
2389
|
+
// apply-seccomp wraps the workload and applies the baked-in BPF filter
|
|
2390
|
+
// that blocks socket(AF_UNIX, ...). Skipped when allowAllUnixSockets is true.
|
|
2391
|
+
if (!allowAllUnixSockets) {
|
|
2392
|
+
applySeccompPrefix = resolveApplySeccompPrefix(seccompConfig?.applyPath, seccompConfig?.argv0);
|
|
2393
|
+
if (!applySeccompPrefix) {
|
|
2394
|
+
logForDebugging('[Sandbox Linux] apply-seccomp binary not available - unix socket blocking disabled. ' +
|
|
2395
|
+
'Install @anthropic-ai/sandbox-runtime globally for full protection.', { level: 'warn' });
|
|
2396
|
+
}
|
|
2397
|
+
else {
|
|
2398
|
+
logForDebugging('[Sandbox Linux] Applying seccomp filter for Unix socket blocking');
|
|
2399
|
+
}
|
|
2400
|
+
}
|
|
2401
|
+
else {
|
|
2402
|
+
logForDebugging('[Sandbox Linux] Skipping seccomp filter - allowAllUnixSockets is enabled');
|
|
2403
|
+
}
|
|
2404
|
+
// ========== VIOLATION OBSERVATION (best-effort) ==========
|
|
2405
|
+
// Only meaningful when apply-seccomp will run — it is the binary that
|
|
2406
|
+
// installs the USER_NOTIF filter and ships the listener fd.
|
|
2407
|
+
if (observeSocketPath && applySeccompPrefix) {
|
|
2408
|
+
if (fs.existsSync(observeSocketPath)) {
|
|
2409
|
+
bwrapArgs.push('--bind', observeSocketPath, observeSocketPath);
|
|
2410
|
+
bwrapArgs.push('--setenv', 'SRT_OBSERVE_SOCK', observeSocketPath);
|
|
2411
|
+
// Tag events with the encoded command so the violation store can
|
|
2412
|
+
// associate them with this invocation (parity with macOS log tag).
|
|
2413
|
+
bwrapArgs.push('--setenv', 'SRT_ENCODED_CMD', attributionKey);
|
|
2414
|
+
}
|
|
2415
|
+
else {
|
|
2416
|
+
logForDebugging('[Sandbox Linux] observe socket missing — supervisor not running; ' +
|
|
2417
|
+
'continuing without violation monitoring');
|
|
2418
|
+
}
|
|
2419
|
+
}
|
|
2420
|
+
// ========== ENV RESTRICTIONS ==========
|
|
2421
|
+
// Drop denied credential env vars from the inherited environment. Emitted
|
|
2422
|
+
// before the proxy --setenv flags below: bwrap applies env operations in
|
|
2423
|
+
// argument order, so SRT's own proxy plumbing vars survive even if a
|
|
2424
|
+
// caller lists one of them as a denied credential.
|
|
2425
|
+
if (hasEnvRestrictions) {
|
|
2426
|
+
for (const name of unsetEnvVars ?? []) {
|
|
2427
|
+
bwrapArgs.push('--unsetenv', name);
|
|
2428
|
+
}
|
|
2429
|
+
// Masked credentials override the inherited real value with a
|
|
2430
|
+
// sentinel; bwrap --setenv replaces any inherited value of NAME.
|
|
2431
|
+
for (const [name, value] of Object.entries(setEnvVars ?? {})) {
|
|
2432
|
+
bwrapArgs.push('--setenv', name, value);
|
|
2433
|
+
}
|
|
2434
|
+
}
|
|
2435
|
+
// ========== GIT CONFIG (safe.directory) ==========
|
|
2436
|
+
// Under `--unshare-user` the repo owner's uid is unmapped so git
|
|
2437
|
+
// sees dubious ownership. `buildPosixGitSafeDirEnv` composes
|
|
2438
|
+
// against the child's actual starting env (process.env inherited,
|
|
2439
|
+
// unsetEnvVars dropped, setEnvVars overlaid) so an ambient
|
|
2440
|
+
// GIT_CONFIG_COUNT is continued, not clobbered.
|
|
2441
|
+
if (gitSafeDirectories && gitSafeDirectories.length > 0) {
|
|
2442
|
+
const gitCfg = buildPosixGitSafeDirEnv({
|
|
2443
|
+
safeDirs: gitSafeDirectories,
|
|
2444
|
+
unsetEnvVars,
|
|
2445
|
+
setEnvVars,
|
|
2446
|
+
});
|
|
2447
|
+
for (const [name, value] of Object.entries(gitCfg)) {
|
|
2448
|
+
bwrapArgs.push('--setenv', name, value);
|
|
2449
|
+
}
|
|
2450
|
+
}
|
|
2451
|
+
// ========== NETWORK RESTRICTIONS ==========
|
|
2452
|
+
if (needsNetworkRestriction) {
|
|
2453
|
+
// Always unshare network namespace to isolate network access
|
|
2454
|
+
// This removes all network interfaces, effectively blocking all network
|
|
2455
|
+
bwrapArgs.push('--unshare-net');
|
|
2456
|
+
// If proxy sockets are provided, bind them into the sandbox to allow
|
|
2457
|
+
// filtered network access through the proxy. If not provided, network
|
|
2458
|
+
// is completely blocked (empty allowedDomains = block all)
|
|
2459
|
+
if (httpSocketPath && socksSocketPath) {
|
|
2460
|
+
// Verify socket files still exist before trying to bind them
|
|
2461
|
+
if (!fs.existsSync(httpSocketPath)) {
|
|
2462
|
+
throw new Error(`Linux HTTP bridge socket does not exist: ${httpSocketPath}. ` +
|
|
2463
|
+
'The bridge process may have died. Try reinitializing the sandbox.');
|
|
2464
|
+
}
|
|
2465
|
+
if (!fs.existsSync(socksSocketPath)) {
|
|
2466
|
+
throw new Error(`Linux SOCKS bridge socket does not exist: ${socksSocketPath}. ` +
|
|
2467
|
+
'The bridge process may have died. Try reinitializing the sandbox.');
|
|
2468
|
+
}
|
|
2469
|
+
// Bind both sockets into the sandbox
|
|
2470
|
+
bwrapArgs.push('--bind', httpSocketPath, httpSocketPath);
|
|
2471
|
+
// When the mux serves both protocols, socksSocketPath is the same
|
|
2472
|
+
// file as httpSocketPath; bwrap rejects a duplicate --bind of the
|
|
2473
|
+
// same source→target.
|
|
2474
|
+
if (socksSocketPath !== httpSocketPath) {
|
|
2475
|
+
bwrapArgs.push('--bind', socksSocketPath, socksSocketPath);
|
|
2476
|
+
}
|
|
2477
|
+
// Add proxy environment variables
|
|
2478
|
+
// HTTP_PROXY points to the socat listener inside the sandbox (port 3128)
|
|
2479
|
+
// which forwards to the Unix socket that bridges to the host's proxy server
|
|
2480
|
+
const proxyEnv = generateProxyEnvVars(3128, // Internal HTTP listener port
|
|
2481
|
+
1080, // Internal SOCKS listener port
|
|
2482
|
+
caCertPath, proxyAuthToken, writeConfig === undefined, attributionKey);
|
|
2483
|
+
bwrapArgs.push(...proxyEnv.flatMap((env) => {
|
|
2484
|
+
const firstEq = env.indexOf('=');
|
|
2485
|
+
const key = env.slice(0, firstEq);
|
|
2486
|
+
const value = env.slice(firstEq + 1);
|
|
2487
|
+
return ['--setenv', key, value];
|
|
2488
|
+
}));
|
|
2489
|
+
// JVMs ignore the proxy env vars above; the agent jar translates
|
|
2490
|
+
// them into system properties + an Authenticator at JVM start
|
|
2491
|
+
// (see java-proxy-agent.ts). Composed against the inherited value
|
|
2492
|
+
// so a caller's own JAVA_TOOL_OPTIONS survives.
|
|
2493
|
+
const javaToolOptions = buildJavaToolOptions({
|
|
2494
|
+
agentJarPath: javaAgentJarPath,
|
|
2495
|
+
unsetEnvVars,
|
|
2496
|
+
inherited: process.env.JAVA_TOOL_OPTIONS,
|
|
2497
|
+
});
|
|
2498
|
+
if (javaToolOptions !== undefined) {
|
|
2499
|
+
bwrapArgs.push('--setenv', 'JAVA_TOOL_OPTIONS', javaToolOptions);
|
|
2500
|
+
}
|
|
2501
|
+
// Add host proxy port environment variables for debugging/transparency
|
|
2502
|
+
// These show which host ports the Unix socket bridges connect to
|
|
2503
|
+
if (httpProxyPort !== undefined) {
|
|
2504
|
+
bwrapArgs.push('--setenv', 'CLAUDE_CODE_HOST_HTTP_PROXY_PORT', String(httpProxyPort));
|
|
2505
|
+
}
|
|
2506
|
+
if (socksProxyPort !== undefined) {
|
|
2507
|
+
bwrapArgs.push('--setenv', 'CLAUDE_CODE_HOST_SOCKS_PROXY_PORT', String(socksProxyPort));
|
|
2508
|
+
}
|
|
2509
|
+
}
|
|
2510
|
+
// If no sockets provided, network is completely blocked (--unshare-net without proxy)
|
|
2511
|
+
}
|
|
2512
|
+
// ========== FILESYSTEM RESTRICTIONS ==========
|
|
2513
|
+
const fsArgs = await generateFilesystemArgs(readConfig, writeConfig, maskedFileBinds, maskedFileStoreDir, ripgrepConfig, mandatoryDenySearchDepth, allowGitConfig, abortSignal);
|
|
2514
|
+
const mountsStart = bwrapArgs.length;
|
|
2515
|
+
bwrapArgs.push(...fsArgs);
|
|
2516
|
+
const mounts = { start: mountsStart, end: bwrapArgs.length };
|
|
2517
|
+
// Always bind /dev
|
|
2518
|
+
bwrapArgs.push('--dev', '/dev');
|
|
2519
|
+
// ========== PID NAMESPACE ISOLATION ==========
|
|
2520
|
+
// IMPORTANT: These must come AFTER filesystem binds for nested bwrap to work
|
|
2521
|
+
// By default, always unshare PID namespace and mount fresh /proc.
|
|
2522
|
+
// If we don't have --unshare-pid, it is possible to escape the sandbox.
|
|
2523
|
+
// If we don't have --proc, it is possible to read host /proc and leak information about code running
|
|
2524
|
+
// outside the sandbox. But, --proc is not available when running in unprivileged docker containers
|
|
2525
|
+
// so we support running without it if explicitly requested.
|
|
2526
|
+
bwrapArgs.push('--unshare-pid');
|
|
2527
|
+
// --unshare-user in both modes: bwrap only auto-creates a userns when
|
|
2528
|
+
// EUID != 0. A root parent in an unprivileged container (Docker's
|
|
2529
|
+
// default: EUID=0 without CAP_SYS_ADMIN) would otherwise try a direct
|
|
2530
|
+
// clone and EPERM.
|
|
2531
|
+
const euid = process.geteuid?.();
|
|
2532
|
+
const hasSetfcap = processHasBoundingCapability(CAP_SETFCAP);
|
|
2533
|
+
if (euid === 0 && !hasSetfcap && !setfcapMissingLogged) {
|
|
2534
|
+
setfcapMissingLogged = true;
|
|
2535
|
+
logForDebugging(`[Sandbox Linux] ${CAP_SETFCAP_MISSING_MESSAGE}`, {
|
|
2536
|
+
level: 'warn',
|
|
2537
|
+
});
|
|
2538
|
+
}
|
|
2539
|
+
bwrapArgs.push('--unshare-user', ...capabilityArgs({
|
|
2540
|
+
euid,
|
|
2541
|
+
hasSetfcap,
|
|
2542
|
+
usesSeccompHelper: applySeccompPrefix !== undefined,
|
|
2543
|
+
}));
|
|
2544
|
+
if (!enableWeakerNestedSandbox) {
|
|
2545
|
+
// Mount fresh /proc if PID namespace is isolated (secure mode).
|
|
2546
|
+
bwrapArgs.push('--proc', '/proc');
|
|
2547
|
+
}
|
|
2548
|
+
else {
|
|
2549
|
+
// --bind /proc /proc: apply-seccomp's nested-userns path writes
|
|
2550
|
+
// /proc/self/setgroups and uid_map. Without --proc above, the
|
|
2551
|
+
// --ro-bind / / leaves /proc read-only and those writes EROFS; a
|
|
2552
|
+
// fresh proc mount is what an unprivileged container refuses.
|
|
2553
|
+
bwrapArgs.push('--bind', '/proc', '/proc');
|
|
2554
|
+
}
|
|
2555
|
+
// apply-seccomp obtains CAP_SYS_ADMIN for its nested PID+mount unshare
|
|
2556
|
+
// by creating a nested user namespace. This requires the host to permit
|
|
2557
|
+
// capability-bearing unprivileged user namespaces (the same requirement
|
|
2558
|
+
// bwrap itself has when not installed setuid). See README for the
|
|
2559
|
+
// Ubuntu 24.04 sysctl if AppArmor restricts this.
|
|
2560
|
+
// ========== COMMAND ==========
|
|
2561
|
+
// Use the user's shell (zsh, bash, etc.) to ensure aliases/snapshots work
|
|
2562
|
+
// Resolve the full path to the shell binary since bwrap doesn't use $PATH
|
|
2563
|
+
const shellName = binShell || 'bash';
|
|
2564
|
+
const shell = whichSync(shellName);
|
|
2565
|
+
if (!shell) {
|
|
2566
|
+
throw new Error(`Shell '${shellName}' not found in PATH`);
|
|
2567
|
+
}
|
|
2568
|
+
bwrapArgs.push('--', shell, '-c');
|
|
2569
|
+
// With network restrictions, route the command through buildSandboxCommand
|
|
2570
|
+
// so socat starts before seccomp is applied. Otherwise invoke apply-seccomp
|
|
2571
|
+
// directly if we have a binary.
|
|
2572
|
+
if (needsNetworkRestriction && httpSocketPath && socksSocketPath) {
|
|
2573
|
+
const sandboxCommand = buildSandboxCommand(httpSocketPath, socksSocketPath, command, applySeccompPrefix, shell, socatPath);
|
|
2574
|
+
bwrapArgs.push(sandboxCommand);
|
|
2575
|
+
}
|
|
2576
|
+
else if (applySeccompPrefix) {
|
|
2577
|
+
const applySeccompCmd = applySeccompPrefix + quote([shell, '-c', command]);
|
|
2578
|
+
bwrapArgs.push(applySeccompCmd);
|
|
2579
|
+
}
|
|
2580
|
+
else {
|
|
2581
|
+
bwrapArgs.push(command);
|
|
2582
|
+
}
|
|
2583
|
+
const wrappedCommand = renderBwrapInvocation(bwrapPath ?? 'bwrap', bwrapArgs, mounts);
|
|
2584
|
+
const restrictions = [];
|
|
2585
|
+
if (needsNetworkRestriction)
|
|
2586
|
+
restrictions.push('network');
|
|
2587
|
+
if (hasReadRestrictions || hasWriteRestrictions)
|
|
2588
|
+
restrictions.push('filesystem');
|
|
2589
|
+
if (hasEnvRestrictions)
|
|
2590
|
+
restrictions.push('env');
|
|
2591
|
+
if (applySeccompPrefix)
|
|
2592
|
+
restrictions.push('seccomp(unix-block)');
|
|
2593
|
+
logForDebugging(`[Sandbox Linux] Wrapped command with bwrap (${restrictions.join(', ')} restrictions)`);
|
|
2594
|
+
return wrappedCommand;
|
|
2595
|
+
}
|
|
2596
|
+
catch (error) {
|
|
2597
|
+
// Undo the activeSandboxCount increment — the caller won't call
|
|
2598
|
+
// cleanupBwrapMountPoints() for a wrap that threw.
|
|
2599
|
+
if (activeSandboxCount > 0) {
|
|
2600
|
+
activeSandboxCount--;
|
|
2601
|
+
}
|
|
2602
|
+
throw error;
|
|
2603
|
+
}
|
|
2604
|
+
}
|
|
2605
|
+
//# sourceMappingURL=linux-sandbox-utils.js.map
|