apex-code 0.0.1-alpha.9 → 0.0.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +150 -2
- package/README.md +9 -8
- package/dist/cli/args.d.ts +25 -1
- package/dist/cli/args.d.ts.map +1 -1
- package/dist/cli/args.js +43 -1
- package/dist/cli/args.js.map +1 -1
- package/dist/cli/mcp-command.d.ts +9 -0
- package/dist/cli/mcp-command.d.ts.map +1 -0
- package/dist/cli/mcp-command.js +53 -0
- package/dist/cli/mcp-command.js.map +1 -0
- package/dist/cli/project-trust.d.ts.map +1 -1
- package/dist/cli/project-trust.js +3 -1
- package/dist/cli/project-trust.js.map +1 -1
- package/dist/cli/startup-ui.d.ts.map +1 -1
- package/dist/cli/startup-ui.js +2 -1
- package/dist/cli/startup-ui.js.map +1 -1
- package/dist/cli.d.ts.map +1 -1
- package/dist/cli.js +94 -5
- package/dist/cli.js.map +1 -1
- package/dist/config.d.ts.map +1 -1
- package/dist/config.js +24 -5
- package/dist/config.js.map +1 -1
- package/dist/core/agent-session-services.d.ts +3 -0
- package/dist/core/agent-session-services.d.ts.map +1 -1
- package/dist/core/agent-session-services.js +1 -0
- package/dist/core/agent-session-services.js.map +1 -1
- package/dist/core/agent-session.d.ts +157 -8
- package/dist/core/agent-session.d.ts.map +1 -1
- package/dist/core/agent-session.js +565 -22
- package/dist/core/agent-session.js.map +1 -1
- package/dist/core/bash-executor.d.ts.map +1 -1
- package/dist/core/bash-executor.js +7 -0
- package/dist/core/bash-executor.js.map +1 -1
- package/dist/core/checkpoints/git-checkpoints.d.ts +39 -0
- package/dist/core/checkpoints/git-checkpoints.d.ts.map +1 -0
- package/dist/core/checkpoints/git-checkpoints.js +251 -0
- package/dist/core/checkpoints/git-checkpoints.js.map +1 -0
- package/dist/core/checkpoints/session-checkpoints.d.ts +23 -0
- package/dist/core/checkpoints/session-checkpoints.d.ts.map +1 -0
- package/dist/core/checkpoints/session-checkpoints.js +36 -0
- package/dist/core/checkpoints/session-checkpoints.js.map +1 -0
- package/dist/core/compaction/branch-summarization.d.ts.map +1 -1
- package/dist/core/compaction/branch-summarization.js +4 -3
- package/dist/core/compaction/branch-summarization.js.map +1 -1
- package/dist/core/compaction/compaction.d.ts +5 -0
- package/dist/core/compaction/compaction.d.ts.map +1 -1
- package/dist/core/compaction/compaction.js +19 -5
- package/dist/core/compaction/compaction.js.map +1 -1
- package/dist/core/delegation/runtime.d.ts +6 -5
- package/dist/core/delegation/runtime.d.ts.map +1 -1
- package/dist/core/delegation/runtime.js +4 -3
- package/dist/core/delegation/runtime.js.map +1 -1
- package/dist/core/exec.d.ts.map +1 -1
- package/dist/core/exec.js +5 -2
- package/dist/core/exec.js.map +1 -1
- package/dist/core/extensions/index.d.ts +1 -1
- package/dist/core/extensions/index.d.ts.map +1 -1
- package/dist/core/extensions/index.js.map +1 -1
- package/dist/core/extensions/loader.d.ts.map +1 -1
- package/dist/core/extensions/loader.js +8 -2
- package/dist/core/extensions/loader.js.map +1 -1
- package/dist/core/extensions/runner.d.ts +5 -0
- package/dist/core/extensions/runner.d.ts.map +1 -1
- package/dist/core/extensions/runner.js +45 -1
- package/dist/core/extensions/runner.js.map +1 -1
- package/dist/core/extensions/source-runtime.d.ts +14 -0
- package/dist/core/extensions/source-runtime.d.ts.map +1 -0
- package/dist/core/extensions/source-runtime.js +56 -0
- package/dist/core/extensions/source-runtime.js.map +1 -0
- package/dist/core/extensions/types.d.ts +22 -2
- package/dist/core/extensions/types.d.ts.map +1 -1
- package/dist/core/extensions/types.js.map +1 -1
- package/dist/core/formatter-lifecycle.d.ts +69 -0
- package/dist/core/formatter-lifecycle.d.ts.map +1 -0
- package/dist/core/formatter-lifecycle.js +360 -0
- package/dist/core/formatter-lifecycle.js.map +1 -0
- package/dist/core/hooks/command-handler.d.ts +19 -0
- package/dist/core/hooks/command-handler.d.ts.map +1 -0
- package/dist/core/hooks/command-handler.js +112 -0
- package/dist/core/hooks/command-handler.js.map +1 -0
- package/dist/core/hooks/http-handler.d.ts +10 -0
- package/dist/core/hooks/http-handler.d.ts.map +1 -0
- package/dist/core/hooks/http-handler.js +36 -0
- package/dist/core/hooks/http-handler.js.map +1 -0
- package/dist/core/hooks/loader.d.ts +29 -0
- package/dist/core/hooks/loader.d.ts.map +1 -0
- package/dist/core/hooks/loader.js +139 -0
- package/dist/core/hooks/loader.js.map +1 -0
- package/dist/core/hooks/runtime.d.ts +13 -0
- package/dist/core/hooks/runtime.d.ts.map +1 -0
- package/dist/core/hooks/runtime.js +63 -0
- package/dist/core/hooks/runtime.js.map +1 -0
- package/dist/core/hooks/types.d.ts +83 -0
- package/dist/core/hooks/types.d.ts.map +1 -0
- package/dist/core/hooks/types.js +24 -0
- package/dist/core/hooks/types.js.map +1 -0
- package/dist/core/mcp/config.d.ts +16 -0
- package/dist/core/mcp/config.d.ts.map +1 -0
- package/dist/core/mcp/config.js +166 -0
- package/dist/core/mcp/config.js.map +1 -0
- package/dist/core/mcp/connector.d.ts +35 -0
- package/dist/core/mcp/connector.d.ts.map +1 -0
- package/dist/core/mcp/connector.js +105 -0
- package/dist/core/mcp/connector.js.map +1 -0
- package/dist/core/mcp/contract.d.ts +19 -0
- package/dist/core/mcp/contract.d.ts.map +1 -0
- package/dist/core/mcp/contract.js +89 -0
- package/dist/core/mcp/contract.js.map +1 -0
- package/dist/core/mcp/mcp-tool.d.ts +32 -0
- package/dist/core/mcp/mcp-tool.d.ts.map +1 -0
- package/dist/core/mcp/mcp-tool.js +171 -0
- package/dist/core/mcp/mcp-tool.js.map +1 -0
- package/dist/core/mcp/metadata-cache.d.ts +29 -0
- package/dist/core/mcp/metadata-cache.d.ts.map +1 -0
- package/dist/core/mcp/metadata-cache.js +99 -0
- package/dist/core/mcp/metadata-cache.js.map +1 -0
- package/dist/core/mcp/oauth/authorize.d.ts +21 -0
- package/dist/core/mcp/oauth/authorize.d.ts.map +1 -0
- package/dist/core/mcp/oauth/authorize.js +32 -0
- package/dist/core/mcp/oauth/authorize.js.map +1 -0
- package/dist/core/mcp/oauth/discover.d.ts +27 -0
- package/dist/core/mcp/oauth/discover.d.ts.map +1 -0
- package/dist/core/mcp/oauth/discover.js +116 -0
- package/dist/core/mcp/oauth/discover.js.map +1 -0
- package/dist/core/mcp/oauth/flow.d.ts +31 -0
- package/dist/core/mcp/oauth/flow.d.ts.map +1 -0
- package/dist/core/mcp/oauth/flow.js +175 -0
- package/dist/core/mcp/oauth/flow.js.map +1 -0
- package/dist/core/mcp/oauth/mcp-token.d.ts +51 -0
- package/dist/core/mcp/oauth/mcp-token.d.ts.map +1 -0
- package/dist/core/mcp/oauth/mcp-token.js +129 -0
- package/dist/core/mcp/oauth/mcp-token.js.map +1 -0
- package/dist/core/mcp/oauth/pkce.d.ts +12 -0
- package/dist/core/mcp/oauth/pkce.d.ts.map +1 -0
- package/dist/core/mcp/oauth/pkce.js +11 -0
- package/dist/core/mcp/oauth/pkce.js.map +1 -0
- package/dist/core/mcp/oauth/register.d.ts +16 -0
- package/dist/core/mcp/oauth/register.d.ts.map +1 -0
- package/dist/core/mcp/oauth/register.js +42 -0
- package/dist/core/mcp/oauth/register.js.map +1 -0
- package/dist/core/mcp/oauth/token-client.d.ts +33 -0
- package/dist/core/mcp/oauth/token-client.d.ts.map +1 -0
- package/dist/core/mcp/oauth/token-client.js +65 -0
- package/dist/core/mcp/oauth/token-client.js.map +1 -0
- package/dist/core/mcp/runtime.d.ts +20 -0
- package/dist/core/mcp/runtime.d.ts.map +1 -0
- package/dist/core/mcp/runtime.js +37 -0
- package/dist/core/mcp/runtime.js.map +1 -0
- package/dist/core/mcp/schema.d.ts +22 -0
- package/dist/core/mcp/schema.d.ts.map +1 -0
- package/dist/core/mcp/schema.js +20 -0
- package/dist/core/mcp/schema.js.map +1 -0
- package/dist/core/mcp/server-manager.d.ts +43 -0
- package/dist/core/mcp/server-manager.d.ts.map +1 -0
- package/dist/core/mcp/server-manager.js +142 -0
- package/dist/core/mcp/server-manager.js.map +1 -0
- package/dist/core/mcp/types.d.ts +100 -0
- package/dist/core/mcp/types.d.ts.map +1 -0
- package/dist/core/mcp/types.js +8 -0
- package/dist/core/mcp/types.js.map +1 -0
- package/dist/core/permissions/gate.d.ts.map +1 -1
- package/dist/core/permissions/gate.js +45 -4
- package/dist/core/permissions/gate.js.map +1 -1
- package/dist/core/permissions/operations.d.ts +20 -0
- package/dist/core/permissions/operations.d.ts.map +1 -0
- package/dist/core/permissions/operations.js +9 -0
- package/dist/core/permissions/operations.js.map +1 -0
- package/dist/core/permissions/policy-command.d.ts +42 -0
- package/dist/core/permissions/policy-command.d.ts.map +1 -0
- package/dist/core/permissions/policy-command.js +44 -0
- package/dist/core/permissions/policy-command.js.map +1 -0
- package/dist/core/permissions/responder.d.ts +49 -2
- package/dist/core/permissions/responder.d.ts.map +1 -1
- package/dist/core/permissions/responder.js +20 -8
- package/dist/core/permissions/responder.js.map +1 -1
- package/dist/core/permissions/rules.d.ts.map +1 -1
- package/dist/core/permissions/rules.js +11 -1
- package/dist/core/permissions/rules.js.map +1 -1
- package/dist/core/permissions/store.d.ts +18 -0
- package/dist/core/permissions/store.d.ts.map +1 -1
- package/dist/core/permissions/store.js +89 -8
- package/dist/core/permissions/store.js.map +1 -1
- package/dist/core/policy-executor.d.ts +47 -0
- package/dist/core/policy-executor.d.ts.map +1 -0
- package/dist/core/policy-executor.js +228 -0
- package/dist/core/policy-executor.js.map +1 -0
- package/dist/core/policy-loader.d.ts +72 -0
- package/dist/core/policy-loader.d.ts.map +1 -0
- package/dist/core/policy-loader.js +195 -0
- package/dist/core/policy-loader.js.map +1 -0
- package/dist/core/project-trust.d.ts +1 -1
- package/dist/core/project-trust.d.ts.map +1 -1
- package/dist/core/project-trust.js.map +1 -1
- package/dist/core/sandbox/bwrap-arguments.d.ts +41 -0
- package/dist/core/sandbox/bwrap-arguments.d.ts.map +1 -0
- package/dist/core/sandbox/bwrap-arguments.js +136 -0
- package/dist/core/sandbox/bwrap-arguments.js.map +1 -0
- package/dist/core/sandbox/child-entry.d.ts.map +1 -1
- package/dist/core/sandbox/child-entry.js +3 -1
- package/dist/core/sandbox/child-entry.js.map +1 -1
- package/dist/core/sandbox/cli-launch.d.ts +84 -4
- package/dist/core/sandbox/cli-launch.d.ts.map +1 -1
- package/dist/core/sandbox/cli-launch.js +115 -16
- package/dist/core/sandbox/cli-launch.js.map +1 -1
- package/dist/core/sandbox/cli-supervisor.d.ts +2 -0
- package/dist/core/sandbox/cli-supervisor.d.ts.map +1 -1
- package/dist/core/sandbox/cli-supervisor.js +50 -2
- package/dist/core/sandbox/cli-supervisor.js.map +1 -1
- package/dist/core/sandbox/full-access.d.ts +28 -0
- package/dist/core/sandbox/full-access.d.ts.map +1 -0
- package/dist/core/sandbox/full-access.js +63 -0
- package/dist/core/sandbox/full-access.js.map +1 -0
- package/dist/core/sandbox/git-identity.d.ts +57 -0
- package/dist/core/sandbox/git-identity.d.ts.map +1 -0
- package/dist/core/sandbox/git-identity.js +78 -0
- package/dist/core/sandbox/git-identity.js.map +1 -0
- package/dist/core/sandbox/host-approval.d.ts +42 -0
- package/dist/core/sandbox/host-approval.d.ts.map +1 -0
- package/dist/core/sandbox/host-approval.js +99 -0
- package/dist/core/sandbox/host-approval.js.map +1 -0
- package/dist/core/sandbox/linux-backend.d.ts +14 -0
- package/dist/core/sandbox/linux-backend.d.ts.map +1 -1
- package/dist/core/sandbox/linux-backend.js +158 -80
- package/dist/core/sandbox/linux-backend.js.map +1 -1
- package/dist/core/sandbox/macos-backend.d.ts +5 -0
- package/dist/core/sandbox/macos-backend.d.ts.map +1 -1
- package/dist/core/sandbox/macos-backend.js +164 -5
- package/dist/core/sandbox/macos-backend.js.map +1 -1
- package/dist/core/sandbox/network-proxy.d.ts +19 -0
- package/dist/core/sandbox/network-proxy.d.ts.map +1 -1
- package/dist/core/sandbox/network-proxy.js +93 -19
- package/dist/core/sandbox/network-proxy.js.map +1 -1
- package/dist/core/sandbox/policy.d.ts +8 -0
- package/dist/core/sandbox/policy.d.ts.map +1 -1
- package/dist/core/sandbox/policy.js +29 -12
- package/dist/core/sandbox/policy.js.map +1 -1
- package/dist/core/sandbox/profiles.d.ts +25 -0
- package/dist/core/sandbox/profiles.d.ts.map +1 -0
- package/dist/core/sandbox/profiles.js +23 -0
- package/dist/core/sandbox/profiles.js.map +1 -0
- package/dist/core/sandbox/rpc/command-client.d.ts +28 -0
- package/dist/core/sandbox/rpc/command-client.d.ts.map +1 -0
- package/dist/core/sandbox/rpc/command-client.js +86 -0
- package/dist/core/sandbox/rpc/command-client.js.map +1 -0
- package/dist/core/sandbox/rpc/command-proxy.d.ts +48 -0
- package/dist/core/sandbox/rpc/command-proxy.d.ts.map +1 -0
- package/dist/core/sandbox/rpc/command-proxy.js +120 -0
- package/dist/core/sandbox/rpc/command-proxy.js.map +1 -0
- package/dist/core/sandbox/rpc/credential-proxy.d.ts.map +1 -1
- package/dist/core/sandbox/rpc/credential-proxy.js +2 -96
- package/dist/core/sandbox/rpc/credential-proxy.js.map +1 -1
- package/dist/core/sandbox/rpc/framing.d.ts +35 -0
- package/dist/core/sandbox/rpc/framing.d.ts.map +1 -0
- package/dist/core/sandbox/rpc/framing.js +114 -0
- package/dist/core/sandbox/rpc/framing.js.map +1 -0
- package/dist/core/sandbox/rpc/git-credential-helper.d.ts +47 -0
- package/dist/core/sandbox/rpc/git-credential-helper.d.ts.map +1 -0
- package/dist/core/sandbox/rpc/git-credential-helper.js +222 -0
- package/dist/core/sandbox/rpc/git-credential-helper.js.map +1 -0
- package/dist/core/sandbox/rpc/git-credential-proxy.d.ts +66 -0
- package/dist/core/sandbox/rpc/git-credential-proxy.d.ts.map +1 -0
- package/dist/core/sandbox/rpc/git-credential-proxy.js +209 -0
- package/dist/core/sandbox/rpc/git-credential-proxy.js.map +1 -0
- package/dist/core/sandbox/supervisor-temp.d.ts +18 -0
- package/dist/core/sandbox/supervisor-temp.d.ts.map +1 -0
- package/dist/core/sandbox/supervisor-temp.js +21 -0
- package/dist/core/sandbox/supervisor-temp.js.map +1 -0
- package/dist/core/sandbox/supervisor.d.ts +12 -1
- package/dist/core/sandbox/supervisor.d.ts.map +1 -1
- package/dist/core/sandbox/supervisor.js +15 -1
- package/dist/core/sandbox/supervisor.js.map +1 -1
- package/dist/core/sandbox/terminal-handoff.d.ts +68 -0
- package/dist/core/sandbox/terminal-handoff.d.ts.map +1 -0
- package/dist/core/sandbox/terminal-handoff.js +221 -0
- package/dist/core/sandbox/terminal-handoff.js.map +1 -0
- package/dist/core/sandbox/terminal-size.d.ts +13 -0
- package/dist/core/sandbox/terminal-size.d.ts.map +1 -1
- package/dist/core/sandbox/terminal-size.js +23 -2
- package/dist/core/sandbox/terminal-size.js.map +1 -1
- package/dist/core/sdk.d.ts +22 -0
- package/dist/core/sdk.d.ts.map +1 -1
- package/dist/core/sdk.js +53 -1
- package/dist/core/sdk.js.map +1 -1
- package/dist/core/session-manager.d.ts +13 -1
- package/dist/core/session-manager.d.ts.map +1 -1
- package/dist/core/session-manager.js +32 -5
- package/dist/core/session-manager.js.map +1 -1
- package/dist/core/settings-manager.d.ts +163 -1
- package/dist/core/settings-manager.d.ts.map +1 -1
- package/dist/core/settings-manager.js +107 -0
- package/dist/core/settings-manager.js.map +1 -1
- package/dist/core/slash-commands.d.ts.map +1 -1
- package/dist/core/slash-commands.js +2 -0
- package/dist/core/slash-commands.js.map +1 -1
- package/dist/core/tools/background-shell.d.ts +39 -0
- package/dist/core/tools/background-shell.d.ts.map +1 -0
- package/dist/core/tools/background-shell.js +81 -0
- package/dist/core/tools/background-shell.js.map +1 -0
- package/dist/core/tools/bash-command-segments.d.ts.map +1 -1
- package/dist/core/tools/bash-command-segments.js +2 -0
- package/dist/core/tools/bash-command-segments.js.map +1 -1
- package/dist/core/tools/bash.d.ts +30 -15
- package/dist/core/tools/bash.d.ts.map +1 -1
- package/dist/core/tools/bash.js +228 -12
- package/dist/core/tools/bash.js.map +1 -1
- package/dist/core/tools/contract-snapshot.d.ts +58 -0
- package/dist/core/tools/contract-snapshot.d.ts.map +1 -0
- package/dist/core/tools/contract-snapshot.js +51 -0
- package/dist/core/tools/contract-snapshot.js.map +1 -0
- package/dist/core/tools/contract.d.ts +53 -5
- package/dist/core/tools/contract.d.ts.map +1 -1
- package/dist/core/tools/contract.js +22 -2
- package/dist/core/tools/contract.js.map +1 -1
- package/dist/core/tools/delegate.d.ts +6 -4
- package/dist/core/tools/delegate.d.ts.map +1 -1
- package/dist/core/tools/delegate.js +21 -11
- package/dist/core/tools/delegate.js.map +1 -1
- package/dist/core/tools/edit-diff.d.ts +48 -0
- package/dist/core/tools/edit-diff.d.ts.map +1 -1
- package/dist/core/tools/edit-diff.js +215 -3
- package/dist/core/tools/edit-diff.js.map +1 -1
- package/dist/core/tools/edit.d.ts +8 -0
- package/dist/core/tools/edit.d.ts.map +1 -1
- package/dist/core/tools/edit.js +53 -7
- package/dist/core/tools/edit.js.map +1 -1
- package/dist/core/tools/find.d.ts.map +1 -1
- package/dist/core/tools/find.js +5 -2
- package/dist/core/tools/find.js.map +1 -1
- package/dist/core/tools/grep.d.ts.map +1 -1
- package/dist/core/tools/grep.js +5 -2
- package/dist/core/tools/grep.js.map +1 -1
- package/dist/core/tools/index.d.ts +10 -1
- package/dist/core/tools/index.d.ts.map +1 -1
- package/dist/core/tools/index.js +3 -0
- package/dist/core/tools/index.js.map +1 -1
- package/dist/core/tools/ls.d.ts.map +1 -1
- package/dist/core/tools/ls.js +5 -2
- package/dist/core/tools/ls.js.map +1 -1
- package/dist/core/tools/lsp.d.ts +19 -1
- package/dist/core/tools/lsp.d.ts.map +1 -1
- package/dist/core/tools/lsp.js +163 -10
- package/dist/core/tools/lsp.js.map +1 -1
- package/dist/core/tools/path-permission.d.ts +3 -0
- package/dist/core/tools/path-permission.d.ts.map +1 -1
- package/dist/core/tools/path-permission.js +16 -4
- package/dist/core/tools/path-permission.js.map +1 -1
- package/dist/core/tools/path-utils.d.ts +27 -2
- package/dist/core/tools/path-utils.d.ts.map +1 -1
- package/dist/core/tools/path-utils.js +221 -4
- package/dist/core/tools/path-utils.js.map +1 -1
- package/dist/core/tools/read.d.ts.map +1 -1
- package/dist/core/tools/read.js +20 -11
- package/dist/core/tools/read.js.map +1 -1
- package/dist/core/tools/test.d.ts +54 -9
- package/dist/core/tools/test.d.ts.map +1 -1
- package/dist/core/tools/test.js +239 -19
- package/dist/core/tools/test.js.map +1 -1
- package/dist/core/tools/write.d.ts +7 -0
- package/dist/core/tools/write.d.ts.map +1 -1
- package/dist/core/tools/write.js +64 -9
- package/dist/core/tools/write.js.map +1 -1
- package/dist/core/verification-lifecycle.d.ts +77 -0
- package/dist/core/verification-lifecycle.d.ts.map +1 -0
- package/dist/core/verification-lifecycle.js +146 -0
- package/dist/core/verification-lifecycle.js.map +1 -0
- package/dist/core/workspace/artifacts.d.ts +45 -0
- package/dist/core/workspace/artifacts.d.ts.map +1 -0
- package/dist/core/workspace/artifacts.js +165 -0
- package/dist/core/workspace/artifacts.js.map +1 -0
- package/dist/core/workspace/comparison.d.ts +27 -0
- package/dist/core/workspace/comparison.d.ts.map +1 -0
- package/dist/core/workspace/comparison.js +94 -0
- package/dist/core/workspace/comparison.js.map +1 -0
- package/dist/core/workspace/git-observer.d.ts +48 -0
- package/dist/core/workspace/git-observer.d.ts.map +1 -0
- package/dist/core/workspace/git-observer.js +346 -0
- package/dist/core/workspace/git-observer.js.map +1 -0
- package/dist/core/workspace/projection.d.ts +3 -0
- package/dist/core/workspace/projection.d.ts.map +1 -0
- package/dist/core/workspace/projection.js +56 -0
- package/dist/core/workspace/projection.js.map +1 -0
- package/dist/core/workspace/state.d.ts +133 -0
- package/dist/core/workspace/state.d.ts.map +1 -0
- package/dist/core/workspace/state.js +96 -0
- package/dist/core/workspace/state.js.map +1 -0
- package/dist/extensions/llama/client.d.ts +6 -0
- package/dist/extensions/llama/client.d.ts.map +1 -1
- package/dist/extensions/llama/client.js +7 -0
- package/dist/extensions/llama/client.js.map +1 -1
- package/dist/extensions/llama/provider.d.ts +3 -1
- package/dist/extensions/llama/provider.d.ts.map +1 -1
- package/dist/extensions/llama/provider.js +27 -6
- package/dist/extensions/llama/provider.js.map +1 -1
- package/dist/index.d.ts +4 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +3 -0
- package/dist/index.js.map +1 -1
- package/dist/main.d.ts +3 -1
- package/dist/main.d.ts.map +1 -1
- package/dist/main.js +41 -9
- package/dist/main.js.map +1 -1
- package/dist/modes/acp/server.d.ts +55 -0
- package/dist/modes/acp/server.d.ts.map +1 -0
- package/dist/modes/acp/server.js +244 -0
- package/dist/modes/acp/server.js.map +1 -0
- package/dist/modes/acp/translate.d.ts +37 -0
- package/dist/modes/acp/translate.d.ts.map +1 -0
- package/dist/modes/acp/translate.js +50 -0
- package/dist/modes/acp/translate.js.map +1 -0
- package/dist/modes/index.d.ts +1 -0
- package/dist/modes/index.d.ts.map +1 -1
- package/dist/modes/index.js +1 -0
- package/dist/modes/index.js.map +1 -1
- package/dist/modes/interactive/components/assistant-message.d.ts +29 -0
- package/dist/modes/interactive/components/assistant-message.d.ts.map +1 -1
- package/dist/modes/interactive/components/assistant-message.js +182 -23
- package/dist/modes/interactive/components/assistant-message.js.map +1 -1
- package/dist/modes/interactive/components/bash-execution.d.ts +7 -0
- package/dist/modes/interactive/components/bash-execution.d.ts.map +1 -1
- package/dist/modes/interactive/components/bash-execution.js +20 -0
- package/dist/modes/interactive/components/bash-execution.js.map +1 -1
- package/dist/modes/interactive/components/custom-editor.d.ts +9 -4
- package/dist/modes/interactive/components/custom-editor.d.ts.map +1 -1
- package/dist/modes/interactive/components/custom-editor.js +20 -9
- package/dist/modes/interactive/components/custom-editor.js.map +1 -1
- package/dist/modes/interactive/components/extension-selector.d.ts +7 -1
- package/dist/modes/interactive/components/extension-selector.d.ts.map +1 -1
- package/dist/modes/interactive/components/extension-selector.js +7 -3
- package/dist/modes/interactive/components/extension-selector.js.map +1 -1
- package/dist/modes/interactive/components/footer.d.ts +19 -0
- package/dist/modes/interactive/components/footer.d.ts.map +1 -1
- package/dist/modes/interactive/components/footer.js +70 -10
- package/dist/modes/interactive/components/footer.js.map +1 -1
- package/dist/modes/interactive/components/model-selector.d.ts.map +1 -1
- package/dist/modes/interactive/components/model-selector.js +6 -6
- package/dist/modes/interactive/components/model-selector.js.map +1 -1
- package/dist/modes/interactive/components/permission-preview.d.ts +15 -0
- package/dist/modes/interactive/components/permission-preview.d.ts.map +1 -0
- package/dist/modes/interactive/components/permission-preview.js +33 -0
- package/dist/modes/interactive/components/permission-preview.js.map +1 -0
- package/dist/modes/interactive/components/session-selector.d.ts.map +1 -1
- package/dist/modes/interactive/components/session-selector.js +2 -2
- package/dist/modes/interactive/components/session-selector.js.map +1 -1
- package/dist/modes/interactive/components/settings-selector.d.ts +2 -0
- package/dist/modes/interactive/components/settings-selector.d.ts.map +1 -1
- package/dist/modes/interactive/components/settings-selector.js +10 -0
- package/dist/modes/interactive/components/settings-selector.js.map +1 -1
- package/dist/modes/interactive/components/tool-execution.d.ts +23 -1
- package/dist/modes/interactive/components/tool-execution.d.ts.map +1 -1
- package/dist/modes/interactive/components/tool-execution.js +64 -10
- package/dist/modes/interactive/components/tool-execution.js.map +1 -1
- package/dist/modes/interactive/interactive-mode.d.ts +34 -0
- package/dist/modes/interactive/interactive-mode.d.ts.map +1 -1
- package/dist/modes/interactive/interactive-mode.js +231 -26
- package/dist/modes/interactive/interactive-mode.js.map +1 -1
- package/dist/modes/interactive/theme/theme.d.ts +12 -0
- package/dist/modes/interactive/theme/theme.d.ts.map +1 -1
- package/dist/modes/interactive/theme/theme.js +15 -5
- package/dist/modes/interactive/theme/theme.js.map +1 -1
- package/dist/modes/print-mode.d.ts.map +1 -1
- package/dist/modes/print-mode.js +27 -12
- package/dist/modes/print-mode.js.map +1 -1
- package/dist/modes/rpc/rpc-client.d.ts +7 -0
- package/dist/modes/rpc/rpc-client.d.ts.map +1 -1
- package/dist/modes/rpc/rpc-client.js +7 -0
- package/dist/modes/rpc/rpc-client.js.map +1 -1
- package/dist/modes/rpc/rpc-mode.d.ts.map +1 -1
- package/dist/modes/rpc/rpc-mode.js +3 -0
- package/dist/modes/rpc/rpc-mode.js.map +1 -1
- package/dist/modes/rpc/rpc-types.d.ts +12 -0
- package/dist/modes/rpc/rpc-types.d.ts.map +1 -1
- package/dist/modes/rpc/rpc-types.js.map +1 -1
- package/dist/package-manager-cli.d.ts.map +1 -1
- package/dist/package-manager-cli.js +0 -6
- package/dist/package-manager-cli.js.map +1 -1
- package/dist/utils/shell.d.ts.map +1 -1
- package/dist/utils/shell.js +6 -4
- package/dist/utils/shell.js.map +1 -1
- package/dist/utils/version-check.d.ts.map +1 -1
- package/dist/utils/version-check.js +6 -4
- package/dist/utils/version-check.js.map +1 -1
- package/docs/compaction.md +4 -0
- package/docs/custom-provider.md +7 -7
- package/docs/environment-variables.md +1 -1
- package/docs/extensions.md +18 -0
- package/docs/keybindings.md +1 -1
- package/docs/quickstart.md +1 -1
- package/docs/rpc.md +23 -0
- package/docs/sdk.md +25 -0
- package/docs/settings.md +12 -4
- package/docs/terminal-setup.md +12 -0
- package/docs/usage.md +4 -3
- package/examples/extensions/built-in-tool-renderer.ts +8 -4
- package/examples/extensions/custom-provider-anthropic/package-lock.json +2 -2
- package/examples/extensions/custom-provider-anthropic/package.json +1 -1
- package/examples/extensions/custom-provider-gitlab-duo/package.json +1 -1
- package/examples/extensions/git-checkpoint.ts +24 -36
- package/examples/extensions/gondolin/package-lock.json +2 -2
- package/examples/extensions/gondolin/package.json +1 -1
- package/examples/extensions/minimal-mode.ts +2 -2
- package/examples/extensions/permission-gate.ts +1 -1
- package/examples/extensions/plan-mode/index.ts +1 -1
- package/examples/extensions/rpc-demo.ts +1 -1
- package/examples/extensions/sandbox/package-lock.json +2 -2
- package/examples/extensions/sandbox/package.json +1 -1
- package/examples/extensions/with-deps/package-lock.json +2 -2
- package/examples/extensions/with-deps/package.json +1 -1
- package/npm-shrinkwrap.json +194 -46
- package/package.json +14 -12
- package/dist/server/create-harness.d.ts +0 -26
- package/dist/server/create-harness.d.ts.map +0 -1
- package/dist/server/create-harness.js +0 -107
- package/dist/server/create-harness.js.map +0 -1
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The explicit opt-out from OS containment.
|
|
3
|
+
*
|
|
4
|
+
* ADR 0005 shipped without one on purpose, and its 2026-08-28 amendment records why that
|
|
5
|
+
* is now a flag rather than an absence: the delegation work removed the reasons people
|
|
6
|
+
* were reaching for an escape hatch, so the hatch can exist without becoming the default
|
|
7
|
+
* route around a boundary that could not otherwise be worked with.
|
|
8
|
+
*
|
|
9
|
+
* Two rules keep it honest. It is spelled out in full on the command line, never inferred
|
|
10
|
+
* from settings, so no repository and no saved configuration can turn the boundary off.
|
|
11
|
+
* And it always announces itself, because a session that silently ran unconfined is
|
|
12
|
+
* indistinguishable afterwards from one that did not.
|
|
13
|
+
*/
|
|
14
|
+
const BANNER = [
|
|
15
|
+
"",
|
|
16
|
+
" ! OS sandbox disabled for this session (--sandbox danger-full-access).",
|
|
17
|
+
"",
|
|
18
|
+
" Tool calls and every process they spawn run with your full account authority:",
|
|
19
|
+
" your home directory, your credentials, and unrestricted network access.",
|
|
20
|
+
" The permission gate still applies. It is not a containment boundary.",
|
|
21
|
+
"",
|
|
22
|
+
].join("\n");
|
|
23
|
+
export function writeFullAccessBanner(stderr) {
|
|
24
|
+
stderr.write(`${BANNER}\n`);
|
|
25
|
+
}
|
|
26
|
+
/**
|
|
27
|
+
* Confirm the opt-out interactively, or accept it unprompted where no human could answer.
|
|
28
|
+
*
|
|
29
|
+
* A non-interactive session is the case the flag legitimately exists for -- CI that is
|
|
30
|
+
* already externally sandboxed -- and there is nobody there to type. Prompting anyway
|
|
31
|
+
* would hang the run rather than protect anything, so the banner carries the weight.
|
|
32
|
+
*/
|
|
33
|
+
export function confirmFullAccess(options) {
|
|
34
|
+
const input = options.input ?? process.stdin;
|
|
35
|
+
const output = options.output ?? process.stdout;
|
|
36
|
+
if (!input.isTTY || !output.isTTY)
|
|
37
|
+
return Promise.resolve(true);
|
|
38
|
+
output.write("Run this session with no OS sandbox? [y/N] ");
|
|
39
|
+
return new Promise((resolve) => {
|
|
40
|
+
let buffer = "";
|
|
41
|
+
const finish = (value) => {
|
|
42
|
+
input.off("data", onData);
|
|
43
|
+
input.off("error", onError);
|
|
44
|
+
input.pause();
|
|
45
|
+
const answer = value.trim().toLowerCase();
|
|
46
|
+
output.write("\n");
|
|
47
|
+
resolve(answer === "y" || answer === "yes");
|
|
48
|
+
};
|
|
49
|
+
const onData = (chunk) => {
|
|
50
|
+
buffer += chunk.toString();
|
|
51
|
+
const newline = buffer.indexOf("\n");
|
|
52
|
+
if (newline >= 0)
|
|
53
|
+
finish(buffer.slice(0, newline));
|
|
54
|
+
};
|
|
55
|
+
// Anything unreadable declines, because an unanswerable prompt must not be the way
|
|
56
|
+
// containment gets switched off.
|
|
57
|
+
const onError = () => finish("");
|
|
58
|
+
input.on("data", onData);
|
|
59
|
+
input.on("error", onError);
|
|
60
|
+
input.resume();
|
|
61
|
+
});
|
|
62
|
+
}
|
|
63
|
+
//# sourceMappingURL=full-access.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"full-access.js","sourceRoot":"","sources":["../../../src/core/sandbox/full-access.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;GAYG;AAEH,MAAM,MAAM,GAAG;IACd,EAAE;IACF,0EAA0E;IAC1E,EAAE;IACF,mFAAmF;IACnF,6EAA6E;IAC7E,0EAA0E;IAC1E,EAAE;CACF,CAAC,IAAI,CAAC,IAAI,CAAC,CAAC;AAEb,MAAM,UAAU,qBAAqB,CAAC,MAA2C,EAAQ;IACxF,MAAM,CAAC,KAAK,CAAC,GAAG,MAAM,IAAI,CAAC,CAAC;AAAA,CAC5B;AAED;;;;;;GAMG;AACH,MAAM,UAAU,iBAAiB,CAAC,OAGjC,EAAoB;IACpB,MAAM,KAAK,GAAG,OAAO,CAAC,KAAK,IAAI,OAAO,CAAC,KAAK,CAAC;IAC7C,MAAM,MAAM,GAAG,OAAO,CAAC,MAAM,IAAI,OAAO,CAAC,MAAM,CAAC;IAChD,IAAI,CAAC,KAAK,CAAC,KAAK,IAAI,CAAC,MAAM,CAAC,KAAK;QAAE,OAAO,OAAO,CAAC,OAAO,CAAC,IAAI,CAAC,CAAC;IAEhE,MAAM,CAAC,KAAK,CAAC,6CAA6C,CAAC,CAAC;IAC5D,OAAO,IAAI,OAAO,CAAC,CAAC,OAAO,EAAE,EAAE,CAAC;QAC/B,IAAI,MAAM,GAAG,EAAE,CAAC;QAChB,MAAM,MAAM,GAAG,CAAC,KAAa,EAAE,EAAE,CAAC;YACjC,KAAK,CAAC,GAAG,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC;YAC1B,KAAK,CAAC,GAAG,CAAC,OAAO,EAAE,OAAO,CAAC,CAAC;YAC5B,KAAK,CAAC,KAAK,EAAE,CAAC;YACd,MAAM,MAAM,GAAG,KAAK,CAAC,IAAI,EAAE,CAAC,WAAW,EAAE,CAAC;YAC1C,MAAM,CAAC,KAAK,CAAC,IAAI,CAAC,CAAC;YACnB,OAAO,CAAC,MAAM,KAAK,GAAG,IAAI,MAAM,KAAK,KAAK,CAAC,CAAC;QAAA,CAC5C,CAAC;QACF,MAAM,MAAM,GAAG,CAAC,KAAsB,EAAE,EAAE,CAAC;YAC1C,MAAM,IAAI,KAAK,CAAC,QAAQ,EAAE,CAAC;YAC3B,MAAM,OAAO,GAAG,MAAM,CAAC,OAAO,CAAC,IAAI,CAAC,CAAC;YACrC,IAAI,OAAO,IAAI,CAAC;gBAAE,MAAM,CAAC,MAAM,CAAC,KAAK,CAAC,CAAC,EAAE,OAAO,CAAC,CAAC,CAAC;QAAA,CACnD,CAAC;QACF,mFAAmF;QACnF,iCAAiC;QACjC,MAAM,OAAO,GAAG,GAAG,EAAE,CAAC,MAAM,CAAC,EAAE,CAAC,CAAC;QACjC,KAAK,CAAC,EAAE,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC;QACzB,KAAK,CAAC,EAAE,CAAC,OAAO,EAAE,OAAO,CAAC,CAAC;QAC3B,KAAK,CAAC,MAAM,EAAE,CAAC;IAAA,CACf,CAAC,CAAC;AAAA,CACH","sourcesContent":["/**\n * The explicit opt-out from OS containment.\n *\n * ADR 0005 shipped without one on purpose, and its 2026-08-28 amendment records why that\n * is now a flag rather than an absence: the delegation work removed the reasons people\n * were reaching for an escape hatch, so the hatch can exist without becoming the default\n * route around a boundary that could not otherwise be worked with.\n *\n * Two rules keep it honest. It is spelled out in full on the command line, never inferred\n * from settings, so no repository and no saved configuration can turn the boundary off.\n * And it always announces itself, because a session that silently ran unconfined is\n * indistinguishable afterwards from one that did not.\n */\n\nconst BANNER = [\n\t\"\",\n\t\" ! OS sandbox disabled for this session (--sandbox danger-full-access).\",\n\t\"\",\n\t\" Tool calls and every process they spawn run with your full account authority:\",\n\t\" your home directory, your credentials, and unrestricted network access.\",\n\t\" The permission gate still applies. It is not a containment boundary.\",\n\t\"\",\n].join(\"\\n\");\n\nexport function writeFullAccessBanner(stderr: { write(message: string): boolean }): void {\n\tstderr.write(`${BANNER}\\n`);\n}\n\n/**\n * Confirm the opt-out interactively, or accept it unprompted where no human could answer.\n *\n * A non-interactive session is the case the flag legitimately exists for -- CI that is\n * already externally sandboxed -- and there is nobody there to type. Prompting anyway\n * would hang the run rather than protect anything, so the banner carries the weight.\n */\nexport function confirmFullAccess(options: {\n\tinput?: NodeJS.ReadStream;\n\toutput?: NodeJS.WriteStream;\n}): Promise<boolean> {\n\tconst input = options.input ?? process.stdin;\n\tconst output = options.output ?? process.stdout;\n\tif (!input.isTTY || !output.isTTY) return Promise.resolve(true);\n\n\toutput.write(\"Run this session with no OS sandbox? [y/N] \");\n\treturn new Promise((resolve) => {\n\t\tlet buffer = \"\";\n\t\tconst finish = (value: string) => {\n\t\t\tinput.off(\"data\", onData);\n\t\t\tinput.off(\"error\", onError);\n\t\t\tinput.pause();\n\t\t\tconst answer = value.trim().toLowerCase();\n\t\t\toutput.write(\"\\n\");\n\t\t\tresolve(answer === \"y\" || answer === \"yes\");\n\t\t};\n\t\tconst onData = (chunk: Buffer | string) => {\n\t\t\tbuffer += chunk.toString();\n\t\t\tconst newline = buffer.indexOf(\"\\n\");\n\t\t\tif (newline >= 0) finish(buffer.slice(0, newline));\n\t\t};\n\t\t// Anything unreadable declines, because an unanswerable prompt must not be the way\n\t\t// containment gets switched off.\n\t\tconst onError = () => finish(\"\");\n\t\tinput.on(\"data\", onData);\n\t\tinput.on(\"error\", onError);\n\t\tinput.resume();\n\t});\n}\n"]}
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
export interface HostGitIdentity {
|
|
2
|
+
readonly name: string;
|
|
3
|
+
readonly email: string;
|
|
4
|
+
}
|
|
5
|
+
export interface ProjectedGitConfig {
|
|
6
|
+
readonly directory: string;
|
|
7
|
+
readonly path: string;
|
|
8
|
+
}
|
|
9
|
+
/**
|
|
10
|
+
* Read the host's git identity before the sandbox exists to hide it.
|
|
11
|
+
*
|
|
12
|
+
* `--global` rather than a plain lookup, because the scope flag is what excludes the
|
|
13
|
+
* workspace's own `.git/config` by construction rather than by choosing a lucky working
|
|
14
|
+
* directory. A repository must not be able to decide what the supervisor projects, the
|
|
15
|
+
* same rule ADR 0016 applies to the network allowlist. Repository scope still works
|
|
16
|
+
* normally inside the child: the workspace is bind-mounted read-write, so git reads
|
|
17
|
+
* `.git/config` there itself and it wins over this projection, exactly as it would on the
|
|
18
|
+
* host.
|
|
19
|
+
*
|
|
20
|
+
* System scope (`/etc/gitconfig`) is deliberately not consulted. A machine-wide commit
|
|
21
|
+
* identity is not a real configuration, and a second `spawnSync` to find one would cost
|
|
22
|
+
* every launch what it saves nobody.
|
|
23
|
+
*
|
|
24
|
+
* Returns undefined unless both keys are present. A name without an email still fails to
|
|
25
|
+
* author a commit, so projecting half an identity would replace one confusing error with
|
|
26
|
+
* another.
|
|
27
|
+
*/
|
|
28
|
+
export declare function resolveHostGitIdentity(options?: {
|
|
29
|
+
/** Injectable only for tests; production uses the supervisor's own environment. */
|
|
30
|
+
environment?: NodeJS.ProcessEnv;
|
|
31
|
+
/**
|
|
32
|
+
* Injectable only for tests, so one can prove repository scope stays excluded even
|
|
33
|
+
* when the lookup runs from inside a repository.
|
|
34
|
+
*/
|
|
35
|
+
cwd?: string;
|
|
36
|
+
}): HostGitIdentity | undefined;
|
|
37
|
+
/**
|
|
38
|
+
* Write a two-key config the child can be pointed at through `GIT_CONFIG_GLOBAL`.
|
|
39
|
+
*
|
|
40
|
+
* Synthesized rather than copied. A real `~/.gitconfig` can carry a `credential.helper`
|
|
41
|
+
* that runs a command, or an `insteadOf` rule rewriting a remote to one carrying a token;
|
|
42
|
+
* projecting the host file would hand both to the child along with the name we wanted.
|
|
43
|
+
* Building the file from a resolved identity means it cannot contain anything else.
|
|
44
|
+
*
|
|
45
|
+
* The directory is its own, not shared with the credential projection, and `0700` for the
|
|
46
|
+
* same reason `resolveCredentialChannelPaths` uses one. TMPDIR is deliberately not
|
|
47
|
+
* honoured: it can point inside the workspace on macOS, which the sandbox may write.
|
|
48
|
+
*/
|
|
49
|
+
export declare function createProjectedGitConfig(identity: HostGitIdentity, options?: {
|
|
50
|
+
/**
|
|
51
|
+
* A `credential.helper` value for the child. It names the supervisor-mediated
|
|
52
|
+
* helper, never a host one: the point is that the child asks over the channel
|
|
53
|
+
* rather than reading any store itself.
|
|
54
|
+
*/
|
|
55
|
+
credentialHelper?: string;
|
|
56
|
+
}): ProjectedGitConfig;
|
|
57
|
+
//# sourceMappingURL=git-identity.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"git-identity.d.ts","sourceRoot":"","sources":["../../../src/core/sandbox/git-identity.ts"],"names":[],"mappings":"AAKA,MAAM,WAAW,eAAe;IAC/B,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAC;IACtB,QAAQ,CAAC,KAAK,EAAE,MAAM,CAAC;CACvB;AAED,MAAM,WAAW,kBAAkB;IAClC,QAAQ,CAAC,SAAS,EAAE,MAAM,CAAC;IAC3B,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAC;CACtB;AAKD;;;;;;;;;;;;;;;;;;GAkBG;AACH,wBAAgB,sBAAsB,CAAC,OAAO,CAAC,EAAE;IAChD,mFAAmF;IACnF,WAAW,CAAC,EAAE,MAAM,CAAC,UAAU,CAAC;IAChC;;;OAGG;IACH,GAAG,CAAC,EAAE,MAAM,CAAC;CACb,GAAG,eAAe,GAAG,SAAS,CAoB9B;AAOD;;;;;;;;;;;GAWG;AACH,wBAAgB,wBAAwB,CACvC,QAAQ,EAAE,eAAe,EACzB,OAAO,CAAC,EAAE;IACT;;;;OAIG;IACH,gBAAgB,CAAC,EAAE,MAAM,CAAC;CAC1B,GACC,kBAAkB,CAUpB","sourcesContent":["import { spawnSync } from \"node:child_process\";\nimport { chmodSync, mkdtempSync, writeFileSync } from \"node:fs\";\nimport { join } from \"node:path\";\nimport { supervisorTempDirectory } from \"./supervisor-temp.ts\";\n\nexport interface HostGitIdentity {\n\treadonly name: string;\n\treadonly email: string;\n}\n\nexport interface ProjectedGitConfig {\n\treadonly directory: string;\n\treadonly path: string;\n}\n\n/** Long enough for a cold `git` on a slow disk, short enough not to stall every launch. */\nconst RESOLUTION_TIMEOUT_MILLISECONDS = 2_000;\n\n/**\n * Read the host's git identity before the sandbox exists to hide it.\n *\n * `--global` rather than a plain lookup, because the scope flag is what excludes the\n * workspace's own `.git/config` by construction rather than by choosing a lucky working\n * directory. A repository must not be able to decide what the supervisor projects, the\n * same rule ADR 0016 applies to the network allowlist. Repository scope still works\n * normally inside the child: the workspace is bind-mounted read-write, so git reads\n * `.git/config` there itself and it wins over this projection, exactly as it would on the\n * host.\n *\n * System scope (`/etc/gitconfig`) is deliberately not consulted. A machine-wide commit\n * identity is not a real configuration, and a second `spawnSync` to find one would cost\n * every launch what it saves nobody.\n *\n * Returns undefined unless both keys are present. A name without an email still fails to\n * author a commit, so projecting half an identity would replace one confusing error with\n * another.\n */\nexport function resolveHostGitIdentity(options?: {\n\t/** Injectable only for tests; production uses the supervisor's own environment. */\n\tenvironment?: NodeJS.ProcessEnv;\n\t/**\n\t * Injectable only for tests, so one can prove repository scope stays excluded even\n\t * when the lookup runs from inside a repository.\n\t */\n\tcwd?: string;\n}): HostGitIdentity | undefined {\n\tconst result = spawnSync(\"git\", [\"config\", \"--global\", \"--get-regexp\", \"^user\\\\.(name|email)$\"], {\n\t\tcwd: options?.cwd,\n\t\tencoding: \"utf8\",\n\t\tenv: options?.environment ?? process.env,\n\t\ttimeout: RESOLUTION_TIMEOUT_MILLISECONDS,\n\t});\n\tif (result.status !== 0 || typeof result.stdout !== \"string\") return undefined;\n\n\tlet name: string | undefined;\n\tlet email: string | undefined;\n\tfor (const line of result.stdout.split(\"\\n\")) {\n\t\tconst separator = line.indexOf(\" \");\n\t\tif (separator < 0) continue;\n\t\tconst value = line.slice(separator + 1).trim();\n\t\tif (value.length === 0) continue;\n\t\tif (line.slice(0, separator) === \"user.name\") name = value;\n\t\tif (line.slice(0, separator) === \"user.email\") email = value;\n\t}\n\treturn name && email ? { name, email } : undefined;\n}\n\n/** Escape a value for git's config syntax, which reads a quoted string with backslash escapes. */\nfunction quote(value: string): string {\n\treturn `\"${value.replace(/\\\\/g, \"\\\\\\\\\").replace(/\"/g, '\\\\\"')}\"`;\n}\n\n/**\n * Write a two-key config the child can be pointed at through `GIT_CONFIG_GLOBAL`.\n *\n * Synthesized rather than copied. A real `~/.gitconfig` can carry a `credential.helper`\n * that runs a command, or an `insteadOf` rule rewriting a remote to one carrying a token;\n * projecting the host file would hand both to the child along with the name we wanted.\n * Building the file from a resolved identity means it cannot contain anything else.\n *\n * The directory is its own, not shared with the credential projection, and `0700` for the\n * same reason `resolveCredentialChannelPaths` uses one. TMPDIR is deliberately not\n * honoured: it can point inside the workspace on macOS, which the sandbox may write.\n */\nexport function createProjectedGitConfig(\n\tidentity: HostGitIdentity,\n\toptions?: {\n\t\t/**\n\t\t * A `credential.helper` value for the child. It names the supervisor-mediated\n\t\t * helper, never a host one: the point is that the child asks over the channel\n\t\t * rather than reading any store itself.\n\t\t */\n\t\tcredentialHelper?: string;\n\t},\n): ProjectedGitConfig {\n\tconst directory = mkdtempSync(join(supervisorTempDirectory(), `apex-git-${process.pid}-`), { encoding: \"utf8\" });\n\tchmodSync(directory, 0o700);\n\tconst path = join(directory, \"config\");\n\tconst sections = [`[user]\\n\\tname = ${quote(identity.name)}\\n\\temail = ${quote(identity.email)}\\n`];\n\tif (options?.credentialHelper) {\n\t\tsections.push(`[credential]\\n\\thelper = ${quote(options.credentialHelper)}\\n`);\n\t}\n\twriteFileSync(path, sections.join(\"\"), { mode: 0o600 });\n\treturn { directory, path };\n}\n"]}
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
import { spawnSync } from "node:child_process";
|
|
2
|
+
import { chmodSync, mkdtempSync, writeFileSync } from "node:fs";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
import { supervisorTempDirectory } from "./supervisor-temp.js";
|
|
5
|
+
/** Long enough for a cold `git` on a slow disk, short enough not to stall every launch. */
|
|
6
|
+
const RESOLUTION_TIMEOUT_MILLISECONDS = 2_000;
|
|
7
|
+
/**
|
|
8
|
+
* Read the host's git identity before the sandbox exists to hide it.
|
|
9
|
+
*
|
|
10
|
+
* `--global` rather than a plain lookup, because the scope flag is what excludes the
|
|
11
|
+
* workspace's own `.git/config` by construction rather than by choosing a lucky working
|
|
12
|
+
* directory. A repository must not be able to decide what the supervisor projects, the
|
|
13
|
+
* same rule ADR 0016 applies to the network allowlist. Repository scope still works
|
|
14
|
+
* normally inside the child: the workspace is bind-mounted read-write, so git reads
|
|
15
|
+
* `.git/config` there itself and it wins over this projection, exactly as it would on the
|
|
16
|
+
* host.
|
|
17
|
+
*
|
|
18
|
+
* System scope (`/etc/gitconfig`) is deliberately not consulted. A machine-wide commit
|
|
19
|
+
* identity is not a real configuration, and a second `spawnSync` to find one would cost
|
|
20
|
+
* every launch what it saves nobody.
|
|
21
|
+
*
|
|
22
|
+
* Returns undefined unless both keys are present. A name without an email still fails to
|
|
23
|
+
* author a commit, so projecting half an identity would replace one confusing error with
|
|
24
|
+
* another.
|
|
25
|
+
*/
|
|
26
|
+
export function resolveHostGitIdentity(options) {
|
|
27
|
+
const result = spawnSync("git", ["config", "--global", "--get-regexp", "^user\\.(name|email)$"], {
|
|
28
|
+
cwd: options?.cwd,
|
|
29
|
+
encoding: "utf8",
|
|
30
|
+
env: options?.environment ?? process.env,
|
|
31
|
+
timeout: RESOLUTION_TIMEOUT_MILLISECONDS,
|
|
32
|
+
});
|
|
33
|
+
if (result.status !== 0 || typeof result.stdout !== "string")
|
|
34
|
+
return undefined;
|
|
35
|
+
let name;
|
|
36
|
+
let email;
|
|
37
|
+
for (const line of result.stdout.split("\n")) {
|
|
38
|
+
const separator = line.indexOf(" ");
|
|
39
|
+
if (separator < 0)
|
|
40
|
+
continue;
|
|
41
|
+
const value = line.slice(separator + 1).trim();
|
|
42
|
+
if (value.length === 0)
|
|
43
|
+
continue;
|
|
44
|
+
if (line.slice(0, separator) === "user.name")
|
|
45
|
+
name = value;
|
|
46
|
+
if (line.slice(0, separator) === "user.email")
|
|
47
|
+
email = value;
|
|
48
|
+
}
|
|
49
|
+
return name && email ? { name, email } : undefined;
|
|
50
|
+
}
|
|
51
|
+
/** Escape a value for git's config syntax, which reads a quoted string with backslash escapes. */
|
|
52
|
+
function quote(value) {
|
|
53
|
+
return `"${value.replace(/\\/g, "\\\\").replace(/"/g, '\\"')}"`;
|
|
54
|
+
}
|
|
55
|
+
/**
|
|
56
|
+
* Write a two-key config the child can be pointed at through `GIT_CONFIG_GLOBAL`.
|
|
57
|
+
*
|
|
58
|
+
* Synthesized rather than copied. A real `~/.gitconfig` can carry a `credential.helper`
|
|
59
|
+
* that runs a command, or an `insteadOf` rule rewriting a remote to one carrying a token;
|
|
60
|
+
* projecting the host file would hand both to the child along with the name we wanted.
|
|
61
|
+
* Building the file from a resolved identity means it cannot contain anything else.
|
|
62
|
+
*
|
|
63
|
+
* The directory is its own, not shared with the credential projection, and `0700` for the
|
|
64
|
+
* same reason `resolveCredentialChannelPaths` uses one. TMPDIR is deliberately not
|
|
65
|
+
* honoured: it can point inside the workspace on macOS, which the sandbox may write.
|
|
66
|
+
*/
|
|
67
|
+
export function createProjectedGitConfig(identity, options) {
|
|
68
|
+
const directory = mkdtempSync(join(supervisorTempDirectory(), `apex-git-${process.pid}-`), { encoding: "utf8" });
|
|
69
|
+
chmodSync(directory, 0o700);
|
|
70
|
+
const path = join(directory, "config");
|
|
71
|
+
const sections = [`[user]\n\tname = ${quote(identity.name)}\n\temail = ${quote(identity.email)}\n`];
|
|
72
|
+
if (options?.credentialHelper) {
|
|
73
|
+
sections.push(`[credential]\n\thelper = ${quote(options.credentialHelper)}\n`);
|
|
74
|
+
}
|
|
75
|
+
writeFileSync(path, sections.join(""), { mode: 0o600 });
|
|
76
|
+
return { directory, path };
|
|
77
|
+
}
|
|
78
|
+
//# sourceMappingURL=git-identity.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"git-identity.js","sourceRoot":"","sources":["../../../src/core/sandbox/git-identity.ts"],"names":[],"mappings":"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","sourcesContent":["import { spawnSync } from \"node:child_process\";\nimport { chmodSync, mkdtempSync, writeFileSync } from \"node:fs\";\nimport { join } from \"node:path\";\nimport { supervisorTempDirectory } from \"./supervisor-temp.ts\";\n\nexport interface HostGitIdentity {\n\treadonly name: string;\n\treadonly email: string;\n}\n\nexport interface ProjectedGitConfig {\n\treadonly directory: string;\n\treadonly path: string;\n}\n\n/** Long enough for a cold `git` on a slow disk, short enough not to stall every launch. */\nconst RESOLUTION_TIMEOUT_MILLISECONDS = 2_000;\n\n/**\n * Read the host's git identity before the sandbox exists to hide it.\n *\n * `--global` rather than a plain lookup, because the scope flag is what excludes the\n * workspace's own `.git/config` by construction rather than by choosing a lucky working\n * directory. A repository must not be able to decide what the supervisor projects, the\n * same rule ADR 0016 applies to the network allowlist. Repository scope still works\n * normally inside the child: the workspace is bind-mounted read-write, so git reads\n * `.git/config` there itself and it wins over this projection, exactly as it would on the\n * host.\n *\n * System scope (`/etc/gitconfig`) is deliberately not consulted. A machine-wide commit\n * identity is not a real configuration, and a second `spawnSync` to find one would cost\n * every launch what it saves nobody.\n *\n * Returns undefined unless both keys are present. A name without an email still fails to\n * author a commit, so projecting half an identity would replace one confusing error with\n * another.\n */\nexport function resolveHostGitIdentity(options?: {\n\t/** Injectable only for tests; production uses the supervisor's own environment. */\n\tenvironment?: NodeJS.ProcessEnv;\n\t/**\n\t * Injectable only for tests, so one can prove repository scope stays excluded even\n\t * when the lookup runs from inside a repository.\n\t */\n\tcwd?: string;\n}): HostGitIdentity | undefined {\n\tconst result = spawnSync(\"git\", [\"config\", \"--global\", \"--get-regexp\", \"^user\\\\.(name|email)$\"], {\n\t\tcwd: options?.cwd,\n\t\tencoding: \"utf8\",\n\t\tenv: options?.environment ?? process.env,\n\t\ttimeout: RESOLUTION_TIMEOUT_MILLISECONDS,\n\t});\n\tif (result.status !== 0 || typeof result.stdout !== \"string\") return undefined;\n\n\tlet name: string | undefined;\n\tlet email: string | undefined;\n\tfor (const line of result.stdout.split(\"\\n\")) {\n\t\tconst separator = line.indexOf(\" \");\n\t\tif (separator < 0) continue;\n\t\tconst value = line.slice(separator + 1).trim();\n\t\tif (value.length === 0) continue;\n\t\tif (line.slice(0, separator) === \"user.name\") name = value;\n\t\tif (line.slice(0, separator) === \"user.email\") email = value;\n\t}\n\treturn name && email ? { name, email } : undefined;\n}\n\n/** Escape a value for git's config syntax, which reads a quoted string with backslash escapes. */\nfunction quote(value: string): string {\n\treturn `\"${value.replace(/\\\\/g, \"\\\\\\\\\").replace(/\"/g, '\\\\\"')}\"`;\n}\n\n/**\n * Write a two-key config the child can be pointed at through `GIT_CONFIG_GLOBAL`.\n *\n * Synthesized rather than copied. A real `~/.gitconfig` can carry a `credential.helper`\n * that runs a command, or an `insteadOf` rule rewriting a remote to one carrying a token;\n * projecting the host file would hand both to the child along with the name we wanted.\n * Building the file from a resolved identity means it cannot contain anything else.\n *\n * The directory is its own, not shared with the credential projection, and `0700` for the\n * same reason `resolveCredentialChannelPaths` uses one. TMPDIR is deliberately not\n * honoured: it can point inside the workspace on macOS, which the sandbox may write.\n */\nexport function createProjectedGitConfig(\n\tidentity: HostGitIdentity,\n\toptions?: {\n\t\t/**\n\t\t * A `credential.helper` value for the child. It names the supervisor-mediated\n\t\t * helper, never a host one: the point is that the child asks over the channel\n\t\t * rather than reading any store itself.\n\t\t */\n\t\tcredentialHelper?: string;\n\t},\n): ProjectedGitConfig {\n\tconst directory = mkdtempSync(join(supervisorTempDirectory(), `apex-git-${process.pid}-`), { encoding: \"utf8\" });\n\tchmodSync(directory, 0o700);\n\tconst path = join(directory, \"config\");\n\tconst sections = [`[user]\\n\\tname = ${quote(identity.name)}\\n\\temail = ${quote(identity.email)}\\n`];\n\tif (options?.credentialHelper) {\n\t\tsections.push(`[credential]\\n\\thelper = ${quote(options.credentialHelper)}\\n`);\n\t}\n\twriteFileSync(path, sections.join(\"\"), { mode: 0o600 });\n\treturn { directory, path };\n}\n"]}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import type { TerminalHandoff } from "./terminal-handoff.ts";
|
|
2
|
+
/**
|
|
3
|
+
* The supervisor's own approval prompt for one refused host.
|
|
4
|
+
*
|
|
5
|
+
* ADR 0023 puts the decision here rather than in the child. An approval asserted from
|
|
6
|
+
* inside the boundary is indistinguishable, at the supervisor, from one forged by a
|
|
7
|
+
* postinstall script or a git hook -- the code the boundary exists to contain -- so the
|
|
8
|
+
* human's answer is read by the side that owns the decision, on a terminal borrowed back
|
|
9
|
+
* from the child for the duration.
|
|
10
|
+
*
|
|
11
|
+
* Returning undefined when there is no terminal is the mechanism, not a fallback. The
|
|
12
|
+
* proxy's own deny-without-asking path then runs unchanged, so ADR 0005's headless, print,
|
|
13
|
+
* JSON, and RPC behaviour holds by construction rather than by a mode check that a later
|
|
14
|
+
* change could forget to make.
|
|
15
|
+
*/
|
|
16
|
+
export interface TerminalPromptOptions {
|
|
17
|
+
handoff: TerminalHandoff;
|
|
18
|
+
input?: NodeJS.ReadStream;
|
|
19
|
+
output?: NodeJS.WriteStream;
|
|
20
|
+
}
|
|
21
|
+
/** Approve one refused host for the rest of the session. */
|
|
22
|
+
export declare function createHostApprover(options: TerminalPromptOptions): ((hostname: string, port: number) => Promise<boolean>) | undefined;
|
|
23
|
+
/**
|
|
24
|
+
* Release the host's own git credential for one host, for the rest of the session.
|
|
25
|
+
*
|
|
26
|
+
* Separate from the host approval above even though both are yes/no, because they are
|
|
27
|
+
* different grants and conflating them would let approving a push also release a token to
|
|
28
|
+
* everything else the session later contacts.
|
|
29
|
+
*/
|
|
30
|
+
export declare function createCredentialReleaser(options: TerminalPromptOptions): ((host: string) => Promise<boolean>) | undefined;
|
|
31
|
+
/**
|
|
32
|
+
* Approve one command to run in a separate child with one extra writable root.
|
|
33
|
+
*
|
|
34
|
+
* Unlike a host or a credential, nothing is remembered. A command is not a stable subject
|
|
35
|
+
* to grant against, and a remembered approval would silently cover the next command that
|
|
36
|
+
* happened to name the same root.
|
|
37
|
+
*/
|
|
38
|
+
export declare function createCommandEscalationApprover(options: TerminalPromptOptions): ((request: {
|
|
39
|
+
command: string;
|
|
40
|
+
writableRoot: string;
|
|
41
|
+
}) => Promise<boolean>) | undefined;
|
|
42
|
+
//# sourceMappingURL=host-approval.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"host-approval.d.ts","sourceRoot":"","sources":["../../../src/core/sandbox/host-approval.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,eAAe,EAAE,MAAM,uBAAuB,CAAC;AAE7D;;;;;;;;;;;;;GAaG;AACH,MAAM,WAAW,qBAAqB;IACrC,OAAO,EAAE,eAAe,CAAC;IACzB,KAAK,CAAC,EAAE,MAAM,CAAC,UAAU,CAAC;IAC1B,MAAM,CAAC,EAAE,MAAM,CAAC,WAAW,CAAC;CAC5B;AAyDD,4DAA4D;AAC5D,wBAAgB,kBAAkB,CACjC,OAAO,EAAE,qBAAqB,GAC5B,CAAC,CAAC,QAAQ,EAAE,MAAM,EAAE,IAAI,EAAE,MAAM,KAAK,OAAO,CAAC,OAAO,CAAC,CAAC,GAAG,SAAS,CAUpE;AAED;;;;;;GAMG;AACH,wBAAgB,wBAAwB,CACvC,OAAO,EAAE,qBAAqB,GAC5B,CAAC,CAAC,IAAI,EAAE,MAAM,KAAK,OAAO,CAAC,OAAO,CAAC,CAAC,GAAG,SAAS,CASlD;AAED;;;;;;GAMG;AACH,wBAAgB,+BAA+B,CAC9C,OAAO,EAAE,qBAAqB,GAC5B,CAAC,CAAC,OAAO,EAAE;IAAE,OAAO,EAAE,MAAM,CAAC;IAAC,YAAY,EAAE,MAAM,CAAA;CAAE,KAAK,OAAO,CAAC,OAAO,CAAC,CAAC,GAAG,SAAS,CAYxF","sourcesContent":["import type { TerminalHandoff } from \"./terminal-handoff.ts\";\n\n/**\n * The supervisor's own approval prompt for one refused host.\n *\n * ADR 0023 puts the decision here rather than in the child. An approval asserted from\n * inside the boundary is indistinguishable, at the supervisor, from one forged by a\n * postinstall script or a git hook -- the code the boundary exists to contain -- so the\n * human's answer is read by the side that owns the decision, on a terminal borrowed back\n * from the child for the duration.\n *\n * Returning undefined when there is no terminal is the mechanism, not a fallback. The\n * proxy's own deny-without-asking path then runs unchanged, so ADR 0005's headless, print,\n * JSON, and RPC behaviour holds by construction rather than by a mode check that a later\n * change could forget to make.\n */\nexport interface TerminalPromptOptions {\n\thandoff: TerminalHandoff;\n\tinput?: NodeJS.ReadStream;\n\toutput?: NodeJS.WriteStream;\n}\n\n/**\n * Build a yes/no prompt the supervisor owns, or nothing at all when there is no terminal.\n *\n * Both sandbox escalations ask the same shape of question and must fail closed the same\n * way, so they share one implementation. Returning undefined rather than a\n * function-that-says-no is deliberate: the caller's own refuse path then runs untouched,\n * which is how ADR 0005's headless behaviour stays correct without a mode check.\n */\nfunction createTerminalPrompt(\n\toptions: TerminalPromptOptions,\n\tcompose: (subject: string) => { question: string; granted: string; refused: string },\n): ((subject: string) => Promise<boolean>) | undefined {\n\tconst input = options.input ?? process.stdin;\n\tconst output = options.output ?? process.stdout;\n\tif (!input.isTTY || !output.isTTY) return undefined;\n\n\tfunction readAnswer(): Promise<string> {\n\t\treturn new Promise((resolve) => {\n\t\t\tlet buffer = \"\";\n\t\t\tconst finish = (value: string) => {\n\t\t\t\tinput.off(\"data\", onData);\n\t\t\t\tinput.off(\"error\", onError);\n\t\t\t\tinput.off(\"end\", onEnd);\n\t\t\t\t// The child had stdin in raw mode and takes it back on resume, so the\n\t\t\t\t// supervisor leaves the stream paused rather than owning it past the answer.\n\t\t\t\tinput.pause();\n\t\t\t\tresolve(value);\n\t\t\t};\n\t\t\tconst onData = (chunk: Buffer | string) => {\n\t\t\t\tbuffer += chunk.toString();\n\t\t\t\tconst newline = buffer.indexOf(\"\\n\");\n\t\t\t\tif (newline >= 0) finish(buffer.slice(0, newline));\n\t\t\t};\n\t\t\t// Anything unreadable declines. Treating an unanswerable prompt as consent would\n\t\t\t// hand the grant to whatever broke the terminal.\n\t\t\tconst onError = () => finish(\"\");\n\t\t\tconst onEnd = () => finish(buffer);\n\t\t\tinput.on(\"data\", onData);\n\t\t\tinput.on(\"error\", onError);\n\t\t\tinput.on(\"end\", onEnd);\n\t\t\tinput.resume();\n\t\t});\n\t}\n\n\treturn (subject: string): Promise<boolean> =>\n\t\toptions.handoff.borrowTerminal(async () => {\n\t\t\tconst text = compose(subject);\n\t\t\toutput.write(`\\n${text.question}`);\n\t\t\tconst answer = (await readAnswer()).trim().toLowerCase();\n\t\t\tconst approved = answer === \"y\" || answer === \"yes\";\n\t\t\toutput.write(`${approved ? text.granted : text.refused}\\n\\n`);\n\t\t\treturn approved;\n\t\t});\n}\n\n/** Approve one refused host for the rest of the session. */\nexport function createHostApprover(\n\toptions: TerminalPromptOptions,\n): ((hostname: string, port: number) => Promise<boolean>) | undefined {\n\tconst prompt = createTerminalPrompt(options, (subject) => ({\n\t\tquestion:\n\t\t\t`The sandboxed session asked to reach ${subject}, which is not on the network allowlist.\\n` +\n\t\t\t`Allow it for this session only? [y/N] `,\n\t\tgranted: `Allowed ${subject} for this session. Add it to \"network.allowedHosts\" in your global settings to make it permanent.`,\n\t\trefused: `Refused ${subject}.`,\n\t}));\n\tif (!prompt) return undefined;\n\treturn (hostname: string, port: number) => prompt(`${hostname}:${port}`);\n}\n\n/**\n * Release the host's own git credential for one host, for the rest of the session.\n *\n * Separate from the host approval above even though both are yes/no, because they are\n * different grants and conflating them would let approving a push also release a token to\n * everything else the session later contacts.\n */\nexport function createCredentialReleaser(\n\toptions: TerminalPromptOptions,\n): ((host: string) => Promise<boolean>) | undefined {\n\treturn createTerminalPrompt(options, (subject) => ({\n\t\tquestion:\n\t\t\t`The sandboxed session asked for your git credential for ${subject}.\\n` +\n\t\t\t`It is read from your host credential store and never written into the workspace.\\n` +\n\t\t\t`Release it for this session only? [y/N] `,\n\t\tgranted: `Released the ${subject} credential for this session.`,\n\t\trefused: `Refused the ${subject} credential.`,\n\t}));\n}\n\n/**\n * Approve one command to run in a separate child with one extra writable root.\n *\n * Unlike a host or a credential, nothing is remembered. A command is not a stable subject\n * to grant against, and a remembered approval would silently cover the next command that\n * happened to name the same root.\n */\nexport function createCommandEscalationApprover(\n\toptions: TerminalPromptOptions,\n): ((request: { command: string; writableRoot: string }) => Promise<boolean>) | undefined {\n\tconst prompt = createTerminalPrompt(options, (subject) => ({\n\t\tquestion:\n\t\t\t`The sandboxed session asked to run a command with write access outside its workspace.\\n\\n` +\n\t\t\t` ${subject}\\n\\n` +\n\t\t\t`It runs once, in a separate sandbox. The session's own boundary does not change.\\n` +\n\t\t\t`Allow it? [y/N] `,\n\t\tgranted: \"Ran the command once with that root writable.\",\n\t\trefused: \"Refused.\",\n\t}));\n\tif (!prompt) return undefined;\n\treturn (request) => prompt(`${request.command}\\n (writable: ${request.writableRoot})`);\n}\n"]}
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Build a yes/no prompt the supervisor owns, or nothing at all when there is no terminal.
|
|
3
|
+
*
|
|
4
|
+
* Both sandbox escalations ask the same shape of question and must fail closed the same
|
|
5
|
+
* way, so they share one implementation. Returning undefined rather than a
|
|
6
|
+
* function-that-says-no is deliberate: the caller's own refuse path then runs untouched,
|
|
7
|
+
* which is how ADR 0005's headless behaviour stays correct without a mode check.
|
|
8
|
+
*/
|
|
9
|
+
function createTerminalPrompt(options, compose) {
|
|
10
|
+
const input = options.input ?? process.stdin;
|
|
11
|
+
const output = options.output ?? process.stdout;
|
|
12
|
+
if (!input.isTTY || !output.isTTY)
|
|
13
|
+
return undefined;
|
|
14
|
+
function readAnswer() {
|
|
15
|
+
return new Promise((resolve) => {
|
|
16
|
+
let buffer = "";
|
|
17
|
+
const finish = (value) => {
|
|
18
|
+
input.off("data", onData);
|
|
19
|
+
input.off("error", onError);
|
|
20
|
+
input.off("end", onEnd);
|
|
21
|
+
// The child had stdin in raw mode and takes it back on resume, so the
|
|
22
|
+
// supervisor leaves the stream paused rather than owning it past the answer.
|
|
23
|
+
input.pause();
|
|
24
|
+
resolve(value);
|
|
25
|
+
};
|
|
26
|
+
const onData = (chunk) => {
|
|
27
|
+
buffer += chunk.toString();
|
|
28
|
+
const newline = buffer.indexOf("\n");
|
|
29
|
+
if (newline >= 0)
|
|
30
|
+
finish(buffer.slice(0, newline));
|
|
31
|
+
};
|
|
32
|
+
// Anything unreadable declines. Treating an unanswerable prompt as consent would
|
|
33
|
+
// hand the grant to whatever broke the terminal.
|
|
34
|
+
const onError = () => finish("");
|
|
35
|
+
const onEnd = () => finish(buffer);
|
|
36
|
+
input.on("data", onData);
|
|
37
|
+
input.on("error", onError);
|
|
38
|
+
input.on("end", onEnd);
|
|
39
|
+
input.resume();
|
|
40
|
+
});
|
|
41
|
+
}
|
|
42
|
+
return (subject) => options.handoff.borrowTerminal(async () => {
|
|
43
|
+
const text = compose(subject);
|
|
44
|
+
output.write(`\n${text.question}`);
|
|
45
|
+
const answer = (await readAnswer()).trim().toLowerCase();
|
|
46
|
+
const approved = answer === "y" || answer === "yes";
|
|
47
|
+
output.write(`${approved ? text.granted : text.refused}\n\n`);
|
|
48
|
+
return approved;
|
|
49
|
+
});
|
|
50
|
+
}
|
|
51
|
+
/** Approve one refused host for the rest of the session. */
|
|
52
|
+
export function createHostApprover(options) {
|
|
53
|
+
const prompt = createTerminalPrompt(options, (subject) => ({
|
|
54
|
+
question: `The sandboxed session asked to reach ${subject}, which is not on the network allowlist.\n` +
|
|
55
|
+
`Allow it for this session only? [y/N] `,
|
|
56
|
+
granted: `Allowed ${subject} for this session. Add it to "network.allowedHosts" in your global settings to make it permanent.`,
|
|
57
|
+
refused: `Refused ${subject}.`,
|
|
58
|
+
}));
|
|
59
|
+
if (!prompt)
|
|
60
|
+
return undefined;
|
|
61
|
+
return (hostname, port) => prompt(`${hostname}:${port}`);
|
|
62
|
+
}
|
|
63
|
+
/**
|
|
64
|
+
* Release the host's own git credential for one host, for the rest of the session.
|
|
65
|
+
*
|
|
66
|
+
* Separate from the host approval above even though both are yes/no, because they are
|
|
67
|
+
* different grants and conflating them would let approving a push also release a token to
|
|
68
|
+
* everything else the session later contacts.
|
|
69
|
+
*/
|
|
70
|
+
export function createCredentialReleaser(options) {
|
|
71
|
+
return createTerminalPrompt(options, (subject) => ({
|
|
72
|
+
question: `The sandboxed session asked for your git credential for ${subject}.\n` +
|
|
73
|
+
`It is read from your host credential store and never written into the workspace.\n` +
|
|
74
|
+
`Release it for this session only? [y/N] `,
|
|
75
|
+
granted: `Released the ${subject} credential for this session.`,
|
|
76
|
+
refused: `Refused the ${subject} credential.`,
|
|
77
|
+
}));
|
|
78
|
+
}
|
|
79
|
+
/**
|
|
80
|
+
* Approve one command to run in a separate child with one extra writable root.
|
|
81
|
+
*
|
|
82
|
+
* Unlike a host or a credential, nothing is remembered. A command is not a stable subject
|
|
83
|
+
* to grant against, and a remembered approval would silently cover the next command that
|
|
84
|
+
* happened to name the same root.
|
|
85
|
+
*/
|
|
86
|
+
export function createCommandEscalationApprover(options) {
|
|
87
|
+
const prompt = createTerminalPrompt(options, (subject) => ({
|
|
88
|
+
question: `The sandboxed session asked to run a command with write access outside its workspace.\n\n` +
|
|
89
|
+
` ${subject}\n\n` +
|
|
90
|
+
`It runs once, in a separate sandbox. The session's own boundary does not change.\n` +
|
|
91
|
+
`Allow it? [y/N] `,
|
|
92
|
+
granted: "Ran the command once with that root writable.",
|
|
93
|
+
refused: "Refused.",
|
|
94
|
+
}));
|
|
95
|
+
if (!prompt)
|
|
96
|
+
return undefined;
|
|
97
|
+
return (request) => prompt(`${request.command}\n (writable: ${request.writableRoot})`);
|
|
98
|
+
}
|
|
99
|
+
//# sourceMappingURL=host-approval.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"host-approval.js","sourceRoot":"","sources":["../../../src/core/sandbox/host-approval.ts"],"names":[],"mappings":"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","sourcesContent":["import type { TerminalHandoff } from \"./terminal-handoff.ts\";\n\n/**\n * The supervisor's own approval prompt for one refused host.\n *\n * ADR 0023 puts the decision here rather than in the child. An approval asserted from\n * inside the boundary is indistinguishable, at the supervisor, from one forged by a\n * postinstall script or a git hook -- the code the boundary exists to contain -- so the\n * human's answer is read by the side that owns the decision, on a terminal borrowed back\n * from the child for the duration.\n *\n * Returning undefined when there is no terminal is the mechanism, not a fallback. The\n * proxy's own deny-without-asking path then runs unchanged, so ADR 0005's headless, print,\n * JSON, and RPC behaviour holds by construction rather than by a mode check that a later\n * change could forget to make.\n */\nexport interface TerminalPromptOptions {\n\thandoff: TerminalHandoff;\n\tinput?: NodeJS.ReadStream;\n\toutput?: NodeJS.WriteStream;\n}\n\n/**\n * Build a yes/no prompt the supervisor owns, or nothing at all when there is no terminal.\n *\n * Both sandbox escalations ask the same shape of question and must fail closed the same\n * way, so they share one implementation. Returning undefined rather than a\n * function-that-says-no is deliberate: the caller's own refuse path then runs untouched,\n * which is how ADR 0005's headless behaviour stays correct without a mode check.\n */\nfunction createTerminalPrompt(\n\toptions: TerminalPromptOptions,\n\tcompose: (subject: string) => { question: string; granted: string; refused: string },\n): ((subject: string) => Promise<boolean>) | undefined {\n\tconst input = options.input ?? process.stdin;\n\tconst output = options.output ?? process.stdout;\n\tif (!input.isTTY || !output.isTTY) return undefined;\n\n\tfunction readAnswer(): Promise<string> {\n\t\treturn new Promise((resolve) => {\n\t\t\tlet buffer = \"\";\n\t\t\tconst finish = (value: string) => {\n\t\t\t\tinput.off(\"data\", onData);\n\t\t\t\tinput.off(\"error\", onError);\n\t\t\t\tinput.off(\"end\", onEnd);\n\t\t\t\t// The child had stdin in raw mode and takes it back on resume, so the\n\t\t\t\t// supervisor leaves the stream paused rather than owning it past the answer.\n\t\t\t\tinput.pause();\n\t\t\t\tresolve(value);\n\t\t\t};\n\t\t\tconst onData = (chunk: Buffer | string) => {\n\t\t\t\tbuffer += chunk.toString();\n\t\t\t\tconst newline = buffer.indexOf(\"\\n\");\n\t\t\t\tif (newline >= 0) finish(buffer.slice(0, newline));\n\t\t\t};\n\t\t\t// Anything unreadable declines. Treating an unanswerable prompt as consent would\n\t\t\t// hand the grant to whatever broke the terminal.\n\t\t\tconst onError = () => finish(\"\");\n\t\t\tconst onEnd = () => finish(buffer);\n\t\t\tinput.on(\"data\", onData);\n\t\t\tinput.on(\"error\", onError);\n\t\t\tinput.on(\"end\", onEnd);\n\t\t\tinput.resume();\n\t\t});\n\t}\n\n\treturn (subject: string): Promise<boolean> =>\n\t\toptions.handoff.borrowTerminal(async () => {\n\t\t\tconst text = compose(subject);\n\t\t\toutput.write(`\\n${text.question}`);\n\t\t\tconst answer = (await readAnswer()).trim().toLowerCase();\n\t\t\tconst approved = answer === \"y\" || answer === \"yes\";\n\t\t\toutput.write(`${approved ? text.granted : text.refused}\\n\\n`);\n\t\t\treturn approved;\n\t\t});\n}\n\n/** Approve one refused host for the rest of the session. */\nexport function createHostApprover(\n\toptions: TerminalPromptOptions,\n): ((hostname: string, port: number) => Promise<boolean>) | undefined {\n\tconst prompt = createTerminalPrompt(options, (subject) => ({\n\t\tquestion:\n\t\t\t`The sandboxed session asked to reach ${subject}, which is not on the network allowlist.\\n` +\n\t\t\t`Allow it for this session only? [y/N] `,\n\t\tgranted: `Allowed ${subject} for this session. Add it to \"network.allowedHosts\" in your global settings to make it permanent.`,\n\t\trefused: `Refused ${subject}.`,\n\t}));\n\tif (!prompt) return undefined;\n\treturn (hostname: string, port: number) => prompt(`${hostname}:${port}`);\n}\n\n/**\n * Release the host's own git credential for one host, for the rest of the session.\n *\n * Separate from the host approval above even though both are yes/no, because they are\n * different grants and conflating them would let approving a push also release a token to\n * everything else the session later contacts.\n */\nexport function createCredentialReleaser(\n\toptions: TerminalPromptOptions,\n): ((host: string) => Promise<boolean>) | undefined {\n\treturn createTerminalPrompt(options, (subject) => ({\n\t\tquestion:\n\t\t\t`The sandboxed session asked for your git credential for ${subject}.\\n` +\n\t\t\t`It is read from your host credential store and never written into the workspace.\\n` +\n\t\t\t`Release it for this session only? [y/N] `,\n\t\tgranted: `Released the ${subject} credential for this session.`,\n\t\trefused: `Refused the ${subject} credential.`,\n\t}));\n}\n\n/**\n * Approve one command to run in a separate child with one extra writable root.\n *\n * Unlike a host or a credential, nothing is remembered. A command is not a stable subject\n * to grant against, and a remembered approval would silently cover the next command that\n * happened to name the same root.\n */\nexport function createCommandEscalationApprover(\n\toptions: TerminalPromptOptions,\n): ((request: { command: string; writableRoot: string }) => Promise<boolean>) | undefined {\n\tconst prompt = createTerminalPrompt(options, (subject) => ({\n\t\tquestion:\n\t\t\t`The sandboxed session asked to run a command with write access outside its workspace.\\n\\n` +\n\t\t\t` ${subject}\\n\\n` +\n\t\t\t`It runs once, in a separate sandbox. The session's own boundary does not change.\\n` +\n\t\t\t`Allow it? [y/N] `,\n\t\tgranted: \"Ran the command once with that root writable.\",\n\t\trefused: \"Refused.\",\n\t}));\n\tif (!prompt) return undefined;\n\treturn (request) => prompt(`${request.command}\\n (writable: ${request.writableRoot})`);\n}\n"]}
|
|
@@ -1,4 +1,6 @@
|
|
|
1
1
|
import { spawn } from "node:child_process";
|
|
2
|
+
import { type CommandEscalationRequest } from "./rpc/command-proxy.ts";
|
|
3
|
+
import { fillHostGitCredential } from "./rpc/git-credential-helper.ts";
|
|
2
4
|
import type { SandboxBackend } from "./supervisor.ts";
|
|
3
5
|
import type { SandboxViolationStore } from "./violations.ts";
|
|
4
6
|
export interface LinuxSandboxBackendOptions {
|
|
@@ -8,6 +10,18 @@ export interface LinuxSandboxBackendOptions {
|
|
|
8
10
|
violationStore?: SandboxViolationStore;
|
|
9
11
|
/** Injectable only to prove crash cleanup in tests; production spawns `bwrap` directly. */
|
|
10
12
|
spawnChild?: typeof spawn;
|
|
13
|
+
/**
|
|
14
|
+
* Injectable only for tests, which have no terminal and would otherwise be unable to
|
|
15
|
+
* exercise a released credential at all. Production builds the releaser from the
|
|
16
|
+
* terminal handoff, and its absence there is what makes a headless session refuse.
|
|
17
|
+
*/
|
|
18
|
+
requestGitCredentialRelease?: (host: string) => Promise<boolean>;
|
|
19
|
+
/** Injectable only for tests; production resolves against the real host store. */
|
|
20
|
+
fillGitCredential?: typeof fillHostGitCredential;
|
|
21
|
+
/** Injectable only for tests, which have no terminal to approve an escalation at. */
|
|
22
|
+
requestCommandEscalation?: (request: CommandEscalationRequest) => Promise<boolean>;
|
|
23
|
+
/** Retained for source compatibility; launch state is now required on each launch. */
|
|
24
|
+
supervisorStateDirectory?: string;
|
|
11
25
|
}
|
|
12
26
|
/**
|
|
13
27
|
* Where the supervisor's proxy socket lives on each side of the boundary.
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"linux-backend.d.ts","sourceRoot":"","sources":["../../../src/core/sandbox/linux-backend.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,KAAK,EAAa,MAAM,oBAAoB,CAAC;AAMtD,OAAO,KAAK,EAAE,cAAc,EAAiB,MAAM,iBAAiB,CAAC;AAErE,OAAO,KAAK,EAAE,qBAAqB,EAAE,MAAM,iBAAiB,CAAC;AAK7D,MAAM,WAAW,0BAA0B;IAC1C,iFAAiF;IACjF,QAAQ,CAAC,EAAE,MAAM,CAAC,QAAQ,CAAC;IAC3B,aAAa,CAAC,EAAE,CAAC,OAAO,EAAE,MAAM,KAAK,OAAO,CAAC;IAC7C,cAAc,CAAC,EAAE,qBAAqB,CAAC;IACvC,2FAA2F;IAC3F,UAAU,CAAC,EAAE,OAAO,KAAK,CAAC;CAC1B;AA6BD;;;;;;;;;;;GAWG;AACH,wBAAgB,uBAAuB,CAAC,kBAAkB,GAAE,MAAiB,GAAG;IAC/E,cAAc,EAAE,MAAM,CAAC;IACvB,eAAe,EAAE,MAAM,CAAC;CACxB,CAKA;AAmBD;;;;GAIG;AACH,wBAAgB,yBAAyB,CAAC,OAAO,CAAC,EAAE,0BAA0B,GAAG,cAAc,CA4L9F","sourcesContent":["import { spawn, spawnSync } from \"node:child_process\";\nimport { randomBytes } from \"node:crypto\";\nimport { closeSync, mkdirSync, openSync, rmSync, writeFileSync } from \"node:fs\";\nimport { tmpdir } from \"node:os\";\nimport { dirname, join, resolve } from \"node:path\";\nimport { createSandboxNetworkProxy, type SandboxNetworkProxy } from \"./network-proxy.ts\";\nimport type { SandboxBackend, SandboxLaunch } from \"./supervisor.ts\";\nimport { publishTerminalSize, TERMINAL_SIZE_PATH_VARIABLE } from \"./terminal-size.ts\";\nimport type { SandboxViolationStore } from \"./violations.ts\";\n\n/** AF_UNIX `sun_path` is 108 bytes on Linux, including the terminating NUL. */\nconst SUN_PATH_LIMIT = 108;\n\nexport interface LinuxSandboxBackendOptions {\n\t/** Injectable only for preflight tests; production uses the running platform. */\n\tplatform?: NodeJS.Platform;\n\tcommandExists?: (command: string) => boolean;\n\tviolationStore?: SandboxViolationStore;\n\t/** Injectable only to prove crash cleanup in tests; production spawns `bwrap` directly. */\n\tspawnChild?: typeof spawn;\n}\n\nfunction commandExists(command: string): boolean {\n\tconst result = spawnSync(command, [\"--version\"], { stdio: \"ignore\", timeout: 1_000 });\n\treturn result.status === 0;\n}\n\nfunction waitForExit(child: ReturnType<typeof spawn>): Promise<number> {\n\treturn new Promise((resolve, reject) => {\n\t\tchild.once(\"error\", reject);\n\t\tchild.once(\"exit\", (code) => resolve(code ?? 1));\n\t});\n}\n\nfunction readOnlyMountArguments(path: string, kind: \"directory\" | \"file\" = \"directory\", descriptor = 3): string[] {\n\tconst target = resolve(path);\n\tconst directory = dirname(target);\n\tconst ancestors: string[] = [];\n\tlet current = directory;\n\twhile (current !== \"/\" && current !== \"/home\") {\n\t\tancestors.push(current);\n\t\tcurrent = dirname(current);\n\t}\n\tconst parentArguments = ancestors.reverse().flatMap((ancestor) => [\"--dir\", ancestor]);\n\treturn kind === \"file\"\n\t\t? [...parentArguments, \"--tmpfs\", directory, \"--perms\", \"0400\", \"--file\", String(descriptor), target]\n\t\t: [...parentArguments, \"--ro-bind\", directory, directory];\n}\n\n/**\n * Where the supervisor's proxy socket lives on each side of the boundary.\n *\n * AF_UNIX caps `sun_path` at 108 bytes, so the socket cannot live under the workspace:\n * a deep-but-ordinary project directory exhausts that budget and the sandbox fails to\n * start at all with `listen EINVAL`. The host side therefore gets a short, unique path\n * under the system temp directory, and it is bind-mounted into the child.\n *\n * The child-side path has to sit under `/home`. That is the one writable mount at the\n * point the mountpoint is created — the sandbox root is a read-only bind, so bwrap\n * cannot create a mountpoint at `/run`, `/tmp`, or anywhere else on it.\n */\nexport function resolveProxySocketPaths(temporaryDirectory: string = tmpdir()): {\n\thostSocketPath: string;\n\tchildSocketPath: string;\n} {\n\tconst name = `apex-${process.pid}-${randomBytes(4).toString(\"hex\")}.sock`;\n\t// An unusually long TMPDIR would reintroduce the very limit this avoids.\n\tconst base = join(temporaryDirectory, name).length + 1 > SUN_PATH_LIMIT ? \"/tmp\" : temporaryDirectory;\n\treturn { hostSocketPath: join(base, name), childSocketPath: `/home/${name}` };\n}\n\n/**\n * Classify an unsuccessful child from what its stderr actually evidences, or return\n * undefined when nothing suggests the boundary refused anything.\n *\n * A non-zero exit is not a violation. An invalid API key, a failing test command, or a\n * script exiting 1 are the child's own business, and recording them as sandbox\n * violations blames the boundary for failures it had no part in — which is worse than\n * silence, because it sends whoever reads the output looking for a refusal that never\n * happened. Only Linux's own refusal wording earns a violation here; the proxy records\n * network refusals itself, before this fallback is consulted.\n */\nfunction classifySandboxFailure(stderr: string): \"filesystem\" | \"network\" | undefined {\n\tif (/Read-only file system|Permission denied|Operation not permitted/i.test(stderr)) return \"filesystem\";\n\tif (/Network is unreachable|ENETUNREACH|EHOSTUNREACH/i.test(stderr)) return \"network\";\n\treturn undefined;\n}\n\n/**\n * Linux's deliberately small platform adapter. The sandbox starts from a read-only\n * host root and bind-mounts only the canonical workspace as writable. `--unshare-net`\n * removes all direct network interfaces for the child and every descendant.\n */\nexport function createLinuxSandboxBackend(options?: LinuxSandboxBackendOptions): SandboxBackend {\n\tconst platform = options?.platform ?? process.platform;\n\tif (platform !== \"linux\") {\n\t\treturn {\n\t\t\tstatus: { kind: \"unavailable\", reason: \"OS sandbox is supported on Linux only.\" },\n\t\t\tasync launch() {\n\t\t\t\tthrow new Error(\"Linux sandbox backend is unavailable.\");\n\t\t\t},\n\t\t\tasync close() {},\n\t\t};\n\t}\n\tconst hasCommand = options?.commandExists ?? commandExists;\n\tconst violationStore = options?.violationStore;\n\tlet proxy: SandboxNetworkProxy | undefined;\n\n\tif (!hasCommand(\"bwrap\")) {\n\t\treturn {\n\t\t\tstatus: { kind: \"unavailable\", reason: \"Bubblewrap (bwrap) is required for OS sandboxing.\" },\n\t\t\tasync launch() {\n\t\t\t\tthrow new Error(\"Linux sandbox backend is unavailable.\");\n\t\t\t},\n\t\t\tasync close() {},\n\t\t};\n\t}\n\treturn {\n\t\tstatus: { kind: \"enforced\" },\n\t\tasync launch(launch: SandboxLaunch): Promise<number> {\n\t\t\tconst stateDirectory = join(launch.policy.workspace, \".apex-code\", \"sandbox-state\");\n\t\t\tmkdirSync(stateDirectory, { recursive: true });\n\n\t\t\tconst violationCountBeforeLaunch = violationStore?.totalCount ?? 0;\n\n\t\t\tconst { hostSocketPath, childSocketPath } = resolveProxySocketPaths();\n\t\t\tproxy = await createSandboxNetworkProxy({\n\t\t\t\tsocketPath: hostSocketPath,\n\t\t\t\tallowedHosts: launch.policy.allowedHosts,\n\t\t\t\tviolationStore,\n\t\t\t});\n\n\t\t\t// .cjs forces CommonJS regardless of the target workspace's package.json \"type\"\n\t\t\t// field -- a plain .js here would be parsed as ESM under \"type\": \"module\" and\n\t\t\t// crash on `require`, since Node resolves module type from the nearest package.json.\n\t\t\tconst relayScriptPath = join(stateDirectory, \"relay.cjs\");\n\t\t\twriteFileSync(\n\t\t\t\trelayScriptPath,\n\t\t\t\t`\nconst net = require(\"node:net\");\nconst child_process = require(\"node:child_process\");\n\nconst socketPath = process.env.APEX_UDS_PATH;\nconst server = net.createServer((c) => {\n\tconst client = net.connect(socketPath);\n\tc.pipe(client).pipe(c);\n\tc.on(\"error\", () => {});\n\tclient.on(\"error\", () => {});\n});\n\nserver.listen(0, \"127.0.0.1\", () => {\n\tconst port = server.address().port;\n\tconst env = Object.assign({}, process.env, {\n\t\tHTTP_PROXY: \\`http://127.0.0.1:\\${port}\\`,\n\t\tHTTPS_PROXY: \\`http://127.0.0.1:\\${port}\\`\n\t});\n\tconst args = process.argv.slice(2);\n\tconst child = child_process.spawn(args[0], args.slice(1), {\n\t\tstdio: \"inherit\",\n\t\tenv: env,\n\t});\n\tchild.on(\"exit\", (code, signal) => {\n\t\tprocess.exit(code ?? (signal ? 128 : 1));\n\t});\n});\nserver.on(\"error\", (err) => {\n\tconsole.error(\"Relay error:\", err);\n\tprocess.exit(1);\n});\n`.trim(),\n\t\t\t);\n\n\t\t\tconst readOnlyMounts = [process.execPath, launch.command, ...(launch.readOnlyPaths ?? [])].flatMap((path) =>\n\t\t\t\treadOnlyMountArguments(path),\n\t\t\t);\n\t\t\tconst readOnlyFileMounts = (launch.readOnlyFiles ?? []).flatMap((path, index) =>\n\t\t\t\treadOnlyMountArguments(path, \"file\", index + 3),\n\t\t\t);\n\t\t\tconst readOnlyFileDescriptors = (launch.readOnlyFiles ?? []).map((path) => openSync(path, \"r\"));\n\t\t\t// The descriptors above are opened before the child spawns and must be closed\n\t\t\t// on every exit path -- including a spawn/wait rejection -- or a crashed launch\n\t\t\t// leaks an open handle onto a host-owned credential file for the process's life.\n\t\t\t// The workspace is bind-mounted read-write into the sandbox, so a file\n\t\t\t// here is visible at the same absolute path on both sides of it.\n\t\t\tconst terminalSizePath = join(stateDirectory, \"terminal-size\");\n\t\t\tconst stopPublishingTerminalSize = publishTerminalSize(terminalSizePath);\n\t\t\ttry {\n\t\t\t\tconst spawnBwrap = options?.spawnChild ?? spawn;\n\t\t\t\tconst child = spawnBwrap(\n\t\t\t\t\t\"bwrap\",\n\t\t\t\t\t[\n\t\t\t\t\t\t\"--new-session\",\n\t\t\t\t\t\t\"--die-with-parent\",\n\t\t\t\t\t\t\"--unshare-user\",\n\t\t\t\t\t\t\"--unshare-pid\",\n\t\t\t\t\t\t\"--unshare-net\",\n\t\t\t\t\t\t\"--ro-bind\",\n\t\t\t\t\t\t\"/\",\n\t\t\t\t\t\t\"/\",\n\t\t\t\t\t\t\"--tmpfs\",\n\t\t\t\t\t\t\"/home\",\n\t\t\t\t\t\t// Immediately after the /home tmpfs: that is the only writable mount at\n\t\t\t\t\t\t// this point, so it is the only place bwrap can create the mountpoint.\n\t\t\t\t\t\t\"--bind\",\n\t\t\t\t\t\thostSocketPath,\n\t\t\t\t\t\tchildSocketPath,\n\t\t\t\t\t\t// The credential channel socket: same writable-under-/home constraint\n\t\t\t\t\t\t// and same position as the network socket above. Its child-side path\n\t\t\t\t\t\t// is what `APEX_CREDENTIAL_PROXY_PATH` names in the launch environment.\n\t\t\t\t\t\t...(launch.credentialChannel\n\t\t\t\t\t\t\t? [\"--bind\", launch.credentialChannel.hostSocketPath, launch.credentialChannel.childSocketPath]\n\t\t\t\t\t\t\t: []),\n\t\t\t\t\t\t...readOnlyMounts,\n\t\t\t\t\t\t...readOnlyFileMounts,\n\t\t\t\t\t\t\"--bind\",\n\t\t\t\t\t\tlaunch.policy.workspace,\n\t\t\t\t\t\tlaunch.policy.workspace,\n\t\t\t\t\t\t// After the workspace bind: these destinations sit inside it, and an\n\t\t\t\t\t\t// earlier mount would be masked when the workspace is bound over them.\n\t\t\t\t\t\t...(launch.readOnlyBinaries ?? []).flatMap(({ source, destination }) => [\n\t\t\t\t\t\t\t\"--ro-bind\",\n\t\t\t\t\t\t\tsource,\n\t\t\t\t\t\t\tdestination,\n\t\t\t\t\t\t]),\n\t\t\t\t\t\t\"--dev\",\n\t\t\t\t\t\t\"/dev\",\n\t\t\t\t\t\t\"--proc\",\n\t\t\t\t\t\t\"/proc\",\n\t\t\t\t\t\t\"--chdir\",\n\t\t\t\t\t\tlaunch.policy.workspace,\n\t\t\t\t\t\t\"--setenv\",\n\t\t\t\t\t\t\"HOME\",\n\t\t\t\t\t\tlaunch.environment?.HOME ?? stateDirectory,\n\t\t\t\t\t\t\"--setenv\",\n\t\t\t\t\t\t\"TMPDIR\",\n\t\t\t\t\t\tlaunch.environment?.TMPDIR ?? stateDirectory,\n\t\t\t\t\t\t\"--setenv\",\n\t\t\t\t\t\t\"APEX_UDS_PATH\",\n\t\t\t\t\t\tchildSocketPath,\n\t\t\t\t\t\t\"--setenv\",\n\t\t\t\t\t\tTERMINAL_SIZE_PATH_VARIABLE,\n\t\t\t\t\t\tterminalSizePath,\n\t\t\t\t\t\t\"--\",\n\t\t\t\t\t\tprocess.execPath,\n\t\t\t\t\t\trelayScriptPath,\n\t\t\t\t\t\tlaunch.command,\n\t\t\t\t\t\t...launch.args,\n\t\t\t\t\t],\n\t\t\t\t\t{ env: launch.environment, stdio: [\"inherit\", \"inherit\", \"pipe\", ...readOnlyFileDescriptors] },\n\t\t\t\t);\n\t\t\t\tlet stderr = \"\";\n\t\t\t\tchild.stderr?.setEncoding(\"utf8\");\n\t\t\t\tchild.stderr?.on(\"data\", (chunk: string) => {\n\t\t\t\t\tstderr += chunk;\n\t\t\t\t\tprocess.stderr.write(chunk);\n\t\t\t\t});\n\t\t\t\tconst exitCode = await waitForExit(child);\n\t\t\t\tawait proxy?.close();\n\t\t\t\tconst proxyAlreadyRecordedAViolation = (violationStore?.totalCount ?? 0) > violationCountBeforeLaunch;\n\t\t\t\tconst kind = exitCode !== 0 && !proxyAlreadyRecordedAViolation ? classifySandboxFailure(stderr) : undefined;\n\t\t\t\tif (kind) {\n\t\t\t\t\tviolationStore?.add({\n\t\t\t\t\t\tkind,\n\t\t\t\t\t\tcommand: [launch.command, ...launch.args].join(\" \"),\n\t\t\t\t\t\tdetail: stderr.trim(),\n\t\t\t\t\t\ttimestamp: new Date(),\n\t\t\t\t\t});\n\t\t\t\t}\n\t\t\t\treturn exitCode;\n\t\t\t} finally {\n\t\t\t\tstopPublishingTerminalSize();\n\t\t\t\tfor (const descriptor of readOnlyFileDescriptors) closeSync(descriptor);\n\t\t\t\t// The socket now lives outside the workspace, so nothing else will ever clean\n\t\t\t\t// it up; leaving it would litter the temp directory once per launch.\n\t\t\t\trmSync(hostSocketPath, { force: true });\n\t\t\t}\n\t\t},\n\t\tasync close() {\n\t\t\tawait proxy?.close();\n\t\t},\n\t};\n}\n"]}
|
|
1
|
+
{"version":3,"file":"linux-backend.d.ts","sourceRoot":"","sources":["../../../src/core/sandbox/linux-backend.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,KAAK,EAAa,MAAM,oBAAoB,CAAC;AAQtD,OAAO,EAEN,KAAK,wBAAwB,EAI7B,MAAM,wBAAwB,CAAC;AAChC,OAAO,EACN,qBAAqB,EAIrB,MAAM,gCAAgC,CAAC;AAExC,OAAO,KAAK,EAAE,cAAc,EAAiB,MAAM,iBAAiB,CAAC;AAQrE,OAAO,KAAK,EAAE,qBAAqB,EAAE,MAAM,iBAAiB,CAAC;AAK7D,MAAM,WAAW,0BAA0B;IAC1C,iFAAiF;IACjF,QAAQ,CAAC,EAAE,MAAM,CAAC,QAAQ,CAAC;IAC3B,aAAa,CAAC,EAAE,CAAC,OAAO,EAAE,MAAM,KAAK,OAAO,CAAC;IAC7C,cAAc,CAAC,EAAE,qBAAqB,CAAC;IACvC,2FAA2F;IAC3F,UAAU,CAAC,EAAE,OAAO,KAAK,CAAC;IAC1B;;;;OAIG;IACH,2BAA2B,CAAC,EAAE,CAAC,IAAI,EAAE,MAAM,KAAK,OAAO,CAAC,OAAO,CAAC,CAAC;IACjE,kFAAkF;IAClF,iBAAiB,CAAC,EAAE,OAAO,qBAAqB,CAAC;IACjD,qFAAqF;IACrF,wBAAwB,CAAC,EAAE,CAAC,OAAO,EAAE,wBAAwB,KAAK,OAAO,CAAC,OAAO,CAAC,CAAC;IACnF,sFAAsF;IACtF,wBAAwB,CAAC,EAAE,MAAM,CAAC;CAClC;AAcD;;;;;;;;;;;GAWG;AACH,wBAAgB,uBAAuB,CAAC,kBAAkB,GAAE,MAAiB,GAAG;IAC/E,cAAc,EAAE,MAAM,CAAC;IACvB,eAAe,EAAE,MAAM,CAAC;CACxB,CAKA;AAmBD;;;;GAIG;AACH,wBAAgB,yBAAyB,CAAC,OAAO,CAAC,EAAE,0BAA0B,GAAG,cAAc,CA2N9F","sourcesContent":["import { spawn, spawnSync } from \"node:child_process\";\nimport { randomBytes } from \"node:crypto\";\nimport { closeSync, mkdirSync, openSync, rmSync, writeFileSync } from \"node:fs\";\nimport { tmpdir } from \"node:os\";\nimport { join } from \"node:path\";\nimport { buildBwrapArguments, descriptorBackedReadOnlyPaths } from \"./bwrap-arguments.ts\";\nimport { createCommandEscalationApprover, createCredentialReleaser, createHostApprover } from \"./host-approval.ts\";\nimport { createSandboxNetworkProxy, type SandboxNetworkProxy } from \"./network-proxy.ts\";\nimport {\n\tCOMMAND_ESCALATION_SOCKET_VARIABLE,\n\ttype CommandEscalationRequest,\n\ttype CommandEscalationResult,\n\tcreateCommandEscalationProxy,\n\tresolveCommandEscalationChannelPaths,\n} from \"./rpc/command-proxy.ts\";\nimport {\n\tfillHostGitCredential,\n\tGIT_CREDENTIAL_SOCKET_VARIABLE,\n\tresolveGitCredentialChannelPaths,\n\twriteGitCredentialHelper,\n} from \"./rpc/git-credential-helper.ts\";\nimport { createGitCredentialProxy, type GitCredentialProxy } from \"./rpc/git-credential-proxy.ts\";\nimport type { SandboxBackend, SandboxLaunch } from \"./supervisor.ts\";\nimport {\n\tcreateTerminalHandoff,\n\tTERMINAL_HANDOFF_ACK_PATH_VARIABLE,\n\tTERMINAL_HANDOFF_PATH_VARIABLE,\n\ttype TerminalHandoff,\n} from \"./terminal-handoff.ts\";\nimport { publishTerminalSize, TERMINAL_SIZE_PATH_VARIABLE } from \"./terminal-size.ts\";\nimport type { SandboxViolationStore } from \"./violations.ts\";\n\n/** AF_UNIX `sun_path` is 108 bytes on Linux, including the terminating NUL. */\nconst SUN_PATH_LIMIT = 108;\n\nexport interface LinuxSandboxBackendOptions {\n\t/** Injectable only for preflight tests; production uses the running platform. */\n\tplatform?: NodeJS.Platform;\n\tcommandExists?: (command: string) => boolean;\n\tviolationStore?: SandboxViolationStore;\n\t/** Injectable only to prove crash cleanup in tests; production spawns `bwrap` directly. */\n\tspawnChild?: typeof spawn;\n\t/**\n\t * Injectable only for tests, which have no terminal and would otherwise be unable to\n\t * exercise a released credential at all. Production builds the releaser from the\n\t * terminal handoff, and its absence there is what makes a headless session refuse.\n\t */\n\trequestGitCredentialRelease?: (host: string) => Promise<boolean>;\n\t/** Injectable only for tests; production resolves against the real host store. */\n\tfillGitCredential?: typeof fillHostGitCredential;\n\t/** Injectable only for tests, which have no terminal to approve an escalation at. */\n\trequestCommandEscalation?: (request: CommandEscalationRequest) => Promise<boolean>;\n\t/** Retained for source compatibility; launch state is now required on each launch. */\n\tsupervisorStateDirectory?: string;\n}\n\nfunction commandExists(command: string): boolean {\n\tconst result = spawnSync(command, [\"--version\"], { stdio: \"ignore\", timeout: 1_000 });\n\treturn result.status === 0;\n}\n\nfunction waitForExit(child: ReturnType<typeof spawn>): Promise<number> {\n\treturn new Promise((resolve, reject) => {\n\t\tchild.once(\"error\", reject);\n\t\tchild.once(\"exit\", (code) => resolve(code ?? 1));\n\t});\n}\n\n/**\n * Where the supervisor's proxy socket lives on each side of the boundary.\n *\n * AF_UNIX caps `sun_path` at 108 bytes, so the socket cannot live under the workspace:\n * a deep-but-ordinary project directory exhausts that budget and the sandbox fails to\n * start at all with `listen EINVAL`. The host side therefore gets a short, unique path\n * under the system temp directory, and it is bind-mounted into the child.\n *\n * The child-side path has to sit under `/home`. That is the one writable mount at the\n * point the mountpoint is created — the sandbox root is a read-only bind, so bwrap\n * cannot create a mountpoint at `/run`, `/tmp`, or anywhere else on it.\n */\nexport function resolveProxySocketPaths(temporaryDirectory: string = tmpdir()): {\n\thostSocketPath: string;\n\tchildSocketPath: string;\n} {\n\tconst name = `apex-${process.pid}-${randomBytes(4).toString(\"hex\")}.sock`;\n\t// An unusually long TMPDIR would reintroduce the very limit this avoids.\n\tconst base = join(temporaryDirectory, name).length + 1 > SUN_PATH_LIMIT ? \"/tmp\" : temporaryDirectory;\n\treturn { hostSocketPath: join(base, name), childSocketPath: `/home/${name}` };\n}\n\n/**\n * Classify an unsuccessful child from what its stderr actually evidences, or return\n * undefined when nothing suggests the boundary refused anything.\n *\n * A non-zero exit is not a violation. An invalid API key, a failing test command, or a\n * script exiting 1 are the child's own business, and recording them as sandbox\n * violations blames the boundary for failures it had no part in — which is worse than\n * silence, because it sends whoever reads the output looking for a refusal that never\n * happened. Only Linux's own refusal wording earns a violation here; the proxy records\n * network refusals itself, before this fallback is consulted.\n */\nfunction classifySandboxFailure(stderr: string): \"filesystem\" | \"network\" | undefined {\n\tif (/Read-only file system|Permission denied|Operation not permitted/i.test(stderr)) return \"filesystem\";\n\tif (/Network is unreachable|ENETUNREACH|EHOSTUNREACH/i.test(stderr)) return \"network\";\n\treturn undefined;\n}\n\n/**\n * Linux's deliberately small platform adapter. The sandbox starts from a read-only\n * host root and bind-mounts only the canonical workspace as writable. `--unshare-net`\n * removes all direct network interfaces for the child and every descendant.\n */\nexport function createLinuxSandboxBackend(options?: LinuxSandboxBackendOptions): SandboxBackend {\n\tconst platform = options?.platform ?? process.platform;\n\tif (platform !== \"linux\") {\n\t\treturn {\n\t\t\tstatus: { kind: \"unavailable\", reason: \"OS sandbox is supported on Linux only.\" },\n\t\t\tasync launch() {\n\t\t\t\tthrow new Error(\"Linux sandbox backend is unavailable.\");\n\t\t\t},\n\t\t\tasync close() {},\n\t\t};\n\t}\n\tconst hasCommand = options?.commandExists ?? commandExists;\n\tconst violationStore = options?.violationStore;\n\tlet proxy: SandboxNetworkProxy | undefined;\n\tlet handoff: TerminalHandoff | undefined;\n\tlet gitCredentialProxy: GitCredentialProxy | undefined;\n\tlet gitCredentialDirectory: string | undefined;\n\tlet escalationProxy: Awaited<ReturnType<typeof createCommandEscalationProxy>> | undefined;\n\tlet escalationDirectory: string | undefined;\n\tif (!hasCommand(\"bwrap\")) {\n\t\treturn {\n\t\t\tstatus: { kind: \"unavailable\", reason: \"Bubblewrap (bwrap) is required for OS sandboxing.\" },\n\t\t\tasync launch() {\n\t\t\t\tthrow new Error(\"Linux sandbox backend is unavailable.\");\n\t\t\t},\n\t\t\tasync close() {},\n\t\t};\n\t}\n\treturn {\n\t\tstatus: { kind: \"enforced\" },\n\t\tasync launch(launch: SandboxLaunch): Promise<number> {\n\t\t\tconst supervisorStateDirectory = launch.supervisorStateDirectory;\n\t\t\tmkdirSync(supervisorStateDirectory, { recursive: true, mode: 0o700 });\n\t\t\tconst stateDirectory = launch.policy.workspace;\n\t\t\tconst acknowledgementPath = join(launch.policy.workspace, \".apex-code\", \"sandbox-handoff-ack\");\n\t\t\tmkdirSync(join(launch.policy.workspace, \".apex-code\"), { recursive: true });\n\n\t\t\tconst violationCountBeforeLaunch = violationStore?.totalCount ?? 0;\n\n\t\t\tconst { hostSocketPath, childSocketPath } = resolveProxySocketPaths();\n\t\t\thandoff = createTerminalHandoff(supervisorStateDirectory, { acknowledgementPath });\n\t\t\tconst gitCredentialPaths = resolveGitCredentialChannelPaths();\n\t\t\tgitCredentialDirectory = gitCredentialPaths.hostSocketDirectory;\n\t\t\tproxy = await createSandboxNetworkProxy({\n\t\t\t\tsocketPath: hostSocketPath,\n\t\t\t\tallowedHosts: launch.policy.allowedHosts,\n\t\t\t\tviolationStore,\n\t\t\t\t// Undefined without a terminal, which leaves the proxy's own\n\t\t\t\t// deny-without-asking path in place for headless, print, JSON, and RPC.\n\t\t\t\trequestApproval: createHostApprover({ handoff }),\n\t\t\t});\n\t\t\t// After the network proxy, because reachability is its answer to give: the\n\t\t\t// credential channel must never hand out a token for a host this session\n\t\t\t// cannot even open a connection to.\n\t\t\tgitCredentialProxy = await createGitCredentialProxy({\n\t\t\t\tsocketPath: gitCredentialPaths.hostSocketPath,\n\t\t\t\tisHostAllowed: (host) => proxy?.isHostReachable(host) ?? false,\n\t\t\t\trequestRelease: options?.requestGitCredentialRelease ?? createCredentialReleaser({ handoff }),\n\t\t\t\t// The supervisor's own environment, deliberately: this runs outside the\n\t\t\t\t// boundary and must resolve against the real host home, which is exactly\n\t\t\t\t// what the child cannot see.\n\t\t\t\tfillCredential: (request) =>\n\t\t\t\t\t(options?.fillGitCredential ?? fillHostGitCredential)(request, { environment: process.env }),\n\t\t\t\tviolationStore,\n\t\t\t});\n\t\t\twriteGitCredentialHelper(supervisorStateDirectory);\n\n\t\t\tconst escalationPaths = resolveCommandEscalationChannelPaths();\n\t\t\tescalationDirectory = escalationPaths.hostSocketDirectory;\n\n\t\t\t// .cjs forces CommonJS regardless of the target workspace's package.json \"type\"\n\t\t\t// field -- a plain .js here would be parsed as ESM under \"type\": \"module\" and\n\t\t\t// crash on `require`, since Node resolves module type from the nearest package.json.\n\t\t\tconst relayScriptPath = join(supervisorStateDirectory, \"relay.cjs\");\n\t\t\twriteFileSync(\n\t\t\t\trelayScriptPath,\n\t\t\t\t`\nconst net = require(\"node:net\");\nconst child_process = require(\"node:child_process\");\n\nconst socketPath = process.env.APEX_UDS_PATH;\nconst server = net.createServer((c) => {\n\tconst client = net.connect(socketPath);\n\tc.pipe(client).pipe(c);\n\tc.on(\"error\", () => {});\n\tclient.on(\"error\", () => {});\n});\n\nserver.listen(0, \"127.0.0.1\", () => {\n\tconst port = server.address().port;\n\tconst env = Object.assign({}, process.env, {\n\t\tHTTP_PROXY: \\`http://127.0.0.1:\\${port}\\`,\n\t\tHTTPS_PROXY: \\`http://127.0.0.1:\\${port}\\`\n\t});\n\tconst args = process.argv.slice(2);\n\tconst child = child_process.spawn(args[0], args.slice(1), {\n\t\tstdio: \"inherit\",\n\t\tenv: env,\n\t});\n\tchild.on(\"exit\", (code, signal) => {\n\t\tprocess.exit(code ?? (signal ? 128 : 1));\n\t});\n});\nserver.on(\"error\", (err) => {\n\tconsole.error(\"Relay error:\", err);\n\tprocess.exit(1);\n});\n`.trim(),\n\t\t\t);\n\n\t\t\tconst primaryReadOnlyPaths = [\n\t\t\t\tprocess.execPath,\n\t\t\t\tlaunch.command,\n\t\t\t\tsupervisorStateDirectory,\n\t\t\t\t...(launch.readOnlyPaths ?? []),\n\t\t\t];\n\t\t\tconst readOnlyFileDescriptors = [\n\t\t\t\t...descriptorBackedReadOnlyPaths(primaryReadOnlyPaths),\n\t\t\t\t...(launch.readOnlyFiles ?? []),\n\t\t\t].map((path) => openSync(path, \"r\"));\n\t\t\t// The descriptors above are opened before the child spawns and must be closed\n\t\t\t// on every exit path -- including a spawn/wait rejection -- or a crashed launch\n\t\t\t// leaks an open handle onto a host-owned credential file for the process's life.\n\t\t\t// The workspace is bind-mounted read-write into the sandbox, so a file\n\t\t\t// here is visible at the same absolute path on both sides of it.\n\t\t\tconst terminalSizePath = join(supervisorStateDirectory, \"terminal-size\");\n\t\t\tconst stopPublishingTerminalSize = publishTerminalSize(terminalSizePath);\n\n\t\t\t// The second child derives its argv from the same builder as the first, with one\n\t\t\t// extra writable root. Nothing about the original child's namespace changes.\n\t\t\tescalationProxy = await createCommandEscalationProxy({\n\t\t\t\tsocketPath: escalationPaths.hostSocketPath,\n\t\t\t\trequestApproval: options?.requestCommandEscalation ?? createCommandEscalationApprover({ handoff }),\n\t\t\t\tviolationStore,\n\t\t\t\trunEscalated: (request) =>\n\t\t\t\t\trunEscalatedCommand({\n\t\t\t\t\t\trequest,\n\t\t\t\t\t\tlaunch,\n\t\t\t\t\t\tstateDirectory,\n\t\t\t\t\t\tspawnBwrap: options?.spawnChild ?? spawn,\n\t\t\t\t\t}),\n\t\t\t});\n\t\t\ttry {\n\t\t\t\tconst spawnBwrap = options?.spawnChild ?? spawn;\n\t\t\t\tconst child = spawnBwrap(\n\t\t\t\t\t\"bwrap\",\n\t\t\t\t\tbuildBwrapArguments({\n\t\t\t\t\t\tworkspace: launch.policy.workspace,\n\t\t\t\t\t\tadditionalWritableRoots: launch.policy.additionalWritableRoots,\n\t\t\t\t\t\treadOnlyPaths: primaryReadOnlyPaths,\n\t\t\t\t\t\treadOnlyFiles: launch.readOnlyFiles ?? [],\n\t\t\t\t\t\treadOnlyBinaries: launch.readOnlyBinaries ?? [],\n\t\t\t\t\t\tsockets: [\n\t\t\t\t\t\t\t{ hostPath: hostSocketPath, childPath: childSocketPath },\n\t\t\t\t\t\t\t// Its child-side path is what `APEX_CREDENTIAL_PROXY_PATH` names.\n\t\t\t\t\t\t\t...(launch.credentialChannel\n\t\t\t\t\t\t\t\t? [\n\t\t\t\t\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\t\t\t\thostPath: launch.credentialChannel.hostSocketPath,\n\t\t\t\t\t\t\t\t\t\t\tchildPath: launch.credentialChannel.childSocketPath,\n\t\t\t\t\t\t\t\t\t\t},\n\t\t\t\t\t\t\t\t\t]\n\t\t\t\t\t\t\t\t: []),\n\t\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\thostPath: gitCredentialPaths.hostSocketPath,\n\t\t\t\t\t\t\t\tchildPath: gitCredentialPaths.childSocketPath,\n\t\t\t\t\t\t\t},\n\t\t\t\t\t\t],\n\t\t\t\t\t\tenvironment: {\n\t\t\t\t\t\t\tHOME: launch.environment?.HOME ?? stateDirectory,\n\t\t\t\t\t\t\tTMPDIR: launch.environment?.TMPDIR ?? stateDirectory,\n\t\t\t\t\t\t\tAPEX_UDS_PATH: childSocketPath,\n\t\t\t\t\t\t\t[TERMINAL_SIZE_PATH_VARIABLE]: terminalSizePath,\n\t\t\t\t\t\t\t[TERMINAL_HANDOFF_PATH_VARIABLE]: supervisorStateDirectory,\n\t\t\t\t\t\t\t[TERMINAL_HANDOFF_ACK_PATH_VARIABLE]: acknowledgementPath,\n\t\t\t\t\t\t\t[GIT_CREDENTIAL_SOCKET_VARIABLE]: gitCredentialPaths.childSocketPath,\n\t\t\t\t\t\t\t[COMMAND_ESCALATION_SOCKET_VARIABLE]: escalationPaths.childSocketPath,\n\t\t\t\t\t\t},\n\t\t\t\t\t\tcommand: process.execPath,\n\t\t\t\t\t\targs: [relayScriptPath, launch.command, ...launch.args],\n\t\t\t\t\t}),\n\t\t\t\t\t{ env: launch.environment, stdio: [\"inherit\", \"inherit\", \"pipe\", ...readOnlyFileDescriptors] },\n\t\t\t\t);\n\t\t\t\tlet stderr = \"\";\n\t\t\t\tchild.stderr?.setEncoding(\"utf8\");\n\t\t\t\tchild.stderr?.on(\"data\", (chunk: string) => {\n\t\t\t\t\tstderr += chunk;\n\t\t\t\t\tprocess.stderr.write(chunk);\n\t\t\t\t});\n\t\t\t\tconst exitCode = await waitForExit(child);\n\t\t\t\tawait proxy?.close();\n\t\t\t\tconst proxyAlreadyRecordedAViolation = (violationStore?.totalCount ?? 0) > violationCountBeforeLaunch;\n\t\t\t\tconst kind = exitCode !== 0 && !proxyAlreadyRecordedAViolation ? classifySandboxFailure(stderr) : undefined;\n\t\t\t\tif (kind) {\n\t\t\t\t\tviolationStore?.add({\n\t\t\t\t\t\tkind,\n\t\t\t\t\t\tcommand: [launch.command, ...launch.args].join(\" \"),\n\t\t\t\t\t\tdetail: stderr.trim(),\n\t\t\t\t\t\ttimestamp: new Date(),\n\t\t\t\t\t});\n\t\t\t\t}\n\t\t\t\treturn exitCode;\n\t\t\t} finally {\n\t\t\t\tstopPublishingTerminalSize();\n\t\t\t\tfor (const descriptor of readOnlyFileDescriptors) closeSync(descriptor);\n\t\t\t\t// The socket now lives outside the workspace, so nothing else will ever clean\n\t\t\t\t// it up; leaving it would litter the temp directory once per launch.\n\t\t\t\trmSync(hostSocketPath, { force: true });\n\t\t\t}\n\t\t},\n\t\tasync close() {\n\t\t\thandoff?.stop();\n\t\t\tawait escalationProxy?.close();\n\t\t\tif (escalationDirectory) rmSync(escalationDirectory, { force: true, recursive: true });\n\t\t\tawait gitCredentialProxy?.close();\n\t\t\tif (gitCredentialDirectory) rmSync(gitCredentialDirectory, { force: true, recursive: true });\n\t\t\tawait proxy?.close();\n\t\t},\n\t};\n}\n\n/**\n * Start one approved command in its own child, with one extra writable root.\n *\n * Deliberately minimal compared with the session child: no network relay, no credential\n * channels, no terminal handoff. An escalated command is a single shell invocation the\n * human just read and approved, not a session, and every channel omitted here is one it\n * cannot reach. Its stdio is captured rather than inherited so the session that asked\n * receives the output instead of the escalated process writing over the TUI.\n */\nasync function runEscalatedCommand(options: {\n\trequest: CommandEscalationRequest;\n\tlaunch: SandboxLaunch;\n\tstateDirectory: string;\n\tspawnBwrap: typeof spawn;\n}): Promise<CommandEscalationResult> {\n\tconst { request, launch, stateDirectory } = options;\n\tconst escalatedReadOnlyPaths = launch.readOnlyPaths ?? [];\n\tconst readOnlyFileDescriptors = [\n\t\t...descriptorBackedReadOnlyPaths(escalatedReadOnlyPaths),\n\t\t...(launch.readOnlyFiles ?? []),\n\t].map((path) => openSync(path, \"r\"));\n\tconst child = options.spawnBwrap(\n\t\t\"bwrap\",\n\t\tbuildBwrapArguments({\n\t\t\tworkspace: launch.policy.workspace,\n\t\t\tadditionalWritableRoots: [...launch.policy.additionalWritableRoots, request.writableRoot],\n\t\t\treadOnlyPaths: escalatedReadOnlyPaths,\n\t\t\treadOnlyFiles: launch.readOnlyFiles ?? [],\n\t\t\treadOnlyBinaries: launch.readOnlyBinaries ?? [],\n\t\t\tsockets: [],\n\t\t\tenvironment: {\n\t\t\t\tHOME: launch.environment?.HOME ?? stateDirectory,\n\t\t\t\tTMPDIR: launch.environment?.TMPDIR ?? stateDirectory,\n\t\t\t},\n\t\t\tcommand: \"/bin/sh\",\n\t\t\targs: [\"-c\", request.command],\n\t\t}),\n\t\t{ stdio: [\"ignore\", \"pipe\", \"pipe\", ...readOnlyFileDescriptors] },\n\t);\n\tlet stdout = \"\";\n\tlet stderr = \"\";\n\ttry {\n\t\tchild.stdout?.setEncoding(\"utf8\");\n\t\tchild.stdout?.on(\"data\", (chunk: string) => {\n\t\t\tstdout += chunk;\n\t\t});\n\t\tchild.stderr?.setEncoding(\"utf8\");\n\t\tchild.stderr?.on(\"data\", (chunk: string) => {\n\t\t\tstderr += chunk;\n\t\t});\n\t\tconst code = await new Promise<number>((resolveCode) => {\n\t\t\tchild.once(\"error\", () => resolveCode(1));\n\t\t\tchild.once(\"exit\", (exitCode) => resolveCode(exitCode ?? 1));\n\t\t});\n\t\treturn { code, stdout, stderr };\n\t} finally {\n\t\tfor (const descriptor of readOnlyFileDescriptors) closeSync(descriptor);\n\t}\n}\n"]}
|