diff --git a/CHANGELOG.md b/CHANGELOG.md index 8d2c59bd..228ff65f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -66,6 +66,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed +- **Codex, Cursor CLI, Antigravity and Crush stop asking where you already + answered.** A sandboxed session of any of them opens straight at its + prompt. Cursor CLI and Antigravity no longer ask again in a worktree of a + folder you trusted, nor Antigravity in its subfolders, and Crush no longer + asks to initialize each new worktree. A sandboxed oh-my-pi session no + longer opens on its setup wizard. - **Claude Code stops asking "trust this folder?" where you already answered.** A folder you trusted in your main account is trusted in every other Claude Code profile from its next session there, and a sandboxed diff --git a/README.es.md b/README.es.md index 0197b2f2..34a07d2d 100644 --- a/README.es.md +++ b/README.es.md @@ -132,8 +132,9 @@ porque lee lo que cada CLI deja escrito y no hay dos que anoten las mismas cosas | Abrir una sesión con un esfuerzo de razonamiento elegido | sí | sí | sí | no | sí | no | en el nombre del modelo | sí | | Abrir una sesión con ultracode activado | sí | no | no | no | no | no | no | no | | Varias cuentas lado a lado como perfiles, cada una con su propio inicio de sesión completo, y una sesión movida a otra cuenta que sigue en la misma tarjeta (en la ventana nativa, experimental: Ajustes › Proveedores) | sí | sí, con sus propios plugins en cada perfil y los ajustes de Personal compartidos por todos | no | sí, con sesión iniciada desde una tarjeta de terminal; una sesión no puede pasar a otro perfil, porque sus conversaciones están en la base de datos que guarda el inicio de sesión | sí, con sesión iniciada desde una tarjeta de terminal, con sus ajustes compartidos por todos los perfiles | no medido | Linux y Windows (en macOS su inicio de sesión está en el Llavero), con sesión iniciada desde una tarjeta de terminal, con `mcp.json`, hooks y skills compartidos por todos los perfiles | solo Linux (macOS guarda su inicio de sesión en el Llavero y en Windows nada lo mueve), con sesión iniciada desde una tarjeta de terminal, con sus conversaciones compartidas por todos los perfiles | -| Una carpeta en la que confiaste en tu cuenta principal se abre sin la pregunta "trust this folder?" en todos los demás perfiles | sí | sí | sin perfiles | no pregunta nada | no pregunta nada | no medido | no medido | no pregunta nada | -| Una sesión en el sandbox se abre sin la pregunta "trust this folder?" | sí | no | no | no pregunta nada | no pregunta nada | no, pregunta si inicializar el proyecto | no | no pregunta nada | +| Una carpeta en la que confiaste en tu cuenta principal se abre sin la pregunta "trust this folder?" en todos los demás perfiles | sí | sí | sin perfiles | no pregunta nada | no pregunta nada | no medido | sí | no pregunta nada | +| Una sesión en el sandbox se abre sin la pregunta "trust this folder?" | sí | sí | sí | no pregunta nada | no pregunta nada | sí, y ya no pregunta si inicializar el proyecto | sí | no pregunta nada | +| Un worktree de una carpeta en la que confiaste se abre sin la pregunta "trust this folder?" | sí | sí | sí | no pregunta nada | no pregunta nada | sí, y ya no pregunta si inicializar el proyecto | sí | no pregunta nada | | Dirigir una sesión en ejecución desde `lich control` o un agente: prompt, detener, modelo, esfuerzo, slash command | sí | no | no | no | no | no | no | no | | Un mensaje de otra sesión llega sin escribirse en la terminal | sí | no | no | no | no | no | no | no | | Un subagente que inicia el agente se ejecuta como una sesión de lich con su propia tarjeta, en el checkout de la sesión que lo pidió, entrega su informe completo y se cierra al terminar; sus propios subagentes también reciben tarjeta, un nivel más abajo | sí | no | no | no | no | no | no | no | diff --git a/README.ja.md b/README.ja.md index 54d22338..37dba2e1 100644 --- a/README.ja.md +++ b/README.ja.md @@ -123,8 +123,9 @@ lich は各 CLI が書き残したものを読むので、書き残す内容は | 推論エフォートを指定してセッションを開く | あり | あり | あり | なし | あり | なし | モデル名で指定 | あり | | ultracode をオンにしてセッションを開く | あり | なし | なし | なし | なし | なし | なし | なし | | 複数のアカウントをプロファイルとして並べて使え、それぞれが独自の完全なログインを持ち、別のアカウントへ移したセッションが同じカードで続行される(ネイティブウィンドウ、実験的機能:Settings › Providers) | あり | あり。各プロファイルが独自のプラグインを持ち、Personal の設定はすべてで共有される | なし | あり。ターミナルカードからログインする。会話がログインを保持するデータベースにあるため、セッションを別のプロファイルへ移すことはできない | あり。ターミナルカードからログインし、設定はすべてのプロファイルで共有される | 未計測 | Linux と Windows(macOS ではログインがキーチェーンにある)。ターミナルカードからログインし、`mcp.json`、フック、スキルはすべてのプロファイルで共有される | Linux のみ(macOS ではログインがキーチェーンにあり、Windows ではそれを移す手段がない)。ターミナルカードからログインし、会話はすべてのプロファイルで共有される | -| メインのアカウントで信頼したフォルダーは、ほかのどのプロファイルでも「trust this folder?」と聞かれずに開く | あり | あり | プロファイルなし | 何も聞かない | 何も聞かない | 未計測 | 未計測 | 何も聞かない | -| サンドボックス内のセッションは「trust this folder?」と聞かれずに開く | あり | なし | なし | 何も聞かない | 何も聞かない | なし。プロジェクトの初期化を聞かれる | なし | 何も聞かない | +| メインのアカウントで信頼したフォルダーは、ほかのどのプロファイルでも「trust this folder?」と聞かれずに開く | あり | あり | プロファイルなし | 何も聞かない | 何も聞かない | 未計測 | あり | 何も聞かない | +| サンドボックス内のセッションは「trust this folder?」と聞かれずに開く | あり | あり | あり | 何も聞かない | 何も聞かない | あり。プロジェクトの初期化も聞かれない | あり | 何も聞かない | +| 信頼したフォルダーの worktree は「trust this folder?」と聞かれずに開く | あり | あり | あり | 何も聞かない | 何も聞かない | あり。プロジェクトの初期化も聞かれない | あり | 何も聞かない | | 実行中のセッションを `lich control` またはエージェントから操作:プロンプト、停止、モデル、エフォート、スラッシュコマンド | あり | なし | なし | なし | なし | なし | なし | なし | | 別のセッションからのメッセージが、ターミナルに入力されることなく届く | あり | なし | なし | なし | なし | なし | なし | なし | | エージェントが起動したサブエージェントが、専用のカードを持つ lich セッションとして、依頼元セッションのチェックアウトで実行され、完全なレポートを返して、完了すると閉じる。そのサブエージェントのサブエージェントにもカードが付き、1段下に表示される | あり | なし | なし | なし | なし | なし | なし | なし | diff --git a/README.ko.md b/README.ko.md index 3d402980..842c4961 100644 --- a/README.ko.md +++ b/README.ko.md @@ -120,8 +120,9 @@ lich로 할 수 있는 일: | 원하는 reasoning effort로 세션 열기 | 예 | 예 | 예 | 아니요 | 예 | 아니요 | 모델 이름으로 | 예 | | ultracode를 켠 채로 세션 열기 | 예 | 아니요 | 아니요 | 아니요 | 아니요 | 아니요 | 아니요 | 아니요 | | 여러 계정을 프로필로 나란히 두고 각자 완전한 로그인을 가지며, 다른 계정으로 옮긴 세션이 같은 카드에서 계속됨(네이티브 창, 실험적 기능: Settings › Providers) | 예 | 예, 프로필마다 자체 플러그인을 두고 Personal의 설정은 모든 프로필이 공유 | 아니요 | 예, 터미널 카드에서 로그인; 대화가 로그인 정보와 같은 데이터베이스에 있어서 세션을 다른 프로필로 옮길 수 없음 | 예, 터미널 카드에서 로그인하며 설정은 모든 프로필이 공유 | 측정 안 됨 | Linux와 Windows(macOS에서는 로그인이 키체인에 있음), 터미널 카드에서 로그인하며 `mcp.json`, hooks, skills는 모든 프로필이 공유 | Linux만(macOS는 로그인을 키체인에 두고, Windows에서는 그 위치를 옮길 방법이 없음), 터미널 카드에서 로그인하며 대화는 모든 프로필이 공유 | -| 기본 계정에서 신뢰한 폴더는 다른 모든 프로필에서도 "trust this folder?" 질문 없이 열림 | 예 | 예 | 프로필 없음 | 아무것도 묻지 않음 | 아무것도 묻지 않음 | 측정 안 됨 | 측정 안 됨 | 아무것도 묻지 않음 | -| 샌드박스 안의 세션은 "trust this folder?" 질문 없이 열림 | 예 | 아니요 | 아니요 | 아무것도 묻지 않음 | 아무것도 묻지 않음 | 아니요, 프로젝트 초기화 여부를 물음 | 아니요 | 아무것도 묻지 않음 | +| 기본 계정에서 신뢰한 폴더는 다른 모든 프로필에서도 "trust this folder?" 질문 없이 열림 | 예 | 예 | 프로필 없음 | 아무것도 묻지 않음 | 아무것도 묻지 않음 | 측정 안 됨 | 예 | 아무것도 묻지 않음 | +| 샌드박스 안의 세션은 "trust this folder?" 질문 없이 열림 | 예 | 예 | 예 | 아무것도 묻지 않음 | 아무것도 묻지 않음 | 예, 프로젝트 초기화 여부도 묻지 않음 | 예 | 아무것도 묻지 않음 | +| 신뢰한 폴더의 worktree는 "trust this folder?" 질문 없이 열림 | 예 | 예 | 예 | 아무것도 묻지 않음 | 아무것도 묻지 않음 | 예, 프로젝트 초기화 여부도 묻지 않음 | 예 | 아무것도 묻지 않음 | | `lich control`이나 에이전트로 실행 중인 세션 조작: 프롬프트, 중지, 모델, effort, 슬래시 명령 | 예 | 아니요 | 아니요 | 아니요 | 아니요 | 아니요 | 아니요 | 아니요 | | 다른 세션이 보낸 메시지가 터미널에 타이핑되지 않고 도착 | 예 | 아니요 | 아니요 | 아니요 | 아니요 | 아니요 | 아니요 | 아니요 | | 에이전트가 시작한 서브에이전트가 자기 카드를 가진 lich 세션으로 요청한 세션의 체크아웃에서 실행되고, 전체 보고서를 돌려준 뒤 끝나면 닫힘; 그 서브에이전트의 서브에이전트도 한 단계 아래에 카드를 가짐 | 예 | 아니요 | 아니요 | 아니요 | 아니요 | 아니요 | 아니요 | 아니요 | diff --git a/README.md b/README.md index 29fc5b47..d6bd7277 100644 --- a/README.md +++ b/README.md @@ -124,8 +124,9 @@ reads what each CLI writes down and no two of them write down the same things. | Open a session at a chosen reasoning effort | yes | yes | yes | no | yes | no | in the model name | yes | | Open a session with ultracode on | yes | no | no | no | no | no | no | no | | Several accounts side by side as profiles, each with its own full login, and a session moved to another account carrying on in the same card (in the native window, experimental: Settings › Providers) | yes | yes, with its own plugins in each profile and Personal's settings shared by all of them | no | yes, logged in from a terminal card; a session cannot move to another profile, since its conversations sit in the database that holds the login | yes, logged in from a terminal card, with its settings shared by every profile | not measured | Linux and Windows (on macOS its login is in the Keychain), logged in from a terminal card, with `mcp.json`, hooks and skills shared by every profile | Linux only (macOS keeps its login in the Keychain, and on Windows nothing moves it), logged in from a terminal card, with its conversations shared by every profile | -| A folder you trusted in your main account opens without the "trust this folder?" question in every other profile | yes | yes | no profiles | asks nothing | asks nothing | not measured | not measured | asks nothing | -| A sandboxed session opens without the "trust this folder?" question | yes | no | no | asks nothing | asks nothing | no, it asks to initialize the project | no | asks nothing | +| A folder you trusted in your main account opens without the "trust this folder?" question in every other profile | yes | yes | no profiles | asks nothing | asks nothing | not measured | yes | asks nothing | +| A sandboxed session opens without the "trust this folder?" question | yes | yes | yes | asks nothing | asks nothing | yes, and it no longer asks to initialize the project | yes | asks nothing | +| A worktree of a folder you trusted opens without the "trust this folder?" question | yes | yes | yes | asks nothing | asks nothing | yes, and it no longer asks to initialize the project | yes | asks nothing | | Drive a running session from `lich control` or an agent: prompt, stop, model, effort, slash command | yes | no | no | no | no | no | no | no | | A message from another session arrives without being typed into the terminal | yes | no | no | no | no | no | no | no | | A subagent the agent starts runs as a lich session with its own card, in the asking session's checkout, hands its full report back and closes once done; its own subagents get cards too, one level down | yes | no | no | no | no | no | no | no | diff --git a/README.pt-BR.md b/README.pt-BR.md index 5caf18ce..cb97c2d7 100644 --- a/README.pt-BR.md +++ b/README.pt-BR.md @@ -135,8 +135,9 @@ mesmas coisas. | Abrir uma sessão com um esforço de raciocínio escolhido | sim | sim | sim | não | sim | não | no nome do modelo | sim | | Abrir uma sessão com o ultracode ligado | sim | não | não | não | não | não | não | não | | Várias contas lado a lado como perfis, cada uma com seu login completo, e uma sessão movida para outra conta continuando no mesmo card (na janela nativa, experimental: Configurações › Provedores) | sim | sim, com plugins próprios em cada perfil e as configurações do Pessoal compartilhadas por todos | não | sim, com login feito em um card de terminal; uma sessão não pode mudar de perfil, já que as conversas dela ficam no banco de dados que guarda o login | sim, com login feito em um card de terminal e as configurações compartilhadas por todos os perfis | não medido | no Linux e no Windows (no macOS o login fica no Keychain), com login feito em um card de terminal, e `mcp.json`, hooks e skills compartilhados por todos os perfis | só no Linux (o macOS guarda o login no Keychain, e no Windows nada muda o lugar dele), com login feito em um card de terminal e as conversas compartilhadas por todos os perfis | -| Uma pasta em que você confiou na conta principal abre sem a pergunta "trust this folder?" em todos os outros perfis | sim | sim | sem perfis | não pergunta nada | não pergunta nada | não medido | não medido | não pergunta nada | -| Uma sessão no sandbox abre sem a pergunta "trust this folder?" | sim | não | não | não pergunta nada | não pergunta nada | não, pergunta se deve inicializar o projeto | não | não pergunta nada | +| Uma pasta em que você confiou na conta principal abre sem a pergunta "trust this folder?" em todos os outros perfis | sim | sim | sem perfis | não pergunta nada | não pergunta nada | não medido | sim | não pergunta nada | +| Uma sessão no sandbox abre sem a pergunta "trust this folder?" | sim | sim | sim | não pergunta nada | não pergunta nada | sim, e não pergunta mais se deve inicializar o projeto | sim | não pergunta nada | +| Uma worktree de uma pasta em que você confiou abre sem a pergunta "trust this folder?" | sim | sim | sim | não pergunta nada | não pergunta nada | sim, e não pergunta mais se deve inicializar o projeto | sim | não pergunta nada | | Conduzir uma sessão em execução por `lich control` ou por um agente: prompt, parar, modelo, esforço, slash command | sim | não | não | não | não | não | não | não | | Uma mensagem de outra sessão chega sem ser digitada no terminal | sim | não | não | não | não | não | não | não | | Um subagente que o agente inicia roda como uma sessão do lich com card próprio, no checkout da sessão que o pediu, devolve o relatório completo e fecha ao terminar; os subagentes dele também ganham cards, um nível abaixo | sim | não | não | não | não | não | não | não | diff --git a/README.ru.md b/README.ru.md index b0451df5..205bd5aa 100644 --- a/README.ru.md +++ b/README.ru.md @@ -136,8 +136,9 @@ lich вообще не запускается. | Открытие сессии с выбранным уровнем рассуждений | да | да | да | нет | да | нет | в названии модели | да | | Открытие сессии с включённым ultracode | да | нет | нет | нет | нет | нет | нет | нет | | Несколько аккаунтов рядом в виде профилей, у каждого свой полный вход, а сессия, перенесённая на другой аккаунт, продолжается в той же карточке (в нативном окне, экспериментально: Настройки › Провайдеры) | да | да, со своими плагинами в каждом профиле и общими для всех настройками Personal | нет | да, вход из терминальной карточки; сессию нельзя перенести в другой профиль, потому что её разговоры лежат в базе данных, где хранится и вход | да, вход из терминальной карточки, настройки общие для всех профилей | не измерялось | Linux и Windows (в macOS вход хранится в связке ключей), вход из терминальной карточки, `mcp.json`, хуки и навыки общие для всех профилей | только Linux (macOS хранит вход в связке ключей, а в Windows его расположение ничем не переносится), вход из терминальной карточки, разговоры общие для всех профилей | -| Папка, которой вы доверились в основном аккаунте, открывается без вопроса «trust this folder?» во всех остальных профилях | да | да | нет профилей | ничего не спрашивает | ничего не спрашивает | не измерялось | не измерялось | ничего не спрашивает | -| Сессия в песочнице открывается без вопроса «trust this folder?» | да | нет | нет | ничего не спрашивает | ничего не спрашивает | нет, спрашивает, инициализировать ли проект | нет | ничего не спрашивает | +| Папка, которой вы доверились в основном аккаунте, открывается без вопроса «trust this folder?» во всех остальных профилях | да | да | нет профилей | ничего не спрашивает | ничего не спрашивает | не измерялось | да | ничего не спрашивает | +| Сессия в песочнице открывается без вопроса «trust this folder?» | да | да | да | ничего не спрашивает | ничего не спрашивает | да, и больше не спрашивает, инициализировать ли проект | да | ничего не спрашивает | +| Worktree папки, которой вы доверились, открывается без вопроса «trust this folder?» | да | да | да | ничего не спрашивает | ничего не спрашивает | да, и больше не спрашивает, инициализировать ли проект | да | ничего не спрашивает | | Управление запущенной сессией через `lich control` или агента: промпт, остановка, модель, уровень рассуждений, слэш-команда | да | нет | нет | нет | нет | нет | нет | нет | | Сообщение от другой сессии приходит без набора в терминале | да | нет | нет | нет | нет | нет | нет | нет | | Субагент, которого запускает агент, работает как сессия lich со своей карточкой, в рабочей копии спросившей сессии, отдаёт ей полный отчёт и закрывается по завершении; его собственные субагенты тоже получают карточки, на уровень ниже | да | нет | нет | нет | нет | нет | нет | нет | diff --git a/README.zh-CN.md b/README.zh-CN.md index de201aa4..4f106b52 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -112,8 +112,9 @@ | 按指定的推理强度打开会话 | 有 | 有 | 有 | 无 | 有 | 无 | 写在模型名里 | 有 | | 以 ultracode 模式打开会话 | 有 | 无 | 无 | 无 | 无 | 无 | 无 | 无 | | 多个账号以 profile 并存,各自完整登录;会话可以移到另一个账号,在同一张卡片里接着做(在原生窗口中,实验性:设置 › Providers) | 有 | 有;每个 profile 有自己的插件,Personal 的设置所有 profile 共用 | 无 | 有;在终端卡片里登录。会话不能移到另一个 profile:对话和登录信息存在同一个数据库里 | 有;在终端卡片里登录,各 profile 共用设置 | 未测量 | Linux 和 Windows(macOS 上登录信息在钥匙串里);在终端卡片里登录,`mcp.json`、hooks 和 skills 各 profile 共用 | 仅 Linux(macOS 把登录信息放在钥匙串里,Windows 上没有办法移动它);在终端卡片里登录,对话由所有 profile 共用 | -| 在主账号里信任过的文件夹,在其他所有 profile 中打开时都不再问“trust this folder?” | 有 | 有 | 无 profile | 什么都不问 | 什么都不问 | 未测量 | 未测量 | 什么都不问 | -| 沙箱里的会话打开时不问“trust this folder?” | 有 | 无 | 无 | 什么都不问 | 什么都不问 | 无,会问是否初始化项目 | 无 | 什么都不问 | +| 在主账号里信任过的文件夹,在其他所有 profile 中打开时都不再问“trust this folder?” | 有 | 有 | 无 profile | 什么都不问 | 什么都不问 | 未测量 | 有 | 什么都不问 | +| 沙箱里的会话打开时不问“trust this folder?” | 有 | 有 | 有 | 什么都不问 | 什么都不问 | 有,也不再问是否初始化项目 | 有 | 什么都不问 | +| 信任过的文件夹的 worktree 打开时不问“trust this folder?” | 有 | 有 | 有 | 什么都不问 | 什么都不问 | 有,也不再问是否初始化项目 | 有 | 什么都不问 | | 用 `lich control` 或智能体操控运行中的会话:发提示、停止、换模型、推理强度、斜杠命令 | 有 | 无 | 无 | 无 | 无 | 无 | 无 | 无 | | 其他会话发来的消息直接送达,不经终端键入 | 有 | 无 | 无 | 无 | 无 | 无 | 无 | 无 | | 智能体启动的子智能体作为 lich 会话运行,有自己的卡片,在发起会话的检出目录中工作,交回完整报告后自动关闭;它自己启动的子智能体也有卡片,再往下一层为止 | 有 | 无 | 无 | 无 | 无 | 无 | 无 | 无 | diff --git a/docs/ceilings.md b/docs/ceilings.md index be757593..5a01334a 100644 --- a/docs/ceilings.md +++ b/docs/ceilings.md @@ -1419,20 +1419,37 @@ work when nobody knows it and that the call site never shows. The mechanism and hard link was measured and drifts just the same, Claude Code replacing the file on write. The global `.claude.json` is a copy everywhere, minus the account and its caches, since Claude Code replaces it through a rename that would turn a link into a file anyway. -- **A folder trusted in Personal is trusted in a Claude Code profile only from that profile's next spawn - into it, and only Claude Code's trust is carried** (`internal/claudeprofile/trust.go`): the copied - `.claude.json` freezes the trust answers at the profile's birth, so before each spawn into a profile lich - writes `hasTrustDialogAccepted` for the checkout's top when Personal trusts it or the main checkout of the - worktree it is. It never trusts a folder nobody trusted, and a folder outside any git checkout is not - carried: Claude Code also finds an answer in a parent folder there, and lich asks git, not the parents. - The write is a read, an edit and a rename beside sessions that rewrite the same file; Claude Code re-reads - it before each write of its own (measured on 2.1.302, a key written mid-session survived its later writes), - so the window left is what either side writes between the other's read and rename. Codex already merges - its project trust across profiles (`codexprofile/config.go`); Cursor CLI's profiles were not measured for it. - A confined Claude Code session is spawned with `CLAUDE_CODE_SANDBOXED=1`, which Claude Code reads as every - folder trusted (measured on 2.1.302): the question guards against a folder's own settings running on the - machine, which is what the sandbox confines. Codex, Cursor CLI and Antigravity still ask inside the sandbox - and Crush asks to initialize; opencode, oh-my-pi and Kiro CLI ask nothing. +- **A provider's "trust this folder?" answer is carried only where lich can find the person's own answer, + and a confined session is told to skip the question** (`internal/claudeprofile/trust.go`, + `internal/terminal/cursor_trust.go`, `antigravity_trust.go`, `crush_init.go`, `confinedEnv` and + `confinedTrustArgs` in `internal/terminal/sandbox.go`). + Claude Code: a profile's copied `.claude.json` freezes the trust answers at its birth, so before each spawn + into a profile lich writes `hasTrustDialogAccepted` for the checkout's top when Personal trusts it or the + main checkout of the worktree it is; Personal itself needs nothing, since Claude Code finds the answer for a + worktree in its main checkout and for a folder in its parents (measured on 2.1.302). A folder outside any + git checkout is not carried into a profile: lich asks git, not the parents. The write is a read, an edit and + a rename beside sessions that rewrite the same file; Claude Code re-reads it before each write of its own (a + key written mid-session survived its later writes), so the window left is what either side writes between + the other's read and rename. Codex keys trust by the main checkout's top, so its worktrees already count, and + its profiles merge Personal's trust (`codexprofile/config.go`). Cursor CLI finds a marker + (`~/.cursor/projects//.workspace-trusted`) for the folder or a parent below the home, and a worktree + sits beside its repository rather than under it, so before each spawn in a worktree lich writes the marker + when the main checkout is trusted; its profiles need nothing, the marker following the home (measured on + 2026.10.16, Windows not). It never trusts a folder nobody trusted. A confined session skips the question: + Claude Code with `CLAUDE_CODE_SANDBOXED=1`, Codex with `-c projects={"
"={trust_level="trusted"}}` + (the dotted spelling still asks; measured on 0.144.5 and 0.162.1) and Cursor CLI with `--trust`, which also + writes its marker, so that folder stays trusted outside the sandbox too. The question guards against a + folder's own settings running on the machine, which is what the sandbox confines. Whether Codex's trust and + Cursor CLI's `--trust` loosen anything beyond the question was not measured. A confined oh-my-pi is spawned + with `OMP_SKIP_SETUP=1`: a profile's `config.yml` links into Personal, which the sandbox does not mount, and + without it the session opened on the setup wizard. Antigravity trusts only the exact folders in + `~/.gemini/antigravity-cli/settings.json` `trustedWorkspaces`, so worktrees and subfolders of a trusted + repository asked again (measured on 1.3.5): before each spawn lich adds the folder when its checkout's top or + main checkout is listed, and every confined folder whatever the list says, so a folder once opened confined + stays trusted outside the sandbox. Crush asks once per checkout whether to initialize the project and keeps + the answer as an empty `.crush/init` at the checkout's top (measured on v0.98.1); lich writes it, with + Crush's own `*` `.gitignore` beside it, before every Crush spawn, so the offer to write `AGENTS.md` never + comes up in a lich session. opencode, oh-my-pi and Kiro CLI ask nothing. - **A Claude Code profile's plugin cache is its own, and each version in it links into one store lich never empties** (`internal/claudeprofile/plugincache.go`): before every spawn, and in the startup pass of the lich plugin, each version a profile's `installed_plugins.json` names under its `plugins/cache` is moved into diff --git a/internal/terminal/antigravity_trust.go b/internal/terminal/antigravity_trust.go new file mode 100644 index 00000000..7fc9b0a1 --- /dev/null +++ b/internal/terminal/antigravity_trust.go @@ -0,0 +1,100 @@ +package terminal + +import ( + "encoding/json" + "errors" + "fmt" + "io/fs" + "log/slog" + "os" + "path/filepath" + "slices" + + "github.com/omartelo/lich/internal/profilestore" + "github.com/omartelo/lich/internal/project" + "github.com/omartelo/lich/internal/providers" +) + +const antigravityTrustKey = "trustedWorkspaces" + +// inheritAntigravityTrust adds the session's folder to Antigravity's trusted +// list when the person trusted its checkout's top or the main checkout of the +// worktree it is, and for every confined session, so neither opens on the +// "trust this folder?" question. Antigravity trusts a folder only by its exact +// path, so every worktree and subfolder of a trusted repository asked again +// (measured on 1.3.5). The entry stays: the folder opens without the question +// outside lich too. A failure costs the session the question, never its spawn. +func inheritAntigravityTrust(kind, cwd string, confined bool) { + if kind != providers.Antigravity || cwd == "" { + return + } + if err := trustAntigravityFolder(userHome(), cwd, confined); err != nil { + slog.Warn("carry folder trust into Antigravity", "cwd", cwd, "err", err) + } +} + +func trustAntigravityFolder(home, cwd string, confined bool) error { + if home == "" { + return errors.New("no home directory to find Antigravity's settings under") + } + folder, err := filepath.EvalSymlinks(cwd) + if err != nil { + return err + } + path := antigravitySettingsFile(home) + doc, trusted, err := readAntigravitySettings(path) + if err != nil || slices.Contains(trusted, folder) { + return err + } + top, origin := project.CheckoutRoots(folder) + inherited := slices.ContainsFunc([]string{top, origin}, func(dir string) bool { + return dir != "" && slices.Contains(trusted, filepath.Clean(dir)) + }) + if !confined && !inherited { + return nil + } + raw, err := json.Marshal(append(trusted, folder)) + if err != nil { + return err + } + doc[antigravityTrustKey] = raw + out, err := json.MarshalIndent(doc, "", " ") + if err != nil { + return err + } + if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { + return fmt.Errorf("create %s: %w", filepath.Dir(path), err) + } + return profilestore.WriteFileAtomic(path, out, 0o644) +} + +// antigravitySettingsFile is where Antigravity keeps its trusted folders (not +// ~/.gemini/trustedFolders.json, which is the Gemini CLI's). No environment +// variable moves it. +func antigravitySettingsFile(home string) string { + return filepath.Join(home, ".gemini", "antigravity-cli", "settings.json") +} + +// readAntigravitySettings is the settings file and its trusted folders, an +// empty document for a file that is not there yet. Every other key is kept as +// it was: Antigravity merges what it finds on disk (measured on 1.3.5). +func readAntigravitySettings(path string) (map[string]json.RawMessage, []string, error) { + doc := map[string]json.RawMessage{} + data, err := os.ReadFile(path) + if errors.Is(err, fs.ErrNotExist) { + return doc, nil, nil + } + if err != nil { + return nil, nil, fmt.Errorf("read %s: %w", path, err) + } + if err := json.Unmarshal(data, &doc); err != nil { + return nil, nil, fmt.Errorf("read %s: %w", path, err) + } + var trusted []string + if raw, ok := doc[antigravityTrustKey]; ok { + if err := json.Unmarshal(raw, &trusted); err != nil { + return nil, nil, fmt.Errorf("read %s: %w", path, err) + } + } + return doc, trusted, nil +} diff --git a/internal/terminal/antigravity_trust_test.go b/internal/terminal/antigravity_trust_test.go new file mode 100644 index 00000000..dce87f00 --- /dev/null +++ b/internal/terminal/antigravity_trust_test.go @@ -0,0 +1,131 @@ +package terminal + +import ( + "encoding/json" + "os" + "path/filepath" + "slices" + "testing" +) + +func writeAntigravitySettings(t *testing.T, home string, doc map[string]any) { + t.Helper() + path := antigravitySettingsFile(home) + data, err := json.Marshal(doc) + if err != nil { + t.Fatal(err) + } + if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(path, data, 0o644); err != nil { + t.Fatal(err) + } +} + +type antigravityDoc struct { + TrustedWorkspaces []string `json:"trustedWorkspaces"` + Theme string `json:"theme"` +} + +func readAntigravityDoc(t *testing.T, home string) antigravityDoc { + t.Helper() + var doc antigravityDoc + data, err := os.ReadFile(antigravitySettingsFile(home)) + if err != nil { + t.Fatal(err) + } + if err := json.Unmarshal(data, &doc); err != nil { + t.Fatal(err) + } + return doc +} + +func resolved(t *testing.T, dir string) string { + t.Helper() + out, err := filepath.EvalSymlinks(dir) + if err != nil { + t.Fatal(err) + } + return out +} + +func TestAntigravityTrustCarriesARepositorysAnswerToItsWorktreeAndSubfolders(t *testing.T) { + main, worktree := gitWorktree(t) + sub := filepath.Join(main, "pkg") + if err := os.Mkdir(sub, 0o755); err != nil { + t.Fatal(err) + } + home := t.TempDir() + writeAntigravitySettings(t, home, map[string]any{"theme": "dark", "trustedWorkspaces": []string{main}}) + + for _, dir := range []string{worktree, sub} { + if err := trustAntigravityFolder(home, dir, false); err != nil { + t.Fatal(err) + } + } + doc := readAntigravityDoc(t, home) + for _, dir := range []string{resolved(t, worktree), resolved(t, sub)} { + if !slices.Contains(doc.TrustedWorkspaces, dir) { + t.Errorf("%s is not trusted: %v", dir, doc.TrustedWorkspaces) + } + } + if doc.Theme != "dark" { + t.Errorf("the other settings did not survive: %+v", doc) + } +} + +func TestAntigravityTrustNeverTrustsWhatNobodyTrustedUnconfined(t *testing.T) { + main, worktree := gitWorktree(t) + home := t.TempDir() + writeAntigravitySettings(t, home, map[string]any{"trustedWorkspaces": []string{filepath.Dir(main)}}) + before, err := os.ReadFile(antigravitySettingsFile(home)) + if err != nil { + t.Fatal(err) + } + if err := trustAntigravityFolder(home, worktree, false); err != nil { + t.Fatal(err) + } + after, err := os.ReadFile(antigravitySettingsFile(home)) + if err != nil { + t.Fatal(err) + } + if string(after) != string(before) { + t.Errorf("an untrusted worktree was trusted:\n%s", after) + } +} + +// A confined session is trusted whatever the list says, and the file is made +// when Antigravity has not written one yet. +func TestAntigravityTrustsAConfinedFolder(t *testing.T) { + plain := t.TempDir() + home := t.TempDir() + if err := trustAntigravityFolder(home, plain, true); err != nil { + t.Fatal(err) + } + if doc := readAntigravityDoc(t, home); !slices.Equal(doc.TrustedWorkspaces, []string{resolved(t, plain)}) { + t.Errorf("trustedWorkspaces = %v, want the confined folder", doc.TrustedWorkspaces) + } + if err := trustAntigravityFolder(home, plain, true); err != nil { + t.Fatal(err) + } + if doc := readAntigravityDoc(t, home); len(doc.TrustedWorkspaces) != 1 { + t.Errorf("a trusted folder was added twice: %v", doc.TrustedWorkspaces) + } +} + +func TestAntigravityTrustRefusesSettingsItCannotRead(t *testing.T) { + home := t.TempDir() + path := antigravitySettingsFile(home) + if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { + t.Fatal(err) + } + for _, data := range []string{`{`, `{"trustedWorkspaces":"x"}`} { + if err := os.WriteFile(path, []byte(data), 0o644); err != nil { + t.Fatal(err) + } + if err := trustAntigravityFolder(home, t.TempDir(), true); err == nil { + t.Errorf("settings %s were read as an empty list", data) + } + } +} diff --git a/internal/terminal/crush_init.go b/internal/terminal/crush_init.go new file mode 100644 index 00000000..d6c1c7c2 --- /dev/null +++ b/internal/terminal/crush_init.go @@ -0,0 +1,57 @@ +package terminal + +import ( + "errors" + "fmt" + "io/fs" + "log/slog" + "os" + "path/filepath" + + "github.com/omartelo/lich/internal/project" + "github.com/omartelo/lich/internal/providers" +) + +// markCrushInitialized writes the marker Crush reads as "this project was +// already offered its init", so a session does not open on the "initialize +// this project?" question, whose Enter runs an init turn. Crush keeps the +// marker in `.crush/` at the checkout's top, or the folder itself outside a +// repository, so every new worktree asked again (measured on v0.98.1). The +// offer to write AGENTS.md goes with it, in every session. A failure costs the +// session the question, never its spawn. +func markCrushInitialized(kind, cwd string) { + if kind != providers.Crush || cwd == "" { + return + } + root, _ := project.CheckoutRoots(cwd) + if root == "" { + root = cwd + } + if err := writeCrushInit(filepath.Join(root, ".crush")); err != nil { + slog.Warn("mark the checkout initialized for Crush", "cwd", cwd, "err", err) + } +} + +// writeCrushInit creates the empty init marker and the `*` .gitignore Crush +// puts beside it on its first start, so the folder never shows in git status +// even when Crush does not get that far. A file already there is left alone. +func writeCrushInit(dir string) error { + if err := os.MkdirAll(dir, 0o755); err != nil { + return fmt.Errorf("create %s: %w", dir, err) + } + for name, content := range map[string]string{".gitignore": "*\n", "init": ""} { + path := filepath.Join(dir, name) + f, err := os.OpenFile(path, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0o644) + if errors.Is(err, fs.ErrExist) { + continue + } + if err != nil { + return fmt.Errorf("create %s: %w", path, err) + } + _, err = f.WriteString(content) + if err := errors.Join(err, f.Close()); err != nil { + return fmt.Errorf("write %s: %w", path, err) + } + } + return nil +} diff --git a/internal/terminal/crush_init_test.go b/internal/terminal/crush_init_test.go new file mode 100644 index 00000000..595e45c4 --- /dev/null +++ b/internal/terminal/crush_init_test.go @@ -0,0 +1,57 @@ +package terminal + +import ( + "os" + "os/exec" + "path/filepath" + "strings" + "testing" + + "github.com/omartelo/lich/internal/providers" +) + +func TestMarkCrushInitializedAtTheCheckoutsTop(t *testing.T) { + _, worktree := gitWorktree(t) + sub := filepath.Join(worktree, "pkg") + if err := os.Mkdir(sub, 0o755); err != nil { + t.Fatal(err) + } + markCrushInitialized(providers.Crush, sub) + if _, err := os.Stat(filepath.Join(worktree, ".crush", "init")); err != nil { + t.Fatalf("no init marker at the checkout's top: %v", err) + } + if _, err := os.Stat(filepath.Join(sub, ".crush")); !os.IsNotExist(err) { + t.Errorf("a .crush was made in the subfolder: %v", err) + } + // Crush's own .gitignore keeps the folder out of git status. + out, err := exec.Command("git", "-C", worktree, "status", "--porcelain").Output() + if err != nil { + t.Fatal(err) + } + if strings.Contains(string(out), ".crush") { + t.Errorf("git status shows the marker folder:\n%s", out) + } +} + +func TestMarkCrushInitializedKeepsWhatIsThere(t *testing.T) { + dir := t.TempDir() + crush := filepath.Join(dir, ".crush") + if err := os.Mkdir(crush, 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(crush, ".gitignore"), []byte("crush.db\n"), 0o644); err != nil { + t.Fatal(err) + } + markCrushInitialized(providers.Crush, dir) + if data, _ := os.ReadFile(filepath.Join(crush, ".gitignore")); string(data) != "crush.db\n" { + t.Errorf(".gitignore was rewritten: %q", data) + } + if _, err := os.Stat(filepath.Join(crush, "init")); err != nil { + t.Errorf("no init marker outside a repository: %v", err) + } + other := t.TempDir() + markCrushInitialized(providers.OpenCode, other) + if _, err := os.Stat(filepath.Join(other, ".crush")); !os.IsNotExist(err) { + t.Errorf("an opencode spawn wrote Crush's marker: %v", err) + } +} diff --git a/internal/terminal/cursor_trust.go b/internal/terminal/cursor_trust.go new file mode 100644 index 00000000..e21eb669 --- /dev/null +++ b/internal/terminal/cursor_trust.go @@ -0,0 +1,114 @@ +package terminal + +import ( + "encoding/json" + "fmt" + "log/slog" + "os" + "path/filepath" + "regexp" + "strings" + "time" + + "github.com/omartelo/lich/internal/profilestore" + "github.com/omartelo/lich/internal/project" + "github.com/omartelo/lich/internal/providers" +) + +// cursorTrustMarker is the file whose presence under a folder's project dir +// means Cursor CLI trusts that folder (read in the 2026.10.16 bundle, +// src/workspace/approval.tsx). Its content is never read. +const cursorTrustMarker = ".workspace-trusted" + +// cursorMinTrustDepth is the fewest path components a parent folder needs for +// its marker to count for the folders under it, the bundle's own floor. +const cursorMinTrustDepth = 3 + +var cursorSlugRun = regexp.MustCompile(`[^a-zA-Z0-9]+`) + +// inheritCursorTrust marks a linked worktree trusted for Cursor CLI when the +// person trusted its main checkout. Cursor finds a trusted folder in the folder +// itself or a parent, and a worktree sits beside its repository rather than +// under it, so every worktree lich makes asked again (measured on 2026.10.01 +// and 2026.10.16). It never trusts a folder nobody trusted, and a failure +// costs the session the question, never its spawn. +func inheritCursorTrust(kind, cwd string) { + if kind != providers.Cursor || cwd == "" { + return + } + top, origin := project.CheckoutRoots(cwd) + if top == "" || origin == "" || filepath.Clean(top) == filepath.Clean(origin) { + return + } + home, err := os.UserHomeDir() + if err != nil { + slog.Warn("carry folder trust into Cursor CLI", "cwd", cwd, "err", err) + return + } + projects := cursorProjectsDir(home) + if cursorTrusts(projects, home, top) || !cursorTrusts(projects, home, origin) { + return + } + if err := writeCursorTrust(projects, top); err != nil { + slog.Warn("carry folder trust into Cursor CLI", "cwd", cwd, "err", err) + } +} + +// cursorProjectsDir is where Cursor CLI keeps a folder of its own per project: +// under CURSOR_DATA_DIR, else ~/.cursor. Neither CURSOR_CONFIG_DIR nor a lich +// profile moves it. +func cursorProjectsDir(home string) string { + if dir := os.Getenv("CURSOR_DATA_DIR"); strings.TrimSpace(dir) != "" { + return filepath.Join(dir, "projects") + } + return filepath.Join(home, ".cursor", "projects") +} + +// cursorSlug is the name of a folder's project dir: every run of characters +// outside [A-Za-z0-9] becomes one dash, trimmed at both ends. +func cursorSlug(dir string) string { + return strings.Trim(cursorSlugRun.ReplaceAllString(dir, "-"), "-") +} + +// cursorTrusts reports whether Cursor CLI would open in dir without asking: a +// marker for dir, or for a parent that is below home and at least +// cursorMinTrustDepth components deep. +func cursorTrusts(projects, home, dir string) bool { + if hasCursorMarker(projects, dir) { + return true + } + for parent := filepath.Dir(dir); parent != dir; dir, parent = parent, filepath.Dir(parent) { + if cursorParentCounts(parent, home) && hasCursorMarker(projects, parent) { + return true + } + } + return false +} + +func cursorParentCounts(dir, home string) bool { + atOrAboveHome := dir == home || strings.HasPrefix(home, dir+string(filepath.Separator)) + depth := len(strings.FieldsFunc(dir, func(r rune) bool { return r == filepath.Separator })) + return !atOrAboveHome && depth >= cursorMinTrustDepth +} + +func hasCursorMarker(projects, dir string) bool { + _, err := os.Stat(filepath.Join(projects, cursorSlug(dir), cursorTrustMarker)) + return err == nil +} + +// writeCursorTrust writes the marker the way Cursor CLI writes its own, so the +// file reads the same to anyone who opens it. +func writeCursorTrust(projects, dir string) error { + folder := filepath.Join(projects, cursorSlug(dir)) + if err := os.MkdirAll(folder, 0o755); err != nil { + return fmt.Errorf("create %s: %w", folder, err) + } + data, err := json.MarshalIndent(map[string]string{ + "trustedAt": time.Now().UTC().Format("2006-01-02T15:04:05.000Z"), + "workspacePath": dir, + }, "", " ") + if err != nil { + return err + } + return profilestore.WriteFileAtomic(filepath.Join(folder, cursorTrustMarker), data, 0o644) +} diff --git a/internal/terminal/cursor_trust_test.go b/internal/terminal/cursor_trust_test.go new file mode 100644 index 00000000..4d0490cf --- /dev/null +++ b/internal/terminal/cursor_trust_test.go @@ -0,0 +1,126 @@ +package terminal + +import ( + "os" + "os/exec" + "path/filepath" + "strings" + "testing" + + "github.com/omartelo/lich/internal/providers" +) + +// gitWorktree is a repository with one commit and a linked worktree of it, +// both as git spells them (resolved, forward slashes on Windows). +func gitWorktree(t *testing.T) (main, worktree string) { + t.Helper() + repo := t.TempDir() + linked := filepath.Join(t.TempDir(), "wt") + for _, args := range [][]string{ + {"init", "-b", "main", repo}, + {"-C", repo, "-c", "user.email=t@example.com", "-c", "user.name=t", "commit", "--allow-empty", "-m", "first"}, + {"-C", repo, "worktree", "add", "-b", "side", linked}, + } { + if out, err := exec.Command("git", args...).CombinedOutput(); err != nil { + t.Skipf("git %v unavailable: %v (%s)", args, err, out) + } + } + top := func(dir string) string { + out, err := exec.Command("git", "-C", dir, "rev-parse", "--show-toplevel").Output() + if err != nil { + t.Fatal(err) + } + return filepath.FromSlash(strings.TrimSpace(string(out))) + } + return top(repo), top(linked) +} + +// cursorMachine points Cursor CLI's projects dir and the home at scratch +// directories, returning the projects dir. +func cursorMachine(t *testing.T) string { + t.Helper() + home := t.TempDir() + t.Setenv("HOME", home) + t.Setenv("USERPROFILE", home) + data := t.TempDir() + t.Setenv("CURSOR_DATA_DIR", data) + return filepath.Join(data, "projects") +} + +func markCursorTrusted(t *testing.T, projects, dir string) { + t.Helper() + if err := writeCursorTrust(projects, dir); err != nil { + t.Fatal(err) + } +} + +func TestInheritCursorTrustCarriesTheMainCheckoutsAnswerToItsWorktree(t *testing.T) { + main, worktree := gitWorktree(t) + projects := cursorMachine(t) + markCursorTrusted(t, projects, main) + + inheritCursorTrust(providers.Cursor, worktree) + if !hasCursorMarker(projects, worktree) { + t.Fatalf("the worktree of a trusted checkout is not trusted: no %s", filepath.Join(projects, cursorSlug(worktree))) + } +} + +func TestInheritCursorTrustNeverTrustsWhatNobodyTrusted(t *testing.T) { + main, worktree := gitWorktree(t) + projects := cursorMachine(t) + + inheritCursorTrust(providers.Cursor, worktree) + inheritCursorTrust(providers.Cursor, main) + if _, err := os.Stat(projects); !os.IsNotExist(err) { + t.Errorf("an untrusted checkout was marked: %v", err) + } + markCursorTrusted(t, projects, main) + inheritCursorTrust(providers.Codex, worktree) + if hasCursorMarker(projects, worktree) { + t.Error("a Codex spawn wrote Cursor CLI's trust") + } +} + +// The rule is the bundle's: one dash per run of anything outside [A-Za-z0-9], +// none at either end. +func TestCursorSlug(t *testing.T) { + for in, want := range map[string]string{ + "/home/u/.local/share/lich/worktrees/e48f04f46a4a/fuzzy-pine": "home-u-local-share-lich-worktrees-e48f04f46a4a-fuzzy-pine", + `C:\Users\u\my repo_1\`: "C-Users-u-my-repo-1", + } { + if got := cursorSlug(in); got != want { + t.Errorf("cursorSlug(%q) = %q, want %q", in, got, want) + } + } +} + +// A marker on a parent counts only below home and three components deep, so +// trusting the home (or / ) once does not trust every folder under it. +func TestCursorTrustsAParentOnlyBelowHome(t *testing.T) { + sep := string(filepath.Separator) + home := filepath.Join(sep+"h", "u") + deep := filepath.Join(home, "src", "app") + for _, tt := range []struct { + parent string + counts bool + }{ + {filepath.Join(home, "src"), true}, + {home, false}, + {filepath.Dir(home), false}, + {filepath.Join(sep+"opt", "x"), false}, + {filepath.Join(sep+"opt", "x", "y"), true}, + } { + if got := cursorParentCounts(tt.parent, home); got != tt.counts { + t.Errorf("cursorParentCounts(%q) = %v, want %v", tt.parent, got, tt.counts) + } + } + projects := t.TempDir() + markCursorTrusted(t, projects, filepath.Join(home, "src")) + if !cursorTrusts(projects, home, filepath.Join(deep, "pkg")) { + t.Error("a folder under a trusted parent is not trusted") + } + markCursorTrusted(t, projects, home) + if cursorTrusts(projects, home, filepath.Join(home, "other")) { + t.Error("the home's marker trusted a folder under it") + } +} diff --git a/internal/terminal/sandbox.go b/internal/terminal/sandbox.go index d5a7af88..fe696f41 100644 --- a/internal/terminal/sandbox.go +++ b/internal/terminal/sandbox.go @@ -4,6 +4,7 @@ import ( "log/slog" "os" "path/filepath" + "strconv" "github.com/omartelo/lich/internal/claudeprofile" "github.com/omartelo/lich/internal/project" @@ -59,12 +60,8 @@ func wrapSandbox( } } spec.bin, spec.args = sandbox.Wrap(sb, spec.bin, spec.args) - // Claude Code reads it as "every folder is trusted" and skips its trust - // question (measured on 2.1.302): what the question guards against, a - // folder's own settings running on the machine, is what the sandbox - // confines. - if kind == providers.Claude { - spec.env = append(spec.env, claudeSandboxedVar+"=1") + if setting, ok := confinedEnv[kind]; ok { + spec.env = append(spec.env, setting) } // The token goes in the child's environment rather than through bubblewrap's // --setenv, which would write it into an argument list any process on the @@ -75,7 +72,58 @@ func wrapSandbox( return spec, sb.SkippedLinks } -const claudeSandboxedVar = "CLAUDE_CODE_SANDBOXED" +// confinedEnv is what a provider is told in a confined spawn so it does not +// open on a screen the sandbox makes moot, the first thing an unattended +// session would otherwise stop on. +var confinedEnv = map[string]string{ + // Read as "every folder is trusted", which skips the trust question + // (measured on 2.1.302): what the question guards against, a folder's own + // settings running on the machine, is what the sandbox confines. + providers.Claude: "CLAUDE_CODE_SANDBOXED=1", + // Skips the first-run setup wizard (measured on 18.8.9). A profile's + // config.yml is a link into Personal, which the sandbox does not mount, + // so a confined omp reads no setup as done and opens on "Sign in". + providers.OMP: "OMP_SKIP_SETUP=1", +} + +// confinedTrustArgs are the flags that keep a confined session from opening on +// its provider's "trust this folder?" question, for the same reason +// confinedEnv carries Claude Code's variable. They go first in the argv, which +// every provider given one reads as a global option. +func confinedTrustArgs(kind, cwd string, confined bool) []string { + if !confined { + return nil + } + switch kind { + case providers.Codex: + return []string{"-c", codexTrustOverride(codexTrustRoot(cwd))} + case providers.Cursor: + return []string{"--trust"} + } + return nil +} + +// codexTrustRoot is the folder Codex keys a checkout's trust by: the main +// checkout's top, which is what accepting its question in a linked worktree +// records (measured on 0.144.5 and 0.162.1). Outside a repository it is the +// folder itself, which 0.144.5 asks about and 0.162.1 does not. +func codexTrustRoot(cwd string) string { + top, origin := project.CheckoutRoots(cwd) + switch { + case origin != "": + return origin + case top != "": + return top + } + return cwd +} + +// codexTrustOverride is a `-c` value trusting root. Only an inline table works: +// the dotted `projects."".trust_level` form still asks (measured on +// 0.144.5 and 0.162.1), and neither writes to config.toml. +func codexTrustOverride(root string) string { + return "projects={" + strconv.Quote(root) + `={trust_level="trusted"}}` +} // sandboxCreds is what a confined session is handed to act on the network as the // user: the ssh agent a push authenticates through, and the GitHub token gh diff --git a/internal/terminal/sandbox_test.go b/internal/terminal/sandbox_test.go index a2c2c590..a0df9a35 100644 --- a/internal/terminal/sandbox_test.go +++ b/internal/terminal/sandbox_test.go @@ -5,6 +5,7 @@ import ( "os/exec" "path/filepath" "slices" + "strconv" "strings" "testing" @@ -336,3 +337,39 @@ func TestWrapSandboxSkipsClaudesTrustQuestion(t *testing.T) { t.Errorf("unconfined Claude Code env carries %s", sandboxed) } } + +// TestConfinedTrustArgs: a confined Codex is told its checkout's main top is +// trusted, as the inline table Codex accepts, and a confined Cursor CLI gets +// --trust; an unconfined spawn and a provider without a flag get nothing. +func TestConfinedTrustArgs(t *testing.T) { + main, worktree := gitWorktree(t) + want := []string{"-c", "projects={" + strconv.Quote(main) + `={trust_level="trusted"}}`} + if got := confinedTrustArgs(providers.Codex, worktree, true); !slices.Equal(got, want) { + t.Errorf("confined Codex in a worktree = %q, want %q", got, want) + } + plain := t.TempDir() + if got := confinedTrustArgs(providers.Codex, plain, true); !slices.Contains(got, "projects={"+strconv.Quote(plain)+`={trust_level="trusted"}}`) { + t.Errorf("confined Codex outside a repository = %q, want the folder itself trusted", got) + } + if got := confinedTrustArgs(providers.Cursor, worktree, true); !slices.Equal(got, []string{"--trust"}) { + t.Errorf("confined Cursor CLI = %q, want --trust", got) + } + for _, kind := range []string{providers.Codex, providers.Cursor} { + if got := confinedTrustArgs(kind, worktree, false); got != nil { + t.Errorf("unconfined %s = %q, want nothing", kind, got) + } + } + if got := confinedTrustArgs(providers.Claude, worktree, true); got != nil { + t.Errorf("confined Claude Code = %q, want nothing (its trust rides confinedEnv)", got) + } +} + +func TestWrapSandboxSkipsOMPSetup(t *testing.T) { + if !sandbox.Available() { + t.Skip("no sandbox backend on this machine") + } + got, _ := wrapSandbox(baseSpec(), providers.OMP, t.TempDir(), "", "", true, sandboxCreds{}) + if !slices.Contains(got.env, "OMP_SKIP_SETUP=1") { + t.Errorf("confined oh-my-pi env lacks OMP_SKIP_SETUP=1: %v", got.env) + } +} diff --git a/internal/terminal/start.go b/internal/terminal/start.go index bb37e715..3c73087f 100644 --- a/internal/terminal/start.go +++ b/internal/terminal/start.go @@ -84,6 +84,8 @@ func (s *Service) Start( return err } inheritClaudeTrust(kind, cwd, profileDir) + inheritCursorTrust(kind, cwd) + markCrushInitialized(kind, cwd) awaits := s.awaitsStart(kind, profileDir) sess, cwd, err := s.spawnSession(id, projectID, cwd, kind, resume, profileDir, name, fork, setup, cols, rows) if err != nil || sess == nil { @@ -161,13 +163,20 @@ func (s *Service) spawnSession( if profileDir != "" { env = profileEnv(kind, env, profileDir) } + // Decided before the argv, which carries the trust flags a confined spawn + // is given (confinedTrustArgs). + inSandbox := confined(s.store, id, kind, projectID, cwd) + // Here rather than beside the other trust writes in Start, before the lock, + // because it depends on the sandbox verdict; it reads one small file and + // asks git once, and only for an Antigravity spawn. + inheritAntigravityTrust(kind, cwd, inSandbox) spec := ptySpec{ bin: resolveCommand(kind, s.store.ProviderBin(kind, projectID), userShell()), - args: providerArgs( + args: append(confinedTrustArgs(kind, cwd, inSandbox), providerArgs( kind, name, resume, s.store.SessionModel(id), s.store.SessionEffort(id), mcpBin, kiroPluginAgent(kind), fork, skipPermissions, ultracode, subagentRoute(kind, depth, cardsOn), s.promptLanguage(), - ), + )...), dir: cwd, env: env, cols: cols, @@ -186,7 +195,6 @@ func (s *Service) spawnSession( } // Outermost, so the setup script and the entrypoint are confined with the // session they run in front of. - inSandbox := confined(s.store, id, kind, projectID, cwd) creds := s.sandboxCredentials(projectID, cwd, inSandbox) spec, skippedLinks := wrapSandbox( spec, kind, userHome(), sessionDropDir(s.dropDir, id, inSandbox), profileDir, inSandbox, creds,