Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
151 changes: 147 additions & 4 deletions newt-cli/src/crew_runner.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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.<name>]` 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.
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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)
};
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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)
Expand Down
31 changes: 20 additions & 11 deletions newt-core/src/agentic/crew_tool.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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<String, String>;
Expand Down Expand Up @@ -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": {
Expand Down
39 changes: 39 additions & 0 deletions newt-core/src/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -166,6 +166,16 @@ pub struct Config {
/// names a `[loadouts.<name>]`. Empty by default. See [`Crew`].
#[serde(default, skip_serializing_if = "std::collections::BTreeMap::is_empty")]
pub crews: std::collections::BTreeMap<String, Crew>,

/// `[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<CrewPolicyConfig>,
}

/// One named mode (`[modes.<name>]`, issue #307): the atomic binding the
Expand Down Expand Up @@ -1903,6 +1913,34 @@ pub struct Crew {
pub budgets: Option<CrewBudgets>,
}

/// `[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.
Expand Down Expand Up @@ -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,
}
}
}
Expand Down
8 changes: 4 additions & 4 deletions newt-core/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
Loading