diff --git a/newt-cli/src/crew_runner.rs b/newt-cli/src/crew_runner.rs index 043a018..39e4bba 100644 --- a/newt-cli/src/crew_runner.rs +++ b/newt-cli/src/crew_runner.rs @@ -81,6 +81,20 @@ impl LocalCrewRunner { BackendPool::from_source(&StaticSource::from_configs(self.cfg.backends.iter())) } + /// The crew authority **clamp** (#749 step 2 tightening point): dispatched + /// crews are met against this, so a crew's effective authority is + /// `session ⊓ clamp`. Sourced from `[crew] clamp` in config; **defaults to + /// `Caveats::top()`**, which makes the meet the identity (behavior unchanged) + /// until an operator — or the per-subtask `team_clamp` (#749 step 8) at this + /// same seam — tightens it. Three Cs: the clamp is *configured* data, not a + /// hardcoded policy. + fn crew_clamp(&self) -> Caveats { + self.cfg + .crew + .as_ref() + .map_or_else(Caveats::top, |c| c.clamp.clone()) + } + /// Resolve the crew to field: a named saved `[crews.]` if `args.crew` /// is given, else compose one from the live environment. Returns the crew /// config, an optional lead (team mode), and the rationale lines. @@ -201,6 +215,20 @@ fn choose_test_cmd( .or_else(|| infer_test_command(dir)) } +/// Compute the caveats a dispatched crew actually runs under: the **meet** of +/// the session's grant and the crew clamp (`session ⊓ clamp`). +/// +/// This is the structural Confused-Deputy bound (#749 step 2). `meet` is the +/// authority lattice's greatest-lower-bound, so the result is **always `≤ session` +/// on every axis** — the overseer can never escalate by fielding a crew — and +/// **`≤ clamp`** (the operator's / per-subtask tightening). With the default +/// `clamp = Caveats::top()` the meet is the identity, so the child equals the +/// session and today's behavior is unchanged while the seam exists. Pure, so the +/// `≤ session` guarantee is unit-testable without a live dispatch. +fn dispatch_caveats(session: &Caveats, clamp: &Caveats) -> Caveats { + session.meet(clamp) +} + /// Decide a crew leaf's dispatch result by whether it actually LANDED work. /// /// `plan_exec` marks a leaf `Done` on `Ok` and `Failed` on `Err`. A crew that ran @@ -305,6 +333,16 @@ impl CrewRunner for LocalCrewRunner { "no verification command — pass 'verify' / --locked-verify or add a justfile / Cargo.toml / pyproject.toml" .to_string() })?; + // OCAP seam (#749 step 2): a dispatched crew runs under + // `session ⊓ crew_clamp`, NEVER the session's full grant — the + // structural bound on the recursion / Confused-Deputy case. `meet` + // keeps the child `≤ session` on every axis (the overseer cannot + // escalate by fielding a crew); the clamp (default `top()`, so the + // meet is the identity today) is the operator / per-subtask + // (`team_clamp`, #749 step 8) tightening point. Computed once here + // and handed to both `run_team` and `run_crew`. + let crew_clamp = self.crew_clamp(); + let child_caveats = dispatch_caveats(caveats, &crew_clamp); let id = worktree_id(); // Leaf composition (#646): fork the worktree off the cumulative // chain tip (the last landed leaf), not bare HEAD, so each leaf @@ -320,13 +358,27 @@ impl CrewRunner for LocalCrewRunner { crew: crew_cfg, max_subtasks: MAX_SUBTASKS, }; - let out = - run_team(&pool, &LocalDispatcher, &mut ws, &team_cfg, caveats, task).await; + let out = run_team( + &pool, + &LocalDispatcher, + &mut ws, + &team_cfg, + &child_caveats, + task, + ) + .await; let passed = out.status == TeamStatus::AllPassed; (render_team(&out), passed) } else { - let out = - run_crew(&pool, &LocalDispatcher, &mut ws, &crew_cfg, caveats, task).await; + let out = run_crew( + &pool, + &LocalDispatcher, + &mut ws, + &crew_cfg, + &child_caveats, + task, + ) + .await; let passed = out.status == CrewStatus::Passed; (render_crew(&out), passed) }; @@ -394,6 +446,16 @@ mod tests { LocalCrewRunner::new(Config::default(), std::env::temp_dir(), Presence::Prompt) } + /// A runner whose `[crew] clamp` is sourced from config (three Cs) — the + /// per-deployment tightening point the `.meet()` seam reads. + fn runner_with_clamp(clamp: Caveats) -> LocalCrewRunner { + let cfg = Config { + crew: Some(newt_core::CrewPolicyConfig { clamp }), + ..Config::default() + }; + LocalCrewRunner::new(cfg, std::env::temp_dir(), Presence::Prompt) + } + #[test] fn locked_verify_outranks_caller_and_is_set_by_builder() { use std::path::Path; @@ -435,6 +497,87 @@ mod tests { assert!(r.unwrap_err().contains("did NOT pass")); } + /// #749 step 2 — the `.meet()` seam: a dispatched crew runs under + /// `session ⊓ crew_clamp`, so its authority can never EXCEED the session. + /// + /// RED on today's code: before this step `dispatch` passed the session + /// `caveats` UNMODIFIED to `run_team`/`run_crew` (no `.meet()`), so the crew's + /// caveats == the session's — with a net-allowing session a crew would + /// `permits_net` despite a clamp that denies it (the false "never the session's + /// full grant" claim). The two assertions below contrast the buggy value (the + /// session itself permits net) with the fixed value (`dispatch_caveats` denies + /// net and stays `≤ session`). This is the machine-checked OCAP claim. + #[test] + fn dispatch_caveats_meets_the_clamp_and_stays_le_session() { + // Session allows the net axis (and everything else). + let session = Caveats::top(); + // Operator clamp denies net (Only(∅)); the other axes stay open. + let clamp = Caveats { + net: Scope::none(), + ..Caveats::top() + }; + // The bug this step fixes: today's unmodified child == session, which + // PERMITS net — so a crew would inherit the session's full net grant. + assert!( + session.permits_net("evil.example.com"), + "the session (today's unmodified child) allows net — the false claim" + ); + // The fix: the child handed to the crew is `session ⊓ clamp`. + let child = dispatch_caveats(&session, &clamp); + assert!( + !child.permits_net("evil.example.com"), + "after the meet the crew's caveats DENY the clamp-denied axis" + ); + // …and the meet is always `≤` the session (the Confused-Deputy bound). + assert!( + child.leq(&session), + "a dispatched crew can never exceed the session ceiling" + ); + } + + /// The default clamp is `Caveats::top()`, so the meet is the IDENTITY — + /// today's behavior is unchanged (the seam exists but does not narrow). + #[test] + fn default_crew_clamp_is_top_so_the_meet_is_identity() { + let r = runner(); // Config::default() → no [crew] section + assert_eq!(r.crew_clamp(), Caveats::top(), "default clamp is top()"); + let session = Caveats { + net: Scope::only(["api.internal".to_string()]), + ..Caveats::top() + }; + // meet with top() leaves the session exactly as-is. + assert_eq!( + dispatch_caveats(&session, &r.crew_clamp()), + session, + "the default seam is the identity — behavior unchanged" + ); + } + + /// Config sources the clamp (three Cs): a `[crew] clamp` that denies net is + /// honored by `crew_clamp()`, and the runner's dispatch composition + /// (`session ⊓ crew_clamp`) denies net while staying `≤ session`. This is the + /// wiring assertion binding the seam to the configured clamp. + #[test] + fn configured_crew_clamp_is_sourced_and_composed() { + let clamp = Caveats { + net: Scope::none(), + ..Caveats::top() + }; + let r = runner_with_clamp(clamp.clone()); + assert_eq!( + r.crew_clamp(), + clamp, + "the [crew] clamp is sourced from config" + ); + let session = Caveats::top(); // session allows net + let child = dispatch_caveats(&session, &r.crew_clamp()); + assert!( + !child.permits_net("evil.example.com"), + "the configured clamp denies net at the dispatch seam" + ); + assert!(child.leq(&session), "still ≤ session"); + } + #[tokio::test] async fn crew_needs_attestation_without_an_established_presence() { // 23.2 — with NO human presence established, dispatching a crew (an amplify) diff --git a/newt-core/src/agentic/crew_tool.rs b/newt-core/src/agentic/crew_tool.rs index 0e3ac93..52cdbc5 100644 --- a/newt-core/src/agentic/crew_tool.rs +++ b/newt-core/src/agentic/crew_tool.rs @@ -10,10 +10,14 @@ //! impl is injected — the `/team` toggle (presence gate). //! //! **OCAP:** a model fielding a crew is the recursion / Confused-Deputy case. The -//! [`CrewRunner`] impl runs every spawned crew under `meet`-**attenuated** caveats -//! (never the session's full grant), so the overseer cannot escalate by dispatching -//! a crew. The toggle is the operator's on/off; the attenuation is the structural -//! bound. See `docs/design/crew-swarm-overseer.md`. +//! [`CrewRunner`] impl runs every spawned crew under `caveats.meet(crew_clamp)`, +//! so a crew's authority can never **exceed** the session ceiling — it is `≤` the +//! session on every axis (the overseer cannot escalate by dispatching a crew) and +//! is further bounded by `crew_clamp`. The clamp is the operator's / per-subtask +//! tightening point (`[crew] clamp`; it **defaults to `Caveats::top()`**, so the +//! meet is the identity today and the bound is exactly "`≤ session`" until an +//! operator narrows it — #749 step 8). The `/team` toggle is the operator's on/off; +//! the `meet` is the structural bound. See `docs/design/crew-swarm-overseer.md`. use crate::caveats::Caveats; use async_trait::async_trait; @@ -24,17 +28,21 @@ use serde_json::Value; /// exactly like [`GitTool`](super::git_tool::GitTool)). /// /// `op` is one of `compose_roster` | `crew` | `team`; `args` is the tool-call -/// argument object. The implementation **attenuates** `caveats` (fail-closed) and -/// returns either a rendered, model-readable result string (`Ok`) — a roster -/// proposal, or a crew's diff + verify status for the overseer to review — or an -/// error string the tool layer surfaces verbatim (`Err`). +/// argument object. The implementation **bounds** `caveats` by the meet with a +/// crew clamp (`session ⊓ clamp`, so the crew runs at `≤ session`) and fails +/// closed on the write / exec / attest gates, then returns either a rendered, +/// model-readable result string (`Ok`) — a roster proposal, or a crew's diff + +/// verify status for the overseer to review — or an error string the tool layer +/// surfaces verbatim (`Err`). /// /// This is the **universal crew primitive**: `LocalCrewRunner` (newt-cli) runs the /// crew here; a future `MeshCrewRunner` ships the task over agent-mesh; and a /// **wyvern resident** implements it server-side to receive crew/plan tasks /// (wyvern-agent#42). Same contract — `(op, args, caveats) → rendered result` — -/// with `caveats` travelling (attenuated per hop) so authority crosses the wire -/// with the work. `async` because dispatch runs inference, not just local I/O. +/// with `caveats` travelling (met with each hop's clamp, so authority is +/// monotone-non-increasing across the wire — never amplified) so authority +/// crosses the wire with the work. `async` because dispatch runs inference, not +/// just local I/O. #[async_trait] pub trait CrewRunner: Send + Sync { async fn dispatch(&self, op: &str, args: &Value, caveats: &Caveats) -> Result; @@ -84,7 +92,8 @@ pub fn crew_tool_definition() -> Value { isolated workspace, runs the verification, and returns the diff \ + status for you to review and accept or re-dispatch. Compose a \ roster first (compose_roster) or name a saved crew. Crews run \ - under attenuated permissions — they cannot exceed your authority.", + under your authority met with the crew clamp — they can never \ + exceed your authority (their permissions are <= yours).", "parameters": { "type": "object", "properties": { diff --git a/newt-core/src/config.rs b/newt-core/src/config.rs index 586a06d..ae28151 100644 --- a/newt-core/src/config.rs +++ b/newt-core/src/config.rs @@ -166,6 +166,16 @@ pub struct Config { /// names a `[loadouts.]`. Empty by default. See [`Crew`]. #[serde(default, skip_serializing_if = "std::collections::BTreeMap::is_empty")] pub crews: std::collections::BTreeMap, + + /// `[crew]` — crew/team **dispatch policy** (#749). Carries the authority + /// *clamp* every dispatched crew is met against, so a crew's effective + /// authority is `session ⊓ clamp` — never above the session ceiling, and as + /// tight as the operator configures. `None` (and the default clamp) is + /// `Caveats::top()`, i.e. the meet is the identity and behavior is unchanged. + /// This is the structural tightening point the per-subtask `team_clamp` + /// (#749 step 8) plugs into. See [`CrewPolicyConfig`]. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub crew: Option, } /// One named mode (`[modes.]`, issue #307): the atomic binding the @@ -1903,6 +1913,34 @@ pub struct Crew { pub budgets: Option, } +/// `[crew]` dispatch policy (#749 step 2): the operator's structural tightening +/// point for crews/teams the overseer fields. +/// +/// A model that fields a crew is the recursion / Confused-Deputy case. Dispatch +/// hands each crew `session ⊓ clamp` (the [`crate::Caveats`] meet), so the crew's +/// authority is **always `≤ session`** (the overseer cannot escalate by +/// dispatching) and **`≤ clamp`** (the operator's bound). With the default +/// `clamp = Caveats::top()` the meet is the identity — today's behavior is +/// unchanged — while the seam exists for tighter clamps (and the per-subtask +/// `team_clamp`, #749 step 8) to plug into. +/// +/// ```toml +/// [crew] +/// # crews may reach only this host, even if the session's net grant is wider +/// [crew.clamp] +/// net = { only = ["registry.internal"] } +/// ``` +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)] +pub struct CrewPolicyConfig { + /// The authority **clamp** dispatched crews are met against + /// (`child = session ⊓ clamp`). Defaults to `Caveats::top()` (identity meet — + /// behavior unchanged). Tighten an axis here to bound every crew below the + /// session ceiling; later steps (#749 step 8) compose a per-subtask clamp on + /// top of this at the same `dispatch` seam. + #[serde(default)] + pub clamp: crate::caveats::Caveats, +} + /// Budgets + review gates for a crew's control loop (`crew-loadout.md` §budgets). /// Consumed by the front door; an honest cap-exit at `max_attempts` returns /// `NeedsHumanReview`, never a false success. @@ -2607,6 +2645,7 @@ impl Default for Config { bundles: std::collections::BTreeMap::new(), loadouts: std::collections::BTreeMap::new(), crews: std::collections::BTreeMap::new(), + crew: None, } } } diff --git a/newt-core/src/lib.rs b/newt-core/src/lib.rs index 1aab5f2..d9243b3 100644 --- a/newt-core/src/lib.rs +++ b/newt-core/src/lib.rs @@ -79,10 +79,10 @@ pub use caveats::{CaveatsExt, CountBoundExt, ScopeExt}; pub use config::{ AgentsConfig, BackendConfig, BackendKind, BundleConfig, ChatStyle, ColorMode, Config, ContextConfig, ContextFeature, ContextFeatureSet, ContextFeatures, ContextManager, - ConversationsConfig, EditMode, FooterMode, Loadout, LoadoutSettings, LogConfig, MarkdownMode, - MemoryConfig, MemoryDisclosure, MemoryProviderKind, OnEmbedFailure, OpenAiApi, - PermissionPreset, PickVia, ProfilePick, ProviderConfig, SemanticConfig, SkillsConfig, - SummarizerConfig, ThinkingMode, ToolPermissions, TuiConfig, + ConversationsConfig, CrewPolicyConfig, EditMode, FooterMode, Loadout, LoadoutSettings, + LogConfig, MarkdownMode, MemoryConfig, MemoryDisclosure, MemoryProviderKind, OnEmbedFailure, + OpenAiApi, PermissionPreset, PickVia, ProfilePick, ProviderConfig, SemanticConfig, + SkillsConfig, SummarizerConfig, ThinkingMode, ToolPermissions, TuiConfig, }; pub use conversation::{ new_conversation_id, session_plan_dir, session_plan_path, ConversationRecord,