mirror of
https://github.com/Kilo-Org/kilocode.git
synced 2026-08-28 19:11:03 +08:00
Merge pull request #12049 from Kilo-Org/configure-network-sandbox-settings
feat: promote sandbox configuration
This commit is contained in:
@@ -0,0 +1,7 @@
|
||||
---
|
||||
"kilo-code": patch
|
||||
"@kilocode/cli": minor
|
||||
"@kilocode/sdk": minor
|
||||
---
|
||||
|
||||
Configure sandboxing through first-class sandbox settings, and show its controls in the dedicated Sandboxing page for all supported macOS and Linux users while keeping it disabled by default.
|
||||
@@ -5,6 +5,10 @@ on:
|
||||
pull_request:
|
||||
types: [opened, synchronize, reopened]
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
pull-requests: read
|
||||
|
||||
jobs:
|
||||
check-paths:
|
||||
name: Check changed paths
|
||||
@@ -42,9 +46,10 @@ jobs:
|
||||
- name: Check baseline auto-commit permissions
|
||||
id: autocommit-check
|
||||
env:
|
||||
BOT_PAT: ${{ secrets.BOT_PAT }}
|
||||
MAINTAINER_APP_ID: ${{ secrets.KILO_MAINTAINER_APP_ID }}
|
||||
MAINTAINER_APP_SECRET: ${{ secrets.KILO_MAINTAINER_APP_SECRET }}
|
||||
run: |
|
||||
if [ "${{ steps.fork-check.outputs.is_fork }}" != "true" ] && [ -n "$BOT_PAT" ]; then
|
||||
if [ "${{ steps.fork-check.outputs.is_fork }}" != "true" ] && [ -n "$MAINTAINER_APP_ID" ] && [ -n "$MAINTAINER_APP_SECRET" ]; then
|
||||
echo "can_autocommit=true" >> "$GITHUB_OUTPUT"
|
||||
else
|
||||
echo "can_autocommit=false" >> "$GITHUB_OUTPUT"
|
||||
@@ -63,8 +68,8 @@ jobs:
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
lfs: true
|
||||
# use BOT_PAT only when later baseline pushes are allowed; github.token is read-only on Dependabot PRs.
|
||||
token: ${{ secrets.BOT_PAT }}
|
||||
# Use github.token for LFS access. The maintainer app is used only for generated commits.
|
||||
token: ${{ github.token }}
|
||||
ref: ${{ github.head_ref }}
|
||||
|
||||
- name: Checkout (read-only)
|
||||
@@ -171,25 +176,33 @@ jobs:
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: Commit and push new baselines (if any)
|
||||
- name: Check for baseline updates
|
||||
if: needs.check-paths.outputs.can_autocommit == 'true' && steps.check-baseline-commit.outputs.is_baseline_update != 'true'
|
||||
id: commit-baselines
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.BOT_PAT }}
|
||||
id: baseline-changes
|
||||
run: |
|
||||
git config user.name "github-actions[bot]"
|
||||
git config user.email "github-actions[bot]@users.noreply.github.com"
|
||||
git add packages/kilo-docs/public/img/screenshot-tests/kilo-ui/
|
||||
if git diff --cached --quiet; then
|
||||
echo "No new baselines — nothing to commit."
|
||||
echo "changed=false" >> "$GITHUB_OUTPUT"
|
||||
else
|
||||
git commit -m "chore: update visual regression baselines"
|
||||
git lfs push --all origin
|
||||
git push --no-verify
|
||||
echo "changed=true" >> "$GITHUB_OUTPUT"
|
||||
fi
|
||||
|
||||
- name: Setup Git Committer
|
||||
if: steps.baseline-changes.outputs.changed == 'true'
|
||||
uses: ./.github/actions/setup-git-committer
|
||||
with:
|
||||
kilo-maintainer-app-id: ${{ secrets.KILO_MAINTAINER_APP_ID }}
|
||||
kilo-maintainer-app-secret: ${{ secrets.KILO_MAINTAINER_APP_SECRET }}
|
||||
|
||||
- name: Commit and push new baselines (if any)
|
||||
if: steps.baseline-changes.outputs.changed == 'true'
|
||||
id: commit-baselines
|
||||
run: |
|
||||
git commit -m "chore: update visual regression baselines"
|
||||
git lfs push --all origin
|
||||
git push --no-verify origin "HEAD:${GITHUB_HEAD_REF}"
|
||||
echo "changed=true" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Fail if baselines changed
|
||||
if: needs.check-paths.outputs.can_autocommit == 'true' && steps.commit-baselines.outputs.changed == 'true'
|
||||
run: |
|
||||
@@ -219,8 +232,8 @@ jobs:
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
lfs: true
|
||||
# use BOT_PAT only when later baseline pushes are allowed; github.token is read-only on Dependabot PRs.
|
||||
token: ${{ secrets.BOT_PAT }}
|
||||
# Use github.token for LFS access. The maintainer app is used only for generated commits.
|
||||
token: ${{ github.token }}
|
||||
ref: ${{ github.head_ref }}
|
||||
|
||||
- name: Checkout (read-only)
|
||||
@@ -357,25 +370,33 @@ jobs:
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: Commit and push new baselines (if any)
|
||||
- name: Check for baseline updates
|
||||
if: needs.check-paths.outputs.can_autocommit == 'true' && steps.check-baseline-commit-vscode.outputs.is_baseline_update != 'true'
|
||||
id: commit-baselines-vscode
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.BOT_PAT }}
|
||||
id: baseline-changes-vscode
|
||||
run: |
|
||||
git config user.name "github-actions[bot]"
|
||||
git config user.email "github-actions[bot]@users.noreply.github.com"
|
||||
git add packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/
|
||||
if git diff --cached --quiet; then
|
||||
echo "No new baselines — nothing to commit."
|
||||
echo "changed=false" >> "$GITHUB_OUTPUT"
|
||||
else
|
||||
git commit -m "chore: update kilo-vscode visual regression baselines"
|
||||
git lfs push --all origin
|
||||
git push --no-verify
|
||||
echo "changed=true" >> "$GITHUB_OUTPUT"
|
||||
fi
|
||||
|
||||
- name: Setup Git Committer
|
||||
if: steps.baseline-changes-vscode.outputs.changed == 'true'
|
||||
uses: ./.github/actions/setup-git-committer
|
||||
with:
|
||||
kilo-maintainer-app-id: ${{ secrets.KILO_MAINTAINER_APP_ID }}
|
||||
kilo-maintainer-app-secret: ${{ secrets.KILO_MAINTAINER_APP_SECRET }}
|
||||
|
||||
- name: Commit and push new baselines (if any)
|
||||
if: steps.baseline-changes-vscode.outputs.changed == 'true'
|
||||
id: commit-baselines-vscode
|
||||
run: |
|
||||
git commit -m "chore: update kilo-vscode visual regression baselines"
|
||||
git lfs push --all origin
|
||||
git push --no-verify origin "HEAD:${GITHUB_HEAD_REF}"
|
||||
echo "changed=true" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Fail if baselines changed
|
||||
if: needs.check-paths.outputs.can_autocommit == 'true' && steps.commit-baselines-vscode.outputs.changed == 'true'
|
||||
run: |
|
||||
|
||||
@@ -161,6 +161,12 @@ For **session** export and import, use the CLI commands:
|
||||
{% /tab %}
|
||||
{% /tabs %}
|
||||
|
||||
## Sandbox
|
||||
|
||||
On macOS and Linux, the VS Code extension includes a dedicated **Sandboxing** settings tab. The sandbox is disabled by default. When enabled, it limits agent filesystem writes and can block outbound network access from model-originated tools. Windows users do not see these settings because Windows sandboxing is not supported.
|
||||
|
||||
See [Sandboxing](/docs/getting-started/settings/sandboxing) for setup instructions, the exact filesystem and network boundaries, and platform limitations.
|
||||
|
||||
## Experimental Features
|
||||
|
||||
{% tabs %}
|
||||
@@ -175,7 +181,6 @@ Available experimental settings include:
|
||||
- **Paste summary** - summarize large clipboard pastes before including them
|
||||
- **Batch tool** - allow the agent to batch multiple tool calls in one step
|
||||
- **OpenTelemetry** - enable Kilo telemetry and optional OTLP export when configured
|
||||
- **Sandbox** - confine agent shell commands and file writes to the project and Kilo state directories, with optional outbound network blocking. See [Sandboxing](/docs/getting-started/settings/sandboxing).
|
||||
|
||||
Advanced options not exposed in the UI can be configured via the `experimental` key in `kilo.jsonc`:
|
||||
|
||||
|
||||
@@ -1,76 +1,163 @@
|
||||
---
|
||||
title: "Sandboxing"
|
||||
description: "Confine agent shell commands and file writes with the experimental OS-level sandbox"
|
||||
description: "Understand and configure filesystem write and network restrictions for agent tools"
|
||||
---
|
||||
|
||||
# Sandboxing
|
||||
|
||||
The experimental sandbox runs agent shell commands and file-tool writes inside an OS-level sandbox that restricts filesystem writes to your project and Kilo state directories, and can block outbound network access from model-originated commands. It is an extra guardrail on top of the permission system: even if the agent is allowed to run a command, the operating system will deny writes outside the allowed roots.
|
||||
The sandbox adds an operating-system boundary around agent tools. It limits where tools can write and, by default, blocks outbound network access from model-originated commands. This boundary applies even when a tool passes Kilo's permission checks.
|
||||
|
||||
The sandbox is **disabled by default**. It does not restrict filesystem reads. An agent can still read any file that your user account can read, but it can write only to explicitly allowed locations.
|
||||
|
||||
{% callout type="warning" %}
|
||||
Sandboxing is experimental. Behavior may change between releases, and it is not available on Windows.
|
||||
Sandboxing is not available on Windows. If the macOS or Linux sandbox backend is unavailable, Kilo reports the reason and runs tools without sandbox confinement. The sandbox does not fail closed.
|
||||
{% /callout %}
|
||||
|
||||
## How it works
|
||||
|
||||
When enabled, the agent's shell commands and file-write tools run confined to a small set of writable directories:
|
||||
|
||||
- Your **project directory** (and its worktree, when running in a linked git worktree)
|
||||
- Kilo **state directories**: data, cache, config, state, tmp, bin, log, and repos
|
||||
|
||||
Everything else is denied at the OS level. File **reads are not confined** — the agent can still read anywhere it has permission to. The `.git` directory is always denied for writes, regardless of location.
|
||||
|
||||
When network restriction is on (the default), outbound network access is blocked for:
|
||||
|
||||
- Shell commands originated by the model
|
||||
- First-party HTTP tools (for example web fetch and browser tools)
|
||||
|
||||
The following are **not** affected by the network restriction:
|
||||
|
||||
- **Provider and model inference traffic** — your LLM API calls keep working
|
||||
- **Local MCP servers and plugin hooks** — these run outside the restriction
|
||||
|
||||
## Enable the sandbox
|
||||
|
||||
The sandbox is off by default. Enable it under the `experimental` key in `kilo.jsonc`:
|
||||
In the VS Code extension:
|
||||
|
||||
1. Open Kilo Code Settings using the gear icon ({% codicon name="gear" /%}).
|
||||
2. Select **Sandboxing**.
|
||||
3. Turn on **Sandbox**.
|
||||
4. Keep **Restrict Network Access** on unless the agent's commands need outbound network access.
|
||||
5. Save the settings.
|
||||
|
||||
The **Sandboxing** tab is visible to all macOS and Linux users, including when the sandbox is off. Windows users do not see the tab because no Windows backend is available.
|
||||
|
||||
You can also configure the default in the global `kilo.jsonc` file:
|
||||
|
||||
```json
|
||||
{
|
||||
"experimental": {
|
||||
"sandbox": true,
|
||||
"sandbox_restrict_network": true
|
||||
"sandbox": {
|
||||
"enabled": true,
|
||||
"network": "deny",
|
||||
"writable_paths": ["~/shared-output"]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
| Key | Default | Effect |
|
||||
|---|---|---|
|
||||
| `experimental.sandbox` | `false` | Turn the sandbox on. When `false`, no confinement applies. |
|
||||
| `experimental.sandbox_restrict_network` | `true` | Block outbound network from model-originated commands and HTTP tools. Set to `false` to allow network (filesystem confinement still applies). |
|
||||
| `sandbox.enabled` | `false` | Use sandbox confinement by default for new sessions. |
|
||||
| `sandbox.network` | `"deny"` | Control outbound network access while filesystem confinement is active. Set this to `"allow"` to permit network access without removing filesystem write restrictions. |
|
||||
| `sandbox.writable_paths` | `[]` | Add writable files or directories outside the built-in writable locations. Only global config may set these paths. |
|
||||
|
||||
You can also enable it from the VS Code Settings webview: gear icon ({% codicon name="gear" /%}) → **Experimental** → **Sandbox**. Once the sandbox is on, a dedicated **Sandboxing** tab appears with the **Restrict Network Access** switch for `sandbox_restrict_network`.
|
||||
Project config may tighten sandbox policy by setting `enabled` to `true` or `network` to `"deny"`. It cannot disable a globally enabled sandbox, allow network denied by global config, or add writable paths. This prevents repository-controlled configuration from weakening the user's security boundary.
|
||||
|
||||
## Toggle per session
|
||||
## When to use sandboxing
|
||||
|
||||
Enabling `experimental.sandbox` sets the default for new sessions, but the setting is ephemeral per session and can be flipped without editing config:
|
||||
Use the sandbox when the agent may run unfamiliar commands, install dependencies, execute code from an untrusted repository, or process content that could contain prompt injection. It provides a second boundary if the model makes a mistake or follows malicious instructions embedded in source files, issue text, web pages, or tool output.
|
||||
|
||||
- **VS Code**: a sandbox toggle appears in the prompt input when `experimental.sandbox` is on (not available for cloud sessions). The tooltip shows whether filesystem writes and network are restricted.
|
||||
- **CLI / TUI**: run the `/sandbox` slash command or the **Toggle sandbox** palette command. A `◆ Sandbox on` indicator appears next to the prompt when active.
|
||||
The sandbox can reduce the impact of an unsafe tool call by:
|
||||
|
||||
Toggling is in-memory and scoped to the current session, so it does not persist across restarts. If the OS sandbox backend is unavailable on your platform, the toggle reports the reason and confinement stays off.
|
||||
- Preventing writes outside the workspace and other explicitly writable locations
|
||||
- Keeping sandboxed commands from changing `.git` metadata
|
||||
- Blocking direct outbound connections from sandboxed commands and policy-aware tools when network restriction is on
|
||||
- Applying the same restrictions to child processes, such as package installation and build scripts launched by a shell command
|
||||
|
||||
This can reduce the risk of auto-approving selected routine commands, such as builds and tests, by placing operating-system limits around many of their effects. It does **not** make **Allow Everything** safe. An allowed command can still modify or delete workspace files, alter other writable Kilo directories, consume data it can read, or write unsafe code that runs later outside the sandbox.
|
||||
|
||||
The sandbox does not protect against every result of prompt injection. In particular, it does not prevent the agent from reading accessible files or including their contents in model context. It also cannot confine local MCP servers, plugin hooks, or any integration that runs outside the sandbox boundary.
|
||||
|
||||
{% callout type="warning" %}
|
||||
The network sandbox is not a provider privacy control. Provider and model inference traffic remains available. If Kilo reads a secret and includes it in a prompt, tool result, or conversation context, that content may be sent to the configured model provider even while network restriction is on. Choose providers with data-handling policies appropriate for your work, consider a local model for sensitive projects, and use read permissions to block or prompt for sensitive files. See [Prompt-Training Model Visibility](/docs/getting-started/settings#prompt-training-model-visibility).
|
||||
{% /callout %}
|
||||
|
||||
## Sandboxing and permissions
|
||||
|
||||
Permissions and sandboxing solve different parts of the security problem and work best together.
|
||||
|
||||
| Control | What it decides | Best used for |
|
||||
|---|---|---|
|
||||
| Permissions | Whether Kilo allows, asks about, or denies a matching tool invocation | Prompting for sensitive file reads, blocking specific commands or tools, reviewing consequential actions, and limiting MCP tool or subagent invocation |
|
||||
| Sandbox | What an allowed tool call can change or connect to while it runs | Limiting the impact of model mistakes, prompt injection, malicious dependencies, and unexpected child-process behavior |
|
||||
|
||||
Permissions can ask or deny Kilo tool invocations that read or change data. For example, set `read` or `external_directory` rules to `ask` or `deny` for credentials, personal files, or directories the agent does not need. Kilo's `read` tool also prompts for `.env` and `.env.*` unless you explicitly create a matching sensitive-file rule. See [Agent Permissions](/docs/customize/agent-permissions) for path and command rules.
|
||||
|
||||
Permission rules are tool-specific and do not create a complete file-confidentiality boundary. A `read` denial controls Kilo's file-reading tool, but an allowed `grep` call, shell command, build script, or other process may read the same file through a different path. A child process can also print sensitive content into tool output, which may then become model context. Configure `grep`, `bash`, and other data-accessing tools separately, and avoid running untrusted code when sensitive files remain readable by your operating-system account.
|
||||
|
||||
For a given tool invocation, approving a shell command does not grant writes outside the sandbox, and a path being writable inside the sandbox does not bypass a matching permission rule. Some integration code runs outside this boundary: plugin hooks can run before a tool's internal permission check, and a local MCP server starts as a separate trusted process. MCP permissions control exposed tool invocations, not everything the server process can do during startup or in the background. Enable only local MCP servers and plugins you trust.
|
||||
|
||||
A practical setup for work on unfamiliar or partially trusted code is:
|
||||
|
||||
- Keep `read`, `grep`, and unnecessary external-directory access set to `ask` or `deny` when they may expose sensitive content.
|
||||
- Allow only routine tools and command patterns that you want to run without interruption.
|
||||
- Keep shell approval prompts for commands with important in-workspace effects or commands that can read sensitive data, because the sandbox still allows workspace writes and filesystem reads.
|
||||
- Enable the sandbox and keep network restriction on to reduce write and direct network-exfiltration impact if an approved action behaves unexpectedly.
|
||||
- Add extra writable paths only when a known workflow requires them.
|
||||
|
||||
For a stronger confidentiality boundary, remove sensitive files from the environment or run Kilo under a separate operating-system account, container, or virtual machine that cannot read them. If file contents must not leave your machine, use local inference and disable other integrations that can send data over the network. If remote processing is acceptable, choose a provider with data-handling terms suitable for the data involved.
|
||||
|
||||
Configure these rules in **Settings > Auto Approve** or `kilo.jsonc`. See [Auto-Approving Actions](/docs/getting-started/settings/auto-approving-actions) for the settings UI and default permission behavior.
|
||||
|
||||
## Filesystem restrictions
|
||||
|
||||
When the sandbox is active, agent tools can read files normally. The sandbox restricts writes, including creating, changing, renaming, and deleting files.
|
||||
|
||||
Writes are allowed in:
|
||||
|
||||
- The active workspace or worktree
|
||||
- Kilo's runtime directories listed below
|
||||
- Paths listed in `sandbox.writable_paths`
|
||||
|
||||
| Writable Kilo path | Purpose |
|
||||
|---|---|
|
||||
| `$XDG_DATA_HOME/kilo` (normally `~/.local/share/kilo`) | Session data, logs, and Kilo's managed repository cache under `repos/` |
|
||||
| `$XDG_CACHE_HOME/kilo` (normally `~/.cache/kilo`) | Cached data and downloaded binaries |
|
||||
| `$XDG_CONFIG_HOME/kilo` (normally `~/.config/kilo`) | Configuration and installed plugins |
|
||||
| `$XDG_STATE_HOME/kilo` (normally `~/.local/state/kilo`) | Runtime state |
|
||||
| `$TMPDIR/kilo` | Temporary files; on macOS this is commonly under `/var/folders/.../T/kilo` |
|
||||
|
||||
Writes are denied everywhere else. The following rules still apply inside writable locations:
|
||||
|
||||
- `.git` directories are always read-only to sandboxed tools.
|
||||
- Kilo's stored sandbox policy and preference files are read-only.
|
||||
- A permission approval for a path outside the sandbox does not make that path writable. Add the path to **Additional Writable Paths** if the tool must modify it.
|
||||
- Linked worktree sessions can write to their active worktree, not the primary checkout or sibling worktrees.
|
||||
|
||||
Shell commands and their child processes inherit the same restrictions. Kilo's file tools perform mutations through a sandboxed worker. Writable file handles are unavailable, so a tool that requires an open read-write handle may fail even for an allowed path.
|
||||
|
||||
Because Kilo's config directory is writable, a shell command can change configuration, permissions, plugins, or additional writable paths that affect future tool calls. Direct filesystem access inside trusted integrations is confined only when the integration uses Kilo's sandbox-aware filesystem service. Starting or restarting a process with the background-process tool is unavailable while sandboxing is active.
|
||||
|
||||
{% callout type="info" %}
|
||||
The sandbox is a write boundary, not a privacy boundary. It does not prevent an agent from reading files outside your workspace if your operating-system account can read them.
|
||||
{% /callout %}
|
||||
|
||||
## Network restrictions
|
||||
|
||||
**Restrict Network Access** controls outbound network access independently of filesystem writes. Turning it off leaves the filesystem write restrictions active.
|
||||
|
||||
When network restriction is on, Kilo blocks:
|
||||
|
||||
- Outbound network access from model-originated shell commands and their child processes
|
||||
- Requests from built-in HTTP tools such as web fetch and web search
|
||||
- Remote MCP tool calls and custom or plugin tools that Kilo cannot prove will remain offline
|
||||
- Built-in tools such as codebase search, semantic search, and LSP that may use opaque or indirect network access
|
||||
|
||||
Network restriction does not block:
|
||||
|
||||
- Provider and model inference traffic, so conversations with the selected model continue to work
|
||||
- Local MCP server processes
|
||||
- Plugin hooks that run outside the sandboxed tool execution
|
||||
- Filesystem reads
|
||||
|
||||
This is not a system-wide firewall. It applies to the sandboxed tool execution boundary, not every Kilo, extension, or local process. Proxy environment variables are removed from sandboxed commands while network access is restricted.
|
||||
|
||||
## Session behavior
|
||||
|
||||
The config setting supplies the initial default for new sessions that do not have a saved preference. Use the lock button in the VS Code prompt or `/sandbox` in the CLI to change the current session. Your latest choice is saved as the default for future sessions in that project, takes precedence over the config default, and persists across restarts.
|
||||
|
||||
Each initialized session keeps its sandbox enabled state and network mode. Changing those settings affects new sessions; use the prompt control or `/sandbox` to change an existing session's enabled state. Changes to **Additional Writable Paths** are read when tools run and therefore also apply to existing sandboxed sessions.
|
||||
|
||||
Forked sessions retain the source session's confinement. Subagents inherit the stricter combination of the parent and child settings: sandboxing remains enabled if either requires it, and network remains blocked if either requires blocking.
|
||||
|
||||
Cloud sessions do not expose the local sandbox control because their tools do not run in your local sandbox.
|
||||
|
||||
## Platform support
|
||||
|
||||
| Platform | Backend | Notes |
|
||||
|---|---|---|
|
||||
| macOS | `sandbox-exec` (seatbelt) | Uses the system `/usr/bin/sandbox-exec`. |
|
||||
| Linux | Bubblewrap (`bwrap`) | Uses system `/usr/bin/bwrap`, or a bundled, SHA-256-verified binary. Override the path with the `KILO_BWRAP_PATH` environment variable. The executable is probed at startup to confirm it can create the sandbox. |
|
||||
| Windows | none | The sandbox backend is unavailable on Windows. Enabling the config has no effect. |
|
||||
|
||||
## Limitations
|
||||
|
||||
- **Windows is not supported.**
|
||||
- Local MCP servers and plugin hooks are **not** covered by the network restriction.
|
||||
- File **reads** are not confined — only writes and shell command effects are.
|
||||
- Writable file handles are unavailable while the sandbox is active; writes are performed through a sandboxed worker, so some tools that open files for writing may behave differently.
|
||||
- The sandbox is additive to the permission system, not a replacement. Permission rules still apply first.
|
||||
| macOS | `sandbox-exec` (Seatbelt) | Uses a Seatbelt profile through `/usr/bin/sandbox-exec`. |
|
||||
| Linux | Bubblewrap (`bwrap`) | Uses system `/usr/bin/bwrap` or a bundled, SHA-256-verified binary. `KILO_BWRAP_PATH` can select another binary. Kilo probes filesystem and network namespace support before enabling confinement. Additional writable paths must already exist before Bubblewrap starts. |
|
||||
| Windows | None | Unsupported. The VS Code settings and prompt controls are hidden, and enabling the config has no effect. |
|
||||
|
||||
+2
-2
@@ -1,3 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:ea36a9604fbc773d2312289f46205221e607a927b0d302b1d9a5e6bdc1d80308
|
||||
size 28888
|
||||
oid sha256:3c0693f1ad6eed615e5a49733ef3a3ec58026fac922cb80e2f3720c94755591f
|
||||
size 47933
|
||||
|
||||
@@ -18,7 +18,7 @@ export async function sandboxDefault(preference: SandboxPreference | undefined,
|
||||
const explicit = preference?.explicit()
|
||||
if (explicit !== undefined) return explicit
|
||||
const { data } = await client.config.get({ directory }, { throwOnError: true })
|
||||
return data.experimental?.sandbox === true
|
||||
return data.sandbox?.enabled === true
|
||||
}
|
||||
|
||||
export async function sandboxSessionMetadata(
|
||||
|
||||
@@ -54,7 +54,7 @@ test.describe("settings tab accessibility", () => {
|
||||
await expect(page.getByRole("tabpanel", { name: "Models" })).toBeVisible()
|
||||
})
|
||||
|
||||
test("shows sandboxing controls when the feature flag and experiment are enabled", async ({ page }) => {
|
||||
test("shows sandboxing controls when the platform supports them", async ({ page }) => {
|
||||
await page.setViewportSize({ width: 420, height: 720 })
|
||||
await page.goto(`/iframe.html?id=settings--sandboxing-panel&viewMode=story&globals=${GLOBALS}`, {
|
||||
waitUntil: "load",
|
||||
@@ -64,10 +64,21 @@ test.describe("settings tab accessibility", () => {
|
||||
await expect(tab).toBeVisible()
|
||||
await expect(tab).toHaveAttribute("aria-selected", "true")
|
||||
await expect(page.getByRole("tabpanel", { name: "Sandboxing" })).toBeVisible()
|
||||
const sandbox = page.getByRole("switch", { name: "Sandbox", exact: true })
|
||||
await expect(sandbox).toHaveAccessibleDescription(/restricts writes to the project and Kilo state directories/)
|
||||
await expect(sandbox).not.toBeChecked()
|
||||
const network = page.getByRole("switch", { name: "Restrict Network Access" })
|
||||
await expect(network).toHaveAccessibleDescription(/Local MCP servers and plugin hooks run outside this restriction/)
|
||||
await expect(network).toBeChecked()
|
||||
await page.locator('[data-slot="switch-control"]').click()
|
||||
await expect(network).toBeDisabled()
|
||||
const path = page.getByRole("textbox", { name: "Additional Writable Paths" })
|
||||
await expect(path).toBeDisabled()
|
||||
await expect(page.getByRole("button", { name: "Add" })).toBeDisabled()
|
||||
await page.locator('[data-slot="switch-control"]').nth(0).click()
|
||||
await expect(sandbox).toBeChecked()
|
||||
await expect(network).toBeEnabled()
|
||||
await expect(path).toBeEnabled()
|
||||
await page.locator('[data-slot="switch-control"]').nth(1).click()
|
||||
await expect(network).not.toBeChecked()
|
||||
await expect(page.locator(".settings-save-bar")).toBeVisible()
|
||||
})
|
||||
|
||||
@@ -20,7 +20,7 @@ describe("NewWorktreeDialog sandbox toggle", () => {
|
||||
expect(src).toContain("sandbox: sandboxVisible() ? sandboxOverride() : undefined")
|
||||
expect(src).toContain("const sandboxVisible = () => features().sandboxControls")
|
||||
expect(provider).toContain("await this.fetchAndSendSandboxDefault(message.contextDirectory, message.requestID)")
|
||||
expect(src).not.toContain("createSignal(config().experimental?.sandbox === true)")
|
||||
expect(src).not.toContain("createSignal(config().sandbox?.enabled === true)")
|
||||
expect(src).not.toContain("visible as isSandboxVisible")
|
||||
})
|
||||
})
|
||||
|
||||
@@ -85,7 +85,7 @@ describe("PromptInput sandbox toggle", () => {
|
||||
expect(src).toContain(
|
||||
'const sandboxVisible = () => features().sandboxControls && !session.currentSessionID()?.startsWith("cloud:")',
|
||||
)
|
||||
expect(src).not.toContain("config().experimental?.sandbox === true")
|
||||
expect(src).not.toContain("config().sandbox?.enabled === true")
|
||||
expect(src).toContain("<Show when={sandboxVisible()}>")
|
||||
expect(src).toContain("{ action: toggleSandbox, enabled: () => sandboxVisible() && !sandboxDisabled() }")
|
||||
expect(src).toContain('if (!sandboxVisible()) hidden.add("sandbox")')
|
||||
@@ -121,9 +121,7 @@ describe("PromptInput sandbox toggle", () => {
|
||||
})
|
||||
|
||||
it("explains filesystem and network state without changing the lock icon", () => {
|
||||
expect(src).toContain(
|
||||
"const sandboxNetworkEnabled = () => config().experimental?.sandbox_restrict_network !== false",
|
||||
)
|
||||
expect(src).toContain('const sandboxNetworkEnabled = () => config().sandbox?.network !== "allow"')
|
||||
expect(src).toContain("<SandboxTooltipContent enabled={sandboxEnabled()} network={sandboxNetworkEnabled()} />")
|
||||
expect(src).toContain('tooltipClass="prompt-sandbox-tooltip-content"')
|
||||
expect(button).toContain('<Icon name="lock" size="small" />')
|
||||
|
||||
@@ -14,15 +14,18 @@ afterEach(() => {
|
||||
})
|
||||
|
||||
describe("Sandboxing settings visibility", () => {
|
||||
test("requires both sandbox control availability and the sandbox experiment", () => {
|
||||
expect(visible(features, {})).toBe(false)
|
||||
expect(visible({ ...features, sandboxControls: true }, {})).toBe(false)
|
||||
expect(visible(features, { experimental: { sandbox: true } })).toBe(false)
|
||||
expect(visible({ ...features, sandboxControls: true }, { experimental: { sandbox: false } })).toBe(false)
|
||||
expect(visible({ ...features, sandboxControls: true }, { experimental: { sandbox: true } })).toBe(true)
|
||||
test("depends only on sandbox control availability", () => {
|
||||
expect(visible(features)).toBe(false)
|
||||
expect(visible({ ...features, sandboxControls: true })).toBe(true)
|
||||
})
|
||||
|
||||
test("enables sandbox controls by default outside Windows", () => {
|
||||
test("edits global sandbox config without promoting project policy", async () => {
|
||||
const src = await Bun.file("webview-ui/src/components/settings/SandboxingTab.tsx").text()
|
||||
expect(src).toContain("const { globalConfig, updateGlobalConfig } = useConfig()")
|
||||
expect(src).not.toContain("const { config, updateConfig } = useConfig()")
|
||||
})
|
||||
|
||||
test("shows sandbox controls outside Windows", () => {
|
||||
setPlatform("darwin")
|
||||
expect(configFeatures().sandboxControls).toBe(true)
|
||||
|
||||
|
||||
@@ -526,7 +526,7 @@ export const NewWorktreeDialog: Component<{ onClose: () => void; defaultBaseBran
|
||||
tooltip={
|
||||
<SandboxTooltipContent
|
||||
enabled={sandbox() ?? false}
|
||||
network={config().experimental?.sandbox_restrict_network !== false}
|
||||
network={config().sandbox?.network !== "allow"}
|
||||
/>
|
||||
}
|
||||
tooltipClass="prompt-sandbox-tooltip-content"
|
||||
|
||||
@@ -183,7 +183,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
||||
const sandboxAvailable = () => (sandboxID() ? sandbox()?.available : sandboxDefault()?.available) ?? false
|
||||
const sandboxReason = () => (sandboxID() ? sandbox()?.reason : sandboxDefault()?.reason)
|
||||
const sandboxReady = () => (sandboxID() ? sandbox() !== undefined : sandboxDefault() !== undefined)
|
||||
const sandboxNetworkEnabled = () => config().experimental?.sandbox_restrict_network !== false
|
||||
const sandboxNetworkEnabled = () => config().sandbox?.network !== "allow"
|
||||
const sandboxRequest = (sessionID?: string) => sandboxRequests()[sessionID ?? ""]
|
||||
const sandboxDisabled = () =>
|
||||
!server.isConnected() || !sandboxReady() || !sandboxAvailable() || sandboxRequest(sandboxID()) !== undefined
|
||||
|
||||
@@ -24,7 +24,7 @@ const SHARE_OPTIONS: ShareOption[] = [
|
||||
]
|
||||
|
||||
const ExperimentalTab: Component = () => {
|
||||
const { config, features, updateConfig } = useConfig()
|
||||
const { config, updateConfig } = useConfig()
|
||||
const language = useLanguage()
|
||||
const imageModels = useImageModels()
|
||||
const vscode = useVSCode()
|
||||
@@ -255,7 +255,7 @@ const ExperimentalTab: Component = () => {
|
||||
<SettingsRow
|
||||
title={language.t("settings.experimental.mcpTimeout.title")}
|
||||
description={language.t("settings.experimental.mcpTimeout.description")}
|
||||
last={!features().sandboxControls}
|
||||
last
|
||||
>
|
||||
<TextField
|
||||
value={String(experimental().mcp_timeout ?? 60000)}
|
||||
@@ -267,22 +267,6 @@ const ExperimentalTab: Component = () => {
|
||||
}}
|
||||
/>
|
||||
</SettingsRow>
|
||||
|
||||
<Show when={features().sandboxControls}>
|
||||
<SettingsRow
|
||||
title={language.t("settings.experimental.sandbox.title")}
|
||||
description={language.t("settings.experimental.sandbox.description")}
|
||||
last
|
||||
>
|
||||
<Switch
|
||||
checked={experimental().sandbox ?? false}
|
||||
onChange={(checked) => updateExperimental("sandbox", checked)}
|
||||
hideLabel
|
||||
>
|
||||
{language.t("settings.experimental.sandbox.title")}
|
||||
</Switch>
|
||||
</SettingsRow>
|
||||
</Show>
|
||||
</Card>
|
||||
|
||||
{/* Tool toggles */}
|
||||
|
||||
@@ -8,16 +8,17 @@ import { useConfig } from "../../context/config"
|
||||
import { useLanguage } from "../../context/language"
|
||||
import SettingsRow from "./SettingsRow"
|
||||
|
||||
const description = "sandbox-network-description"
|
||||
const enabledDescription = "sandbox-enabled-description"
|
||||
const networkDescription = "sandbox-network-description"
|
||||
const writablePathsDescription = "sandbox-writable-paths-description"
|
||||
|
||||
const SandboxingTab: Component = () => {
|
||||
const { config, updateConfig } = useConfig()
|
||||
const { globalConfig, updateGlobalConfig } = useConfig()
|
||||
const language = useLanguage()
|
||||
const experimental = createMemo(() => config().experimental ?? {})
|
||||
const sandbox = createMemo(() => globalConfig().sandbox ?? {})
|
||||
const [newPath, setNewPath] = createSignal("")
|
||||
|
||||
const writablePaths = () => experimental().sandbox_writable_paths ?? []
|
||||
const writablePaths = () => sandbox().writable_paths ?? []
|
||||
|
||||
const addPath = () => {
|
||||
const value = newPath().trim()
|
||||
@@ -25,8 +26,8 @@ const SandboxingTab: Component = () => {
|
||||
const current = [...writablePaths()]
|
||||
if (!current.includes(value)) {
|
||||
current.push(value)
|
||||
updateConfig({
|
||||
experimental: { ...experimental(), sandbox_writable_paths: current },
|
||||
updateGlobalConfig({
|
||||
sandbox: { ...sandbox(), writable_paths: current },
|
||||
})
|
||||
}
|
||||
setNewPath("")
|
||||
@@ -35,27 +36,44 @@ const SandboxingTab: Component = () => {
|
||||
const removePath = (index: number) => {
|
||||
const current = [...writablePaths()]
|
||||
current.splice(index, 1)
|
||||
updateConfig({
|
||||
experimental: { ...experimental(), sandbox_writable_paths: current },
|
||||
updateGlobalConfig({
|
||||
sandbox: { ...sandbox(), writable_paths: current },
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<SettingsRow
|
||||
title={language.t("settings.sandboxing.network.title")}
|
||||
description={language.t("settings.sandboxing.network.description")}
|
||||
descriptionId={description}
|
||||
title={language.t("settings.sandboxing.enabled.title")}
|
||||
description={language.t("settings.sandboxing.enabled.description")}
|
||||
descriptionId={enabledDescription}
|
||||
>
|
||||
<Switch
|
||||
checked={experimental().sandbox_restrict_network !== false}
|
||||
inputProps={{ "aria-describedby": description }}
|
||||
checked={sandbox().enabled ?? false}
|
||||
inputProps={{ "aria-describedby": enabledDescription }}
|
||||
onChange={(checked) =>
|
||||
updateConfig({
|
||||
experimental: {
|
||||
...experimental(),
|
||||
sandbox_restrict_network: checked,
|
||||
},
|
||||
updateGlobalConfig({
|
||||
sandbox: { ...sandbox(), enabled: checked },
|
||||
})
|
||||
}
|
||||
hideLabel
|
||||
>
|
||||
{language.t("settings.sandboxing.enabled.title")}
|
||||
</Switch>
|
||||
</SettingsRow>
|
||||
|
||||
<SettingsRow
|
||||
title={language.t("settings.sandboxing.network.title")}
|
||||
description={language.t("settings.sandboxing.network.description")}
|
||||
descriptionId={networkDescription}
|
||||
>
|
||||
<Switch
|
||||
checked={sandbox().network !== "allow"}
|
||||
disabled={sandbox().enabled !== true}
|
||||
inputProps={{ "aria-describedby": networkDescription }}
|
||||
onChange={(checked) =>
|
||||
updateGlobalConfig({
|
||||
sandbox: { ...sandbox(), network: checked ? "deny" : "allow" },
|
||||
})
|
||||
}
|
||||
hideLabel
|
||||
@@ -85,6 +103,7 @@ const SandboxingTab: Component = () => {
|
||||
<div style={{ flex: 1 }}>
|
||||
<TextField
|
||||
value={newPath()}
|
||||
disabled={sandbox().enabled !== true}
|
||||
placeholder="/tmp"
|
||||
onChange={(val) => setNewPath(val)}
|
||||
onKeyDown={(e: KeyboardEvent) => {
|
||||
@@ -94,7 +113,7 @@ const SandboxingTab: Component = () => {
|
||||
label={language.t("settings.sandboxing.writablePaths.title")}
|
||||
/>
|
||||
</div>
|
||||
<Button variant="secondary" onClick={addPath}>
|
||||
<Button variant="secondary" disabled={sandbox().enabled !== true} onClick={addPath}>
|
||||
{language.t("common.add")}
|
||||
</Button>
|
||||
</div>
|
||||
@@ -118,7 +137,13 @@ const SandboxingTab: Component = () => {
|
||||
>
|
||||
{path}
|
||||
</span>
|
||||
<IconButton size="small" variant="ghost" icon="close" onClick={() => removePath(index())} />
|
||||
<IconButton
|
||||
size="small"
|
||||
variant="ghost"
|
||||
icon="close"
|
||||
disabled={sandbox().enabled !== true}
|
||||
onClick={() => removePath(index())}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</For>
|
||||
|
||||
@@ -39,11 +39,11 @@ const Settings: Component<SettingsProps> = (props) => {
|
||||
const server = useServer()
|
||||
const language = useLanguage()
|
||||
const vscode = useVSCode()
|
||||
const { config, loading, isDirty, saving, saveError, saveConfig, discardConfig, features } = useConfig()
|
||||
const { loading, isDirty, saving, saveError, saveConfig, discardConfig, features } = useConfig()
|
||||
const session = useSession()
|
||||
const [active, setActive] = createSignal(props.tab ?? "models")
|
||||
const [errorExpanded, setErrorExpanded] = createSignal(false)
|
||||
const sandboxing = createMemo(() => Sandboxing.visible(features(), config()))
|
||||
const sandboxing = createMemo(() => Sandboxing.visible(features()))
|
||||
|
||||
const busyCount = () => Object.values(session.allStatusMap()).filter((s) => s.type === "busy").length
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { Config, FeatureFlags } from "../../types/messages"
|
||||
import type { FeatureFlags } from "../../types/messages"
|
||||
|
||||
export function visible(features: FeatureFlags, config: Config) {
|
||||
return features.sandboxControls && config.experimental?.sandbox === true
|
||||
export function visible(features: FeatureFlags) {
|
||||
return features.sandboxControls
|
||||
}
|
||||
|
||||
@@ -38,6 +38,7 @@ export const KNOWN_KEYS: ReadonlyArray<string> = [
|
||||
"terminal_command_display",
|
||||
"code_edit_display",
|
||||
"hide_prompt_training_models",
|
||||
"sandbox",
|
||||
"indexing",
|
||||
"experimental",
|
||||
]
|
||||
|
||||
+2
-2
@@ -1561,8 +1561,8 @@ export const dict = {
|
||||
"settings.agentBehaviour.workflows.empty": "لم يتم تهيئة أوامر مخصصة. أضف أوامر إلى opencode.json لرؤيتها هنا.",
|
||||
"settings.agentBehaviour.workflows.detail.description": "الوصف",
|
||||
"settings.agentBehaviour.workflows.detail.template": "القالب",
|
||||
"settings.experimental.sandbox.title": "Sandbox",
|
||||
"settings.experimental.sandbox.description":
|
||||
"settings.sandboxing.enabled.title": "Sandbox",
|
||||
"settings.sandboxing.enabled.description":
|
||||
"تشغيل أوامر shell الخاصة بالوكيل داخل sandbox على مستوى نظام التشغيل يقيّد الكتابة على مجلدات حالة المشروع و Kilo",
|
||||
|
||||
"settings.autoApprove.description":
|
||||
|
||||
+2
-2
@@ -1603,8 +1603,8 @@ export const dict = {
|
||||
"Nenhum comando personalizado configurado. Adicione comandos ao opencode.json para vê-los aqui.",
|
||||
"settings.agentBehaviour.workflows.detail.description": "Descrição",
|
||||
"settings.agentBehaviour.workflows.detail.template": "Modelo",
|
||||
"settings.experimental.sandbox.title": "Sandbox",
|
||||
"settings.experimental.sandbox.description":
|
||||
"settings.sandboxing.enabled.title": "Sandbox",
|
||||
"settings.sandboxing.enabled.description":
|
||||
"Executar os comandos shell do agente dentro de um sandbox a nível de sistema operacional que restringe escritas aos diretórios de estado do projeto e do Kilo",
|
||||
|
||||
"settings.autoApprove.description":
|
||||
|
||||
+2
-2
@@ -1595,8 +1595,8 @@ export const dict = {
|
||||
"Nema konfiguriranih prilagođenih komandi. Dodajte komande u opencode.json da ih vidite ovdje.",
|
||||
"settings.agentBehaviour.workflows.detail.description": "Opis",
|
||||
"settings.agentBehaviour.workflows.detail.template": "Predložak",
|
||||
"settings.experimental.sandbox.title": "Sandbox",
|
||||
"settings.experimental.sandbox.description":
|
||||
"settings.sandboxing.enabled.title": "Sandbox",
|
||||
"settings.sandboxing.enabled.description":
|
||||
"Pokrenite shell komande agenta unutar sandboxa na nivou operativnog sistema koji ograničava pisanje na direktorije stanja projekta i Kilo",
|
||||
|
||||
"settings.autoApprove.description":
|
||||
|
||||
+2
-2
@@ -1588,8 +1588,8 @@ export const dict = {
|
||||
"Ingen brugerdefinerede kommandoer konfigureret. Tilføj kommandoer til opencode.json for at se dem her.",
|
||||
"settings.agentBehaviour.workflows.detail.description": "Beskrivelse",
|
||||
"settings.agentBehaviour.workflows.detail.template": "Skabelon",
|
||||
"settings.experimental.sandbox.title": "Sandbox",
|
||||
"settings.experimental.sandbox.description":
|
||||
"settings.sandboxing.enabled.title": "Sandbox",
|
||||
"settings.sandboxing.enabled.description":
|
||||
"Kør shell-kommandoer for agenten i en sandbox på operativsystemniveau, der begrænser skrivning til projekt- og Kilo-tilstandsmapperne",
|
||||
|
||||
"settings.autoApprove.description":
|
||||
|
||||
@@ -1622,8 +1622,8 @@ export const dict = {
|
||||
"Keine benutzerdefinierten Befehle konfiguriert. Fügen Sie Befehle zu opencode.json hinzu, um sie hier zu sehen.",
|
||||
"settings.agentBehaviour.workflows.detail.description": "Beschreibung",
|
||||
"settings.agentBehaviour.workflows.detail.template": "Vorlage",
|
||||
"settings.experimental.sandbox.title": "Sandbox",
|
||||
"settings.experimental.sandbox.description":
|
||||
"settings.sandboxing.enabled.title": "Sandbox",
|
||||
"settings.sandboxing.enabled.description":
|
||||
"Shell-Befehle des Agenten in einer Sandbox auf Betriebssystemebene ausführen, die Schreibvorgänge auf die Projekt- und Kilo-Statusverzeichnisse beschränkt",
|
||||
|
||||
"settings.autoApprove.description":
|
||||
|
||||
@@ -1425,8 +1425,8 @@ export const dict = {
|
||||
"Enable experimental tools for reading, editing, and executing VS Code notebooks",
|
||||
"settings.experimental.continueOnDeny.title": "Continue on Deny",
|
||||
"settings.experimental.continueOnDeny.description": "Continue the agent loop when a permission is denied",
|
||||
"settings.experimental.sandbox.title": "Sandbox",
|
||||
"settings.experimental.sandbox.description":
|
||||
"settings.sandboxing.enabled.title": "Sandbox",
|
||||
"settings.sandboxing.enabled.description":
|
||||
"Run agent shell commands inside an OS-level sandbox that restricts writes to the project and Kilo state directories",
|
||||
"settings.sandboxing.title": "Sandboxing",
|
||||
"settings.sandboxing.network.title": "Restrict Network Access",
|
||||
|
||||
+2
-2
@@ -1611,8 +1611,8 @@ export const dict = {
|
||||
"No hay comandos personalizados configurados. Añada comandos a opencode.json para verlos aquí.",
|
||||
"settings.agentBehaviour.workflows.detail.description": "Descripción",
|
||||
"settings.agentBehaviour.workflows.detail.template": "Plantilla",
|
||||
"settings.experimental.sandbox.title": "Sandbox",
|
||||
"settings.experimental.sandbox.description":
|
||||
"settings.sandboxing.enabled.title": "Sandbox",
|
||||
"settings.sandboxing.enabled.description":
|
||||
"Ejecutar los comandos de shell del agente dentro de un sandbox a nivel de sistema operativo que restringe las escrituras a los directorios de estado del proyecto y de Kilo",
|
||||
|
||||
"settings.autoApprove.description":
|
||||
|
||||
+2
-2
@@ -1628,8 +1628,8 @@ export const dict = {
|
||||
"Aucune commande personnalisée configurée. Ajoutez des commandes à opencode.json pour les voir ici.",
|
||||
"settings.agentBehaviour.workflows.detail.description": "Description",
|
||||
"settings.agentBehaviour.workflows.detail.template": "Modèle",
|
||||
"settings.experimental.sandbox.title": "Sandbox",
|
||||
"settings.experimental.sandbox.description":
|
||||
"settings.sandboxing.enabled.title": "Sandbox",
|
||||
"settings.sandboxing.enabled.description":
|
||||
"Exécuter les commandes shell de l'agent dans un sandbox au niveau du système d'exploitation qui restreint les écritures aux répertoires d'état du projet et de Kilo",
|
||||
|
||||
"settings.autoApprove.description":
|
||||
|
||||
+2
-2
@@ -1306,8 +1306,8 @@ export const dict = {
|
||||
"Fai clic per limitare le scritture nel file system e l'accesso alla rete.",
|
||||
"prompt.action.sandbox.description.disabledNetworkAllowed":
|
||||
"Fai clic per limitare le scritture nel file system. L'accesso alla rete resta consentito dalle impostazioni della sandbox.",
|
||||
"settings.experimental.sandbox.title": "Sandbox",
|
||||
"settings.experimental.sandbox.description":
|
||||
"settings.sandboxing.enabled.title": "Sandbox",
|
||||
"settings.sandboxing.enabled.description":
|
||||
"Esegui i comandi shell dell'agente all'interno di un sandbox a livello di sistema operativo che limita le scritture alle directory di stato del progetto e di Kilo",
|
||||
|
||||
"settings.agentBehaviour.skillPaths": "Percorsi cartelle skill",
|
||||
|
||||
+2
-2
@@ -1585,8 +1585,8 @@ export const dict = {
|
||||
"カスタムコマンドが設定されていません。opencode.json にコマンドを追加するとここに表示されます。",
|
||||
"settings.agentBehaviour.workflows.detail.description": "説明",
|
||||
"settings.agentBehaviour.workflows.detail.template": "テンプレート",
|
||||
"settings.experimental.sandbox.title": "サンドボックス",
|
||||
"settings.experimental.sandbox.description":
|
||||
"settings.sandboxing.enabled.title": "サンドボックス",
|
||||
"settings.sandboxing.enabled.description":
|
||||
"エージェントのシェルコマンドを、プロジェクトおよびKiloの状態ディレクトリへの書き込みを制限するOSレベルのサンドボックス内で実行",
|
||||
|
||||
"settings.autoApprove.description":
|
||||
|
||||
+2
-2
@@ -1573,8 +1573,8 @@ export const dict = {
|
||||
"구성된 사용자 정의 명령이 없습니다. opencode.json에 명령을 추가하면 여기에 표시됩니다.",
|
||||
"settings.agentBehaviour.workflows.detail.description": "설명",
|
||||
"settings.agentBehaviour.workflows.detail.template": "템플릿",
|
||||
"settings.experimental.sandbox.title": "샌드박스",
|
||||
"settings.experimental.sandbox.description":
|
||||
"settings.sandboxing.enabled.title": "샌드박스",
|
||||
"settings.sandboxing.enabled.description":
|
||||
"에이전트 셸 명령을 프로젝트 및 Kilo 상태 디렉터리에 대한 쓰기를 제한하는 OS 수준의 샌드박스 내에서 실행",
|
||||
|
||||
"settings.autoApprove.description":
|
||||
|
||||
+2
-2
@@ -1472,8 +1472,8 @@ export const dict = {
|
||||
"settings.experimental.remote.inactive": "Inactief",
|
||||
"settings.experimental.remote.hint": "Gebruik /remote in de chat om te schakelen",
|
||||
"settings.experimental.toolToggles": "Tool Schakelaars",
|
||||
"settings.experimental.sandbox.title": "Sandbox",
|
||||
"settings.experimental.sandbox.description":
|
||||
"settings.sandboxing.enabled.title": "Sandbox",
|
||||
"settings.sandboxing.enabled.description":
|
||||
"Shell-opdrachten van de agent uitvoeren in een sandbox op besturingssysteemniveau die schrijfbewerkingen beperkt tot de project- en Kilo-statusmappen",
|
||||
|
||||
"settings.agentBehaviour.defaultAgent.title": "Standaard Agent",
|
||||
|
||||
+2
-2
@@ -1588,8 +1588,8 @@ export const dict = {
|
||||
"Ingen egendefinerte kommandoer konfigurert. Legg til kommandoer i opencode.json for å se dem her.",
|
||||
"settings.agentBehaviour.workflows.detail.description": "Beskrivelse",
|
||||
"settings.agentBehaviour.workflows.detail.template": "Mal",
|
||||
"settings.experimental.sandbox.title": "Sandbox",
|
||||
"settings.experimental.sandbox.description":
|
||||
"settings.sandboxing.enabled.title": "Sandbox",
|
||||
"settings.sandboxing.enabled.description":
|
||||
"Kjør shell-kommandoer for agenten i en sandbox på operativsystemnivå som begrenser skriving til prosjekt- og Kilo-tilstandsmapper",
|
||||
|
||||
"settings.autoApprove.description":
|
||||
|
||||
+2
-2
@@ -1592,8 +1592,8 @@ export const dict = {
|
||||
"Brak skonfigurowanych niestandardowych komend. Dodaj komendy do opencode.json, aby je tu zobaczyć.",
|
||||
"settings.agentBehaviour.workflows.detail.description": "Opis",
|
||||
"settings.agentBehaviour.workflows.detail.template": "Szablon",
|
||||
"settings.experimental.sandbox.title": "Sandbox",
|
||||
"settings.experimental.sandbox.description":
|
||||
"settings.sandboxing.enabled.title": "Sandbox",
|
||||
"settings.sandboxing.enabled.description":
|
||||
"Uruchamiaj polecenia shell agenta w sandboxie na poziomie systemu operacyjnego, który ogranicza zapisy do katalogów stanu projektu i Kilo",
|
||||
|
||||
"settings.autoApprove.description":
|
||||
|
||||
+2
-2
@@ -1593,8 +1593,8 @@ export const dict = {
|
||||
"Пользовательские команды не настроены. Добавьте команды в opencode.json, чтобы увидеть их здесь.",
|
||||
"settings.agentBehaviour.workflows.detail.description": "Описание",
|
||||
"settings.agentBehaviour.workflows.detail.template": "Шаблон",
|
||||
"settings.experimental.sandbox.title": "Песочница",
|
||||
"settings.experimental.sandbox.description":
|
||||
"settings.sandboxing.enabled.title": "Песочница",
|
||||
"settings.sandboxing.enabled.description":
|
||||
"Выполнять команды оболочки агента в песочнице на уровне ОС, которая ограничивает запись в каталоги состояния проекта и Kilo",
|
||||
|
||||
"settings.autoApprove.description":
|
||||
|
||||
+2
-2
@@ -1570,8 +1570,8 @@ export const dict = {
|
||||
"ไม่มีคำสั่งแบบกำหนดเองที่กำหนดค่าไว้ เพิ่มคำสั่งใน opencode.json เพื่อดูที่นี่",
|
||||
"settings.agentBehaviour.workflows.detail.description": "คำอธิบาย",
|
||||
"settings.agentBehaviour.workflows.detail.template": "เทมเพลต",
|
||||
"settings.experimental.sandbox.title": "Sandbox",
|
||||
"settings.experimental.sandbox.description":
|
||||
"settings.sandboxing.enabled.title": "Sandbox",
|
||||
"settings.sandboxing.enabled.description":
|
||||
"เรียกใช้คำสั่ง shell ของ agent ใน sandbox ระดับระบบปฏิบัติการที่จำกัดการเขียนไปยังโฟลเดอร์สถานะของโปรเจ็กต์และ Kilo",
|
||||
|
||||
"settings.autoApprove.description":
|
||||
|
||||
+2
-2
@@ -1462,8 +1462,8 @@ export const dict = {
|
||||
"settings.experimental.remote.inactive": "Pasif",
|
||||
"settings.experimental.remote.hint": "Geçiş yapmak için sohbette /remote kullanın",
|
||||
"settings.experimental.toolToggles": "Araç Açma/Kapatma",
|
||||
"settings.experimental.sandbox.title": "Sandbox",
|
||||
"settings.experimental.sandbox.description":
|
||||
"settings.sandboxing.enabled.title": "Sandbox",
|
||||
"settings.sandboxing.enabled.description":
|
||||
"Agent shell komutlarını, proje ve Kilo durum dizinlerine yazmaları kısıtlanan işletim sistemi düzeyinde bir sandbox içinde çalıştırın",
|
||||
|
||||
"settings.agentBehaviour.defaultAgent.title": "Varsayılan Ajan",
|
||||
|
||||
+2
-2
@@ -1460,8 +1460,8 @@ export const dict = {
|
||||
"settings.experimental.remote.inactive": "Неактивний",
|
||||
"settings.experimental.remote.hint": "Використовуйте /remote у чаті для перемикання",
|
||||
"settings.experimental.toolToggles": "Перемикачі інструментів",
|
||||
"settings.experimental.sandbox.title": "Пісочниця",
|
||||
"settings.experimental.sandbox.description":
|
||||
"settings.sandboxing.enabled.title": "Пісочниця",
|
||||
"settings.sandboxing.enabled.description":
|
||||
"Виконувати команди оболонки агента в пісочниці на рівні ОС, яка обмежує запис до каталогів стану проєкту та Kilo",
|
||||
|
||||
"settings.agentBehaviour.defaultAgent.title": "Агент за замовчуванням",
|
||||
|
||||
+2
-2
@@ -1533,8 +1533,8 @@ export const dict = {
|
||||
"settings.agentBehaviour.workflows.empty": "未配置自定义命令。将命令添加到 opencode.json 即可在此处看到。",
|
||||
"settings.agentBehaviour.workflows.detail.description": "描述",
|
||||
"settings.agentBehaviour.workflows.detail.template": "模板",
|
||||
"settings.experimental.sandbox.title": "沙盒",
|
||||
"settings.experimental.sandbox.description":
|
||||
"settings.sandboxing.enabled.title": "沙盒",
|
||||
"settings.sandboxing.enabled.description":
|
||||
"在操作系统级沙盒中运行代理 shell 命令,将写入限制在项目和 Kilo 状态目录内",
|
||||
|
||||
"settings.autoApprove.description":
|
||||
|
||||
+2
-2
@@ -1499,8 +1499,8 @@ export const dict = {
|
||||
"settings.agentBehaviour.workflows.empty": "未設定自訂命令。將命令新增至 opencode.json 即可在此處看到。",
|
||||
"settings.agentBehaviour.workflows.detail.description": "描述",
|
||||
"settings.agentBehaviour.workflows.detail.template": "範本",
|
||||
"settings.experimental.sandbox.title": "沙盒",
|
||||
"settings.experimental.sandbox.description":
|
||||
"settings.sandboxing.enabled.title": "沙盒",
|
||||
"settings.sandboxing.enabled.description":
|
||||
"在作業系統層級沙盒中執行代理 shell 指令,將寫入限制在專案和 Kilo 狀態目錄內",
|
||||
|
||||
"settings.autoApprove.description":
|
||||
|
||||
@@ -52,12 +52,9 @@ export const SettingsPanel: Story = {
|
||||
}
|
||||
|
||||
export const SandboxingPanel: Story = {
|
||||
name: "Settings — sandboxing network restriction",
|
||||
name: "Settings — sandboxing controls",
|
||||
render: () => (
|
||||
<StoryProviders
|
||||
config={{ experimental: { sandbox: true, sandbox_restrict_network: true } }}
|
||||
features={{ sandboxControls: true }}
|
||||
>
|
||||
<StoryProviders config={{ sandbox: { network: "deny" } }} features={{ sandboxControls: true }}>
|
||||
<div style={{ height: "700px", display: "flex", "flex-direction": "column" }}>
|
||||
<Settings tab="sandboxing" />
|
||||
</div>
|
||||
|
||||
@@ -48,13 +48,16 @@ export interface ExperimentalConfig {
|
||||
primary_tools?: string[]
|
||||
continue_loop_on_deny?: boolean
|
||||
mcp_timeout?: number
|
||||
sandbox?: boolean
|
||||
sandbox_restrict_network?: boolean
|
||||
sandbox_writable_paths?: string[]
|
||||
swe_pruner?: boolean
|
||||
swe_pruner_model?: string
|
||||
}
|
||||
|
||||
export interface SandboxConfig {
|
||||
enabled?: boolean
|
||||
network?: "allow" | "deny"
|
||||
writable_paths?: string[]
|
||||
}
|
||||
|
||||
export interface CommitMessageConfig {
|
||||
prompt?: string
|
||||
}
|
||||
@@ -151,6 +154,7 @@ export interface Config {
|
||||
tools?: Record<string, boolean>
|
||||
auto_collapse_reasoning?: boolean
|
||||
experimental?: ExperimentalConfig
|
||||
sandbox?: SandboxConfig
|
||||
indexing?: IndexingConfig
|
||||
}
|
||||
|
||||
|
||||
@@ -54,6 +54,7 @@ import { primaryPaths } from "../kilocode/primary-worktree"
|
||||
import { Git } from "@/git"
|
||||
import { KilocodeDefaultPlugins } from "@/kilocode/config/default-plugins"
|
||||
import { KilocodeGlobalConfigStamp } from "@/kilocode/config/global-stamp"
|
||||
import { SandboxConfig } from "@/kilocode/sandbox/config"
|
||||
import {
|
||||
IndexingConfig as KiloIndexingConfig,
|
||||
IndexingSchema as KiloIndexingSchema,
|
||||
@@ -250,6 +251,7 @@ export const Info = Schema.Struct({
|
||||
hide_prompt_training_models: Schema.optional(Schema.Boolean).annotate({
|
||||
description: "Hide Kilo Gateway models that may train on your prompts from model listings",
|
||||
}),
|
||||
sandbox: Schema.optional(SandboxConfig.Info),
|
||||
model: Schema.optional(Schema.NullOr(ConfigModelID)).annotate({
|
||||
description: "Model to use in the format of provider/model, eg anthropic/claude-2",
|
||||
}),
|
||||
@@ -416,18 +418,6 @@ export const Info = Schema.Struct({
|
||||
description: "Continue the agent loop when a tool call is denied",
|
||||
}),
|
||||
// kilocode_change start
|
||||
sandbox: Schema.optional(Schema.Boolean).annotate({
|
||||
description:
|
||||
"Run agent tools inside a sandbox that restricts writes to project and Kilo state directories and can restrict outbound network access",
|
||||
}),
|
||||
sandbox_restrict_network: Schema.optional(Schema.Boolean).annotate({
|
||||
description:
|
||||
"Restrict outbound network access for model-originated commands and first-party HTTP tools; local MCP servers and plugin hooks are not covered (default: true)",
|
||||
}),
|
||||
sandbox_writable_paths: Schema.optional(Schema.mutable(Schema.Array(Schema.String))).annotate({
|
||||
description:
|
||||
"Additional filesystem paths the sandbox allows writes to (e.g. ['/tmp', '/var/log']). These are merged with the default writable paths when the sandbox is active.",
|
||||
}),
|
||||
swe_pruner: Schema.optional(Schema.Boolean).annotate({
|
||||
description:
|
||||
"Enable SWE-Pruner: task-aware pruning of large read, grep, and bash tool outputs guided by a focus question provided by the agent (default: false)",
|
||||
@@ -785,10 +775,7 @@ export const layer = Layer.effect(
|
||||
// kilocode_change start
|
||||
const merge = Effect.fnUntraced(function* (source: string, next: Info, kind?: ConfigPlugin.Scope) {
|
||||
const scope = kind ?? (yield* pluginScopeForSource(source))
|
||||
// sandbox_writable_paths is security-sensitive — only global config may set it.
|
||||
// A project kilo.json must not widen the sandbox beyond the user's intent.
|
||||
if (scope === "local") delete next.experimental?.sandbox_writable_paths
|
||||
const scoped = KilocodeConfig.scopeIndexing(next, scope)
|
||||
const scoped = KilocodeConfig.scopeIndexing(SandboxConfig.scope(next, scope), scope)
|
||||
result = mergeConfigConcatArrays(result, scoped)
|
||||
return yield* mergePluginOrigins(source, scoped.plugin, scope)
|
||||
})
|
||||
|
||||
@@ -39,7 +39,7 @@ function View(props: {
|
||||
}) {
|
||||
createEffect(
|
||||
on(
|
||||
() => props.api.state.config.experimental?.sandbox,
|
||||
() => props.api.state.config.sandbox?.enabled,
|
||||
() => void props.load(props.sessionID, true),
|
||||
),
|
||||
)
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
import { Schema } from "effect"
|
||||
|
||||
export namespace SandboxConfig {
|
||||
export const Network = Schema.Literals(["allow", "deny"])
|
||||
export type Network = Schema.Schema.Type<typeof Network>
|
||||
|
||||
export const Info = Schema.Struct({
|
||||
enabled: Schema.optional(
|
||||
Schema.Boolean.annotate({ description: "Enable sandbox confinement for new sessions (default: false)" }),
|
||||
),
|
||||
network: Schema.optional(
|
||||
Network.annotate({ description: "Control outbound network access from sandboxed tools (default: deny)" }),
|
||||
),
|
||||
writable_paths: Schema.optional(
|
||||
Schema.mutable(Schema.Array(Schema.String)).annotate({
|
||||
description: "Additional filesystem paths that sandboxed tools may write to",
|
||||
}),
|
||||
),
|
||||
}).annotate({ description: "Sandbox configuration for agent tools" })
|
||||
export type Info = Schema.Schema.Type<typeof Info>
|
||||
|
||||
export function resolve(config: { sandbox?: Info }) {
|
||||
return {
|
||||
enabled: config.sandbox?.enabled ?? false,
|
||||
mode: config.sandbox?.network ?? "deny",
|
||||
}
|
||||
}
|
||||
|
||||
export function scope<T extends { sandbox?: Info }>(config: T, source: "global" | "local"): T {
|
||||
if (source === "global" || config.sandbox === undefined) return config
|
||||
const scoped = { ...config }
|
||||
const sandbox: Info = {
|
||||
...(config.sandbox.enabled === true ? { enabled: true } : {}),
|
||||
...(config.sandbox.network === "deny" ? { network: "deny" as const } : {}),
|
||||
}
|
||||
if (Object.keys(sandbox).length > 0) scoped.sandbox = sandbox
|
||||
else delete scoped.sandbox
|
||||
return scoped
|
||||
}
|
||||
}
|
||||
@@ -13,6 +13,7 @@ import { Changed } from "./event"
|
||||
import * as Network from "./network"
|
||||
import { SandboxPreference } from "./preference"
|
||||
import * as SandboxState from "./state"
|
||||
import { SandboxConfig } from "./config"
|
||||
import { SandboxStore } from "./store"
|
||||
|
||||
export type Snapshot = SandboxStore.Snapshot
|
||||
@@ -39,8 +40,8 @@ const resolveInitial = Effect.fn("SandboxPolicy.resolveInitial")(function* (dire
|
||||
const cfg = yield* (yield* Config.Service).get()
|
||||
const chosen = yield* SandboxState.read(sessionID)
|
||||
const pref = yield* Effect.promise(() => SandboxPreference.read(directory))
|
||||
const mode = cfg.experimental?.sandbox_restrict_network === false ? "allow" : "deny"
|
||||
return initial(chosen?.enabled, pref, cfg.experimental?.sandbox ?? false, mode)
|
||||
const fallback = SandboxConfig.resolve(cfg)
|
||||
return initial(chosen?.enabled, pref, fallback.enabled, fallback.mode)
|
||||
})
|
||||
function locked<A, E, R>(sessionID: SessionID, effect: Effect.Effect<A, E, R>) {
|
||||
return Effect.acquireUseRelease(
|
||||
@@ -170,10 +171,13 @@ const snapshot = Effect.fn("SandboxPolicy.snapshot")(function* (sessionID: Sessi
|
||||
|
||||
export const configuredSupport = Effect.fn("SandboxPolicy.configuredSupport")(function* () {
|
||||
const cfg = yield* (yield* Config.Service).get()
|
||||
const mode = cfg.experimental?.sandbox_restrict_network === false ? "allow" : "deny"
|
||||
return backendSupport({ mode, allowedHosts: [] })
|
||||
return backendSupport({ mode: SandboxConfig.resolve(cfg).mode, allowedHosts: [] })
|
||||
})
|
||||
|
||||
export function fallback(config: Config.Info) {
|
||||
return SandboxConfig.resolve(config)
|
||||
}
|
||||
|
||||
export const status = Effect.fn("SandboxPolicy.status")(function* (sessionID: SessionID) {
|
||||
const current = yield* snapshot(sessionID)
|
||||
const support = backendSupport({ mode: current.state.mode, allowedHosts: [] })
|
||||
@@ -304,7 +308,7 @@ function execute<A, E, R>(sessionID: SessionID, effect: Effect.Effect<A, E, R>)
|
||||
const support = backendSupport({ mode: current.state.mode, allowedHosts: [] })
|
||||
if (!current.state.enabled || !support.available) return yield* unrestricted(effect)
|
||||
const cfg = yield* (yield* Config.Service).get()
|
||||
const raw = cfg.experimental?.sandbox_writable_paths
|
||||
const raw = cfg.sandbox?.writable_paths
|
||||
const extraWritable = raw?.map((p) => (p.startsWith("~") ? path.join(os.homedir(), p.slice(1)) : p))
|
||||
return yield* runSandbox(profile(yield* InstanceState.context, current.state.mode, extraWritable), effect)
|
||||
})
|
||||
|
||||
@@ -178,8 +178,7 @@ export const TaskTool = Tool.define(
|
||||
const rules = KiloTask.inherited({ caller, session: parent, mcp: cfg.mcp })
|
||||
// kilocode_change end
|
||||
// kilocode_change start - refresh current parent restrictions when resuming an existing task session
|
||||
const mode: "allow" | "deny" = cfg.experimental?.sandbox_restrict_network === false ? "allow" : "deny"
|
||||
const fallback = { enabled: cfg.experimental?.sandbox ?? false, mode }
|
||||
const fallback = SandboxPolicy.fallback(cfg)
|
||||
if (session) {
|
||||
yield* SandboxPolicy.inherit(ctx.sessionID, session.id, fallback)
|
||||
const permission = KiloTask.merge(
|
||||
|
||||
@@ -242,8 +242,8 @@ describe("kilocode indexing config", () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe("kilocode sandbox writable paths config", () => {
|
||||
test("honors sandbox_writable_paths from global config only, ignoring project config", async () => {
|
||||
describe("kilocode sandbox config", () => {
|
||||
test("prevents project config from weakening sandbox policy", async () => {
|
||||
await using globalTmp = await tmpdir()
|
||||
await using tmp = await tmpdir({ git: true })
|
||||
|
||||
@@ -255,18 +255,48 @@ describe("kilocode sandbox writable paths config", () => {
|
||||
try {
|
||||
await writeConfig(globalTmp.path, {
|
||||
$schema: "https://app.kilo.ai/config.json",
|
||||
experimental: { sandbox_writable_paths: ["/tmp/global"] },
|
||||
sandbox: { enabled: true, network: "deny", writable_paths: ["/tmp/global"] },
|
||||
})
|
||||
// A project kilo.json must not widen the sandbox: its writable paths are dropped at merge time.
|
||||
await writeConfig(tmp.path, {
|
||||
experimental: { sandbox_writable_paths: ["/tmp/project"] },
|
||||
sandbox: { enabled: false, network: "allow", writable_paths: ["/tmp/project"] },
|
||||
})
|
||||
|
||||
await provideTestInstance({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const config = await load()
|
||||
expect(config.experimental?.sandbox_writable_paths).toEqual(["/tmp/global"])
|
||||
expect(config.sandbox).toEqual({ enabled: true, network: "deny", writable_paths: ["/tmp/global"] })
|
||||
},
|
||||
})
|
||||
} finally {
|
||||
;(Global.Path as { config: string }).config = prev
|
||||
await clear()
|
||||
await disposeAllInstances()
|
||||
}
|
||||
})
|
||||
|
||||
test("allows project config to strengthen sandbox policy", async () => {
|
||||
await using globalTmp = await tmpdir()
|
||||
await using tmp = await tmpdir({ git: true })
|
||||
|
||||
const prev = Global.Path.config
|
||||
;(Global.Path as { config: string }).config = globalTmp.path
|
||||
await clear()
|
||||
await disposeAllInstances()
|
||||
|
||||
try {
|
||||
await writeConfig(globalTmp.path, {
|
||||
sandbox: { enabled: false, network: "allow", writable_paths: ["/tmp/global"] },
|
||||
})
|
||||
await writeConfig(tmp.path, {
|
||||
sandbox: { enabled: true, network: "deny", writable_paths: ["/tmp/project"] },
|
||||
})
|
||||
|
||||
await provideTestInstance({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const config = await load()
|
||||
expect(config.sandbox).toEqual({ enabled: true, network: "deny", writable_paths: ["/tmp/global"] })
|
||||
},
|
||||
})
|
||||
} finally {
|
||||
|
||||
@@ -29,10 +29,7 @@ function layer(restrict?: boolean) {
|
||||
TestConfig.layer({
|
||||
get: () =>
|
||||
Effect.succeed({
|
||||
experimental: {
|
||||
sandbox: true,
|
||||
sandbox_restrict_network: restrict,
|
||||
},
|
||||
sandbox: { enabled: true, network: restrict === false ? "allow" : "deny" },
|
||||
}),
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -3,14 +3,11 @@ import type { Config as ConfigV1 } from "@kilocode/sdk"
|
||||
import type { Config as ConfigV2 } from "@kilocode/sdk/v2"
|
||||
|
||||
const value = {
|
||||
experimental: {
|
||||
sandbox: true,
|
||||
sandbox_restrict_network: false,
|
||||
},
|
||||
sandbox: { enabled: true, network: "allow" as const, writable_paths: ["/tmp/output"] },
|
||||
}
|
||||
|
||||
test("both public SDK Config types expose sandbox policy fields", () => {
|
||||
const legacy = value satisfies ConfigV1
|
||||
const current = value satisfies ConfigV2
|
||||
expect(legacy.experimental).toEqual(current.experimental)
|
||||
expect(legacy.sandbox).toEqual(current.sandbox)
|
||||
})
|
||||
|
||||
@@ -89,7 +89,7 @@ function context(directory: string, main: string, sandboxes: string[]): Instance
|
||||
}
|
||||
|
||||
const config = TestConfig.layer({
|
||||
get: () => Effect.succeed({ experimental: { sandbox: true } }),
|
||||
get: () => Effect.succeed({ sandbox: { enabled: true } }),
|
||||
})
|
||||
const agents = Layer.mock(Agent.Service)({
|
||||
get: () => Effect.succeed(agent),
|
||||
|
||||
@@ -32,7 +32,7 @@ describe("sandbox session cleanup", () => {
|
||||
it.live("forks inherit the source session snapshot", () =>
|
||||
Effect.gen(function* () {
|
||||
const sessions = yield* Session.Service
|
||||
const dir = yield* tmpdirScoped({ git: true, config: { experimental: { sandbox: true } } })
|
||||
const dir = yield* tmpdirScoped({ git: true, config: { sandbox: { enabled: true } } })
|
||||
const source = yield* provideInstance(dir)(sessions.create({ title: "sandbox-source" }))
|
||||
const status = yield* provideInstance(dir)(SandboxPolicy.status(source.id))
|
||||
if (!status.available) return
|
||||
@@ -49,7 +49,7 @@ describe("sandbox session cleanup", () => {
|
||||
it.live("forks into another directory carry the source confinement", () =>
|
||||
Effect.gen(function* () {
|
||||
const sessions = yield* Session.Service
|
||||
const dir = yield* tmpdirScoped({ git: true, config: { experimental: { sandbox: true } } })
|
||||
const dir = yield* tmpdirScoped({ git: true, config: { sandbox: { enabled: true } } })
|
||||
const worktree = yield* tmpdirScoped({ git: true })
|
||||
const source = yield* provideInstance(dir)(sessions.create({ title: "sandbox-source" }))
|
||||
const status = yield* provideInstance(dir)(SandboxPolicy.status(source.id))
|
||||
@@ -68,7 +68,7 @@ describe("sandbox session cleanup", () => {
|
||||
Effect.gen(function* () {
|
||||
const sessions = yield* Session.Service
|
||||
// Config default is disabled; the create-time toggle asks for enabled.
|
||||
const dir = yield* tmpdirScoped({ git: true, config: { experimental: { sandbox: false } } })
|
||||
const dir = yield* tmpdirScoped({ git: true, config: { sandbox: { enabled: false } } })
|
||||
const session = yield* provideInstance(dir)(
|
||||
sessions.create({ title: "sandbox-explicit", metadata: { "kilocode.sandbox": { enabled: true, version: 0 } } }),
|
||||
)
|
||||
|
||||
@@ -31,10 +31,7 @@ function configured(restrict: boolean) {
|
||||
TestConfig.layer({
|
||||
get: () =>
|
||||
Effect.succeed({
|
||||
experimental: {
|
||||
sandbox: true,
|
||||
sandbox_restrict_network: restrict,
|
||||
},
|
||||
sandbox: { enabled: true, network: restrict ? "deny" : "allow" },
|
||||
}),
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -6,7 +6,7 @@ import { Deferred, Effect, Exit, Fiber, Layer } from "effect"
|
||||
import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"
|
||||
import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner"
|
||||
import { Flag } from "@opencode-ai/core/flag/flag"
|
||||
import { assertNetwork, enabled as sandboxed } from "@kilocode/sandbox"
|
||||
import { assertNetwork, assertWrite, enabled as sandboxed } from "@kilocode/sandbox"
|
||||
import { Bus } from "@/bus"
|
||||
import { Config } from "@/config/config"
|
||||
import * as Network from "@/kilocode/sandbox/network"
|
||||
@@ -69,9 +69,9 @@ test("restores the session snapshot after a backend restart", async () => {
|
||||
}
|
||||
|
||||
try {
|
||||
const initial = run({ experimental: { sandbox: true, sandbox_restrict_network: true } })
|
||||
const initial = run({ sandbox: { enabled: true, network: "deny" } })
|
||||
expect(initial.state).toEqual({ enabled: true, mode: "deny", version: 0 })
|
||||
const restored = run({ experimental: { sandbox: false, sandbox_restrict_network: false } })
|
||||
const restored = run({ sandbox: { enabled: false, network: "allow" } })
|
||||
expect(restored.state).toEqual(initial.state)
|
||||
expect(restored.status.enabled).toBe(restored.status.available)
|
||||
} finally {
|
||||
@@ -127,7 +127,7 @@ linux("reports configured network namespace availability", async () => {
|
||||
'import { SessionID } from "@/session/schema"',
|
||||
"const directory = process.cwd()",
|
||||
'const context = { directory, worktree: directory, project: { id: "sandbox-status", worktree: directory, vcs: "git", time: { created: 0, updated: 0 }, sandboxes: [] } }',
|
||||
"const status = (restrict) => SandboxPolicy.status(SessionID.make(`ses_sandbox_status_${restrict}`)).pipe(Effect.provide(Layer.mock(Config.Service, { get: () => Effect.succeed({ experimental: { sandbox: true, sandbox_restrict_network: restrict } }) })), Effect.provideService(InstanceRef, context), Effect.runPromise)",
|
||||
"const status = (restrict) => SandboxPolicy.status(SessionID.make(`ses_sandbox_status_${restrict}`)).pipe(Effect.provide(Layer.mock(Config.Service, { get: () => Effect.succeed({ sandbox: { enabled: true, network: restrict ? 'deny' : 'allow' } }) })), Effect.provideService(InstanceRef, context), Effect.runPromise)",
|
||||
"const deny = await status(true)",
|
||||
"const allow = await status(false)",
|
||||
'if (deny.available || deny.enabled || !deny.reason?.includes("Linux network sandbox")) process.exit(2)',
|
||||
@@ -161,10 +161,8 @@ it.instance("snapshots the primary kilo config for the session lifetime", () =>
|
||||
const file = path.join(test.directory, "kilo.json")
|
||||
const legacy = path.join(test.directory, "opencode.json")
|
||||
const config = yield* Config.Service
|
||||
yield* Effect.promise(() =>
|
||||
Bun.write(file, JSON.stringify({ experimental: { sandbox: true, sandbox_restrict_network: true } })),
|
||||
)
|
||||
yield* config.update({ experimental: { sandbox: true, sandbox_restrict_network: true } })
|
||||
yield* Effect.promise(() => Bun.write(file, JSON.stringify({ sandbox: { enabled: true, network: "deny" } })))
|
||||
yield* config.update({ sandbox: { enabled: true, network: "deny" } })
|
||||
|
||||
const id = SessionID.make("ses_sandbox_config")
|
||||
const initial = yield* SandboxPolicy.status(id)
|
||||
@@ -172,12 +170,10 @@ it.instance("snapshots the primary kilo config for the session lifetime", () =>
|
||||
expect(initial.version).toBe(0)
|
||||
if (!initial.available) return
|
||||
|
||||
yield* Effect.promise(() =>
|
||||
Bun.write(file, JSON.stringify({ experimental: { sandbox: false, sandbox_restrict_network: false } })),
|
||||
)
|
||||
yield* config.update({ experimental: { sandbox: false, sandbox_restrict_network: false } })
|
||||
yield* Effect.promise(() => Bun.write(file, JSON.stringify({ sandbox: { enabled: false, network: "allow" } })))
|
||||
yield* config.update({ sandbox: { enabled: false, network: "allow" } })
|
||||
|
||||
expect((yield* config.get()).experimental?.sandbox).toBe(false)
|
||||
expect((yield* config.get()).sandbox?.enabled).toBeUndefined()
|
||||
expect(yield* Effect.promise(() => Bun.file(legacy).exists())).toBe(false)
|
||||
expect((yield* SandboxPolicy.status(id)).enabled).toBe(true)
|
||||
expect(yield* execute(id, sandboxed)).toBe(true)
|
||||
@@ -191,7 +187,7 @@ it.instance("snapshots the primary kilo config for the session lifetime", () =>
|
||||
),
|
||||
)
|
||||
|
||||
it.instance("does not enable authless sessions without the experimental sandbox flag", () =>
|
||||
it.instance("does not enable authless sessions without sandbox enabled", () =>
|
||||
Effect.acquireUseRelease(
|
||||
Effect.sync(() => {
|
||||
const password = Flag.KILO_SERVER_PASSWORD
|
||||
@@ -215,6 +211,30 @@ it.instance("does not enable authless sessions without the experimental sandbox
|
||||
),
|
||||
)
|
||||
|
||||
it.instance("applies configured writable paths during tool execution", () =>
|
||||
Effect.gen(function* () {
|
||||
const test = yield* TestInstance
|
||||
const outside = path.join(path.dirname(test.directory), `sandbox-writable-${path.basename(test.directory)}`)
|
||||
yield* Effect.promise(() => fs.mkdir(outside, { recursive: true }))
|
||||
yield* Effect.addFinalizer(() => Effect.promise(() => fs.rm(outside, { recursive: true, force: true })))
|
||||
|
||||
const id = SessionID.make("ses_sandbox_writable_config")
|
||||
const result = yield* Effect.gen(function* () {
|
||||
const status = yield* SandboxPolicy.status(id)
|
||||
if (!status.available) return undefined
|
||||
return yield* execute(id, assertWrite(path.join(outside, "allowed.txt")).pipe(Effect.exit))
|
||||
}).pipe(
|
||||
Effect.provide(
|
||||
Layer.mock(Config.Service, {
|
||||
get: () => Effect.succeed({ sandbox: { enabled: true, network: "allow", writable_paths: [outside] } }),
|
||||
}),
|
||||
),
|
||||
)
|
||||
if (result === undefined) return
|
||||
expect(Exit.isSuccess(result)).toBe(true)
|
||||
}),
|
||||
)
|
||||
|
||||
it.instance(
|
||||
"runs sandboxed when config is on and no override exists",
|
||||
() =>
|
||||
@@ -224,7 +244,7 @@ it.instance(
|
||||
expect(status.enabled).toBe(status.available)
|
||||
expect(yield* execute(id, sandboxed)).toBe(status.available)
|
||||
}),
|
||||
{ config: { experimental: { sandbox: true } } },
|
||||
{ config: { sandbox: { enabled: true } } },
|
||||
)
|
||||
|
||||
it.instance(
|
||||
@@ -240,7 +260,7 @@ it.instance(
|
||||
expect((yield* SandboxPolicy.status(second)).enabled).toBe(false)
|
||||
expect(yield* execute(second, sandboxed)).toBe(false)
|
||||
}),
|
||||
{ config: { experimental: { sandbox: true } } },
|
||||
{ config: { sandbox: { enabled: true } } },
|
||||
)
|
||||
|
||||
it.instance("persists an authless toggle to later sessions", () =>
|
||||
@@ -271,7 +291,7 @@ it.instance(
|
||||
expect((yield* SandboxPolicy.status(third)).enabled).toBe(true)
|
||||
expect(yield* execute(third, sandboxed)).toBe(true)
|
||||
}),
|
||||
{ config: { experimental: { sandbox: true } } },
|
||||
{ config: { sandbox: { enabled: true } } },
|
||||
)
|
||||
|
||||
it.instance("isolates concurrent session overrides and clears them", () =>
|
||||
@@ -364,7 +384,7 @@ it.instance(
|
||||
expect((yield* SandboxPolicy.status(child)).enabled).toBe(true)
|
||||
expect(yield* execute(child, sandboxed)).toBe(true)
|
||||
}),
|
||||
{ config: { experimental: { sandbox: true } } },
|
||||
{ config: { sandbox: { enabled: true } } },
|
||||
)
|
||||
|
||||
it.instance("enforces writes only while the macOS session override is active", () =>
|
||||
|
||||
@@ -26,7 +26,7 @@ describe("sandbox TUI", () => {
|
||||
expect(content).toContain("await ensureSession(api)")
|
||||
expect(content).toContain("api.client.session.create")
|
||||
expect(content).toContain('api.route.navigate("session", { sessionID })')
|
||||
expect(content).toContain("props.api.state.config.experimental?.sandbox")
|
||||
expect(content).toContain("props.api.state.config.sandbox?.enabled")
|
||||
expect(content).toContain("void props.load(props.sessionID, true)")
|
||||
expect(content).toContain('api.event.on("sandbox.status.changed"')
|
||||
})
|
||||
|
||||
@@ -435,7 +435,7 @@ describe("Kilo task nesting", () => {
|
||||
expect(count).toBeGreaterThan(0)
|
||||
expect(resumed.permission?.filter((rule) => rule.permission === "bash")).toHaveLength(count ?? 0)
|
||||
}),
|
||||
{ config: { experimental: { sandbox: true } } },
|
||||
{ config: { sandbox: { enabled: true } } },
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
@@ -58,6 +58,37 @@ if (sseTypesPatched === sseTypesSource) {
|
||||
}
|
||||
await Bun.write(sseTypesPath, sseTypesPatched)
|
||||
|
||||
// The legacy SDK generator is retired, but this public Config type remains exported.
|
||||
// Keep Kilo's released sandbox settings aligned with the current generated client.
|
||||
const legacyTypesPath = "./src/gen/types.gen.ts"
|
||||
const legacyTypesFile = Bun.file(legacyTypesPath)
|
||||
const legacySource = await legacyTypesFile.text()
|
||||
const sandbox = ` /**
|
||||
* Sandbox configuration for agent tools
|
||||
*/
|
||||
sandbox?: {
|
||||
/**
|
||||
* Enable sandbox confinement for new sessions (default: false)
|
||||
*/
|
||||
enabled?: boolean
|
||||
/**
|
||||
* Control outbound network access from sandboxed tools (default: deny)
|
||||
*/
|
||||
network?: "allow" | "deny"
|
||||
/**
|
||||
* Additional filesystem paths that sandboxed tools may write to
|
||||
*/
|
||||
writable_paths?: Array<string>
|
||||
}
|
||||
`
|
||||
const legacyPatched = legacySource.includes(sandbox)
|
||||
? legacySource
|
||||
: legacySource.replace(" experimental?: {\n", sandbox + " experimental?: {\n")
|
||||
if (!legacyPatched.includes(sandbox)) {
|
||||
throw new Error(`Legacy Config sandbox patch did not apply (${legacyTypesPath})`)
|
||||
}
|
||||
await Bun.write(legacyTypesPath, legacyPatched)
|
||||
|
||||
await $`bun prettier --write src/gen`
|
||||
await $`bun prettier --write src/v2`
|
||||
await $`rm -rf dist tsconfig.tsbuildinfo`
|
||||
|
||||
@@ -1343,6 +1343,23 @@ export type Config = {
|
||||
*/
|
||||
url?: string
|
||||
}
|
||||
/**
|
||||
* Sandbox configuration for agent tools
|
||||
*/
|
||||
sandbox?: {
|
||||
/**
|
||||
* Enable sandbox confinement for new sessions (default: false)
|
||||
*/
|
||||
enabled?: boolean
|
||||
/**
|
||||
* Control outbound network access from sandboxed tools (default: deny)
|
||||
*/
|
||||
network?: "allow" | "deny"
|
||||
/**
|
||||
* Additional filesystem paths that sandboxed tools may write to
|
||||
*/
|
||||
writable_paths?: Array<string>
|
||||
}
|
||||
experimental?: {
|
||||
hook?: {
|
||||
file_edited?: {
|
||||
@@ -1373,14 +1390,6 @@ export type Config = {
|
||||
* Enable OpenTelemetry spans for AI SDK calls (using the 'experimental_telemetry' flag)
|
||||
*/
|
||||
openTelemetry?: boolean
|
||||
/**
|
||||
* Run agent tools inside a sandbox that restricts writes to project and Kilo state directories and can restrict outbound network access
|
||||
*/
|
||||
sandbox?: boolean
|
||||
/**
|
||||
* Restrict outbound network access for model-originated commands and first-party HTTP tools; local MCP servers and plugin hooks are not covered (default: true)
|
||||
*/
|
||||
sandbox_restrict_network?: boolean
|
||||
/**
|
||||
* Tools that should only be available to primary agents.
|
||||
*/
|
||||
|
||||
@@ -1577,6 +1577,23 @@ export type Config = {
|
||||
terminal_command_display?: "expanded" | "collapsed"
|
||||
code_edit_display?: "expanded" | "collapsed"
|
||||
hide_prompt_training_models?: boolean
|
||||
/**
|
||||
* Sandbox configuration for agent tools
|
||||
*/
|
||||
sandbox?: {
|
||||
/**
|
||||
* Enable sandbox confinement for new sessions (default: false)
|
||||
*/
|
||||
enabled?: boolean
|
||||
/**
|
||||
* Control outbound network access from sandboxed tools (default: deny)
|
||||
*/
|
||||
network?: "allow" | "deny"
|
||||
/**
|
||||
* Additional filesystem paths that sandboxed tools may write to
|
||||
*/
|
||||
writable_paths?: Array<string>
|
||||
}
|
||||
model?: string
|
||||
small_model?: string
|
||||
subagent_model?: string
|
||||
@@ -1693,9 +1710,6 @@ export type Config = {
|
||||
openTelemetry?: boolean
|
||||
primary_tools?: Array<string>
|
||||
continue_loop_on_deny?: boolean
|
||||
sandbox?: boolean
|
||||
sandbox_restrict_network?: boolean
|
||||
sandbox_writable_paths?: Array<string>
|
||||
swe_pruner?: boolean
|
||||
swe_pruner_model?: string
|
||||
mcp_timeout?: number
|
||||
|
||||
+23
-12
@@ -24916,6 +24916,29 @@
|
||||
"hide_prompt_training_models": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"sandbox": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"enabled": {
|
||||
"type": "boolean",
|
||||
"description": "Enable sandbox confinement for new sessions (default: false)"
|
||||
},
|
||||
"network": {
|
||||
"type": "string",
|
||||
"enum": ["allow", "deny"],
|
||||
"description": "Control outbound network access from sandboxed tools (default: deny)"
|
||||
},
|
||||
"writable_paths": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string"
|
||||
},
|
||||
"description": "Additional filesystem paths that sandboxed tools may write to"
|
||||
}
|
||||
},
|
||||
"additionalProperties": false,
|
||||
"description": "Sandbox configuration for agent tools"
|
||||
},
|
||||
"model": {
|
||||
"type": "string"
|
||||
},
|
||||
@@ -25266,18 +25289,6 @@
|
||||
"continue_loop_on_deny": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"sandbox": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"sandbox_restrict_network": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"sandbox_writable_paths": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"swe_pruner": {
|
||||
"type": "boolean"
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user