From 4e36297668bb36ab34c0b4f0bc6a0484baef3145 Mon Sep 17 00:00:00 2001 From: Marius Date: Fri, 7 Aug 2026 13:37:06 +0200 Subject: [PATCH] fix(cli): apply saved sandbox settings to existing sessions (#12600) * fix(cli): apply saved sandbox settings to existing sessions * fix(cli): emit config update event on global overlay saves * fix(cli): scope live sandbox policy refresh * fix(cli): refresh project sandbox policies * test(cli): use external path for sandbox overlay --- .changeset/sandbox-live-settings.md | 6 + .../getting-started/settings/sandboxing.md | 4 +- .../kilo-vscode/webview-ui/src/i18n/ar.ts | 2 +- .../kilo-vscode/webview-ui/src/i18n/br.ts | 2 +- .../kilo-vscode/webview-ui/src/i18n/bs.ts | 2 +- .../kilo-vscode/webview-ui/src/i18n/da.ts | 2 +- .../kilo-vscode/webview-ui/src/i18n/de.ts | 2 +- .../kilo-vscode/webview-ui/src/i18n/en.ts | 2 +- .../kilo-vscode/webview-ui/src/i18n/es.ts | 2 +- .../kilo-vscode/webview-ui/src/i18n/fr.ts | 2 +- .../kilo-vscode/webview-ui/src/i18n/it.ts | 2 +- .../kilo-vscode/webview-ui/src/i18n/ja.ts | 2 +- .../kilo-vscode/webview-ui/src/i18n/ko.ts | 2 +- .../kilo-vscode/webview-ui/src/i18n/nl.ts | 2 +- .../kilo-vscode/webview-ui/src/i18n/no.ts | 2 +- .../kilo-vscode/webview-ui/src/i18n/pl.ts | 2 +- .../kilo-vscode/webview-ui/src/i18n/ru.ts | 2 +- .../kilo-vscode/webview-ui/src/i18n/th.ts | 2 +- .../kilo-vscode/webview-ui/src/i18n/tr.ts | 2 +- .../kilo-vscode/webview-ui/src/i18n/uk.ts | 2 +- .../kilo-vscode/webview-ui/src/i18n/zh.ts | 2 +- .../kilo-vscode/webview-ui/src/i18n/zht.ts | 2 +- packages/opencode/src/config/config.ts | 7 +- packages/opencode/src/format/index.ts | 5 +- .../src/kilocode/background-process/index.ts | 8 +- .../opencode/src/kilocode/config/writer.ts | 16 +- .../kilocode/interactive-terminal/index.ts | 7 +- packages/opencode/src/kilocode/process/env.ts | 13 + .../opencode/src/kilocode/sandbox/policy.ts | 268 ++++++++++++----- .../opencode/src/kilocode/sandbox/state.ts | 8 +- .../opencode/src/kilocode/sandbox/store.ts | 5 +- .../server/httpapi/handlers/config-console.ts | 26 +- packages/opencode/src/lsp/launch.ts | 3 + packages/opencode/src/mcp/index.ts | 8 +- packages/opencode/src/tool/shell.ts | 6 +- packages/opencode/src/util/process.ts | 4 +- .../test/kilocode/config/config.test.ts | 40 +++ .../test/kilocode/sandbox/state.test.ts | 279 +++++++++++++++++- .../kilocode/server/config-overlay.test.ts | 160 ++++++++-- .../test/kilocode/tool/shell-env.test.ts | 98 ++++++ packages/opencode/test/lsp/launch.test.ts | 31 ++ .../test/server/httpapi-exercise/backend.ts | 20 +- packages/opencode/test/util/process.test.ts | 19 ++ 43 files changed, 930 insertions(+), 151 deletions(-) create mode 100644 .changeset/sandbox-live-settings.md create mode 100644 packages/opencode/src/kilocode/process/env.ts create mode 100644 packages/opencode/test/kilocode/tool/shell-env.test.ts diff --git a/.changeset/sandbox-live-settings.md b/.changeset/sandbox-live-settings.md new file mode 100644 index 0000000000..f356683b99 --- /dev/null +++ b/.changeset/sandbox-live-settings.md @@ -0,0 +1,6 @@ +--- +"kilo-code": patch +"@kilocode/cli": patch +--- + +Apply saved sandbox settings to existing sessions and use the latest settings when enabling sandboxing diff --git a/packages/kilo-docs/pages/getting-started/settings/sandboxing.md b/packages/kilo-docs/pages/getting-started/settings/sandboxing.md index 72f1029bc4..462a582ab7 100644 --- a/packages/kilo-docs/pages/getting-started/settings/sandboxing.md +++ b/packages/kilo-docs/pages/getting-started/settings/sandboxing.md @@ -161,9 +161,9 @@ A configured destination is an egress route, not tenant, organization, repositor 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 snapshots its network mode, allowed destinations, and additional writable paths. Changing config affects new sessions. The prompt control or `/sandbox` can change the current session's enabled state, but it cannot change these authority lists, and they never expand during an active session. +Each session preserves its enabled or disabled choice. Saving changes through Kilo settings to network mode, allowed destinations, or additional writable paths refreshes existing session policies before their next tool execution. Enabling sandboxing also reads the latest settings. A tool that is already running keeps the policy it started with. -Forked sessions retain the source session's confinement. Subagents inherit the stricter combination of parent and child settings: sandboxing remains enabled if either requires it, deny-all wins over destination exceptions, destination lists intersect, and additional writable paths intersect. +At creation, forked sessions retain the source session's confinement and subagents inherit the stricter combination of parent and child settings: sandboxing remains enabled if either requires it, deny-all wins over destination exceptions, destination lists intersect, and additional writable paths intersect. Later trusted sandbox settings replace those network and writable-path limits before the affected session's next tool execution. Cloud sessions do not expose the local sandbox control because their tools do not run in your local sandbox. diff --git a/packages/kilo-vscode/webview-ui/src/i18n/ar.ts b/packages/kilo-vscode/webview-ui/src/i18n/ar.ts index b94db9c418..23449611fb 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/ar.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/ar.ts @@ -872,7 +872,7 @@ export const dict = { "settings.sandboxing.allowedHosts.title": "وجهات الشبكة المسموح بها", "settings.sandboxing.allowedHosts.description": - "وجهات مضيف ومنفذ DNS لحركة مرور وكيل HTTP وHTTPS المعزولة. يحتاج GitHub CLI وHTTPS Git عادةً إلى github.com:443 وapi.github.com:443. تنطبق التغييرات على الجلسات الجديدة.", + "وجهات مضيف ومنفذ DNS لحركة مرور وكيل HTTP وHTTPS المعزولة. يحتاج GitHub CLI وHTTPS Git عادةً إلى github.com:443 وapi.github.com:443.", "settings.sandboxing.writablePaths.title": "مسارات قابلة للكتابة إضافية", "settings.sandboxing.writablePaths.description": "مسارات نظام ملفات إضافية يسمح صندوق الرمل بالكتابة إليها (مثل /tmp، /var/log). يتم دمجها مع مسارات الكتابة الافتراضية عندما يكون صندوق الرمل نشطًا.", diff --git a/packages/kilo-vscode/webview-ui/src/i18n/br.ts b/packages/kilo-vscode/webview-ui/src/i18n/br.ts index cea577d817..87e6d21fc9 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/br.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/br.ts @@ -903,7 +903,7 @@ export const dict = { "settings.sandboxing.allowedHosts.title": "Destinos de rede permitidos", "settings.sandboxing.allowedHosts.description": - "Destinos de host e porta DNS para o tráfego de proxy HTTP e HTTPS em sandbox. GitHub CLI e HTTPS Git geralmente precisam de github.com:443 e api.github.com:443. As alterações se aplicam a novas sessões.", + "Destinos de host e porta DNS para o tráfego de proxy HTTP e HTTPS em sandbox. GitHub CLI e HTTPS Git geralmente precisam de github.com:443 e api.github.com:443.", "settings.sandboxing.writablePaths.title": "Caminhos graváveis adicionais", "settings.sandboxing.writablePaths.description": "Caminhos adicionais do sistema de arquivos onde o sandbox permite gravação (por exemplo, /tmp, /var/log). Eles são mesclados com os caminhos graváveis padrão quando o sandbox está ativo.", diff --git a/packages/kilo-vscode/webview-ui/src/i18n/bs.ts b/packages/kilo-vscode/webview-ui/src/i18n/bs.ts index e4427c3696..0f2f3d1345 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/bs.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/bs.ts @@ -896,7 +896,7 @@ export const dict = { "settings.sandboxing.allowedHosts.title": "Dozvoljena mrežna odredišta", "settings.sandboxing.allowedHosts.description": - "DNS odredišta hosta i porta za sandboxirani HTTP i HTTPS proxy promet. GitHub CLI i HTTPS Git obično trebaju github.com:443 i api.github.com:443. Promjene se primjenjuju na nove sesije.", + "DNS odredišta hosta i porta za sandboxirani HTTP i HTTPS proxy promet. GitHub CLI i HTTPS Git obično trebaju github.com:443 i api.github.com:443.", "settings.sandboxing.writablePaths.title": "Dodatne upisive putanje", "settings.sandboxing.writablePaths.description": "Dodatne putanje sistema datoteka u koje sandbox dozvoljava upis (npr. /tmp, /var/log). Spajaju se sa zadanim upisivim putanjama kada je sandbox aktivan.", diff --git a/packages/kilo-vscode/webview-ui/src/i18n/da.ts b/packages/kilo-vscode/webview-ui/src/i18n/da.ts index b7da0320b0..def5fb47b2 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/da.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/da.ts @@ -895,7 +895,7 @@ export const dict = { "settings.sandboxing.allowedHosts.title": "Tilladte netværksdestinationer", "settings.sandboxing.allowedHosts.description": - "DNS-værts- og portdestinationer for sandboxet HTTP- og HTTPS-proxytrafik. GitHub CLI og HTTPS Git kræver typisk github.com:443 og api.github.com:443. Ændringer gælder for nye sessioner.", + "DNS-værts- og portdestinationer for sandboxet HTTP- og HTTPS-proxytrafik. GitHub CLI og HTTPS Git kræver typisk github.com:443 og api.github.com:443.", "settings.sandboxing.writablePaths.title": "Yderligere skrivbare stier", "settings.sandboxing.writablePaths.description": "Yderligere filsystemstier, som sandkassen tillader skrivning til (f.eks. /tmp, /var/log). Disse flettes med de standardskrivbare stier, når sandkassen er aktiv.", diff --git a/packages/kilo-vscode/webview-ui/src/i18n/de.ts b/packages/kilo-vscode/webview-ui/src/i18n/de.ts index 8e809d91af..c9e0d5aa20 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/de.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/de.ts @@ -917,7 +917,7 @@ export const dict = { "settings.sandboxing.allowedHosts.title": "Zulässige Netzwerkziele", "settings.sandboxing.allowedHosts.description": - "DNS-Host- und Portziele für Sandbox-HTTP- und HTTPS-Proxy-Datenverkehr. GitHub CLI und HTTPS Git benötigen üblicherweise github.com:443 und api.github.com:443. Änderungen gelten für neue Sitzungen.", + "DNS-Host- und Portziele für Sandbox-HTTP- und HTTPS-Proxy-Datenverkehr. GitHub CLI und HTTPS Git benötigen üblicherweise github.com:443 und api.github.com:443.", "settings.sandboxing.writablePaths.title": "Zusätzliche schreibbare Pfade", "settings.sandboxing.writablePaths.description": "Zusätzliche Dateisystempfade, in die die Sandbox Schreibvorgänge erlaubt (z. B. /tmp, /var/log). Diese werden mit den Standard-Schreibpfaden zusammengeführt, wenn die Sandbox aktiv ist.", diff --git a/packages/kilo-vscode/webview-ui/src/i18n/en.ts b/packages/kilo-vscode/webview-ui/src/i18n/en.ts index 32cbfb44fd..6a53383af8 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/en.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/en.ts @@ -875,7 +875,7 @@ export const dict = { "Block direct outbound access from model-originated commands and HTTP tools. Local and remote MCP tools are unavailable while restricted. Provider traffic and trusted plugin hooks remain outside this restriction.", "settings.sandboxing.allowedHosts.title": "Allowed Network Destinations", "settings.sandboxing.allowedHosts.description": - "DNS host and port destinations for sandboxed HTTP and HTTPS proxy traffic. GitHub CLI and HTTPS Git commonly need github.com:443 and api.github.com:443. Changes apply to new sessions.", + "DNS host and port destinations for sandboxed HTTP and HTTPS proxy traffic. GitHub CLI and HTTPS Git commonly need github.com:443 and api.github.com:443.", "settings.sandboxing.writablePaths.title": "Additional Writable Paths", "settings.sandboxing.writablePaths.description": "Extra 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.", diff --git a/packages/kilo-vscode/webview-ui/src/i18n/es.ts b/packages/kilo-vscode/webview-ui/src/i18n/es.ts index f4cb701f0a..e6756eb4e4 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/es.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/es.ts @@ -906,7 +906,7 @@ export const dict = { "settings.sandboxing.allowedHosts.title": "Destinos de red permitidos", "settings.sandboxing.allowedHosts.description": - "Destinos de host y puerto DNS para el tráfico de proxy HTTP y HTTPS en zona de pruebas. GitHub CLI y HTTPS Git suelen necesitar github.com:443 y api.github.com:443. Los cambios se aplican a las sesiones nuevas.", + "Destinos de host y puerto DNS para el tráfico de proxy HTTP y HTTPS en zona de pruebas. GitHub CLI y HTTPS Git suelen necesitar github.com:443 y api.github.com:443.", "settings.sandboxing.writablePaths.title": "Rutas de escritura adicionales", "settings.sandboxing.writablePaths.description": "Rutas del sistema de archivos adicionales donde el sandbox permite escritura (por ej., /tmp, /var/log). Se combinan con las rutas de escritura predeterminadas cuando el sandbox está activo.", diff --git a/packages/kilo-vscode/webview-ui/src/i18n/fr.ts b/packages/kilo-vscode/webview-ui/src/i18n/fr.ts index e1878c0b26..61399af09a 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/fr.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/fr.ts @@ -918,7 +918,7 @@ export const dict = { "settings.sandboxing.allowedHosts.title": "Destinations réseau autorisées", "settings.sandboxing.allowedHosts.description": - "Destinations d’hôte et de port DNS pour le trafic proxy HTTP et HTTPS isolé. GitHub CLI et HTTPS Git nécessitent généralement github.com:443 et api.github.com:443. Les modifications s’appliquent aux nouvelles sessions.", + "Destinations d’hôte et de port DNS pour le trafic proxy HTTP et HTTPS isolé. GitHub CLI et HTTPS Git nécessitent généralement github.com:443 et api.github.com:443.", "settings.sandboxing.writablePaths.title": "Chemins en écriture supplémentaires", "settings.sandboxing.writablePaths.description": "Chemins système supplémentaires autorisés en écriture par le bac à sable (par ex. /tmp, /var/log). Ils sont fusionnés avec les chemins en écriture par défaut lorsque le bac à sable est actif.", diff --git a/packages/kilo-vscode/webview-ui/src/i18n/it.ts b/packages/kilo-vscode/webview-ui/src/i18n/it.ts index 9c1f48e977..054ca513bf 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/it.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/it.ts @@ -743,7 +743,7 @@ export const dict = { "settings.sandboxing.allowedHosts.title": "Destinazioni di rete consentite", "settings.sandboxing.allowedHosts.description": - "Destinazioni DNS di host e porta per il traffico proxy HTTP e HTTPS in sandbox. GitHub CLI e HTTPS Git richiedono comunemente github.com:443 e api.github.com:443. Le modifiche si applicano alle nuove sessioni.", + "Destinazioni DNS di host e porta per il traffico proxy HTTP e HTTPS in sandbox. GitHub CLI e HTTPS Git richiedono comunemente github.com:443 e api.github.com:443.", "settings.sandboxing.writablePaths.title": "Percorsi di scrittura aggiuntivi", "settings.sandboxing.writablePaths.description": "Percorsi aggiuntivi del file system in cui la sandbox consente la scrittura (es. /tmp, /var/log). Vengono uniti con i percorsi di scrittura predefiniti quando la sandbox è attiva.", diff --git a/packages/kilo-vscode/webview-ui/src/i18n/ja.ts b/packages/kilo-vscode/webview-ui/src/i18n/ja.ts index dd63cc906b..bdeab14e6e 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/ja.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/ja.ts @@ -888,7 +888,7 @@ export const dict = { "settings.sandboxing.allowedHosts.title": "許可されたネットワーク接続先", "settings.sandboxing.allowedHosts.description": - "サンドボックス化された HTTP および HTTPS プロキシトラフィックの DNS ホストとポートの宛先。GitHub CLI と HTTPS Git では通常、github.com:443 と api.github.com:443 が必要です。変更は新しいセッションに適用されます。", + "サンドボックス化された HTTP および HTTPS プロキシトラフィックの DNS ホストとポートの宛先。GitHub CLI と HTTPS Git では通常、github.com:443 と api.github.com:443 が必要です。", "settings.sandboxing.writablePaths.title": "追加の書き込み可能パス", "settings.sandboxing.writablePaths.description": "サンドボックスでの書き込みを許可する追加のファイルシステムパス(例: /tmp、/var/log)。サンドボックス有効時、デフォルトの書き込み可能パスと統合されます。", diff --git a/packages/kilo-vscode/webview-ui/src/i18n/ko.ts b/packages/kilo-vscode/webview-ui/src/i18n/ko.ts index 09bb7ce274..98a13c0ac1 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/ko.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/ko.ts @@ -885,7 +885,7 @@ export const dict = { "settings.sandboxing.allowedHosts.title": "허용된 네트워크 대상", "settings.sandboxing.allowedHosts.description": - "샌드박스 처리된 HTTP 및 HTTPS 프록시 트래픽의 DNS 호스트 및 포트 대상입니다. GitHub CLI 및 HTTPS Git에는 일반적으로 github.com:443 및 api.github.com:443가 필요합니다. 변경 사항은 새 세션에 적용됩니다.", + "샌드박스 처리된 HTTP 및 HTTPS 프록시 트래픽의 DNS 호스트 및 포트 대상입니다. GitHub CLI 및 HTTPS Git에는 일반적으로 github.com:443 및 api.github.com:443가 필요합니다.", "settings.sandboxing.writablePaths.title": "추가 쓰기 가능 경로", "settings.sandboxing.writablePaths.description": "샌드박스에서 쓰기를 허용하는 추가 파일시스템 경로(예: /tmp, /var/log). 샌드박스가 활성화되면 기본 쓰기 가능 경로와 병합됩니다.", diff --git a/packages/kilo-vscode/webview-ui/src/i18n/nl.ts b/packages/kilo-vscode/webview-ui/src/i18n/nl.ts index 9cd7ecba66..e5ad95f2bf 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/nl.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/nl.ts @@ -896,7 +896,7 @@ export const dict = { "settings.sandboxing.allowedHosts.title": "Toegestane netwerkbestemmingen", "settings.sandboxing.allowedHosts.description": - "DNS-host- en poortbestemmingen voor HTTP- en HTTPS-proxyverkeer in een sandbox. GitHub CLI en HTTPS Git hebben doorgaans github.com:443 en api.github.com:443 nodig. Wijzigingen gelden voor nieuwe sessies.", + "DNS-host- en poortbestemmingen voor HTTP- en HTTPS-proxyverkeer in een sandbox. GitHub CLI en HTTPS Git hebben doorgaans github.com:443 en api.github.com:443 nodig.", "settings.sandboxing.writablePaths.title": "Extra schrijfbare paden", "settings.sandboxing.writablePaths.description": "Extra bestandssysteempaden waar de sandbox schrijftoestemming voor geeft (bijv. /tmp, /var/log). Deze worden samengevoegd met de standaard schrijfbare paden wanneer de sandbox actief is.", diff --git a/packages/kilo-vscode/webview-ui/src/i18n/no.ts b/packages/kilo-vscode/webview-ui/src/i18n/no.ts index 2eb8edf1f9..f8bcbb5981 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/no.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/no.ts @@ -856,7 +856,7 @@ export const dict = { "settings.sandboxing.allowedHosts.title": "Tillatte nettverksmål", "settings.sandboxing.allowedHosts.description": - "DNS-verts- og portdestinasjoner for HTTP- og HTTPS-proxytrafikk i sandkassen. GitHub CLI og HTTPS Git trenger vanligvis github.com:443 og api.github.com:443. Endringer gjelder for nye økter.", + "DNS-verts- og portdestinasjoner for HTTP- og HTTPS-proxytrafikk i sandkassen. GitHub CLI og HTTPS Git trenger vanligvis github.com:443 og api.github.com:443.", "settings.sandboxing.writablePaths.title": "Ytterligere skrivbare baner", "settings.sandboxing.writablePaths.description": "Ytterligere filsystembaner som sandkassen tillater skriving til (f.eks. /tmp, /var/log). Disse flettes med de standardskrivbare banene når sandkassen er aktiv.", diff --git a/packages/kilo-vscode/webview-ui/src/i18n/pl.ts b/packages/kilo-vscode/webview-ui/src/i18n/pl.ts index 27d92d2a16..74a593a6ea 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/pl.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/pl.ts @@ -853,7 +853,7 @@ export const dict = { "settings.sandboxing.allowedHosts.title": "Dozwolone miejsca docelowe sieci", "settings.sandboxing.allowedHosts.description": - "Docelowe hosty DNS i porty dla ruchu HTTP i HTTPS przez proxy w piaskownicy. GitHub CLI i HTTPS Git zwykle wymagają github.com:443 i api.github.com:443. Zmiany dotyczą nowych sesji.", + "Docelowe hosty DNS i porty dla ruchu HTTP i HTTPS przez proxy w piaskownicy. GitHub CLI i HTTPS Git zwykle wymagają github.com:443 i api.github.com:443.", "settings.sandboxing.writablePaths.title": "Dodatkowe ścieżki zapisu", "settings.sandboxing.writablePaths.description": "Dodatkowe ścieżki systemu plików, do których sandbox zezwala na zapis (np. /tmp, /var/log). Są one łączone z domyślnymi ścieżkami zapisu, gdy sandbox jest aktywny.", diff --git a/packages/kilo-vscode/webview-ui/src/i18n/ru.ts b/packages/kilo-vscode/webview-ui/src/i18n/ru.ts index dac8b4e2ba..963cecd49a 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/ru.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/ru.ts @@ -892,7 +892,7 @@ export const dict = { "settings.sandboxing.allowedHosts.title": "Разрешенные сетевые назначения", "settings.sandboxing.allowedHosts.description": - "Целевые DNS-хосты и порты для прокси-трафика HTTP и HTTPS в песочнице. GitHub CLI и HTTPS Git обычно требуют github.com:443 и api.github.com:443. Изменения применяются к новым сеансам.", + "Целевые DNS-хосты и порты для прокси-трафика HTTP и HTTPS в песочнице. GitHub CLI и HTTPS Git обычно требуют github.com:443 и api.github.com:443.", "settings.sandboxing.writablePaths.title": "Дополнительные пути для записи", "settings.sandboxing.writablePaths.description": "Дополнительные пути файловой системы, в которые разрешена запись в песочнице (например, /tmp, /var/log). Они объединяются с путями записи по умолчанию при активной песочнице.", diff --git a/packages/kilo-vscode/webview-ui/src/i18n/th.ts b/packages/kilo-vscode/webview-ui/src/i18n/th.ts index f2732e1bd9..e6e549adb3 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/th.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/th.ts @@ -881,7 +881,7 @@ export const dict = { "settings.sandboxing.allowedHosts.title": "ปลายทางเครือข่ายที่อนุญาต", "settings.sandboxing.allowedHosts.description": - "โฮสต์ DNS และพอร์ตปลายทางสำหรับทราฟฟิกพร็อกซี HTTP และ HTTPS ในแซนด์บ็อกซ์ GitHub CLI และ HTTPS Git มักต้องใช้ github.com:443 และ api.github.com:443 การเปลี่ยนแปลงจะมีผลกับเซสชันใหม่", + "โฮสต์ DNS และพอร์ตปลายทางสำหรับทราฟฟิกพร็อกซี HTTP และ HTTPS ในแซนด์บ็อกซ์ GitHub CLI และ HTTPS Git มักต้องใช้ github.com:443 และ api.github.com:443", "settings.sandboxing.writablePaths.title": "เส้นทางที่เขียนได้เพิ่มเติม", "settings.sandboxing.writablePaths.description": "เส้นทางระบบไฟล์เพิ่มเติมที่แซนด์บ็อกซ์อนุญาตให้เขียนได้ (เช่น /tmp, /var/log) จะถูกรวมเข้ากับเส้นทางที่เขียนได้เริ่มต้นเมื่อแซนด์บ็อกซ์เปิดใช้งาน", diff --git a/packages/kilo-vscode/webview-ui/src/i18n/tr.ts b/packages/kilo-vscode/webview-ui/src/i18n/tr.ts index ad0f1a75c7..e2d7c590c5 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/tr.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/tr.ts @@ -885,7 +885,7 @@ export const dict = { "settings.sandboxing.allowedHosts.title": "İzin Verilen Ağ Hedefleri", "settings.sandboxing.allowedHosts.description": - "Korumalı alana alınmış HTTP ve HTTPS proxy trafiği için DNS ana bilgisayar ve bağlantı noktası hedefleri. GitHub CLI ve HTTPS Git genellikle github.com:443 ve api.github.com:443 gerektirir. Değişiklikler yeni oturumlara uygulanır.", + "Korumalı alana alınmış HTTP ve HTTPS proxy trafiği için DNS ana bilgisayar ve bağlantı noktası hedefleri. GitHub CLI ve HTTPS Git genellikle github.com:443 ve api.github.com:443 gerektirir.", "settings.sandboxing.writablePaths.title": "Ek Yazılabilir Yollar", "settings.sandboxing.writablePaths.description": "Sandığın yazılmasına izin veren ek dosya sistemi yolları (ör. /tmp, /var/log). Sandık etkinken varsayılan yazılabilir yollarla birleştirilir.", diff --git a/packages/kilo-vscode/webview-ui/src/i18n/uk.ts b/packages/kilo-vscode/webview-ui/src/i18n/uk.ts index 7e9f8ece40..1d7678524b 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/uk.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/uk.ts @@ -887,7 +887,7 @@ export const dict = { "settings.sandboxing.allowedHosts.title": "Дозволені мережеві адреси", "settings.sandboxing.allowedHosts.description": - "DNS-вузли та порти призначення для ізольованого proxy-трафіку HTTP і HTTPS. GitHub CLI та HTTPS Git зазвичай потребують github.com:443 і api.github.com:443. Зміни застосовуються до нових сеансів.", + "DNS-вузли та порти призначення для ізольованого proxy-трафіку HTTP і HTTPS. GitHub CLI та HTTPS Git зазвичай потребують github.com:443 і api.github.com:443.", "settings.sandboxing.writablePaths.title": "Додаткові шляхи для запису", "settings.sandboxing.writablePaths.description": "Додаткові шляхи файлової системи, у які дозволено запис у пісочниці (наприклад, /tmp, /var/log). Вони об'єднуються зі шляхами запису за замовчуванням, коли пісочниця активна.", diff --git a/packages/kilo-vscode/webview-ui/src/i18n/zh.ts b/packages/kilo-vscode/webview-ui/src/i18n/zh.ts index d1742c8b2e..344b079161 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/zh.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/zh.ts @@ -859,7 +859,7 @@ export const dict = { "settings.sandboxing.allowedHosts.title": "允许的网络目标", "settings.sandboxing.allowedHosts.description": - "用于沙盒化 HTTP 和 HTTPS 代理流量的 DNS 主机和端口目标。GitHub CLI 和 HTTPS Git 通常需要 github.com:443 和 api.github.com:443。更改将应用于新会话。", + "用于沙盒化 HTTP 和 HTTPS 代理流量的 DNS 主机和端口目标。GitHub CLI 和 HTTPS Git 通常需要 github.com:443 和 api.github.com:443。", "settings.sandboxing.writablePaths.title": "额外可写路径", "settings.sandboxing.writablePaths.description": "沙盒允许写入的额外文件系统路径(例如 /tmp、/var/log)。沙盒启用后,这些路径会与默认可写路径合并。", diff --git a/packages/kilo-vscode/webview-ui/src/i18n/zht.ts b/packages/kilo-vscode/webview-ui/src/i18n/zht.ts index b7ef8bce0a..1e7c7001df 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/zht.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/zht.ts @@ -819,7 +819,7 @@ export const dict = { "settings.sandboxing.allowedHosts.title": "允許的網路目的地", "settings.sandboxing.allowedHosts.description": - "適用於沙盒 HTTP 和 HTTPS Proxy 流量的 DNS 主機與連接埠目標。GitHub CLI 和 HTTPS Git 通常需要 github.com:443 和 api.github.com:443。變更將套用至新工作階段。", + "適用於沙盒 HTTP 和 HTTPS Proxy 流量的 DNS 主機與連接埠目標。GitHub CLI 和 HTTPS Git 通常需要 github.com:443 和 api.github.com:443。", "settings.sandboxing.writablePaths.title": "額外可寫路徑", "settings.sandboxing.writablePaths.description": "沙盒允許寫入的額外檔案系統路徑(例如 /tmp、/var/log)。沙盒啟用後,這些路徑會與預設可寫路徑合併。", diff --git a/packages/opencode/src/config/config.ts b/packages/opencode/src/config/config.ts index 02dc30b52f..0d5e87b932 100644 --- a/packages/opencode/src/config/config.ts +++ b/packages/opencode/src/config/config.ts @@ -1014,7 +1014,7 @@ const layer = Layer.effect( directory: ctx.directory, payload: { type: Event.ConfigUpdated.type, - properties: {}, + properties: { sandbox: Object.hasOwn(config, "sandbox") }, }, }), ) @@ -1069,6 +1069,7 @@ const layer = Layer.effect( .pipe(Effect.orDie) const next = result.next const changed = result.changed + const sandboxChanged = changed && Object.hasOwn(config, "sandbox") // kilocode_change end // kilocode_change start - skip dispose when caller opts out @@ -1080,7 +1081,7 @@ const layer = Layer.effect( directory: "global", payload: { type: Event.ConfigUpdated.type, - properties: {}, + properties: { sandbox: sandboxChanged }, }, }), ).pipe(Effect.catchCause(() => Effect.void)) @@ -1097,7 +1098,7 @@ const layer = Layer.effect( directory: "global", payload: { type: Event.ConfigUpdated.type, - properties: {}, + properties: { sandbox: sandboxChanged }, }, }), ).pipe(Effect.catchCause(() => Effect.void)) diff --git a/packages/opencode/src/format/index.ts b/packages/opencode/src/format/index.ts index 4288f83efc..418d01a325 100644 --- a/packages/opencode/src/format/index.ts +++ b/packages/opencode/src/format/index.ts @@ -9,6 +9,7 @@ import { mergeDeep } from "remeda" import { Config } from "@/config/config" import { RuntimeFlags } from "@/effect/runtime-flags" import { errorMessage } from "@/util/error" +import { model as modelEnv } from "@/kilocode/process/env" // kilocode_change import * as Formatter from "./formatter" export const Status = Schema.Struct({ @@ -85,8 +86,8 @@ const layer = Layer.effect( .run( ChildProcess.make(replaced[0]!, replaced.slice(1), { cwd: dir, - env: item.environment, - extendEnv: true, + env: modelEnv(item.environment), // kilocode_change - formatters must not inherit backend credentials + extendEnv: false, // kilocode_change stdin: "ignore", stdout: "ignore", stderr: "ignore", diff --git a/packages/opencode/src/kilocode/background-process/index.ts b/packages/opencode/src/kilocode/background-process/index.ts index e022911c95..3433baf243 100644 --- a/packages/opencode/src/kilocode/background-process/index.ts +++ b/packages/opencode/src/kilocode/background-process/index.ts @@ -5,6 +5,7 @@ import { makeRuntime } from "@/effect/run-service" import { Identifier } from "@/id/id" import { Instance, type InstanceContext } from "@/kilocode/instance" import { KiloShutdown } from "@/kilocode/cli/shutdown" +import { model as modelEnv } from "@/kilocode/process/env" import { SessionID } from "@/session/schema" import { Shell } from "@opencode-ai/core/shell" import { ProjectV2 } from "@opencode-ai/core/project" @@ -574,14 +575,11 @@ export namespace BackgroundProcess { } function env(id?: ID, token?: string) { - const result: NodeJS.ProcessEnv = { - ...process.env, + const result: NodeJS.ProcessEnv = modelEnv({ TERM: "dumb", ...(id ? { KILO_BACKGROUND_PROCESS_ID: id } : {}), ...(token ? { KILO_BACKGROUND_PROCESS_TOKEN: token } : {}), - } - delete result.KILO_SERVER_PASSWORD - delete result.KILO_SERVER_USERNAME + }) delete result.KILO_BACKGROUND_PROCESS_PORTS return result } diff --git a/packages/opencode/src/kilocode/config/writer.ts b/packages/opencode/src/kilocode/config/writer.ts index 162ee82126..5d7a4a4d7a 100644 --- a/packages/opencode/src/kilocode/config/writer.ts +++ b/packages/opencode/src/kilocode/config/writer.ts @@ -15,7 +15,12 @@ export namespace KilocodeConfigWriter { target: KilocodeConfigOverlay.Target } - export type Result = { ok: true; target: KilocodeConfigOverlay.Target } | Conflict + export type Result = { + ok: true + target: KilocodeConfigOverlay.Target + changed: boolean + sandboxChanged: boolean + } | Conflict export async function write(input: { directory: string @@ -45,7 +50,7 @@ export namespace KilocodeConfigWriter { } const patch = KilocodeConfigOverlay.patch({ scope: input.scope, set: input.set, unset: input.unset }) - if (Object.keys(patch).length === 0) return { ok: true, target } + if (Object.keys(patch).length === 0) return { ok: true, target, changed: false, sandboxChanged: false } await mkdir(path.dirname(target.path), { recursive: true }) await input.beforeWrite?.() const checked = await KilocodeConfigOverlay.target(input) @@ -77,7 +82,12 @@ export namespace KilocodeConfigWriter { ? 0o600 : undefined if (updated !== before) await (input.write ?? Filesystem.write)(checked.path, updated, mode) - return { ok: true, target: await KilocodeConfigOverlay.target(input) } + return { + ok: true, + target: await KilocodeConfigOverlay.target(input), + changed: updated !== before, + sandboxChanged: updated !== before && Object.hasOwn(patch, "sandbox"), + } } function patchJsonc(input: string, patch: unknown, parts: string[] = []): string { diff --git a/packages/opencode/src/kilocode/interactive-terminal/index.ts b/packages/opencode/src/kilocode/interactive-terminal/index.ts index 7b5afca568..ce38fefab0 100644 --- a/packages/opencode/src/kilocode/interactive-terminal/index.ts +++ b/packages/opencode/src/kilocode/interactive-terminal/index.ts @@ -3,6 +3,7 @@ import { BusEvent } from "@/bus/bus-event" import { InstanceState } from "@/effect/instance-state" import { makeRuntime } from "@/effect/run-service" import { appendTerminalOutput } from "@/kilocode/interactive-terminal/output" +import { model as modelEnv } from "@/kilocode/process/env" import { Identifier } from "@/id/id" import { Instance, type InstanceContext } from "@/kilocode/instance" import { SessionID } from "@/session/schema" @@ -220,14 +221,10 @@ export namespace InteractiveTerminal { } function environment(input: NodeJS.ProcessEnv) { - const env = Object.fromEntries( - Object.entries(input).filter((entry): entry is [string, string] => entry[1] !== undefined), - ) + const env = modelEnv(input) env.TERM = "xterm-256color" env.KILO_TERMINAL = "1" env.KILO_INTERACTIVE_TERMINAL = "1" - delete env.KILO_SERVER_PASSWORD - delete env.KILO_SERVER_USERNAME if (process.platform === "win32") { env.LC_ALL = "C.UTF-8" env.LC_CTYPE = "C.UTF-8" diff --git a/packages/opencode/src/kilocode/process/env.ts b/packages/opencode/src/kilocode/process/env.ts new file mode 100644 index 0000000000..87b190b009 --- /dev/null +++ b/packages/opencode/src/kilocode/process/env.ts @@ -0,0 +1,13 @@ +export function model(extra?: NodeJS.ProcessEnv | null): Record { + const env = Object.fromEntries( + Object.entries({ ...process.env, ...(extra ?? {}) }).filter( + (entry): entry is [string, string] => typeof entry[1] === "string", + ), + ) + delete env.KILO_SERVER_PASSWORD + delete env.KILO_SERVER_USERNAME + delete env.KILO_CONFIG + delete env.KILO_CONFIG_CONTENT + delete env.KILO_CONFIG_DIR + return env +} diff --git a/packages/opencode/src/kilocode/sandbox/policy.ts b/packages/opencode/src/kilocode/sandbox/policy.ts index 77999ca260..3b88543936 100644 --- a/packages/opencode/src/kilocode/sandbox/policy.ts +++ b/packages/opencode/src/kilocode/sandbox/policy.ts @@ -3,8 +3,10 @@ import os from "node:os" import path from "node:path" import { Effect, Semaphore } from "effect" import { Global } from "@opencode-ai/core/global" +import { Database } from "@opencode-ai/core/database/database" import { backendSupport, run as runSandbox, unrestricted, type Profile } from "@kilocode/sandbox" import { Bus } from "@/bus" +import { GlobalBus } from "@/bus/global" import { Instance } from "@/kilocode/instance" import { Config } from "@/config/config" import { InstanceState } from "@/effect/instance-state" @@ -21,14 +23,62 @@ export type Snapshot = SandboxStore.Snapshot export type Target = { id: SessionID; directory: string } const snapshots = new Map() +const synced = new Map() const locks = new Map() +const refreshes = new Map() const gates = new Map() const permits = 1_000_000 +let revision = 0 + +GlobalBus.on("event", (event) => { + if (event.payload?.type === "global.config.updated" && event.payload.properties?.sandbox === true) revision++ +}) function key(directory: string, sessionID: SessionID) { return directory + "\0" + sessionID } +function limits(fallback: ReturnType) { + return { + mode: fallback.mode, + allowedHosts: fallback.allowedHosts, + writablePaths: fallback.writablePaths.map((value) => + value.startsWith("~") ? path.join(os.homedir(), value.slice(1)) : value, + ), + } +} + +function apply(current: Snapshot, fallback: ReturnType) { + return { ...current, ...limits(fallback) } +} + +function matches(current: Snapshot, next: Snapshot) { + return ( + current.mode === next.mode && + current.allowedHosts.join("\0") === next.allowedHosts.join("\0") && + current.writablePaths.join("\0") === next.writablePaths.join("\0") + ) +} + +function changed(sessionID: SessionID, directory: string, next: Snapshot) { + const support = backendSupport({ mode: next.mode, allowedHosts: next.allowedHosts }) + GlobalBus.emit("event", { + directory, + payload: { + id: Bus.createID(), + type: Changed.type, + properties: { + sessionID, + directory, + enabled: next.enabled && support.available, + available: support.available, + reason: support.reason, + version: next.version, + }, + }, + }) +} + function initial( chosen: boolean | undefined, pref: boolean | undefined, @@ -36,11 +86,7 @@ function initial( fallback: ReturnType, ): Snapshot { const state = { - mode: fallback.mode, - allowedHosts: fallback.allowedHosts, - writablePaths: fallback.writablePaths.map((value) => - value.startsWith("~") ? path.join(os.homedir(), value.slice(1)) : value, - ), + ...limits(fallback), version: 0, } if (chosen !== undefined) return { ...state, enabled: chosen } @@ -76,6 +122,23 @@ function lockedAll(sessions: readonly SessionID[], effect: Effect.Effec return [...new Set(sessions)].reduceRight((next, sessionID) => locked(sessionID, next), effect) } +function refreshing(sessionID: SessionID, effect: Effect.Effect) { + return Effect.acquireUseRelease( + Effect.sync(() => { + const entry = refreshes.get(sessionID) ?? { semaphore: Semaphore.makeUnsafe(1), refs: 0 } + entry.refs++ + refreshes.set(sessionID, entry) + return entry + }), + (entry) => entry.semaphore.withPermits(1)(effect), + (entry) => + Effect.sync(() => { + entry.refs-- + if (entry.refs === 0 && refreshes.get(sessionID) === entry) refreshes.delete(sessionID) + }), + ) +} + function gated(sessionID: SessionID, count: number, effect: Effect.Effect) { return Effect.acquireUseRelease( Effect.sync(() => { @@ -166,13 +229,7 @@ export function profile( allowedHosts, }, environment: { - deny: [ - "KILO_CONFIG", - "KILO_CONFIG_CONTENT", - "KILO_CONFIG_DIR", - "KILO_SERVER_PASSWORD", - "KILO_SERVER_USERNAME", - ], + deny: ["KILO_CONFIG", "KILO_CONFIG_CONTENT", "KILO_CONFIG_DIR", "KILO_SERVER_PASSWORD", "KILO_SERVER_USERNAME"], set: { TMPDIR: Global.Path.tmp, TMP: Global.Path.tmp, @@ -205,14 +262,31 @@ const snapshot = Effect.fn("SandboxPolicy.snapshot")(function* (sessionID: Sessi // session moved or created with an explicit choice keeps that choice instead of resetting. The // persisted per-directory preference (last toggled state) is the next precedence, so new sessions // inherit the last /sandbox choice. The config default applies when neither is present. + const version = revision const next = yield* resolveInitial(directory, sessionID) yield* Effect.promise(() => SandboxStore.write(directory, sessionID, next)) - snapshots.set(key(directory, sessionID), next) + const id = key(directory, sessionID) + snapshots.set(id, next) + synced.set(id, version) return { directory, state: next } }), ) }) +function current( + sessionID: SessionID, + inside = false, +): Effect.Effect<{ directory: string; state: Snapshot }, never, Config.Service | Database.Service> { + return Effect.gen(function* () { + const expected = revision + const state = yield* snapshot(sessionID) + const id = key(state.directory, sessionID) + if (synced.get(id) !== expected) yield* inside ? reconcile(sessionID, expected) : refresh(sessionID, expected) + if (revision !== expected) return yield* current(sessionID, inside) + return yield* snapshot(sessionID) + }) +} + export const configuredSupport = Effect.fn("SandboxPolicy.configuredSupport")(function* () { const cfg = yield* (yield* Config.Service).get() const state = SandboxConfig.resolve(cfg) @@ -224,22 +298,53 @@ export function fallback(config: Config.Info) { } export const status = Effect.fn("SandboxPolicy.status")(function* (sessionID: SessionID) { - const current = yield* snapshot(sessionID) - const support = backendSupport({ mode: current.state.mode, allowedHosts: current.state.allowedHosts }) + const active = yield* current(sessionID) + const support = backendSupport({ mode: active.state.mode, allowedHosts: active.state.allowedHosts }) return { - directory: current.directory, - enabled: current.state.enabled && support.available, + directory: active.directory, + enabled: active.state.enabled && support.available, available: support.available, reason: support.reason, - version: current.state.version, + version: active.state.version, } }) export const networkRestricted = Effect.fn("SandboxPolicy.networkRestricted")(function* (sessionID: SessionID) { - const current = yield* snapshot(sessionID) - return current.state.enabled && current.state.mode !== "allow" + const active = yield* current(sessionID) + return active.state.enabled && active.state.mode !== "allow" }) +const reconcile = Effect.fn("SandboxPolicy.reconcile")(function* (sessionID: SessionID, version = revision) { + const directory = yield* InstanceState.directory + return yield* refreshing( + sessionID, + Effect.gen(function* () { + const id = key(directory, sessionID) + if (synced.get(id) === version) return false + const current = yield* read(directory, sessionID) + if (!current) return false + const config = yield* (yield* Config.Service).get() + const next: Snapshot = { + ...apply(current, SandboxConfig.resolve(config)), + version: current.version + 1, + } + if (matches(current, next)) { + synced.set(id, version) + return false + } + yield* Effect.promise(() => SandboxStore.write(directory, sessionID, next)) + snapshots.set(id, next) + synced.set(id, version) + yield* Effect.sync(() => changed(sessionID, directory, next)) + return true + }), + ) +}) + +export const refresh = Effect.fn("SandboxPolicy.refresh")((sessionID: SessionID, version = revision) => + locked(sessionID, reconcile(sessionID, version)), +) + function change( sessionID: SessionID, guard: @@ -255,7 +360,10 @@ function change( Effect.gen(function* () { const stored = yield* read(directory, sessionID) const current = stored ?? (yield* resolveInitial(directory, sessionID)) - const support = backendSupport({ mode: current.mode, allowedHosts: current.allowedHosts }) + const enabling = !current.enabled + const version = revision + const base = enabling ? apply(current, SandboxConfig.resolve(yield* (yield* Config.Service).get())) : current + const support = backendSupport({ mode: base.mode, allowedHosts: base.allowedHosts }) const status = { directory, enabled: current.enabled && support.available, @@ -263,34 +371,40 @@ function change( reason: support.reason, version: current.version, } - const enabling = !current.enabled if (enabling && !status.available) return status const targets = enabling && family ? yield* family : [{ id: sessionID, directory }] const sessions = targets.map((target) => target.id) - const update = Effect.gen(function* () { - yield* typeof guard === "function" ? guard(enabling, targets) : guard - const next: Snapshot = { ...current, enabled: enabling, version: status.version + 1 } - yield* Effect.promise(() => SandboxStore.write(directory, sessionID, next)) - snapshots.set(key(directory, sessionID), next) - if (enabling) { - yield* Effect.forEach( - targets, - (target) => - target.id === sessionID ? Effect.void : inheritSnapshot(target.directory, next, target.id), - { discard: true }, + const update = refreshing( + sessionID, + Effect.gen(function* () { + yield* typeof guard === "function" ? guard(enabling, targets) : guard + const next: Snapshot = { ...base, enabled: enabling, version: status.version + 1 } + yield* Effect.promise(() => SandboxStore.write(directory, sessionID, next)) + const id = key(directory, sessionID) + snapshots.set(id, next) + synced.set(id, version) + if (enabling) { + yield* Effect.forEach( + targets, + (target) => + target.id === sessionID + ? Effect.void + : refreshing(target.id, inheritSnapshot(target.directory, next, target.id)), + { discard: true }, + ) + } + // The per-session SandboxStore is the authoritative state; the per-directory + // preference only seeds future sessions. A preference write failure must not + // fail the toggle or desync the in-memory cache from the persisted snapshot. + yield* Effect.promise(() => SandboxPreference.write(directory, next.enabled)).pipe( + Effect.catch(() => Effect.void), ) - } - // The per-session SandboxStore is the authoritative state; the per-directory - // preference only seeds future sessions. A preference write failure must not - // fail the toggle or desync the in-memory cache from the persisted snapshot. - yield* Effect.promise(() => SandboxPreference.write(directory, next.enabled)).pipe( - Effect.catch(() => Effect.void), - ) - const value = { ...status, enabled: next.enabled && support.available, version: next.version } - // Publish through the standalone Bus facade so HTTP handlers do not need Bus.Service. - yield* Effect.promise(() => Bus.publish(Instance.current, Changed, { sessionID, ...value })) - return value - }) + const value = { ...status, enabled: next.enabled && support.available, version: next.version } + // Publish through the standalone Bus facade so HTTP handlers do not need Bus.Service. + yield* Effect.promise(() => Bus.publish(Instance.current, Changed, { sessionID, ...value })) + return value + }), + ) if (enabling) { const children = sessions.filter((id) => id !== sessionID) return yield* lockedAll( @@ -339,6 +453,7 @@ const inheritSnapshot = Effect.fn("SandboxPolicy.inheritSnapshot")(function* ( directory: string, parent: Snapshot, sessionID: SessionID, + version = revision, ) { const child = yield* read(directory, sessionID) const next: Snapshot = child @@ -357,7 +472,10 @@ const inheritSnapshot = Effect.fn("SandboxPolicy.inheritSnapshot")(function* ( ) return yield* Effect.promise(() => SandboxStore.write(directory, sessionID, next)) - snapshots.set(key(directory, sessionID), next) + const id = key(directory, sessionID) + snapshots.set(id, next) + synced.set(id, version) + yield* Effect.sync(() => changed(sessionID, directory, next)) }) export const inherit = Effect.fn("SandboxPolicy.inherit")(function* ( @@ -368,7 +486,7 @@ export const inherit = Effect.fn("SandboxPolicy.inherit")(function* ( ) { const directory = yield* InstanceState.directory const source = sourceDirectory ?? directory - yield* locked( + yield* refreshing( parentID, Effect.gen(function* () { const stored = yield* read(source, parentID) @@ -377,9 +495,9 @@ export const inherit = Effect.fn("SandboxPolicy.inherit")(function* ( // Only persist the parent snapshot when it actually belongs to this directory. A fallback // carries confinement from another directory (e.g. forking into a worktree) and must not be // written back under the parent's key here, or it leaks a phantom parent record. - yield* locked( + yield* refreshing( sessionID, - inheritSnapshot(directory, parent, sessionID), + inheritSnapshot(directory, parent, sessionID, synced.get(key(source, parentID)) ?? -1), ) }), ) @@ -404,10 +522,17 @@ export function retire( return locked( sessionID, Effect.gen(function* () { - const result = yield* effect - yield* Effect.promise(() => SandboxStore.remove(directory, sessionID)) - snapshots.delete(key(directory, sessionID)) - return result + return yield* refreshing( + sessionID, + Effect.gen(function* () { + const result = yield* effect + yield* Effect.promise(() => SandboxStore.remove(directory, sessionID)) + const id = key(directory, sessionID) + snapshots.delete(id) + synced.delete(id) + return result + }), + ) }), ) } @@ -416,13 +541,20 @@ export function dispose(sessionID: SessionID, effect: Effect.Effect SandboxStore.dispose(sessionID)) - const suffix = "\0" + sessionID - for (const id of snapshots.keys()) { - if (id.endsWith(suffix)) snapshots.delete(id) - } - return result + return yield* refreshing( + sessionID, + Effect.gen(function* () { + const result = yield* effect + yield* Effect.promise(() => SandboxStore.dispose(sessionID)) + const suffix = "\0" + sessionID + for (const id of snapshots.keys()) { + if (!id.endsWith(suffix)) continue + snapshots.delete(id) + synced.delete(id) + } + return result + }), + ) }), ) } @@ -436,20 +568,18 @@ function execute(sessionID: SessionID, effect: Effect.Effect) sessionID, 1, Effect.gen(function* () { - const current = yield* snapshot(sessionID) - if (!current.state.enabled) return yield* unrestricted(effect) - const support = backendSupport({ mode: current.state.mode, allowedHosts: current.state.allowedHosts }) + const active = yield* current(sessionID, true) + if (!active.state.enabled) return yield* unrestricted(effect) + const support = backendSupport({ mode: active.state.mode, allowedHosts: active.state.allowedHosts }) if (!support.available) { - return yield* Effect.fail( - new Error(support.reason ?? "The configured sandbox backend is unavailable"), - ) + return yield* Effect.fail(new Error(support.reason ?? "The configured sandbox backend is unavailable")) } return yield* runSandbox( profile( yield* InstanceState.context, - current.state.mode, - current.state.writablePaths, - current.state.allowedHosts, + active.state.mode, + active.state.writablePaths, + active.state.allowedHosts, ), effect, ) diff --git a/packages/opencode/src/kilocode/sandbox/state.ts b/packages/opencode/src/kilocode/sandbox/state.ts index bd3834854c..c4e88349c3 100644 --- a/packages/opencode/src/kilocode/sandbox/state.ts +++ b/packages/opencode/src/kilocode/sandbox/state.ts @@ -75,10 +75,10 @@ export const clear = Effect.fn("SandboxState.clear")(function* (sessionID: Sessi .transaction((tx) => Effect.gen(function* () { const row = yield* tx - .select({ metadata: SessionTable.metadata }) - .from(SessionTable) - .where(eq(SessionTable.id, sessionID)) - .get() + .select({ metadata: SessionTable.metadata }) + .from(SessionTable) + .where(eq(SessionTable.id, sessionID)) + .get() if (!row) return yield* tx .update(SessionTable) diff --git a/packages/opencode/src/kilocode/sandbox/store.ts b/packages/opencode/src/kilocode/sandbox/store.ts index b9486b69a0..5e733949a1 100644 --- a/packages/opencode/src/kilocode/sandbox/store.ts +++ b/packages/opencode/src/kilocode/sandbox/store.ts @@ -7,7 +7,7 @@ import type { Profile } from "@kilocode/sandbox" import type { SessionID } from "@/session/schema" export namespace SandboxStore { - /** Session confinement authority captured independently from later configuration reloads. */ + /** Persisted session confinement authority, refreshed from trusted settings between tool executions. */ export type Snapshot = { enabled: boolean mode: Profile["network"]["mode"] @@ -42,7 +42,8 @@ export namespace SandboxStore { if (state.allowedHosts !== undefined && !Array.isArray(state.allowedHosts)) return false if (state.writablePaths !== undefined && !Array.isArray(state.writablePaths)) return false if (Array.isArray(state.allowedHosts) && state.allowedHosts.some((value) => typeof value !== "string")) return false - if (Array.isArray(state.writablePaths) && state.writablePaths.some((value) => typeof value !== "string")) return false + if (Array.isArray(state.writablePaths) && state.writablePaths.some((value) => typeof value !== "string")) + return false if (state.mode === "proxy" && (!Array.isArray(state.allowedHosts) || state.allowedHosts.length === 0)) return false return true } diff --git a/packages/opencode/src/kilocode/server/httpapi/handlers/config-console.ts b/packages/opencode/src/kilocode/server/httpapi/handlers/config-console.ts index eb528da1c2..85720a61bd 100644 --- a/packages/opencode/src/kilocode/server/httpapi/handlers/config-console.ts +++ b/packages/opencode/src/kilocode/server/httpapi/handlers/config-console.ts @@ -1,5 +1,6 @@ import { Account } from "@/account/account" import { Auth } from "@/auth" +import { GlobalBus } from "@/bus/global" import { Config } from "@/config/config" import * as InstanceState from "@/effect/instance-state" import { KilocodeConfigOverlay } from "@/kilocode/config/overlay" @@ -10,6 +11,7 @@ import { ConfigRules } from "@/kilocode/server/routes/config-rules" import { KilocodeKeybinds } from "@/kilocode/tui/keybinds" import { KilocodeTuiConfig } from "@/kilocode/tui/config" import { disposeAllInstancesAndEmitGlobalDisposed } from "@/server/global-lifecycle" +import { Event } from "@/server/event" import { InstanceHttpApi } from "@/server/routes/instance/httpapi/api" import { markInstanceForDisposal } from "@/server/routes/instance/httpapi/lifecycle" import { InvalidRequestError } from "@/server/routes/instance/httpapi/errors" @@ -106,8 +108,30 @@ export const configConsoleHandlers = HttpApiBuilder.group(InstanceHttpApi, "conf const hot = body.scope === "global" && Object.keys(patch).every((key) => key === "console") if (body.scope === "global") { yield* config.invalidate() + if (result.changed) { + yield* Effect.sync(() => + GlobalBus.emit("event", { + directory: "global", + payload: { + type: Event.ConfigUpdated.type, + properties: { sandbox: result.sandboxChanged }, + }, + }), + ).pipe(Effect.catchCause(() => Effect.void)) + } } else { yield* config.update({}) + if (result.sandboxChanged) { + yield* Effect.sync(() => + GlobalBus.emit("event", { + directory: instance.directory, + payload: { + type: Event.ConfigUpdated.type, + properties: { sandbox: true }, + }, + }), + ).pipe(Effect.catchCause(() => Effect.void)) + } yield* markInstanceForDisposal(instance) } const all = yield* auth.all().pipe(Effect.orElseSucceed(() => ({}))) @@ -140,7 +164,7 @@ export const configConsoleHandlers = HttpApiBuilder.group(InstanceHttpApi, "conf sources: sources.sources, }), ) - if (body.scope === "global" && !hot) { + if (body.scope === "global" && result.changed && !hot) { yield* disposeAllInstancesAndEmitGlobalDisposed({ swallowErrors: true }).pipe( Effect.catchCause(() => Effect.void), ) diff --git a/packages/opencode/src/lsp/launch.ts b/packages/opencode/src/lsp/launch.ts index 4f687b1972..642509750e 100644 --- a/packages/opencode/src/lsp/launch.ts +++ b/packages/opencode/src/lsp/launch.ts @@ -1,5 +1,6 @@ import type { ChildProcessWithoutNullStreams } from "child_process" import { Process } from "@/util/process" +import { model as modelEnv } from "@/kilocode/process/env" // kilocode_change type Child = Process.Child & ChildProcessWithoutNullStreams @@ -10,6 +11,8 @@ export function spawn(cmd: string, argsOrOpts?: string[] | Process.Options, opts const cfg = Array.isArray(argsOrOpts) ? opts : argsOrOpts const proc = Process.spawn([cmd, ...args], { ...cfg, + env: modelEnv(cfg?.env), // kilocode_change - language servers must not inherit backend credentials + extendEnv: false, // kilocode_change stdin: "pipe", stdout: "pipe", stderr: "pipe", diff --git a/packages/opencode/src/mcp/index.ts b/packages/opencode/src/mcp/index.ts index 50e2d92f6c..8729be5421 100644 --- a/packages/opencode/src/mcp/index.ts +++ b/packages/opencode/src/mcp/index.ts @@ -39,6 +39,7 @@ import { TuiEvent } from "@/server/tui-event" import open from "open" import { Cause, Effect, Exit, Layer, Context, Schema, Stream } from "effect" import { EffectBridge } from "@/effect/bridge" +import { model as modelEnv } from "@/kilocode/process/env" // kilocode_change import { InstanceState } from "@/effect/instance-state" import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process" import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner" @@ -367,11 +368,12 @@ const layer = Layer.effect( command: cmd, args: finalArgs, // kilocode_change cwd, - env: { - ...process.env, + // kilocode_change start - local MCPs must not inherit backend credentials + env: modelEnv({ ...(cmd === "opencode" ? { BUN_BE_BUN: "1" } : {}), ...mcp.environment, - }, + }), + // kilocode_change end }) // kilocode_change start - a piped stderr stream must be consumed or verbose MCP servers can block transport.stderr?.on("data", (chunk: Buffer) => { diff --git a/packages/opencode/src/tool/shell.ts b/packages/opencode/src/tool/shell.ts index 31ab3e36fd..41d464db5f 100644 --- a/packages/opencode/src/tool/shell.ts +++ b/packages/opencode/src/tool/shell.ts @@ -12,6 +12,7 @@ import { FSUtil } from "@opencode-ai/core/fs-util" import { fileURLToPath } from "url" import { Config } from "@/config/config" import { RuntimeFlags } from "@/effect/runtime-flags" +import { model as modelEnv } from "@/kilocode/process/env" // kilocode_change import { Shell } from "@opencode-ai/core/shell" import { ShellID } from "./shell/id" @@ -522,10 +523,7 @@ export const ShellTool = Tool.define( { cwd, sessionID: ctx.sessionID, callID: ctx.callID }, { env: {} }, ) - return { - ...process.env, - ...extra.env, - } + return modelEnv(extra.env) // kilocode_change - model shells must not inherit backend credentials }) const run = Effect.fn("ShellTool.run")(function* ( diff --git a/packages/opencode/src/util/process.ts b/packages/opencode/src/util/process.ts index 173210f23c..334e682df9 100644 --- a/packages/opencode/src/util/process.ts +++ b/packages/opencode/src/util/process.ts @@ -10,6 +10,7 @@ export type Shell = boolean | string export interface Options { cwd?: string env?: NodeJS.ProcessEnv | null + extendEnv?: boolean // kilocode_change - allow a complete sanitized environment stdin?: Stdio stdout?: Stdio stderr?: Stdio @@ -63,7 +64,7 @@ export function spawn(cmd: string[], opts: Options = {}): Child { const proc = launch(cmd[0], cmd.slice(1), { cwd: opts.cwd, shell: opts.shell, - env: opts.env === null ? {} : opts.env ? { ...process.env, ...opts.env } : undefined, + env: opts.env === null ? {} : opts.env ? (opts.extendEnv === false ? opts.env : { ...process.env, ...opts.env }) : undefined, // kilocode_change stdio: [opts.stdin ?? "ignore", opts.stdout ?? "ignore", opts.stderr ?? "ignore"], windowsHide: process.platform === "win32", }) @@ -115,6 +116,7 @@ export async function run(cmd: string[], opts: RunOptions = {}): Promise const proc = spawn(cmd, { cwd: opts.cwd, env: opts.env, + extendEnv: opts.extendEnv, // kilocode_change stdin: opts.stdin, shell: opts.shell, abort: opts.abort, diff --git a/packages/opencode/test/kilocode/config/config.test.ts b/packages/opencode/test/kilocode/config/config.test.ts index 2a4c8306d7..2b2d7eeba0 100644 --- a/packages/opencode/test/kilocode/config/config.test.ts +++ b/packages/opencode/test/kilocode/config/config.test.ts @@ -12,6 +12,7 @@ import { Npm } from "@opencode-ai/core/npm" import { HttpClient } from "effect/unstable/http" import { Account } from "../../../src/account/account" import { Auth } from "../../../src/auth" +import { GlobalBus } from "../../../src/bus/global" import { Config } from "../../../src/config/config" import { ConfigMarkdown } from "../../../src/config/markdown" import { ConfigParse } from "../../../src/config/parse" @@ -110,6 +111,45 @@ describe("markdown substitutions", () => { }) describe("global config updates", () => { + test("marks only sandbox updates for live policy refresh", async () => { + await using globalTmp = await tmpdir() + await using tmp = await tmpdir() + const prev = Global.Path.config + ;(Global.Path as { config: string }).config = globalTmp.path + await clear() + await disposeAllInstances() + const events: Array<{ payload?: { type?: string; properties?: { sandbox?: boolean } } }> = [] + const listener = (event: (typeof events)[number]) => events.push(event) + GlobalBus.on("event", listener) + + try { + await provideTestInstance({ + directory: tmp.path, + fn: async () => { + await Effect.runPromise( + Config.Service.use((svc) => + Effect.all([ + svc.updateGlobal({ permission: { edit: "ask" } }, { dispose: false }), + svc.updateGlobal({ sandbox: { network: "deny" } }, { dispose: false }), + ]), + ).pipe(Effect.scoped, Effect.provide(layer)), + ) + }, + }) + + expect( + events + .filter((event) => event.payload?.type === "global.config.updated") + .map((event) => event.payload?.properties?.sandbox), + ).toEqual([false, true]) + } finally { + GlobalBus.off("event", listener) + ;(Global.Path as { config: string }).config = prev + await clear() + await disposeAllInstances() + } + }) + test("preserves concurrent permission updates", async () => { await using globalTmp = await tmpdir() await using tmp = await tmpdir() diff --git a/packages/opencode/test/kilocode/sandbox/state.test.ts b/packages/opencode/test/kilocode/sandbox/state.test.ts index eb6ed1cac6..6ce82cd3e4 100644 --- a/packages/opencode/test/kilocode/sandbox/state.test.ts +++ b/packages/opencode/test/kilocode/sandbox/state.test.ts @@ -10,6 +10,7 @@ import { Flag } from "@opencode-ai/core/flag/flag" import { Database } from "@opencode-ai/core/database/database" import { assertNetwork, assertWrite, enabled as sandboxed } from "@kilocode/sandbox" import { Bus } from "@/bus" +import { GlobalBus } from "@/bus/global" import { Config } from "@/config/config" import * as Network from "@/kilocode/sandbox/network" import * as SandboxPolicy from "@/kilocode/sandbox/policy" @@ -34,7 +35,7 @@ function execute(sessionID: SessionID, effect: Effect.Effect) return SandboxPolicy.executeTool(sessionID, tool, effect) } -test("restores the session snapshot after a backend restart", async () => { +test("refreshes the session snapshot after a backend restart", async () => { const root = await fs.mkdtemp(path.join(os.tmpdir(), "kilo-sandbox-restart-")) const directory = path.join(root, "project") await fs.mkdir(directory) @@ -98,7 +99,13 @@ test("restores the session snapshot after a backend restart", async () => { const restored = run({ sandbox: { enabled: false, network: "deny", allowed_hosts: ["evil.example"], writable_paths: ["/tmp/evil"] }, }) - expect(restored.state).toEqual(initial.state) + expect(restored.state).toEqual({ + enabled: true, + mode: "proxy", + allowedHosts: ["evil.example:443"], + writablePaths: ["/tmp/evil"], + version: 1, + }) expect(restored.status.enabled).toBe(restored.status.available) } finally { await fs.rm(root, { recursive: true, force: true }) @@ -178,7 +185,7 @@ linux("reports configured network namespace availability", async () => { } }) -it.instance("snapshots the primary kilo config for the session lifetime", () => +it.instance("does not let project config weaken an initialized policy", () => Effect.acquireUseRelease( Effect.sync(() => { const password = Flag.KILO_SERVER_PASSWORD @@ -208,6 +215,7 @@ it.instance("snapshots the primary kilo config for the session lifetime", () => expect((yield* SandboxPolicy.status(id)).enabled).toBe(true) expect(yield* execute(id, sandboxed)).toBe(true) expect(Exit.isFailure(yield* execute(id, assertNetwork("https://example.com").pipe(Effect.exit)))).toBe(true) + expect(yield* SandboxPolicy.peek(test.directory, id)).toMatchObject({ mode: "deny", version: 0 }) const next = SessionID.make("ses_sandbox_config_next") expect((yield* SandboxPolicy.status(next)).enabled).toBe(false) @@ -265,6 +273,182 @@ it.instance("applies configured writable paths during tool execution", () => }), ) +it.instance("refreshes an initialized policy from current settings", () => + Effect.gen(function* () { + const test = yield* TestInstance + const id = SessionID.make("ses_sandbox_refresh") + yield* Effect.promise(() => + SandboxStore.write(test.directory, id, { + enabled: false, + mode: "deny", + allowedHosts: [], + writablePaths: [], + version: 0, + }), + ) + yield* SandboxPolicy.peek(test.directory, id) + + const changed = yield* SandboxPolicy.refresh(id).pipe( + Effect.provide( + Layer.mock(Config.Service, { + get: () => + Effect.succeed({ + sandbox: { network: "allow", writable_paths: ["~/sandbox-refresh"] }, + }), + }), + ), + ) + + expect(changed).toBe(true) + expect(yield* SandboxPolicy.peek(test.directory, id)).toEqual({ + enabled: false, + mode: "allow", + allowedHosts: [], + writablePaths: [path.join(os.homedir(), "sandbox-refresh")], + version: 1, + }) + }), +) + +it.instance("uses current settings when enabling an initialized policy", () => + Effect.gen(function* () { + const test = yield* TestInstance + const id = SessionID.make("ses_sandbox_enable_refresh") + yield* Effect.promise(() => + SandboxStore.write(test.directory, id, { + enabled: false, + mode: "deny", + allowedHosts: [], + writablePaths: [], + version: 0, + }), + ) + + const status = yield* SandboxPolicy.toggle(id).pipe( + Effect.provide( + Layer.mock(Config.Service, { + get: () => + Effect.succeed({ + sandbox: { enabled: true, network: "allow", writable_paths: ["/sandbox-enable-refresh"] }, + }), + }), + ), + ) + + if (!status.available) { + expect(status.enabled).toBe(false) + expect(status.version).toBe(0) + expect(yield* SandboxPolicy.peek(test.directory, id)).toEqual({ + enabled: false, + mode: "deny", + allowedHosts: [], + writablePaths: [], + version: 0, + }) + return + } + + expect(status.enabled).toBe(true) + expect(status.version).toBe(1) + expect(yield* SandboxPolicy.peek(test.directory, id)).toEqual({ + enabled: true, + mode: "allow", + allowedHosts: [], + writablePaths: ["/sandbox-enable-refresh"], + version: 1, + }) + }), +) + +it.instance("applies trusted settings to inherited sessions", () => + Effect.gen(function* () { + const test = yield* TestInstance + const parent = SessionID.make("ses_sandbox_refresh_parent") + const child = SessionID.make("ses_sandbox_refresh_child") + yield* Effect.promise(() => + SandboxStore.write(test.directory, parent, { + enabled: true, + mode: "deny", + allowedHosts: [], + writablePaths: ["/shared"], + version: 0, + }), + ) + yield* Effect.promise(() => + SandboxStore.write(test.directory, child, { + enabled: false, + mode: "deny", + allowedHosts: [], + writablePaths: ["/shared"], + version: 0, + }), + ) + yield* SandboxPolicy.peek(test.directory, parent) + yield* SandboxPolicy.peek(test.directory, child) + + const config = Layer.mock(Config.Service, { + get: () => + Effect.succeed({ + sandbox: { network: "allow", writable_paths: ["/shared", "/new"] }, + }), + }) + yield* SandboxPolicy.refresh(parent).pipe(Effect.provide(config)) + yield* SandboxPolicy.refresh(child).pipe(Effect.provide(config)) + + expect(yield* SandboxPolicy.peek(test.directory, parent)).toMatchObject({ + enabled: true, + mode: "allow", + writablePaths: ["/shared", "/new"], + }) + expect(yield* SandboxPolicy.peek(test.directory, child)).toEqual({ + enabled: false, + mode: "allow", + allowedHosts: [], + writablePaths: ["/shared", "/new"], + version: 1, + }) + }), +) + +it.instance("emits a sandbox status event after refreshing policy", () => + Effect.gen(function* () { + const test = yield* TestInstance + const id = SessionID.make("ses_sandbox_refresh_event") + yield* Effect.promise(() => + SandboxStore.write(test.directory, id, { + enabled: true, + mode: "deny", + allowedHosts: [], + writablePaths: [], + version: 0, + }), + ) + const events: Array<{ directory?: string; payload: { type?: string; properties?: { sessionID?: string } } }> = [] + const listener = (event: (typeof events)[number]) => events.push(event) + GlobalBus.on("event", listener) + yield* Effect.addFinalizer(() => Effect.sync(() => GlobalBus.off("event", listener))) + + yield* SandboxPolicy.refresh(id).pipe( + Effect.provide( + Layer.mock(Config.Service, { + get: () => Effect.succeed({ sandbox: { network: "allow" } }), + }), + ), + ) + + expect(events).toContainEqual( + expect.objectContaining({ + directory: test.directory, + payload: expect.objectContaining({ + id: expect.any(String), + type: "sandbox.status.changed", + properties: expect.objectContaining({ sessionID: id }), + }), + }), + ) + }), +) + it.instance( "runs sandboxed when config is on and no override exists", () => @@ -412,6 +596,45 @@ it.instance("serializes activation with unrestricted tool start", () => }), ) +it.instance("refreshes queued tools after config changes", () => + (() => { + const config = { sandbox: { enabled: true, network: "allow" as "allow" | "deny" } } + return Effect.gen(function* () { + const id = SessionID.make("ses_sandbox_queued_refresh") + if (!(yield* SandboxPolicy.status(id)).available) return + + const entered = yield* Deferred.make() + const release = yield* Deferred.make() + const running = yield* execute( + id, + Effect.gen(function* () { + yield* Deferred.succeed(entered, undefined) + yield* Deferred.await(release) + return false + }), + ).pipe(Effect.forkChild) + yield* Deferred.await(entered) + + const queued = yield* execute(id, assertNetwork("https://example.com").pipe(Effect.exit)).pipe(Effect.forkChild) + config.sandbox.network = "deny" + GlobalBus.emit("event", { + directory: "global", + payload: { type: "global.config.updated", properties: { sandbox: true } }, + }) + yield* Deferred.succeed(release, undefined) + yield* Fiber.join(running) + expect(Exit.isFailure(yield* Fiber.join(queued))).toBe(true) + expect(yield* SandboxPolicy.peek((yield* TestInstance).directory, id)).toMatchObject({ mode: "deny" }) + }).pipe( + Effect.provide( + Layer.mock(Config.Service, { + get: () => Effect.succeed(config), + }), + ), + ) + })(), +) + it.instance("prevents a queued toggle from restoring a retired override", () => Effect.gen(function* () { const test = yield* TestInstance @@ -501,6 +724,56 @@ it.instance("intersects inherited network and write authority", () => }), ) +it.instance("refreshes a child inherited while its parent policy is stale", () => + (() => { + const config = { sandbox: { enabled: true, network: "allow" as "allow" | "deny" } } + return Effect.gen(function* () { + const parent = SessionID.make("ses_sandbox_stale_parent") + const child = SessionID.make("ses_sandbox_stale_child") + yield* SandboxPolicy.status(parent) + config.sandbox.network = "deny" + GlobalBus.emit("event", { + directory: "global", + payload: { type: "global.config.updated", properties: { sandbox: true } }, + }) + + yield* SandboxPolicy.inherit(parent, child) + yield* SandboxPolicy.status(child) + + expect(yield* SandboxPolicy.peek((yield* TestInstance).directory, child)).toMatchObject({ mode: "deny" }) + }).pipe( + Effect.provide( + Layer.mock(Config.Service, { + get: () => Effect.succeed(config), + }), + ), + ) + })(), +) + +it.instance("refreshes a cold child inherited from an untracked stored parent", () => + Effect.gen(function* () { + const test = yield* TestInstance + const parent = SessionID.make("ses_sandbox_cold_parent") + const child = SessionID.make("ses_sandbox_cold_child") + yield* Effect.promise(() => + SandboxStore.write(test.directory, parent, { + enabled: true, + mode: "allow", + allowedHosts: [], + writablePaths: [], + version: 0, + }), + ) + + yield* SandboxPolicy.inherit(parent, child) + yield* SandboxPolicy.status(child) + + expect(yield* SandboxPolicy.peek(test.directory, child)).toMatchObject({ mode: "deny" }) + }), + { config: { sandbox: { enabled: true, network: "deny" } } }, +) + it.instance("enforces writes only while the macOS session override is active", () => Effect.gen(function* () { if (process.platform !== "darwin") return diff --git a/packages/opencode/test/kilocode/server/config-overlay.test.ts b/packages/opencode/test/kilocode/server/config-overlay.test.ts index 5a331d0fce..f87246fb36 100644 --- a/packages/opencode/test/kilocode/server/config-overlay.test.ts +++ b/packages/opencode/test/kilocode/server/config-overlay.test.ts @@ -10,6 +10,9 @@ import { KilocodeConfigOverlay } from "../../../src/kilocode/config/overlay" import { KilocodeConfigWriter } from "../../../src/kilocode/config/writer" import { Permission } from "../../../src/permission" import { PtyPaths } from "../../../src/server/routes/instance/httpapi/groups/pty" +import { SessionPaths } from "../../../src/server/routes/instance/httpapi/groups/session" +import { SandboxStore } from "../../../src/kilocode/sandbox/store" +import type { Session } from "../../../src/session/session" import { Filesystem } from "../../../src/util/filesystem" import { resetDatabase } from "../../fixture/db" import { disposeAllInstances, tmpdir } from "../../fixture/fixture" @@ -673,30 +676,28 @@ describe("config overlay routes", () => { expect(saved.mcp).toEqual({ shared: { enabled: false } }) }) - test.serial( - "refreshes effective config after project permission update", - async () => { - await using global = await tmpdir() - await using project = await tmpdir() - await setGlobal(global.path, { permission: { edit: "allow" } }) + test.serial("refreshes effective config after project permission update", async () => { + await using global = await tmpdir() + await using project = await tmpdir() + await setGlobal(global.path, { permission: { edit: "allow" } }) - const before = await json(await req(project.path, "/agent")) - expect( - Permission.evaluate("edit", "*", before.find((item) => item.name === "code")?.permission ?? []).action, - ).toBe("allow") + const before = await json(await req(project.path, "/agent")) + expect(Permission.evaluate("edit", "*", before.find((item) => item.name === "code")?.permission ?? []).action).toBe( + "allow", + ) - await json( - await req(project.path, "/config/overlay", { - method: "PATCH", - headers: { "content-type": "application/json" }, - body: JSON.stringify({ scope: "project", set: { permission: { edit: { "*": "ask" } } } }), - }), - ) - const body = await json> } }>( - await req(project.path, "/config/overlay?scope=project"), - ) - const edit = body.effective.permission.edit - const after = await json(await req(project.path, "/agent")) + await json( + await req(project.path, "/config/overlay", { + method: "PATCH", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ scope: "project", set: { permission: { edit: { "*": "ask" } } } }), + }), + ) + const body = await json> } }>( + await req(project.path, "/config/overlay?scope=project"), + ) + const edit = body.effective.permission.edit + const after = await json(await req(project.path, "/agent")) expect(typeof edit === "string" ? edit : edit?.["*"]).toBe("ask") expect( @@ -738,6 +739,121 @@ describe("config overlay routes", () => { ) }) + test.serial( + "applies saved global sandbox settings to initialized sessions", + async () => { + await using global = await tmpdir() + await using project = await tmpdir({ git: true }) + await using writable = await tmpdir() + await setGlobal(global.path, { sandbox: { enabled: true, network: "deny" } }) + const session = await json( + await req(project.path, SessionPaths.create, { + method: "POST", + headers: { "content-type": "application/json" }, + body: "{}", + }), + ) + await json(await req(project.path, `/session/${session.id}/sandbox`)) + expect(await SandboxStore.read(project.path, session.id)).toMatchObject({ mode: "deny", version: 0 }) + + await json( + await req(project.path, "/config/overlay", { + method: "PATCH", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + scope: "global", + set: { sandbox: { enabled: true, network: "allow", writable_paths: [writable.path] } }, + }), + }), + ) + + // The global update disposes instances asynchronously. Poll the sandbox status + // until the reloaded instance applies the saved policy, mirroring how the + // extension re-checks status after saving settings. + for (let i = 0; i < 40; i++) { + await json(await req(project.path, `/session/${session.id}/sandbox`)) + const snap = await SandboxStore.read(project.path, session.id) + if (snap && snap.mode === "allow" && snap.version === 1) break + await Bun.sleep(250) + } + + expect(await SandboxStore.read(project.path, session.id)).toMatchObject({ + enabled: true, + mode: "allow", + writablePaths: [writable.path], + version: 1, + }) + }, + 20_000, + ) + + test.serial("applies saved project sandbox settings to initialized sessions", async () => { + await using global = await tmpdir() + await using project = await tmpdir({ git: true }) + await setGlobal(global.path, { sandbox: { enabled: true, network: "allow" } }) + const session = await json( + await req(project.path, SessionPaths.create, { + method: "POST", + headers: { "content-type": "application/json" }, + body: "{}", + }), + ) + await json(await req(project.path, `/session/${session.id}/sandbox`)) + expect(await SandboxStore.read(project.path, session.id)).toMatchObject({ mode: "allow", version: 0 }) + + await json( + await req(project.path, "/config/overlay", { + method: "PATCH", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ scope: "project", set: { sandbox: { enabled: true, network: "deny" } } }), + }), + ) + await json(await req(project.path, `/session/${session.id}/sandbox`)) + + expect(await SandboxStore.read(project.path, session.id)).toMatchObject({ mode: "deny", version: 1 }) + }) + + test.serial("does not relax inherited sandbox policy after unrelated global saves", async () => { + await using global = await tmpdir() + await using project = await tmpdir({ git: true }) + await setGlobal(global.path, { sandbox: { enabled: true, network: "deny" } }) + const parent = await json( + await req(project.path, SessionPaths.create, { + method: "POST", + headers: { "content-type": "application/json" }, + body: "{}", + }), + ) + await json(await req(project.path, `/session/${parent.id}/sandbox`)) + const child = await json( + await req(project.path, SessionPaths.create, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ parentID: parent.id }), + }), + ) + await json(await req(project.path, `/session/${child.id}/sandbox`)) + expect(await SandboxStore.read(project.path, child.id)).toMatchObject({ mode: "deny" }) + + // Simulate config changing while the backend is unaware. The unrelated save below + // must not treat that wider policy as a trusted sandbox settings update. + await Bun.write( + path.join(global.path, "kilo.json"), + JSON.stringify({ sandbox: { enabled: true, network: "allow" } }, null, 2), + ) + + await json( + await req(project.path, "/config/overlay", { + method: "PATCH", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ scope: "global", set: { permission: { edit: "ask" } } }), + }), + ) + await json(await req(project.path, `/session/${child.id}/sandbox`)) + + expect(await SandboxStore.read(project.path, child.id)).toMatchObject({ mode: "deny" }) + }) + terminal("preserves active terminals after updating global console preferences", async () => { await using global = await tmpdir() await using project = await tmpdir() diff --git a/packages/opencode/test/kilocode/tool/shell-env.test.ts b/packages/opencode/test/kilocode/tool/shell-env.test.ts new file mode 100644 index 0000000000..df8a48adf2 --- /dev/null +++ b/packages/opencode/test/kilocode/tool/shell-env.test.ts @@ -0,0 +1,98 @@ +import { expect } from "bun:test" +import { Effect, Layer } from "effect" +import type * as Scope from "effect/Scope" +import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder" +import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner" +import { FSUtil } from "@opencode-ai/core/fs-util" +import { Agent } from "@/agent/agent" +import { Config } from "@/config/config" +import { RuntimeFlags } from "@/effect/runtime-flags" +import { Plugin } from "@/plugin" +import { MessageID, SessionID } from "@/session/schema" +import { ShellTool } from "@/tool/shell" +import { Truncate } from "@/tool/truncate" +import type { Tool } from "@/tool/tool" +import { InstanceStore } from "@/project/instance-store" +import { provideInstance, testInstanceStoreLayer, tmpdirScoped } from "../../fixture/fixture" +import { testEffect } from "../../lib/effect" + +const layer = Layer.mergeAll( + AppNodeBuilder.build(CrossSpawnSpawner.node), + AppNodeBuilder.build(FSUtil.node), + AppNodeBuilder.build(Plugin.node), + AppNodeBuilder.build(Truncate.node), + AppNodeBuilder.build(Config.node), + AppNodeBuilder.build(Agent.node), + AppNodeBuilder.build(RuntimeFlags.node), + testInstanceStoreLayer, +) +const it = testEffect(layer) +type Services = + | (typeof layer extends Layer.Layer ? ROut : never) + | InstanceStore.Service + | Scope.Scope + +const ctx = { + sessionID: SessionID.make("ses_shell_env"), + messageID: MessageID.make("msg_shell_env"), + callID: "", + agent: "code", + abort: AbortSignal.any([]), + messages: [], + metadata: () => Effect.void, + ask: () => Effect.void, +} + +const run = Effect.fn("ShellEnvTest.run")(function* (args: Tool.InferParameters) { + const info = yield* ShellTool + const tool = yield* info.init() + return yield* tool.execute(args, ctx) +}) + +it.effect("does not expose backend credentials or config to model shell commands", () => + Effect.acquireUseRelease( + Effect.sync(() => { + const values = { + password: process.env.KILO_SERVER_PASSWORD, + username: process.env.KILO_SERVER_USERNAME, + config: process.env.KILO_CONFIG, + content: process.env.KILO_CONFIG_CONTENT, + directory: process.env.KILO_CONFIG_DIR, + } + process.env.KILO_SERVER_PASSWORD = "secret" + process.env.KILO_SERVER_USERNAME = "kilo" + process.env.KILO_CONFIG = "/secret/config.json" + process.env.KILO_CONFIG_CONTENT = '{"provider":{"apiKey":"secret"}}' + process.env.KILO_CONFIG_DIR = "/secret/config" + return values + }), + () => + tmpdirScoped().pipe( + Effect.flatMap((tmp) => + provideInstance(tmp)( + run({ + command: + process.platform === "win32" + ? "if ($env:KILO_SERVER_PASSWORD -or $env:KILO_SERVER_USERNAME -or $env:KILO_CONFIG -or $env:KILO_CONFIG_CONTENT -or $env:KILO_CONFIG_DIR) { 'set' } else { 'unset' }" + : 'test -z "$KILO_SERVER_PASSWORD" && test -z "$KILO_SERVER_USERNAME" && test -z "$KILO_CONFIG" && test -z "$KILO_CONFIG_CONTENT" && test -z "$KILO_CONFIG_DIR" && printf unset', + description: "Check backend credential isolation", + }), + ), + ), + Effect.map((result) => expect(result.output.trim()).toBe("unset")), + ) as Effect.Effect, + (values) => + Effect.sync(() => { + if (values.password === undefined) delete process.env.KILO_SERVER_PASSWORD + else process.env.KILO_SERVER_PASSWORD = values.password + if (values.username === undefined) delete process.env.KILO_SERVER_USERNAME + else process.env.KILO_SERVER_USERNAME = values.username + if (values.config === undefined) delete process.env.KILO_CONFIG + else process.env.KILO_CONFIG = values.config + if (values.content === undefined) delete process.env.KILO_CONFIG_CONTENT + else process.env.KILO_CONFIG_CONTENT = values.content + if (values.directory === undefined) delete process.env.KILO_CONFIG_DIR + else process.env.KILO_CONFIG_DIR = values.directory + }), + ), +) diff --git a/packages/opencode/test/lsp/launch.test.ts b/packages/opencode/test/lsp/launch.test.ts index 258e92524d..61e6247990 100644 --- a/packages/opencode/test/lsp/launch.test.ts +++ b/packages/opencode/test/lsp/launch.test.ts @@ -5,6 +5,37 @@ import { spawn } from "../../src/lsp/launch" import { tmpdir } from "../fixture/fixture" describe("lsp.launch", () => { + // kilocode_change start + test("does not expose backend credentials or config", async () => { + const keys = [ + "KILO_SERVER_PASSWORD", + "KILO_SERVER_USERNAME", + "KILO_CONFIG", + "KILO_CONFIG_CONTENT", + "KILO_CONFIG_DIR", + ] as const + const saved = Object.fromEntries(keys.map((key) => [key, process.env[key]])) + for (const key of keys) process.env[key] = "secret" + + try { + const proc = spawn(process.execPath, ["-e", `console.log(${JSON.stringify(keys)}.some((key) => process.env[key]))`]) + const output = await new Promise((resolve, reject) => { + const chunks: Buffer[] = [] + proc.stdout.on("data", (chunk) => chunks.push(Buffer.from(chunk))) + proc.on("error", reject) + proc.on("close", () => resolve(Buffer.concat(chunks).toString().trim())) + }) + expect(output).toBe("false") + } finally { + for (const key of keys) { + const value = saved[key] + if (value === undefined) delete process.env[key] + else process.env[key] = value + } + } + }) + // kilocode_change end + test("spawns cmd scripts with spaces on Windows", async () => { if (process.platform !== "win32") return diff --git a/packages/opencode/test/server/httpapi-exercise/backend.ts b/packages/opencode/test/server/httpapi-exercise/backend.ts index e2277817fc..181fbd261f 100644 --- a/packages/opencode/test/server/httpapi-exercise/backend.ts +++ b/packages/opencode/test/server/httpapi-exercise/backend.ts @@ -45,9 +45,25 @@ type CachedApp = BackendApp & { readonly dispose: () => Promise } const appCache: Partial> = {} export async function disposeApps() { - const apps = Object.values(appCache) + // kilocode_change start - an in-flight SSE fiber can leave the in-process router scope unable + // to close; bound disposal so a completed scenario run cannot wedge the exerciser or CI + const apps = Object.entries(appCache) for (const key of Object.keys(appCache)) delete appCache[key] - await Promise.all(apps.flatMap((app) => (app === undefined ? [] : [app.dispose()]))) + await Promise.all( + apps.flatMap(([key, app]) => + app === undefined + ? [] + : [ + Promise.race([ + app.dispose(), + Bun.sleep(3_000).then(() => { + console.error(`httpapi-exercise: router dispose did not settle for ${JSON.stringify(key)} after 3s`) + }), + ]), + ], + ), + ) + // kilocode_change end } function app(modules: Runtime, options: CallOptions) { diff --git a/packages/opencode/test/util/process.test.ts b/packages/opencode/test/util/process.test.ts index 294a286b0b..775dddb137 100644 --- a/packages/opencode/test/util/process.test.ts +++ b/packages/opencode/test/util/process.test.ts @@ -77,6 +77,25 @@ describe("util.process", () => { expect(out.stdout.toString()).toBe("set") }) + // kilocode_change start + test("can use a complete environment without inherited values", async () => { + const key = "KILO_TEST_INHERITED_ENV" + const saved = process.env[key] + process.env[key] = "secret" + + try { + const out = await Process.run(node(`process.stdout.write(process.env.${key} ?? "unset")`), { + env: { PATH: process.env.PATH }, + extendEnv: false, + }) + expect(out.stdout.toString()).toBe("unset") + } finally { + if (saved === undefined) delete process.env[key] + else process.env[key] = saved + } + }) + // kilocode_change end + test("uses shell in run on Windows", async () => { if (process.platform !== "win32") return