mirror of
https://github.com/Kilo-Org/kilocode.git
synced 2026-08-29 03:44:06 +08:00
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
This commit is contained in:
@@ -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
|
||||
@@ -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.
|
||||
|
||||
|
||||
+1
-1
@@ -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). يتم دمجها مع مسارات الكتابة الافتراضية عندما يكون صندوق الرمل نشطًا.",
|
||||
|
||||
+1
-1
@@ -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.",
|
||||
|
||||
+1
-1
@@ -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.",
|
||||
|
||||
+1
-1
@@ -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.",
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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.",
|
||||
|
||||
+1
-1
@@ -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.",
|
||||
|
||||
+1
-1
@@ -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.",
|
||||
|
||||
+1
-1
@@ -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.",
|
||||
|
||||
+1
-1
@@ -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)。サンドボックス有効時、デフォルトの書き込み可能パスと統合されます。",
|
||||
|
||||
+1
-1
@@ -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). 샌드박스가 활성화되면 기본 쓰기 가능 경로와 병합됩니다.",
|
||||
|
||||
+1
-1
@@ -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.",
|
||||
|
||||
+1
-1
@@ -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.",
|
||||
|
||||
+1
-1
@@ -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.",
|
||||
|
||||
+1
-1
@@ -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). Они объединяются с путями записи по умолчанию при активной песочнице.",
|
||||
|
||||
+1
-1
@@ -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) จะถูกรวมเข้ากับเส้นทางที่เขียนได้เริ่มต้นเมื่อแซนด์บ็อกซ์เปิดใช้งาน",
|
||||
|
||||
+1
-1
@@ -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.",
|
||||
|
||||
+1
-1
@@ -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). Вони об'єднуються зі шляхами запису за замовчуванням, коли пісочниця активна.",
|
||||
|
||||
+1
-1
@@ -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)。沙盒启用后,这些路径会与默认可写路径合并。",
|
||||
|
||||
+1
-1
@@ -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)。沙盒啟用後,這些路徑會與預設可寫路徑合併。",
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
export function model(extra?: NodeJS.ProcessEnv | null): Record<string, string> {
|
||||
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
|
||||
}
|
||||
@@ -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<string, Snapshot>()
|
||||
const synced = new Map<string, number>()
|
||||
const locks = new Map<SessionID, { semaphore: Semaphore.Semaphore; refs: number }>()
|
||||
const refreshes = new Map<SessionID, { semaphore: Semaphore.Semaphore; refs: number }>()
|
||||
const gates = new Map<SessionID, { semaphore: Semaphore.Semaphore; refs: number }>()
|
||||
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<typeof SandboxConfig.resolve>) {
|
||||
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<typeof SandboxConfig.resolve>) {
|
||||
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<typeof SandboxConfig.resolve>,
|
||||
): 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<A, E, R>(sessions: readonly SessionID[], effect: Effect.Effec
|
||||
return [...new Set(sessions)].reduceRight((next, sessionID) => locked(sessionID, next), effect)
|
||||
}
|
||||
|
||||
function refreshing<A, E, R>(sessionID: SessionID, effect: Effect.Effect<A, E, R>) {
|
||||
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<A, E, R>(sessionID: SessionID, count: number, effect: Effect.Effect<A, E, R>) {
|
||||
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<E, R, F = never, Q = never, P = never, S = never>(
|
||||
sessionID: SessionID,
|
||||
guard:
|
||||
@@ -255,7 +360,10 @@ function change<E, R, F = never, Q = never, P = never, S = never>(
|
||||
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<E, R, F = never, Q = never, P = never, S = never>(
|
||||
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<A, E, R>(
|
||||
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<A, E, R>(sessionID: SessionID, effect: Effect.Effect<A,
|
||||
return locked(
|
||||
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)) 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<A, E, R>(sessionID: SessionID, effect: Effect.Effect<A, E, R>)
|
||||
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,
|
||||
)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
)
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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* (
|
||||
|
||||
@@ -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<Result>
|
||||
const proc = spawn(cmd, {
|
||||
cwd: opts.cwd,
|
||||
env: opts.env,
|
||||
extendEnv: opts.extendEnv, // kilocode_change
|
||||
stdin: opts.stdin,
|
||||
shell: opts.shell,
|
||||
abort: opts.abort,
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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<A, E, R>(sessionID: SessionID, effect: Effect.Effect<A, E, R>)
|
||||
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<void>()
|
||||
const release = yield* Deferred.make<void>()
|
||||
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
|
||||
|
||||
@@ -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<Agent[]>(await req(project.path, "/agent"))
|
||||
expect(
|
||||
Permission.evaluate("edit", "*", before.find((item) => item.name === "code")?.permission ?? []).action,
|
||||
).toBe("allow")
|
||||
const before = await json<Agent[]>(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<Overlay & { effective: { permission: Record<string, string | Record<string, string>> } }>(
|
||||
await req(project.path, "/config/overlay?scope=project"),
|
||||
)
|
||||
const edit = body.effective.permission.edit
|
||||
const after = await json<Agent[]>(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<Overlay & { effective: { permission: Record<string, string | Record<string, string>> } }>(
|
||||
await req(project.path, "/config/overlay?scope=project"),
|
||||
)
|
||||
const edit = body.effective.permission.edit
|
||||
const after = await json<Agent[]>(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<Session.Info>(
|
||||
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<Session.Info>(
|
||||
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<Session.Info>(
|
||||
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<Session.Info>(
|
||||
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()
|
||||
|
||||
@@ -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<infer ROut, infer _E, infer _RIn> ? 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<typeof ShellTool>) {
|
||||
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<void, never, Services>,
|
||||
(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
|
||||
}),
|
||||
),
|
||||
)
|
||||
@@ -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<string>((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
|
||||
|
||||
|
||||
@@ -45,9 +45,25 @@ type CachedApp = BackendApp & { readonly dispose: () => Promise<void> }
|
||||
const appCache: Partial<Record<string, CachedApp>> = {}
|
||||
|
||||
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) {
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
Reference in New Issue
Block a user