diff --git a/CHANGELOG.md b/CHANGELOG.md index 10c958c19..60f21396e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,20 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added +- Children (sub-agents and Fleet workers) inherit the session's permission + posture faithfully: Auto-Review's deterministic floor and model guardian + decide a worker's held calls (fail closed when unavailable, never a + prompt); under Ask a held call is raised in the parent's approval UI and + the worker waits visibly; Full Access still fails closed on the safety + floor. Each prompt-less decision is a one-line note in that worker's + transcript (focus mode) and an audit-log record. +- Worker role defaults keep what the role does not intend to withhold: + every built-in role keeps network reads; `planner` may run read-only + shell probes; `custom` inherits the parent's write/network/shell posture + and is narrowed only by its explicit tool list or the spawning call. + Read-only roles (`scout`, `reviewer`, `planner`, `verifier`, + `consultant`) still never write the workspace. The focused worker's + header states its effective posture from the runtime snapshot. - Whale Teams in the terminal: the six Signal Cut whale identities (Scout, Patch, Harbor, Echo, Keel, Lantern) appear as species badges on `/fleet` roster rows and worker rows, with an identity portrait in the roster detail diff --git a/crates/tui/locales/ca.json b/crates/tui/locales/ca.json index 8922c482a..827e4a431 100644 --- a/crates/tui/locales/ca.json +++ b/crates/tui/locales/ca.json @@ -1440,5 +1440,13 @@ "AgentFocusFollowUpFailed": "No s'ha pogut lliurar a {agent}: {reason}", "FooterHintForAgents": "agents", "FooterHintToManage": "gestionar", - "AgentRailQueuedCount": "{count} a la cua" + "AgentRailQueuedCount": "{count} a la cua", + "AgentFocusPosture": "{role} · {write} · {network} · {shell}", + "AgentFocusPostureWrites": "escriu", + "AgentFocusPostureReadOnly": "només lectura", + "AgentFocusPostureNetwork": "xarxa", + "AgentFocusPostureNoNetwork": "sense xarxa", + "AgentFocusPostureShellFull": "shell", + "AgentFocusPostureShellReadOnly": "shell de només lectura", + "AgentFocusPostureShellNone": "sense shell" } diff --git a/crates/tui/locales/de.json b/crates/tui/locales/de.json index 57eafb8e9..3d75ae5dd 100644 --- a/crates/tui/locales/de.json +++ b/crates/tui/locales/de.json @@ -1440,5 +1440,13 @@ "AgentFocusFollowUpFailed": "Zustellung an {agent} nicht möglich: {reason}", "FooterHintForAgents": "Agenten", "FooterHintToManage": "verwalten", - "AgentRailQueuedCount": "{count} eingereiht" + "AgentRailQueuedCount": "{count} eingereiht", + "AgentFocusPosture": "{role} · {write} · {network} · {shell}", + "AgentFocusPostureWrites": "schreibt", + "AgentFocusPostureReadOnly": "nur lesen", + "AgentFocusPostureNetwork": "Netzwerk", + "AgentFocusPostureNoNetwork": "kein Netzwerk", + "AgentFocusPostureShellFull": "Shell", + "AgentFocusPostureShellReadOnly": "Nur-Lese-Shell", + "AgentFocusPostureShellNone": "keine Shell" } diff --git a/crates/tui/locales/en.json b/crates/tui/locales/en.json index 5fd6e9cf5..36b1a1174 100644 --- a/crates/tui/locales/en.json +++ b/crates/tui/locales/en.json @@ -1440,5 +1440,13 @@ "AgentFocusFollowUpFailed": "Could not deliver to {agent}: {reason}", "FooterHintForAgents": "for agents", "FooterHintToManage": "to manage", - "AgentRailQueuedCount": "{count} queued" + "AgentRailQueuedCount": "{count} queued", + "AgentFocusPosture": "{role} · {write} · {network} · {shell}", + "AgentFocusPostureWrites": "writes", + "AgentFocusPostureReadOnly": "read-only", + "AgentFocusPostureNetwork": "network", + "AgentFocusPostureNoNetwork": "no network", + "AgentFocusPostureShellFull": "shell", + "AgentFocusPostureShellReadOnly": "read-only shell", + "AgentFocusPostureShellNone": "no shell" } diff --git a/crates/tui/locales/es-419.json b/crates/tui/locales/es-419.json index 8af863cde..47fda49d5 100644 --- a/crates/tui/locales/es-419.json +++ b/crates/tui/locales/es-419.json @@ -1440,5 +1440,13 @@ "AgentFocusFollowUpFailed": "No se pudo entregar a {agent}: {reason}", "FooterHintForAgents": "agentes", "FooterHintToManage": "gestionar", - "AgentRailQueuedCount": "{count} en cola" + "AgentRailQueuedCount": "{count} en cola", + "AgentFocusPosture": "{role} · {write} · {network} · {shell}", + "AgentFocusPostureWrites": "escribe", + "AgentFocusPostureReadOnly": "solo lectura", + "AgentFocusPostureNetwork": "red", + "AgentFocusPostureNoNetwork": "sin red", + "AgentFocusPostureShellFull": "shell", + "AgentFocusPostureShellReadOnly": "shell de solo lectura", + "AgentFocusPostureShellNone": "sin shell" } diff --git a/crates/tui/locales/fr.json b/crates/tui/locales/fr.json index a950f946c..bf876f3d7 100644 --- a/crates/tui/locales/fr.json +++ b/crates/tui/locales/fr.json @@ -1440,5 +1440,13 @@ "AgentFocusFollowUpFailed": "Impossible de transmettre à {agent} : {reason}", "FooterHintForAgents": "agents", "FooterHintToManage": "gérer", - "AgentRailQueuedCount": "{count} en file" + "AgentRailQueuedCount": "{count} en file", + "AgentFocusPosture": "{role} · {write} · {network} · {shell}", + "AgentFocusPostureWrites": "écrit", + "AgentFocusPostureReadOnly": "lecture seule", + "AgentFocusPostureNetwork": "réseau", + "AgentFocusPostureNoNetwork": "sans réseau", + "AgentFocusPostureShellFull": "shell", + "AgentFocusPostureShellReadOnly": "shell en lecture seule", + "AgentFocusPostureShellNone": "sans shell" } diff --git a/crates/tui/locales/hi.json b/crates/tui/locales/hi.json index 1a1d9c382..2f3b00005 100644 --- a/crates/tui/locales/hi.json +++ b/crates/tui/locales/hi.json @@ -1440,5 +1440,13 @@ "AgentFocusFollowUpFailed": "{agent} तक नहीं पहुँचाया जा सका: {reason}", "FooterHintForAgents": "एजेंट", "FooterHintToManage": "प्रबंधन", - "AgentRailQueuedCount": "{count} कतार में" + "AgentRailQueuedCount": "{count} कतार में", + "AgentFocusPosture": "{role} · {write} · {network} · {shell}", + "AgentFocusPostureWrites": "लिख सकता है", + "AgentFocusPostureReadOnly": "केवल-पढ़ने", + "AgentFocusPostureNetwork": "नेटवर्क", + "AgentFocusPostureNoNetwork": "नेटवर्क नहीं", + "AgentFocusPostureShellFull": "शेल", + "AgentFocusPostureShellReadOnly": "केवल-पढ़ने शेल", + "AgentFocusPostureShellNone": "शेल नहीं" } diff --git a/crates/tui/locales/id.json b/crates/tui/locales/id.json index 065dc9a1c..94e22ee97 100644 --- a/crates/tui/locales/id.json +++ b/crates/tui/locales/id.json @@ -1440,5 +1440,13 @@ "AgentFocusFollowUpFailed": "Tidak dapat mengirim ke {agent}: {reason}", "FooterHintForAgents": "agen", "FooterHintToManage": "kelola", - "AgentRailQueuedCount": "{count} antre" + "AgentRailQueuedCount": "{count} antre", + "AgentFocusPosture": "{role} · {write} · {network} · {shell}", + "AgentFocusPostureWrites": "menulis", + "AgentFocusPostureReadOnly": "hanya-baca", + "AgentFocusPostureNetwork": "jaringan", + "AgentFocusPostureNoNetwork": "tanpa jaringan", + "AgentFocusPostureShellFull": "shell", + "AgentFocusPostureShellReadOnly": "shell hanya-baca", + "AgentFocusPostureShellNone": "tanpa shell" } diff --git a/crates/tui/locales/ja.json b/crates/tui/locales/ja.json index b529b1bb5..d560c6c1b 100644 --- a/crates/tui/locales/ja.json +++ b/crates/tui/locales/ja.json @@ -1440,5 +1440,13 @@ "AgentFocusFollowUpFailed": "{agent} に届けられませんでした: {reason}", "FooterHintForAgents": "エージェント", "FooterHintToManage": "管理", - "AgentRailQueuedCount": "{count} 件キュー" + "AgentRailQueuedCount": "{count} 件キュー", + "AgentFocusPosture": "{role} · {write} · {network} · {shell}", + "AgentFocusPostureWrites": "書き込み可", + "AgentFocusPostureReadOnly": "読み取り専用", + "AgentFocusPostureNetwork": "ネットワーク可", + "AgentFocusPostureNoNetwork": "ネットワーク不可", + "AgentFocusPostureShellFull": "シェル可", + "AgentFocusPostureShellReadOnly": "読み取り専用シェル", + "AgentFocusPostureShellNone": "シェル不可" } diff --git a/crates/tui/locales/ko.json b/crates/tui/locales/ko.json index d98596eaf..f7329426e 100644 --- a/crates/tui/locales/ko.json +++ b/crates/tui/locales/ko.json @@ -1440,5 +1440,13 @@ "AgentFocusFollowUpFailed": "{agent}에게 전달할 수 없습니다: {reason}", "FooterHintForAgents": "에이전트", "FooterHintToManage": "관리", - "AgentRailQueuedCount": "{count}개 대기" + "AgentRailQueuedCount": "{count}개 대기", + "AgentFocusPosture": "{role} · {write} · {network} · {shell}", + "AgentFocusPostureWrites": "쓰기 가능", + "AgentFocusPostureReadOnly": "읽기 전용", + "AgentFocusPostureNetwork": "네트워크 가능", + "AgentFocusPostureNoNetwork": "네트워크 없음", + "AgentFocusPostureShellFull": "셸 가능", + "AgentFocusPostureShellReadOnly": "읽기 전용 셸", + "AgentFocusPostureShellNone": "셸 없음" } diff --git a/crates/tui/locales/pt-BR.json b/crates/tui/locales/pt-BR.json index 61f306e6e..7fa08b68c 100644 --- a/crates/tui/locales/pt-BR.json +++ b/crates/tui/locales/pt-BR.json @@ -1440,5 +1440,13 @@ "AgentFocusFollowUpFailed": "Não foi possível entregar a {agent}: {reason}", "FooterHintForAgents": "agentes", "FooterHintToManage": "gerenciar", - "AgentRailQueuedCount": "{count} na fila" + "AgentRailQueuedCount": "{count} na fila", + "AgentFocusPosture": "{role} · {write} · {network} · {shell}", + "AgentFocusPostureWrites": "escreve", + "AgentFocusPostureReadOnly": "somente leitura", + "AgentFocusPostureNetwork": "rede", + "AgentFocusPostureNoNetwork": "sem rede", + "AgentFocusPostureShellFull": "shell", + "AgentFocusPostureShellReadOnly": "shell somente leitura", + "AgentFocusPostureShellNone": "sem shell" } diff --git a/crates/tui/locales/ru.json b/crates/tui/locales/ru.json index db697027c..20f19938a 100644 --- a/crates/tui/locales/ru.json +++ b/crates/tui/locales/ru.json @@ -1440,5 +1440,13 @@ "AgentFocusFollowUpFailed": "Не удалось доставить {agent}: {reason}", "FooterHintForAgents": "агенты", "FooterHintToManage": "управление", - "AgentRailQueuedCount": "{count} в очереди" + "AgentRailQueuedCount": "{count} в очереди", + "AgentFocusPosture": "{role} · {write} · {network} · {shell}", + "AgentFocusPostureWrites": "пишет", + "AgentFocusPostureReadOnly": "только чтение", + "AgentFocusPostureNetwork": "сеть", + "AgentFocusPostureNoNetwork": "без сети", + "AgentFocusPostureShellFull": "shell", + "AgentFocusPostureShellReadOnly": "shell только чтение", + "AgentFocusPostureShellNone": "без shell" } diff --git a/crates/tui/locales/uk.json b/crates/tui/locales/uk.json index e6382aaf4..df03ed4fc 100644 --- a/crates/tui/locales/uk.json +++ b/crates/tui/locales/uk.json @@ -1440,5 +1440,13 @@ "AgentFocusFollowUpFailed": "Не вдалося доставити {agent}: {reason}", "FooterHintForAgents": "агенти", "FooterHintToManage": "керування", - "AgentRailQueuedCount": "{count} у черзі" + "AgentRailQueuedCount": "{count} у черзі", + "AgentFocusPosture": "{role} · {write} · {network} · {shell}", + "AgentFocusPostureWrites": "пише", + "AgentFocusPostureReadOnly": "лише читання", + "AgentFocusPostureNetwork": "мережа", + "AgentFocusPostureNoNetwork": "без мережі", + "AgentFocusPostureShellFull": "shell", + "AgentFocusPostureShellReadOnly": "shell лише читання", + "AgentFocusPostureShellNone": "без shell" } diff --git a/crates/tui/locales/vi.json b/crates/tui/locales/vi.json index c0d8ea73e..3cc0b2a95 100644 --- a/crates/tui/locales/vi.json +++ b/crates/tui/locales/vi.json @@ -1440,5 +1440,13 @@ "AgentFocusFollowUpFailed": "Không thể gửi tới {agent}: {reason}", "FooterHintForAgents": "agent", "FooterHintToManage": "quản lý", - "AgentRailQueuedCount": "{count} đang chờ" + "AgentRailQueuedCount": "{count} đang chờ", + "AgentFocusPosture": "{role} · {write} · {network} · {shell}", + "AgentFocusPostureWrites": "được ghi", + "AgentFocusPostureReadOnly": "chỉ đọc", + "AgentFocusPostureNetwork": "có mạng", + "AgentFocusPostureNoNetwork": "không mạng", + "AgentFocusPostureShellFull": "shell", + "AgentFocusPostureShellReadOnly": "shell chỉ đọc", + "AgentFocusPostureShellNone": "không shell" } diff --git a/crates/tui/locales/zh-Hans.json b/crates/tui/locales/zh-Hans.json index 486cddb8c..240104f3a 100644 --- a/crates/tui/locales/zh-Hans.json +++ b/crates/tui/locales/zh-Hans.json @@ -1440,5 +1440,13 @@ "AgentFocusFollowUpFailed": "无法发送给 {agent}:{reason}", "FooterHintForAgents": "智能体", "FooterHintToManage": "管理", - "AgentRailQueuedCount": "{count} 条排队" + "AgentRailQueuedCount": "{count} 条排队", + "AgentFocusPosture": "{role} · {write} · {network} · {shell}", + "AgentFocusPostureWrites": "可写入", + "AgentFocusPostureReadOnly": "只读", + "AgentFocusPostureNetwork": "可联网", + "AgentFocusPostureNoNetwork": "无网络", + "AgentFocusPostureShellFull": "可用 shell", + "AgentFocusPostureShellReadOnly": "只读 shell", + "AgentFocusPostureShellNone": "无 shell" } diff --git a/crates/tui/locales/zh-Hant.json b/crates/tui/locales/zh-Hant.json index 400fbba12..81bcacddc 100644 --- a/crates/tui/locales/zh-Hant.json +++ b/crates/tui/locales/zh-Hant.json @@ -1440,5 +1440,13 @@ "AgentFocusFollowUpFailed": "無法傳送給 {agent}:{reason}", "FooterHintForAgents": "代理", "FooterHintToManage": "管理", - "AgentRailQueuedCount": "{count} 則排隊" + "AgentRailQueuedCount": "{count} 則排隊", + "AgentFocusPosture": "{role} · {write} · {network} · {shell}", + "AgentFocusPostureWrites": "可寫入", + "AgentFocusPostureReadOnly": "唯讀", + "AgentFocusPostureNetwork": "可連網", + "AgentFocusPostureNoNetwork": "無網路", + "AgentFocusPostureShellFull": "可用 shell", + "AgentFocusPostureShellReadOnly": "唯讀 shell", + "AgentFocusPostureShellNone": "無 shell" } diff --git a/crates/tui/src/localization.rs b/crates/tui/src/localization.rs index b4e1cc6aa..a5bbca9c0 100644 --- a/crates/tui/src/localization.rs +++ b/crates/tui/src/localization.rs @@ -1618,6 +1618,14 @@ pub enum MessageId { AgentFocusOpened, AgentFocusClosed, AgentFocusBanner, + AgentFocusPosture, + AgentFocusPostureWrites, + AgentFocusPostureReadOnly, + AgentFocusPostureNetwork, + AgentFocusPostureNoNetwork, + AgentFocusPostureShellFull, + AgentFocusPostureShellReadOnly, + AgentFocusPostureShellNone, AgentFocusComposerChip, AgentFocusPlaceholder, AgentFocusNoTranscript, @@ -3065,6 +3073,14 @@ pub const ALL_MESSAGE_IDS: &[MessageId] = &[ MessageId::AgentFocusOpened, MessageId::AgentFocusClosed, MessageId::AgentFocusBanner, + MessageId::AgentFocusPosture, + MessageId::AgentFocusPostureWrites, + MessageId::AgentFocusPostureReadOnly, + MessageId::AgentFocusPostureNetwork, + MessageId::AgentFocusPostureNoNetwork, + MessageId::AgentFocusPostureShellFull, + MessageId::AgentFocusPostureShellReadOnly, + MessageId::AgentFocusPostureShellNone, MessageId::AgentFocusComposerChip, MessageId::AgentFocusPlaceholder, MessageId::AgentFocusNoTranscript, diff --git a/crates/tui/src/tui/agent_focus.rs b/crates/tui/src/tui/agent_focus.rs index f4c96587d..9760bd59b 100644 --- a/crates/tui/src/tui/agent_focus.rs +++ b/crates/tui/src/tui/agent_focus.rs @@ -385,6 +385,40 @@ pub(crate) fn focused_status(app: &App) -> Option<(char, String)> { }) } +/// One short line naming the focused worker's effective posture: its role, +/// whether it may write the workspace, reach the network, and run shell — +/// from the runtime's persisted permission snapshot, never guessed. +pub(crate) fn focused_posture(app: &App) -> Option { + let focus = app.agent_focus.as_ref()?; + let agent = app + .subagent_cache + .iter() + .find(|agent| agent.agent_id == focus.agent_id)?; + let permissions = agent.runtime_permissions.as_ref()?; + let write = app.tr(if permissions.write { + MessageId::AgentFocusPostureWrites + } else { + MessageId::AgentFocusPostureReadOnly + }); + let network = app.tr(if permissions.network { + MessageId::AgentFocusPostureNetwork + } else { + MessageId::AgentFocusPostureNoNetwork + }); + let shell = app.tr(match permissions.shell.as_str() { + "full" => MessageId::AgentFocusPostureShellFull, + "read_only" => MessageId::AgentFocusPostureShellReadOnly, + _ => MessageId::AgentFocusPostureShellNone, + }); + Some( + app.tr(MessageId::AgentFocusPosture) + .replace("{role}", agent.agent_type.as_str()) + .replace("{write}", &write) + .replace("{network}", &network) + .replace("{shell}", &shell), + ) +} + /// Whether any worker exists to list, focus, or manage this session. pub(crate) fn agents_exist(app: &App) -> bool { !app.subagent_cache.is_empty() || !app.agent_progress.is_empty() @@ -454,7 +488,7 @@ pub(crate) fn render_focus(app: &mut App, area: Rect, buf: &mut Buffer) { .tr(MessageId::AgentFocusBanner) .replace("{agent}", &focus.label) .replace("{status}", &status_word); - let banner_line = Line::from(vec![ + let mut banner_spans = vec![ Span::styled( format!("{status_glyph} "), Style::default().fg(theme.accent_action), @@ -465,7 +499,16 @@ pub(crate) fn render_focus(app: &mut App, area: Rect, buf: &mut Buffer) { .fg(theme.accent_action) .add_modifier(Modifier::BOLD), ), - ]); + ]; + // The worker's effective posture, in the same dot chain: what it may do + // is stated where its conversation is read, not hidden in a role name. + if let Some(posture) = focused_posture(app) { + banner_spans.push(Span::styled( + format!(" · {posture}"), + Style::default().fg(theme.text_muted), + )); + } + let banner_line = Line::from(banner_spans); let width = area.width.max(1); let mut lines: Vec> = Vec::new(); if focus.omitted_messages > 0 { @@ -610,6 +653,68 @@ mod tests { assert!(!exit_focus(&mut app)); } + #[test] + fn focus_banner_states_the_workers_effective_posture_from_the_runtime_snapshot() { + let tmp = tempdir().expect("tempdir"); + let mut app = test_app(tmp.path().to_path_buf()); + seed_resident_transcript( + &mut app, + "agent_scout", + json!([{"role": "user", "content": [{"type": "text", "text": "look around", "cache_control": null}]}]), + ); + app.subagent_cache + .push(crate::tools::subagent::SubAgentResult { + name: "agent_scout".to_string(), + agent_id: "agent_scout".to_string(), + context_mode: "fresh".to_string(), + fork_context: false, + workspace: None, + git_branch: None, + agent_type: crate::tools::subagent::FleetRole::Scout, + assignment: crate::tools::subagent::SubAgentAssignment { + objective: "look around".to_string(), + role: Some("scout".to_string()), + }, + model: "deepseek-v4-flash".to_string(), + nickname: None, + status: SubAgentStatus::Running, + worker_status: None, + runtime_permissions: Some(codewhale_protocol::fleet::FleetEffectivePermissions { + write: false, + network: true, + shell: "read_only".to_string(), + tool_scope: "inherit".to_string(), + tools: Vec::new(), + background: true, + max_spawn_depth: 1, + profile_id: None, + profile_origin: None, + source: "built_in".to_string(), + }), + parent_run_id: None, + spawn_depth: 1, + child_route: None, + result: None, + steps_taken: 0, + checkpoint: None, + needs_input: None, + duration_ms: 0, + started_at: None, + from_prior_session: false, + }); + focus_agent(&mut app, "agent_scout"); + let posture = focused_posture(&app).expect("posture line from the snapshot"); + assert_eq!(posture, "scout · read-only · network · read-only shell"); + let screen = render(&mut app, 100, 8); + assert!( + screen.contains("read-only · network · read-only shell"), + "{screen}" + ); + // No snapshot, no guess. + app.subagent_cache[0].runtime_permissions = None; + assert!(focused_posture(&app).is_none()); + } + fn focus_label(app: &App) -> String { app.agent_focus.as_ref().map(|f| f.label.clone()).unwrap() } diff --git a/docs/FLEET.md b/docs/FLEET.md index d0e820362..25e36d332 100644 --- a/docs/FLEET.md +++ b/docs/FLEET.md @@ -266,7 +266,14 @@ holding an `http`/`https`/`ws`/`wss`/`ftp` address. A URL appearing in file A member's `role` picks the worker posture (and system prompt) when that role already fits inside the ceiling — `reviewer`, `verifier`, `consultant`, `planner` — and a domain-specific role -such as `auditor` falls back to the narrowest posture the ceiling allows. Tasks +such as `auditor` falls back to the narrowest posture the ceiling allows. +Built-in role defaults withhold only what the role intends: read-only roles +never write the workspace, every role keeps network reads, `planner` may run +read-only shell probes, and `builder`/`worker`/`custom` inherit the parent's +effective write/network/shell posture as their ceiling (see the role table in +`docs/SUBAGENTS.md`). The session's permission posture — Ask, Auto-Review, +Full Access — then gates each worker call exactly as it gates the parent +(`docs/MODES.md`, "Children"). Tasks cannot override any of it: `model`, `thinking`, `subagent_type`, `allowed_tools`, and `write_authority` are rejected on an exact Fleet rather than silently ignored. diff --git a/docs/MODES.md b/docs/MODES.md index 231208fd2..3bd87c760 100644 --- a/docs/MODES.md +++ b/docs/MODES.md @@ -242,6 +242,31 @@ narrowed before metadata is built and cannot invent approval authority. An explicit Full Access sub-agent handoff preserves the parent's standing posture so ordinary child work does not begin prompting again. +### Children (sub-agents and Fleet workers) + +Children inherit the session posture faithfully rather than a bare +auto-approve bit: + +- **Auto-Review**: a worker's held call goes through the same deterministic + policy (proven-safe calls run; publish-like and destructive background work + is hard-blocked) and, for holds it cannot prove safe, the same one-shot + model guardian using the child's own session client. No prompt is ever + opened for a child; an unavailable guardian denies, fail closed. +- **Ask**: a call the role may delegate runs. A held call is raised as an + approval prompt in the parent's UI (`agent::approval:`) when the + host is an interactive TUI; the worker waits visibly (`waiting for user`) + and the person's answer is routed back to it, whether the parent turn is + idle or itself awaiting an approval. Hosts that cannot prompt deny with the + reason. +- **Full Access**: ordinary calls run; destructive detached work still fails + closed, because children are background workers. + +Role posture and the execution envelope are checked before and after this +gate and never widen. Every decision a person did not make at a prompt is +written to the audit log and to the child's transcript as a one-line note +(`Auto-Review allowed 'bash' (low risk, model guardian): …`), visible when +the worker is focused. + ## Small-Screen Status Behavior When terminal height is constrained, the status area compacts first so header/chat/composer/footer remain visible: diff --git a/docs/SUBAGENTS.md b/docs/SUBAGENTS.md index 95bd3efa8..73dd72fe0 100644 --- a/docs/SUBAGENTS.md +++ b/docs/SUBAGENTS.md @@ -62,16 +62,36 @@ diff, linked issues, tests, and CI before merging, harvesting, closing, or deferring it. A sub-agent's result is a working set, not a substitute for stewardship. -| Role | Stance | Writes? | Shell posture | Typical use | -|---------------|----------------------------------------|---------|---------------|----------------------------------------------| -| `worker` | flexible; do whatever the parent says | yes | yes | the default; multi-step tasks | -| `scout` | read-only; map the relevant code fast | no | read-only (net + bounded verify) | "find every call site of `Foo`; check the PR with gh" | -| `planner` | analyse and produce a strategy | minimal | minimal | "design the migration; don't execute" | -| `reviewer` | read-and-grade with severity scores | no | read-only (net + bounded verify) | "audit this PR for bugs" | -| `builder` | land a specific change with min edit | yes | yes | "rewrite `bar.rs::Foo::bar` to do X" | -| `verifier` | run tests / validation, report outcome | no | test-focused | "run cargo test --workspace, report" | -| `consultant` | short-lived, high-reasoning counsel | no | none | "what are we missing in this design?" | -| `custom` | explicit narrow tool allowlist | depends | depends | locked-down dispatch with hand-picked tools | +| Role | Stance | Writes? | Network? | Shell posture | Typical use | +|---------------|----------------------------------------|---------|----------|---------------|----------------------------------------------| +| `worker` | flexible; do whatever the parent says | yes | yes | yes | the default; multi-step tasks | +| `scout` | read-only; map the relevant code fast | no | yes | read-only (net + bounded verify) | "find every call site of `Foo`; check the PR with gh" | +| `planner` | analyse and produce a strategy | no | yes | read-only probes | "design the migration; don't execute" | +| `reviewer` | read-and-grade with severity scores | no | yes | read-only (net + bounded verify) | "audit this PR for bugs" | +| `builder` | land a specific change with min edit | yes | yes | yes | "rewrite `bar.rs::Foo::bar` to do X" | +| `verifier` | run tests / validation, report outcome | no | yes | test-focused | "run cargo test --workspace, report" | +| `consultant` | short-lived, high-reasoning counsel | no | yes | none | "what are we missing in this design?" | +| `custom` | explicit narrow tool allowlist | inherits | inherits | inherits | hand-picked tools on the parent's posture | + +A role's default is what the role *intends*, and the parent's effective +posture is always the ceiling (a child never widens beyond its parent). +Read-only roles withhold **workspace writes** by intent; nothing else is +taken away by default — every role keeps network reads, and `custom` +inherits the parent's write/network/shell posture and is narrowed only by +its explicit tool list or the spawning call. The focused worker's header +states the effective posture (`scout · read-only · network · read-only +shell`) from the runtime's own permission snapshot. + +The session's **permission posture** applies inside every child exactly as +it applies to the parent turn: under Auto-Review the same deterministic +floor and one-shot model guardian decide a worker's held calls (never a +prompt; an unavailable guardian denies, fail closed); under Ask a held call +the role cannot delegate is raised as an approval prompt in the parent's +UI and the worker waits visibly (`waiting for user`), or is denied with the +reason on hosts that cannot prompt; Full Access still fails closed on the +non-bypassable safety floor. Each decision nobody was prompted for is a +one-line note in that worker's transcript (visible when it is focused) and +an audit-log record. See `docs/MODES.md`. Each role's full system prompt lives in `crates/tui/src/tools/subagent/mod.rs` (search for