diff --git a/crates/stella-cli/src/tool_docs.rs b/crates/stella-cli/src/tool_docs.rs index 44b0a554c..f69c91efc 100644 --- a/crates/stella-cli/src/tool_docs.rs +++ b/crates/stella-cli/src/tool_docs.rs @@ -21,9 +21,8 @@ //! | Field | Source of truth | //! |---|---| //! | `name`, `description`, `input_schema` | the tool's own [`ToolSchema`] | -//! | `read_only`, `available_for_speculation`, `category`, `availability` | [`stella_tools::catalog`] | +//! | `read_only`, `available_for_speculation`, `category`, `availability`, `risk_level` | [`stella_tools::catalog`] | //! | `output_schema` | [`ToolOutput`]'s serde encoding, read off the type | -//! | `risk_level` | **nothing declares it** — see [`RISK_NOTE`] | //! | the example payloads | an observed bench capture, via a committed fixture | //! //! # Why this lives in a `#[cfg(test)]` module of a binary crate @@ -51,35 +50,33 @@ const REFRESH_ENV: &str = "STELLA_REFRESH_TOOL_DOCS"; /// Where the generated pages live, relative to the repository root. const DOCS_DIR: &str = "docs/tools"; -/// The one thing in these pages that is a stated absence rather than a value. +/// What `risk_level` means on these pages, and why it is declared rather than +/// computed. /// -/// `risk_level` was requested as a field and does not exist as data. Nothing -/// in [`stella_tools::catalog`], in [`ToolSchema`], or in the policy layer -/// carries a per-tool risk, and the one PR that would have introduced the -/// machinery for it (#2716's `ToolContract`) was closed `wontfix`. +/// This field used to read `"undeclared"`: nothing in the repository carried a +/// per-tool risk, because #2716's `ToolContract` — the machinery for it — had +/// been closed `wontfix`. #3060 asked for the fix to land as a `risk` column +/// on `ToolEntry`, declared once beside the flags it sits with and reviewed +/// like them, and that is where it now lives +/// ([`stella_tools::catalog::ToolEntry::risk`]). /// -/// Two options were available and only one is honest. Deriving a three-rung -/// ordering from `read_only` + `speculation_safe` is arithmetic on two -/// booleans the page already prints: it would put `save_state` — which writes -/// into a self-deleting `TempDir` — at the same rung as `task` — which spends -/// money — and it would read to every future maintainer as a reviewed -/// judgement rather than as a relabelling. Writing a judgement call per tool -/// by hand is worse: it manufactures a source of truth nobody reviewed, in -/// the one artifact whose entire value proposition is that it is derived. -/// -/// So the field is emitted, and its value is `"undeclared"`, with the note -/// below and an issue. That keeps the requested shape, keeps the page honest, -/// and puts the fix where it belongs: a `risk` column on `ToolEntry`, declared -/// once beside the flags it sits with and reviewed like them. +/// The note survives the fix because the reasoning it records is still what +/// keeps the column honest: a rung derived from `read_only` + +/// `speculation_safe` would be arithmetic on two booleans this page already +/// prints, and it would put `save_state` — which writes into a self-deleting +/// `TempDir` — at the same rung as `task`, which spends money. The grades are +/// reviewed judgements against a stated rubric, which is exactly why they are +/// declared. const RISK_NOTE: &str = "\ -# risk_level: NOT DECLARED. Nothing in this repository carries a per-tool risk -# level — not the catalog, not ToolSchema, not the policy layer (#2716, which -# would have introduced the machinery, was closed wontfix). The two booleans -# above are the only machine-checked safety claims a tool makes. Relabelling -# them \"low/medium/high\" would add no information while reading as a reviewed -# judgement, and hand-writing one judgement per tool would manufacture a source -# of truth nobody reviewed. Tracked in #3060: put a `risk` column on -# `ToolEntry`, where it is declared once and reviewed like every other column."; +# risk_level: how bad one honest call is — a reviewed judgement, declared in +# crates/stella-tools/src/catalog.rs beside the flags above and graded against +# the rubric on `ToolEntry::risk` (#2716, #3060). A DIFFERENT axis from +# `read_only`: `task` mutates no file and spends real money, while +# `task_create` mutates a board that dies with the session. Deliberately not +# derived from the two booleans above — that would be a relabelling, not +# information. A policy grant is expressed as a ceiling over this grade, and +# every non-built-in tool (MCP, custom manifest) is graded `high` for being +# unreviewed."; // ── the committed example fixture ─────────────────────────────────────────── @@ -413,9 +410,8 @@ fn render_tool(entry: &ToolEntry, schema: &ToolSchema, fixture: &Fixture) -> Str # Where each field comes from:\n\ # name / description / input_schema the tool's own ToolSchema\n\ # read_only / available_for_speculation / category / availability\n\ - # crates/stella-tools/src/catalog.rs\n\ - # output_schema stella_protocol::ToolOutput\n\ - # risk_level nothing declares it (see below)", + # risk_level crates/stella-tools/src/catalog.rs\n\ + # output_schema stella_protocol::ToolOutput", name = entry.name, ); @@ -434,7 +430,7 @@ fn render_tool(entry: &ToolEntry, schema: &ToolSchema, fixture: &Fixture) -> Str available_for_speculation = {speculation}\n\ \n\ {risk_note}\n\ - risk_level = \"undeclared\"\n\ + risk_level = {risk:?}\n\ \n\ description = {description}\n\ \n\ @@ -457,6 +453,7 @@ fn render_tool(entry: &ToolEntry, schema: &ToolSchema, fixture: &Fixture) -> Str read_only = entry.read_only, speculation = entry.speculation_safe, risk_note = RISK_NOTE, + risk = entry.risk.as_str(), description = literal_block("description", &schema.description), input_schema = literal_block("input_schema", &input_schema), output_schema = literal_block("output_schema", &output_schema), @@ -502,12 +499,18 @@ fn render_index(entries: &[&ToolEntry], fixture: &Fixture) -> String { )); out.push_str( "Each page carries the tool's name, description, input schema, output schema, \ - `read_only`, `available_for_speculation`, category, and a commented example \ - input and output payload.\n\n\ - Two fields are stated absences rather than values, because inventing them \ + `read_only`, `available_for_speculation`, `risk_level`, category, and a \ + commented example input and output payload.\n\n\ + `risk_level` is a reviewed judgement declared beside the flags it sits with \ + in `crates/stella-tools/src/catalog.rs`, graded against the rubric on \ + `ToolEntry::risk` (#2716, #3060). It answers a different question from \ + `read_only` — what one honest call costs the world, rather than whether the \ + workspace changes — and is deliberately not derived from the booleans above \ + it, which would be a relabelling rather than information. A policy grant is \ + expressed as a ceiling over this grade; every tool that is not a built-in \ + (MCP, custom manifest) is graded `high` for being unreviewed.\n\n\ + One field remains a stated absence rather than a value, because inventing it \ would manufacture a source of truth nobody reviewed:\n\n\ - - **`risk_level` is `\"undeclared\"`.** Nothing in the repository carries a \ - per-tool risk level. Tracked in #3060.\n\ - **`output_schema` is the envelope only.** Every tool answers in \ `ToolOutput { ok | error }`, which is declared and is what the field \ holds; the shape of the text inside `ok.content` is a per-tool \ @@ -682,10 +685,19 @@ fn generated_pages_parse_and_carry_every_promised_field() { Some(name.trim_end_matches(".toml")), "{name} documents a different tool than its filename claims" ); - // The two declared gaps are gaps on purpose. If either ever becomes - // real data, this assertion is the reminder to change the prose that - // explains why it is not. - assert_eq!(table["risk_level"].as_str(), Some("undeclared")); + // `risk_level` stopped being a declared gap in #2716/#3060, so the + // assertion got stronger rather than disappearing: the page must print + // the grade its catalog row actually declares. Presence alone would + // let the generator drift from the declaration it derives from, which + // is the one failure this whole directory exists to prevent. + let declared = catalog::get(name.trim_end_matches(".toml")) + .expect("every page is generated from a catalog row") + .risk; + assert_eq!( + table["risk_level"].as_str(), + Some(declared.as_str()), + "{name} prints a risk grade its catalog row does not declare" + ); let schema = table["input_schema"] .as_str() .expect("input_schema is a string"); diff --git a/crates/stella-core/README.md b/crates/stella-core/README.md index 02725b31a..0ff19309c 100644 --- a/crates/stella-core/README.md +++ b/crates/stella-core/README.md @@ -105,6 +105,7 @@ lib.rs), never as a planning assumption. | [`src/restore.rs`](src/restore.rs) + [`src/driver/restore.rs`](src/driver/restore.rs) | Working-set restoration (#2685): the pure decide-what-to-restore half (span reads, active skill bodies, the budget-proven fit with named drops), and the driver's overflow summarizer plus the restoration loop that re-reads files through `ToolExecutor` with zero model calls. | | [`src/driver/live_services.rs`](src/driver/live_services.rs) | The end-of-turn assertion (#2764): a turn about to declare done while a service it started is still listening is asked about it once. Names handles; stops nothing. | | [`src/ports.rs`](src/ports.rs) | The port boundary: `ToolExecutor`, `ReadOnlyTools`, `GrantedTools`, `LiveService`, `Clock`, `TurnGate`, `TurnSteering`, `FallbackResolver`. | +| [`src/ports/authz.rs`](src/ports/authz.rs) | The pluggable authorization seam (#2716): `AuthzGate`, `Principal`, `AuthzDecision`, `AuthzEvalError`, and the `NoAuthz`/`RiskCeiling` built-ins. An `Err` from a gate is an unconditional deny no enforcement flag can soften, folded through `hooks::decision::resolve_precedence` so the gate joins the one precedence ladder rather than forking it. | | [`src/budget.rs`](src/budget.rs) | `BudgetGuard` — USD spend against a turn and/or session cap. Returns `BudgetOutcome`; aborts nothing itself. | | [`src/compaction.rs`](src/compaction.rs) | `compact()` — dedup, supersession, aging, eviction. Open when the conversation is being rewritten wrongly. | | [`src/estimator.rs`](src/estimator.rs) | Conservative token estimate plus `Calibration`/`CalibrationMap`, the per-model drift correction fed by reported usage. | diff --git a/crates/stella-core/src/driver/restore.rs b/crates/stella-core/src/driver/restore.rs index 2e73371d4..4bd0f76bf 100644 --- a/crates/stella-core/src/driver/restore.rs +++ b/crates/stella-core/src/driver/restore.rs @@ -419,8 +419,6 @@ mod tests { //! neither the constant nor the behavior); `the_literal_is_the_constant` //! pins the spelling. - use std::sync::Mutex; - use async_trait::async_trait; use serde_json::Value; use std::sync::Mutex; @@ -444,21 +442,6 @@ mod tests { async fn sleep(&self, _duration_ms: u64) {} } - /// An executor that offers nothing. The starvation witnesses below drive - /// the summarizer, never a tool, so the port only has to exist. - struct NoTools; - #[async_trait] - impl ToolExecutor for NoTools { - fn schemas(&self) -> Vec { - Vec::new() - } - async fn execute(&self, _name: &str, _input: &Value) -> ToolOutput { - ToolOutput::Ok { - content: String::new(), - } - } - } - /// Always answers "SUMMARY" — the summarizer path under test is the /// restoration that follows the splice, not the summary itself. struct SummaryProvider; diff --git a/crates/stella-core/src/ports.rs b/crates/stella-core/src/ports.rs index 0c5573327..7c0cea74b 100644 --- a/crates/stella-core/src/ports.rs +++ b/crates/stella-core/src/ports.rs @@ -2,10 +2,14 @@ //! never imports a provider SDK, a filesystem call, or a terminal library — //! it drives through these traits, mirroring the TS engine's `ports.ts`. +pub mod authz; + use async_trait::async_trait; use serde_json::Value; use stella_protocol::{Provider, ToolOutput, ToolSchema}; +pub use authz::{AuthzDecision, AuthzEvalError, AuthzGate, NoAuthz, Principal, RiskCeiling}; + /// Executes one tool call. Implemented by `stella-tools::ToolRegistry` (and /// by test doubles). The engine treats it as a black box that never panics. #[async_trait] diff --git a/crates/stella-core/src/ports/authz.rs b/crates/stella-core/src/ports/authz.rs new file mode 100644 index 000000000..c53582294 --- /dev/null +++ b/crates/stella-core/src/ports/authz.rs @@ -0,0 +1,434 @@ +//! The pluggable authorization seam (#2716): one port, asked once per tool +//! call, that an operator or an embedding product implements to govern every +//! tool in the session without patching a single tool. +//! +//! # Why a port and not a policy +//! +//! `stella-tools::policy::ToolPolicy` already answers "is this tool switched +//! on in this session?" — a session-global fact read from `settings.json`. +//! What it cannot express is *who is asking*. A sub-agent, a pipeline role, a +//! human at the keyboard, and an opaque principal handed over the serve wire +//! by an embedding host all reach the same dispatch through the same +//! executor, and today they arrive indistinguishable. Any RBAC system worth +//! the name needs that distinction, and it must be able to supply its own +//! rules rather than convince us to hardcode them — invariant #1, ports not +//! concretions. +//! +//! So: the engine defines [`Principal`] (who), consumes +//! [`stella_protocol::ToolContract`] (what, and how dangerous), and delegates +//! the verdict to whoever implements [`AuthzGate`]. Nothing in `stella-core` +//! interprets a host-supplied principal — reading meaning into +//! [`Principal::Host`] is the plugin's job, not ours. +//! +//! # Fail closed, structurally +//! +//! [`AuthzGate::check`] returns `Result`, and +//! the two arms are not interchangeable. `Ok(Deny)` is a *decision* — a rule +//! matched and refused, and an operator running with enforcement softened may +//! legitimately downgrade it to a warning. `Err` is the absence of a +//! decision: the policy store was unreachable, the resolver panicked, a rule +//! failed to compile. That is never softenable, and [`authz_verdict`] is +//! where the difference is enforced rather than remembered — it hands the +//! `Err` to [`resolve_precedence`], whose signature already proves no value +//! of the softening flag can rescue an errored evaluation. +//! +//! This is oxagen-platform's OXA-2056 defect encoded at the type level: there, +//! an authorization check that *failed to evaluate* was treated as one that +//! passed, and the leniency flag applied to both. A gate that cannot decide +//! must refuse. +//! +//! # One ladder, not a second one +//! +//! The vocabulary here is deliberately [`crate::bus::HookDecision`] minus +//! `Modify` — a gate authorizes, it does not rewrite arguments — and +//! [`authz_verdict`] folds through the same [`resolve_precedence`] the bus +//! chains and shell hooks already fold through. Three producers, one +//! precedence order: **operator deny > any ask > any allow**. A second ladder +//! would be a second place for the order to be wrong. + +use serde_json::Value; +use stella_protocol::{RiskLevel, ToolContract}; + +use crate::bus::HookDecision; +use crate::hooks::decision::{GateVerdict, OperatorPosture, resolve_precedence}; + +/// Who is asking for a tool call. +/// +/// Engine-side identity, not an authenticated one: nothing here proves a +/// claim, it names the caller so a gate can apply rules to it. Authentication +/// — if the deployment has any — happens in the host that constructs the +/// principal, which is why [`Self::Host`] is opaque. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum Principal { + /// The human driving this session directly. + User, + /// A pipeline role acting inside a staged run (`triage`, `witness_author`, + /// …). Carries the role name as the pipeline spells it. + Role(String), + /// A sub-agent the loop spawned, identified by its dispatch id. + SubAgent(String), + /// An identity supplied by an embedding host over the serve wire. + /// + /// Deliberately an opaque string: `stella-core` must not grow an opinion + /// about a host's identity model (invariant #1). The gate that the host + /// also supplies is the thing that understands it. + Host(String), +} + +impl Principal { + /// A short stable label for audit lines and deny reasons — never parsed, + /// only displayed. + #[must_use] + pub fn label(&self) -> String { + match self { + Self::User => "user".into(), + Self::Role(role) => format!("role:{role}"), + Self::SubAgent(id) => format!("subagent:{id}"), + Self::Host(id) => format!("host:{id}"), + } + } +} + +/// What a gate decided about one call. +/// +/// [`crate::bus::HookDecision`]'s vocabulary minus `Modify`: authorizing a +/// call and rewriting its arguments are different powers, and a gate that +/// could silently edit the input would make the contract's input schema a +/// suggestion. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum AuthzDecision { + /// This principal may make this call. + Allow, + /// Refuse without asking anyone. + Deny { + /// Why — surfaced to the model and the journal. + reason: String, + }, + /// Park and ask a human; the answer decides. + RequireApproval { + /// What the human is being asked to permit. + reason: String, + }, +} + +impl From for HookDecision { + fn from(decision: AuthzDecision) -> Self { + match decision { + AuthzDecision::Allow => HookDecision::Allow, + AuthzDecision::Deny { reason } => HookDecision::Deny { reason }, + AuthzDecision::RequireApproval { reason } => HookDecision::RequireApproval { reason }, + } + } +} + +/// A gate that could not reach a decision. +/// +/// Named and typed rather than a bare `String` (invariant #5) because the +/// distinction from `Ok(Deny)` is the entire safety property: callers must +/// not be able to conflate "refused" with "could not tell", and a +/// `Result<_, String>` invites exactly that by making both arms prose. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct AuthzEvalError { + /// Which gate failed — its [`AuthzGate::name`], for the audit line. + pub gate: String, + /// What went wrong, for a human reading the refusal. + pub detail: String, +} + +impl AuthzEvalError { + /// Build an evaluation failure. + #[must_use] + pub fn new(gate: impl Into, detail: impl Into) -> Self { + Self { + gate: gate.into(), + detail: detail.into(), + } + } +} + +impl std::fmt::Display for AuthzEvalError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!( + f, + "gate `{}` could not evaluate: {}", + self.gate, self.detail + ) + } +} + +impl std::error::Error for AuthzEvalError {} + +/// The pluggable authorization port: one question, asked once per call, +/// before the tool runs. +/// +/// Implementations must be **pure over their inputs plus data they prefetched** +/// (invariant #2): no I/O here. A gate that needs to consult a policy store +/// loads it when it is constructed, so "why was this denied" stays a value a +/// test can assert on rather than a log line and a network round trip. +pub trait AuthzGate: Send + Sync { + /// Name this gate, for audit lines and for the "chosen by name" rule + /// below. No default — a gate that will not say what it is has no + /// business deciding anything. + fn name(&self) -> &'static str; + + /// Decide whether `principal` may call `contract` with `input`. + /// + /// Return `Err` **only** when no decision could be reached; it is an + /// unconditional refusal that no enforcement flag can soften. + fn check( + &self, + contract: &ToolContract, + principal: &Principal, + input: &Value, + ) -> Result; +} + +/// The explicit "no authorization plane is installed" gate. +/// +/// Chosen **by name** at construction, never by a nullable slot that defaults +/// open. That is not a stylistic preference: oxagen-platform carries five +/// module-level gate slots initialized to null, and a bootstrap path that +/// forgot to fill one shipped as a live no-authz surface with nothing in the +/// type system objecting. Here, an engine with no gate does not compile — +/// somebody has to type `NoAuthz`, and that word appears in review. +pub struct NoAuthz; + +impl AuthzGate for NoAuthz { + fn name(&self) -> &'static str { + "no-authz" + } + + fn check( + &self, + _contract: &ToolContract, + _principal: &Principal, + _input: &Value, + ) -> Result { + Ok(AuthzDecision::Allow) + } +} + +/// A gate that refuses anything graded above a ceiling — the built-in +/// implementation that makes [`RiskLevel`] do real work. +/// +/// This exists to keep risk from becoming decorative. oxagen-platform's docs +/// promise that a high-risk capability requires approval while its code keys +/// only on a separate boolean, so the grade is written down, displayed, and +/// enforced nowhere. Here the grade has exactly one job and does it: a +/// principal's grant is a ceiling, and a contract above it does not run. +/// +/// Note what this means for third-party tools without anyone writing a rule +/// about them: [`stella_protocol::ToolContract::declared`] grades every +/// unreviewed tool [`RiskLevel::High`], so a `Medium` ceiling refuses the lot +/// of them, and admitting them is a deliberate act. +pub struct RiskCeiling { + ceiling: RiskLevel, +} + +impl RiskCeiling { + /// Refuse any contract graded above `ceiling`. + #[must_use] + pub fn new(ceiling: RiskLevel) -> Self { + Self { ceiling } + } +} + +impl AuthzGate for RiskCeiling { + fn name(&self) -> &'static str { + "risk-ceiling" + } + + fn check( + &self, + contract: &ToolContract, + principal: &Principal, + _input: &Value, + ) -> Result { + if contract.risk.within(self.ceiling) { + return Ok(AuthzDecision::Allow); + } + Ok(AuthzDecision::Deny { + reason: format!( + "`{}` is graded `{}`, above the `{}` ceiling for {}", + contract.name(), + contract.risk.as_str(), + self.ceiling.as_str(), + principal.label() + ), + }) + } +} + +/// Fold an operator posture and a gate evaluation into the verdict dispatch +/// acts on. +/// +/// A thin adapter over [`resolve_precedence`] on purpose — the precedence +/// order and the fail-closed rule live there, shared with the bus chains and +/// the shell-hook surface, and this function exists so that the gate joins +/// that ladder instead of growing a parallel one. +#[must_use] +pub fn authz_verdict( + operator: &OperatorPosture, + evaluation: Result, + enforcement_softened: bool, +) -> GateVerdict { + match evaluation { + Ok(decision) => { + let decision: HookDecision = decision.into(); + resolve_precedence(operator, Ok(&decision), enforcement_softened) + } + Err(error) => { + // Deliberately routed through the same `Err` arm the hook surface + // uses: one fail-closed rule, one implementation. + let detail = error.to_string(); + resolve_precedence(operator, Err(&detail), enforcement_softened) + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use stella_protocol::{Provenance, ToolSchema}; + + fn contract(name: &str, risk: RiskLevel) -> ToolContract { + ToolContract::builtin( + ToolSchema { + name: name.into(), + description: "d".into(), + input_schema: serde_json::json!({}), + read_only: false, + speculation_safe: false, + }, + risk, + ) + } + + struct BrokenGate; + + impl AuthzGate for BrokenGate { + fn name(&self) -> &'static str { + "broken" + } + fn check( + &self, + _contract: &ToolContract, + _principal: &Principal, + _input: &Value, + ) -> Result { + Err(AuthzEvalError::new("broken", "policy store unreachable")) + } + } + + #[test] + fn no_authz_allows_everything_including_destructive() { + let decision = NoAuthz + .check( + &contract("delete_file", RiskLevel::Destructive), + &Principal::User, + &serde_json::json!({}), + ) + .unwrap(); + assert_eq!(decision, AuthzDecision::Allow); + } + + #[test] + fn a_risk_ceiling_admits_below_and_refuses_above() { + let gate = RiskCeiling::new(RiskLevel::Medium); + let input = serde_json::json!({}); + + assert_eq!( + gate.check( + &contract("write_file", RiskLevel::Medium), + &Principal::User, + &input + ) + .unwrap(), + AuthzDecision::Allow + ); + + match gate + .check(&contract("bash", RiskLevel::High), &Principal::User, &input) + .unwrap() + { + AuthzDecision::Deny { reason } => { + assert!(reason.contains("bash"), "names the tool: {reason}"); + assert!(reason.contains("high"), "names the grade: {reason}"); + } + other => panic!("expected a deny, got {other:?}"), + } + } + + /// The trust asymmetry reaching the gate: nobody wrote a rule about this + /// MCP tool, and a `Medium` ceiling still refuses it, because + /// `ToolContract::declared` graded it `High` for being unreviewed. + #[test] + fn an_unreviewed_tool_is_refused_by_a_medium_ceiling_with_no_rule_about_it() { + let declared = ToolContract::declared(ToolSchema { + name: "mcp__vendor__do_thing".into(), + description: "d".into(), + input_schema: serde_json::json!({}), + read_only: true, + speculation_safe: false, + }); + assert_eq!(declared.provenance, Provenance::Declared); + + let verdict = RiskCeiling::new(RiskLevel::Medium) + .check(&declared, &Principal::User, &serde_json::json!({})) + .unwrap(); + assert!(matches!(verdict, AuthzDecision::Deny { .. })); + } + + /// **The OXA-2056 witness.** An evaluation *failure* denies even with + /// enforcement softening turned on — the arm that separates "refused" + /// from "could not tell". + #[test] + fn an_eval_error_denies_even_with_enforcement_softened() { + let evaluation = BrokenGate.check( + &contract("bash", RiskLevel::High), + &Principal::User, + &serde_json::json!({}), + ); + assert!(evaluation.is_err()); + + for softened in [false, true] { + match authz_verdict(&OperatorPosture::NoOpinion, evaluation.clone(), softened) { + GateVerdict::Deny { reason } => assert!( + reason.contains("failing closed"), + "must name the posture: {reason}" + ), + other => panic!("softened={softened} must still deny, got {other:?}"), + } + } + } + + /// The other half: an operator deny outranks a gate that allows. + #[test] + fn an_operator_deny_outranks_a_gate_allow() { + let verdict = authz_verdict( + &OperatorPosture::Deny { + reason: "org policy".into(), + }, + Ok(AuthzDecision::Allow), + false, + ); + assert!(matches!(verdict, GateVerdict::Deny { .. })); + } + + #[test] + fn require_approval_folds_to_the_ask_verdict() { + let verdict = authz_verdict( + &OperatorPosture::NoOpinion, + Ok(AuthzDecision::RequireApproval { + reason: "pushes to a shared branch".into(), + }), + false, + ); + match verdict { + GateVerdict::RequireApproval { reason } => { + assert_eq!(reason, "pushes to a shared branch"); + } + other => panic!("expected an ask, got {other:?}"), + } + } +} diff --git a/crates/stella-protocol/README.md b/crates/stella-protocol/README.md index 37828064d..005a29250 100644 --- a/crates/stella-protocol/README.md +++ b/crates/stella-protocol/README.md @@ -106,6 +106,7 @@ tests with them. | [`src/completion.rs`](src/completion.rs) | `CompletionRequest` / `CompletionResult` / `CompletionUsage`, `GenerationParams`, `FinishReason`. The one envelope every provider adapter translates to and from. | | [`src/provider.rs`](src/provider.rs) | The `Provider` port and `ToolCallObserver`, the seam speculative tool execution hangs on. | | [`src/tool.rs`](src/tool.rs) | `ToolSchema`, `ToolCall`, `ToolOutput`, `ToolResult` — the engine's single internal tool dialect. | +| [`src/contract.rs`](src/contract.rs) | `ToolContract`, `RiskLevel`, `Provenance` — the governance half of a tool's declaration (#2716): what a call costs the world, and whether the claim saying so was reviewed. It **contains** `ToolSchema` rather than restating its fields, so the bytes advertised to the model stay exactly what they were and a governance field can never perturb the prompt-cache prefix (invariant #7). | | [`src/attachment.rs`](src/attachment.rs) | Multimodal *input* attachments, plus `classify_media_type`, `media_type_for_path`, `human_bytes`. | | [`src/role.rs`](src/role.rs) | `Role` (worker/triage/plan/research/verifier/embed/vision/image/video) and `ModelRef`. | | [`src/error.rs`](src/error.rs) | `ProviderError` and its retry classification. | diff --git a/crates/stella-protocol/src/contract.rs b/crates/stella-protocol/src/contract.rs new file mode 100644 index 000000000..5fbb164ec --- /dev/null +++ b/crates/stella-protocol/src/contract.rs @@ -0,0 +1,419 @@ +//! The governance half of a tool's declaration (#2716): what a call can +//! *cost the world*, and whether the claim saying so is trustworthy. +//! +//! [`crate::ToolSchema`] answers "what may the model send, and may this run +//! concurrently" — questions about dispatch. It cannot answer the question an +//! authorization plane asks, which is **"if this call does what it is allowed +//! to do, how bad is that, and who says so?"** `read_only` is the closest +//! thing to an answer and it is the wrong axis twice over: a metered web +//! search mutates nothing and still spends the user's money, and an MCP +//! server's `read_only: false` is a self-report from a party the workspace +//! has no reason to trust. +//! +//! # Composition, not restatement +//! +//! [`ToolContract`] **contains** the schema rather than re-declaring its +//! fields. That is deliberate and load-bearing for invariant #7: the bytes +//! advertised to the model are literally today's [`crate::ToolSchema`], +//! serialized by today's code, so introducing contracts cannot perturb the +//! prompt-cache prefix. A future field added *here* is a governance fact that +//! never reaches the prompt; a field added to the schema is a deliberate +//! cache-invalidation event, and keeping the two types separate is what makes +//! the difference visible in review. +//! +//! # Three axes, three fields +//! +//! The one defect this design most wants to avoid is a conflated axis +//! (oxagen-platform's `sensitivity` smuggles "read-only" into a risk grade, +//! and its docs promise that `riskLevel: high` forces approval while the code +//! keys only on a separate boolean). So: +//! +//! - [`ToolSchema::read_only`] — can this mutate workspace state? *Dispatch.* +//! - [`RiskLevel`] — how bad is the worst honest outcome? *Policy input.* +//! - [`ToolContract::requires_approval`] — must a human say yes? *Its own +//! boolean, never inferred from risk.* +//! +//! Risk does real work — a [`crate::contract::RiskLevel`] ceiling is what a +//! principal's grant is expressed in — but it never silently becomes an +//! approval prompt. If a tool needs asking, it says so. + +use serde::{Deserialize, Serialize}; + +use crate::tool::ToolSchema; + +/// How much damage one honest call of a tool can do. +/// +/// "Honest" is the operative word: this grades what the tool does when it +/// works as designed, not what a defect or a hostile implementation could +/// achieve. A [`Provenance::Declared`] tool's *stated* risk is a claim like +/// any other and is treated as one by [`ToolContract::declared`]. +/// +/// Ordered, and the order is the point — a policy expresses a grant as a +/// ceiling (`risk <= Medium`), so the comparison has to mean something. +/// +/// # Unknown tokens read as the maximum, deliberately +/// +/// [`Self::Destructive`] carries `#[serde(other)]`, so a grade minted by a +/// newer build (`"catastrophic"`) deserializes here as `Destructive` rather +/// than failing the decode — the same forward-compat posture as +/// [`crate::ErrorClass`], pointed the one direction that is safe for a +/// security field. Degrading an unrecognized risk to `Low` would let a newer +/// emitter's most dangerous tools through an older reader's ceiling; degrading +/// to the maximum can only ever refuse too much, which is recoverable. +/// Lossy on the way out for the same reason `ErrorClass` is: re-serializing +/// writes `"destructive"`, not the token that came in. +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)] +#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))] +#[serde(rename_all = "snake_case")] +pub enum RiskLevel { + /// Observes. Touches nothing outside the process except state the agent + /// derives for itself — reading a file, listing the task board, a graph + /// query that brings its own index up to date on the way to answering. + Low, + /// Real but bounded and locally reversible: writing inside the workspace, + /// spending a metered API call, starting a process. + Medium, + /// Reaches outside the workspace or costs something a `git checkout` + /// cannot undo — pushing a branch, filing an issue, running an arbitrary + /// shell command. + High, + /// Irreversible by the agent that did it. Also the grade every + /// unrecognized token reads as (see the type docs). + #[serde(other)] + Destructive, +} + +impl RiskLevel { + /// The wire/storage token — byte-identical to the serde `snake_case` + /// spelling (pinned by test), so a stored grade and a wire grade can + /// never disagree about what a level is called. + #[must_use] + pub fn as_str(self) -> &'static str { + match self { + Self::Low => "low", + Self::Medium => "medium", + Self::High => "high", + Self::Destructive => "destructive", + } + } + + /// Whether this level sits at or below `ceiling` — the shape a grant is + /// written in ("this principal may call up to `Medium`"). + #[must_use] + pub fn within(self, ceiling: Self) -> bool { + self <= ceiling + } +} + +/// Who is making the claims in a contract, and therefore whether they may be +/// believed. +/// +/// The asymmetry this encodes is the whole reason a plugin platform can be +/// governed at all: a built-in's declaration was read by a human in review, +/// and a third party's was not. Without this field a hostile MCP server and a +/// reviewed built-in that make identical claims are indistinguishable to +/// every policy above them — which is exactly the state #2716 exists to end. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))] +#[serde(rename_all = "snake_case")] +pub enum Provenance { + /// Compiled into this binary and declared in the canonical catalog. Its + /// claims are as trustworthy as the review that landed them. + Builtin, + /// Self-declared by something outside the binary — an MCP server's tool + /// annotations, a `.stella/tools/*.toml` manifest. Every claim is a + /// *claim*; see [`ToolContract::trusted_read_only`] for the one place + /// that distinction is load-bearing today. + /// + /// Unknown tokens read as this variant: a provenance a newer build knows + /// about is, from here, exactly "not one we can vouch for". + #[serde(other)] + Declared, +} + +/// Why a contract is not internally consistent. +/// +/// Typed rather than a `String` (invariant #5) because the registry branches +/// on which claim is contradictory when it refuses to register a tool. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum ContractError { + /// `speculation_safe` without `read_only`. Speculation runs a call twice + /// *before its step commits*; a tool that mutates cannot survive that, so + /// the stronger claim structurally implies the weaker one (#923). + SpeculativeButMutating { + /// The offending tool. + name: String, + }, + /// A trusted read-only tool graded above [`RiskLevel::Medium`]. A + /// built-in that mutates nothing cannot be irreversible; one of the two + /// declarations is wrong, and a human has to say which. + ReadOnlyButDangerous { + /// The offending tool. + name: String, + /// The grade it declared. + risk: RiskLevel, + }, +} + +impl std::fmt::Display for ContractError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::SpeculativeButMutating { name } => write!( + f, + "`{name}` declares speculation_safe without read_only: a speculated \ + call may execute twice before its step commits" + ), + Self::ReadOnlyButDangerous { name, risk } => write!( + f, + "`{name}` declares read_only with risk `{}`: a built-in that mutates \ + nothing cannot be graded above `medium`", + risk.as_str() + ), + } + } +} + +impl std::error::Error for ContractError {} + +/// One tool's complete declaration: what the model may send +/// ([`ToolSchema`]), plus what an authorization plane needs to decide whether +/// this caller may send it at all. +/// +/// Serde-first (invariant #4) because it crosses crate boundaries — the +/// engine's [`crate::ToolSchema`] half already does, and the governance half +/// is what an embedding host reads to authorize a remoted call from metadata +/// instead of maintaining its own name → capability side-table. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))] +pub struct ToolContract { + /// What is advertised to the model — unchanged, and serialized by the + /// same code that always serialized it (see the module docs on why this + /// is composition rather than restatement). + pub schema: ToolSchema, + /// How bad one honest call can be. Policy input, never an implicit + /// approval prompt. + pub risk: RiskLevel, + /// Whether a human must say yes before this runs, independent of + /// [`Self::risk`]. Separate on purpose: a docs-versus-code disagreement + /// about which field gates approval is how oxagen-platform shipped + /// high-risk capabilities that ran unprompted. + #[serde(default)] + pub requires_approval: bool, + /// Whether the claims above were reviewed or merely asserted. + pub provenance: Provenance, +} + +impl ToolContract { + /// A contract for a tool compiled into this binary, whose claims a human + /// reviewed. + #[must_use] + pub fn builtin(schema: ToolSchema, risk: RiskLevel) -> Self { + Self { + schema, + risk, + requires_approval: false, + provenance: Provenance::Builtin, + } + } + + /// A contract for a tool that described *itself* — an MCP server's + /// advertisement, a customer's TOML manifest. + /// + /// Graded [`RiskLevel::High`] regardless of what it claims, because the + /// honest answer is "unknown" and this is the ceiling-shaped way to say + /// so: an operator who wants third-party tools raises the ceiling to + /// `High` deliberately, rather than discovering that an unreviewed tool + /// inherited `Low` from a field it filled in itself. Not + /// [`RiskLevel::Destructive`] — that grade means "irreversible", and + /// spending it on "unknown" would leave nothing to say about an + /// unreviewed tool that really does delete things. + /// + /// The schema's own `read_only`/`speculation_safe` claims are preserved + /// verbatim: they are what the tool *said*, and a policy that wants to + /// display or reason about the claim needs it. What they must never do is + /// silently buy dispatch privileges — see [`Self::trusted_read_only`]. + #[must_use] + pub fn declared(schema: ToolSchema) -> Self { + Self { + schema, + risk: RiskLevel::High, + requires_approval: false, + provenance: Provenance::Declared, + } + } + + /// The tool's dispatch name. + #[must_use] + pub fn name(&self) -> &str { + &self.schema.name + } + + /// Read-only **as a fact the engine may act on**, not as a claim someone + /// made: `read_only` conjoined with reviewed provenance. + /// + /// This is #2716 §6's trust boundary reduced to one function. A manifest + /// or MCP tool asserting `read_only = true` is asserting something nobody + /// checked, and the read-only bit is not decorative — it admits a tool + /// into concurrent dispatch alongside other reads, and fences a verifier + /// into a set it cannot mutate the workspace from. A false claim there is + /// a data race and a broken verification boundary, not a cosmetic error. + /// + /// Deliberately **not** used to filter what is advertised: an untrusted + /// tool is still callable, still policy-governed, and still displays its + /// own claim. It just cannot buy privileges with it. + #[must_use] + pub fn trusted_read_only(&self) -> bool { + self.schema.read_only && matches!(self.provenance, Provenance::Builtin) + } + + /// Check the claims against each other. + /// + /// The read-only/risk implication is asserted for [`Provenance::Builtin`] + /// only, and that exemption is the design rather than a hole: a declared + /// tool claiming `read_only` while graded `High` is the *expected* output + /// of [`Self::declared`] — an unreviewed claim next to a cautious grade. + /// Rejecting it would force the untrusted path to either trust the claim + /// or discard it, and both directions lose the information a policy needs. + pub fn validate(&self) -> Result<(), ContractError> { + if self.schema.speculation_safe && !self.schema.read_only { + return Err(ContractError::SpeculativeButMutating { + name: self.schema.name.clone(), + }); + } + if matches!(self.provenance, Provenance::Builtin) + && self.schema.read_only + && !self.risk.within(RiskLevel::Medium) + { + return Err(ContractError::ReadOnlyButDangerous { + name: self.schema.name.clone(), + risk: self.risk, + }); + } + Ok(()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn schema(name: &str, read_only: bool, speculation_safe: bool) -> ToolSchema { + ToolSchema { + name: name.into(), + description: "d".into(), + input_schema: serde_json::json!({}), + read_only, + speculation_safe, + } + } + + #[test] + fn contract_roundtrips() { + let contract = ToolContract::builtin(schema("read_file", true, true), RiskLevel::Low); + let json = serde_json::to_string(&contract).unwrap(); + let back: ToolContract = serde_json::from_str(&json).unwrap(); + assert_eq!(back, contract); + } + + #[test] + fn risk_token_and_serde_spelling_agree() { + for risk in [ + RiskLevel::Low, + RiskLevel::Medium, + RiskLevel::High, + RiskLevel::Destructive, + ] { + let wire = serde_json::to_value(risk).unwrap(); + assert_eq!(wire, serde_json::Value::String(risk.as_str().into())); + } + } + + /// The safe direction for a security field: a grade this build has never + /// heard of must read as the most restrictive one, never the least. + #[test] + fn an_unknown_risk_token_reads_as_the_maximum() { + let risk: RiskLevel = serde_json::from_str("\"catastrophic\"").unwrap(); + assert_eq!(risk, RiskLevel::Destructive); + assert!( + !risk.within(RiskLevel::High), + "must not clear a High ceiling" + ); + } + + /// Same posture one axis over: a provenance we do not recognize is, by + /// definition, not one whose claims we can vouch for. + #[test] + fn an_unknown_provenance_token_reads_as_untrusted() { + let provenance: Provenance = serde_json::from_str("\"signed_by_someone\"").unwrap(); + assert_eq!(provenance, Provenance::Declared); + } + + #[test] + fn risk_is_ordered_so_a_ceiling_means_something() { + assert!(RiskLevel::Low.within(RiskLevel::Medium)); + assert!(RiskLevel::Medium.within(RiskLevel::Medium)); + assert!(!RiskLevel::High.within(RiskLevel::Medium)); + assert!(!RiskLevel::Destructive.within(RiskLevel::High)); + } + + /// The trust asymmetry, stated as the test that would fail if someone + /// "simplified" `trusted_read_only` to `self.schema.read_only`. + #[test] + fn a_declared_tools_read_only_claim_buys_it_nothing() { + let claimed = ToolContract::declared(schema("mcp__x__peek", true, true)); + assert!(claimed.schema.read_only, "the claim is preserved verbatim"); + assert!( + !claimed.trusted_read_only(), + "an unreviewed claim must not admit a tool to the read-only set" + ); + + let reviewed = ToolContract::builtin(schema("read_file", true, true), RiskLevel::Low); + assert!(reviewed.trusted_read_only()); + } + + #[test] + fn a_declared_tool_is_graded_high_whatever_it_claims() { + let contract = ToolContract::declared(schema("mcp__x__harmless", true, false)); + assert_eq!(contract.risk, RiskLevel::High); + assert!(!contract.risk.within(RiskLevel::Medium)); + // And that shape must survive validation — it is the expected output + // of the untrusted path, not a contradiction. + assert!(contract.validate().is_ok()); + } + + #[test] + fn speculation_without_read_only_is_rejected() { + let contract = ToolContract::builtin(schema("bash", false, true), RiskLevel::High); + assert_eq!( + contract.validate(), + Err(ContractError::SpeculativeButMutating { + name: "bash".into() + }) + ); + } + + #[test] + fn a_trusted_read_only_tool_cannot_be_graded_dangerous() { + let contract = ToolContract::builtin(schema("grep", true, true), RiskLevel::Destructive); + assert_eq!( + contract.validate(), + Err(ContractError::ReadOnlyButDangerous { + name: "grep".into(), + risk: RiskLevel::Destructive, + }) + ); + } + + /// Invariant #7's structural guarantee: the advertised half of a contract + /// is byte-identical to the `ToolSchema` that existed before contracts, + /// so nothing about this type can perturb the prompt-cache prefix. + #[test] + fn the_advertised_half_serializes_exactly_as_a_bare_schema() { + let schema = schema("read_file", true, true); + let contract = ToolContract::builtin(schema.clone(), RiskLevel::Low); + assert_eq!( + serde_json::to_string(&contract.schema).unwrap(), + serde_json::to_string(&schema).unwrap() + ); + } +} diff --git a/crates/stella-protocol/src/lib.rs b/crates/stella-protocol/src/lib.rs index 1b3efc1be..d166a42f8 100644 --- a/crates/stella-protocol/src/lib.rs +++ b/crates/stella-protocol/src/lib.rs @@ -54,6 +54,7 @@ pub mod cache; pub mod compaction_rewrite; pub mod completion; pub mod context_event; +pub mod contract; pub mod delivery_event; pub mod error; pub mod event; @@ -81,6 +82,7 @@ pub use completion::{ Verbosity, }; pub use context_event::{CompiledContextFrameBuilt, LifecycleEvent, LifecycleEventEnvelope}; +pub use contract::{ContractError, Provenance, RiskLevel, ToolContract}; pub use delivery_event::{DeliveryDecline, DeliveryOutcome}; pub use error::ProviderError; pub use event::{ diff --git a/crates/stella-tools/README.md b/crates/stella-tools/README.md index 6346910d7..2e8c7bdab 100644 --- a/crates/stella-tools/README.md +++ b/crates/stella-tools/README.md @@ -87,7 +87,9 @@ landing in `registry.rs`. |---|---| | [`src/lib.rs`](src/lib.rs) | The module list and the crate doc — what is dispatchable and what is mechanism. | | [`src/registry.rs`](src/registry.rs), [`src/registry/approval.rs`](src/registry/approval.rs), [`src/registry/executor.rs`](src/registry/executor.rs), [`src/registry/validate.rs`](src/registry/validate.rs) | The `Tool` trait, `ToolRegistry`, construction, and the single `execute` path all cross-cutting behaviour hangs off. `approval.rs` is the `tool.call.requested` blocking policy chain plus the #2676 interactive approval flow (a `RequireApproval` parks on an injected responder with a TTL instead of dead-ending); `executor.rs` is the `ToolExecutor` port impl (`schemas`/`execute` plus the drains and aggregations the engine reads); `validate.rs` is dispatch-time input validation against the advertised schema (#3144). | -| [`src/catalog.rs`](src/catalog.rs) | The canonical tool table. Open it to add a tool or to answer "is this name taken / is it read-only". | +| [`src/catalog.rs`](src/catalog.rs) | The canonical tool table. Open it to add a tool or to answer "is this name taken / is it read-only / how is it graded". | +| [`src/contracts.rs`](src/contracts.rs) | The trust boundary in one function (#2716): a name in the catalog resolves to a **reviewed** `ToolContract` carrying its declared risk; every other name — MCP, a customer's manifest, one this build never heard of — resolves to an untrusted contract graded `High`, whose `read_only` claim buys it nothing. Sound only because `RESERVED_NAMES` is aliased to `ALL_NAMES`, which its tests assert rather than assume. | +| [`src/gated.rs`](src/gated.rs) | `GatedToolSet` — where an `AuthzGate` is actually called. A decorator rather than registry-internal logic, because the registry sees only the built-ins while custom and MCP tools (the ones most worth governing) are layered above it. Enforces at `execute()` and deliberately not at `schemas()`: an authorization decision can depend on the input, which does not exist while building the advertised list. | | [`src/subagent.rs`](src/subagent.rs) | The `task` tool: sub-agent delegation over a host-attached dispatcher (#922), with turn controls and a spend ledger the engine drains at step boundaries. | | [`src/tasks.rs`](src/tasks.rs) | The six `task_*` tools over the session board, plus `task_assign`'s spawn queue. | | [`src/scratch.rs`](src/scratch.rs) | The scratch state plane: `ScratchDir` and the four state tools. | diff --git a/crates/stella-tools/src/catalog.rs b/crates/stella-tools/src/catalog.rs index b6317490c..40abf1583 100644 --- a/crates/stella-tools/src/catalog.rs +++ b/crates/stella-tools/src/catalog.rs @@ -9,13 +9,14 @@ //! on the docs side) caught after the fact. //! //! This module is the fix. Every built-in is declared exactly once in the -//! `catalog!` invocation below, with its read-only flag and its policy group. -//! Everything that used to be duplicated is derived from it: +//! `catalog!` invocation below, with its read-only flag, its risk grade and +//! its policy group. Everything that used to be duplicated is derived from it: //! //! - the registry's expected-name set (`registry.rs` tests), //! - the read-only partition (same), //! - [`crate::custom::RESERVED_NAMES`] (aliased straight to [`ALL_NAMES`]), -//! - the per-tool reference pages under `docs/tools/`. +//! - the per-tool reference pages under `docs/tools/`, +//! - every built-in's [`stella_protocol::ToolContract`] ([`crate::contracts`]). //! //! **To add a tool:** register it in //! [`ToolRegistry::new`](crate::registry::ToolRegistry), then add one line @@ -24,6 +25,8 @@ //! registered but never declared here fails the registry tests by *name*, //! not by an off-by-one. +use stella_protocol::RiskLevel; + /// What has to be true for a tool to be registered. /// /// **Availability is not policy.** A variant here names something the @@ -64,6 +67,33 @@ pub struct ToolEntry { /// (#923): a failed stream attempt re-announces its prefix on retry. /// Meaningless (and kept false) on mutating rows. pub speculation_safe: bool, + /// How bad one honest call is — the governance grade a policy ceiling is + /// written against (#2716), and a **different question** from + /// [`Self::read_only`]: that one asks whether the workspace changes, this + /// one asks what the call costs the world. The two come apart in both + /// directions, which is why they are separate columns — `task` mutates + /// nothing in the workspace and spends real money, while `task_create` + /// mutates a board that dies with the session. + /// + /// The rubric, so the column stays consistent as tools are added: + /// + /// | Grade | Means | + /// |---|---| + /// | [`RiskLevel::Low`] | Observes, or touches only state that dies with the session | + /// | [`RiskLevel::Medium`] | Bounded and locally undoable: a workspace write, a metered call, a repo-declared command | + /// | [`RiskLevel::High`] | Leaves the workspace, spends money, or runs something nobody bounded | + /// | [`RiskLevel::Destructive`] | The agent cannot undo it | + /// + /// Today's twelve built-ins populate only `Low` and `High` — the surface + /// is the task board, the scratch plane and one environment report, and + /// the single interesting split is delegation versus everything else. + /// The upper rungs are not speculative scaffolding: every tool that is + /// *not* a built-in — an MCP server's, a `.stella/tools/*.toml` + /// manifest's — is graded [`RiskLevel::High`] by + /// [`stella_protocol::ToolContract::declared`] for being unreviewed, so a + /// `Medium` ceiling separates the reviewed surface from everything a + /// third party supplied without a rule being written about any of them. + pub risk: RiskLevel, /// What has to be true for it to register. pub availability: Availability, /// The family this tool belongs to, and the name an operator can switch @@ -79,7 +109,7 @@ pub struct ToolEntry { /// Declares the canonical table once and derives every flat name list from it, /// so the two can never disagree. macro_rules! catalog { - ($($name:literal => ($read_only:expr, $speculation_safe:expr, $availability:expr, $group:literal)),* $(,)?) => { + ($($name:literal => ($read_only:expr, $speculation_safe:expr, $risk:expr, $availability:expr, $group:literal)),* $(,)?) => { /// Every tool Stella can dispatch by name, sorted, declared once. /// /// See the [module docs](self) for how to add one. @@ -88,6 +118,7 @@ macro_rules! catalog { name: $name, read_only: $read_only, speculation_safe: $speculation_safe, + risk: $risk, availability: $availability, group: $group, }),* @@ -100,30 +131,40 @@ macro_rules! catalog { } use Availability::Always; +use RiskLevel::{High, Low}; -// Column order: (read_only, speculation_safe, availability, group). The +// Column order: (read_only, speculation_safe, risk, availability, group). The // second column only ever narrows the first: `true` means the read is pure -// enough to run twice per step (speculation + a stream retry — #923). +// enough to run twice per step (speculation + a stream retry — #923). The +// third is the governance grade — see `ToolEntry::risk` for the rubric. catalog! { - // The session task board (in-memory) - "task_create" => (false, false, Always, "task"), - "task_list" => (true, true, Always, "task"), - "task_start" => (false, false, Always, "task"), - "task_complete" => (false, false, Always, "task"), - "task_cancel" => (false, false, Always, "task"), - "task_assign" => (false, false, Always, "task"), + // The session task board. In-memory and session-scoped: every row here is + // `Low` because what it mutates cannot outlive the process, which is the + // clearest demonstration that `risk` is not `read_only` spelled twice. + "task_create" => (false, false, Low, Always, "task"), + "task_list" => (true, true, Low, Always, "task"), + "task_start" => (false, false, Low, Always, "task"), + "task_complete" => (false, false, Low, Always, "task"), + "task_cancel" => (false, false, Low, Always, "task"), + "task_assign" => (false, false, Low, Always, "task"), // Sub-agent delegation (#922). NOT read_only — it spends money, and that // flag is also what caps nesting: children run behind `ReadOnlyTools`, so - // a read-only `task` would let them spawn children of their own. - "task" => (false, false, Always, "task"), - // Session scratch state (tempfile::TempDir, self-deleting) - "save_state" => (false, false, Always, "scratch"), - "get_state" => (true, true, Always, "scratch"), - "list_state" => (true, true, Always, "scratch"), - "delete_state" => (false, false, Always, "scratch"), + // a read-only `task` would let them spawn children of their own. `High` + // for the same reason it is not read-only, and it is the one built-in + // that a risk ceiling meaningfully separates from the rest: it spends + // real money, and the child it spawns wields a whole tool surface of its + // own. + "task" => (false, false, High, Always, "task"), + // Session scratch state (tempfile::TempDir, self-deleting) — the plane + // dies with the session, `delete_state` included, so nothing here is + // irreversible in any sense that outlives the run. + "save_state" => (false, false, Low, Always, "scratch"), + "get_state" => (true, true, Low, Always, "scratch"), + "list_state" => (true, true, Low, Always, "scratch"), + "delete_state" => (false, false, Low, Always, "scratch"), // One-shot environment report: workspace root, git/worktree bit, // platform, OS release, shell dialect, scratch dir (#2697). - "get_environment" => (true, true, Always, "environment"), + "get_environment" => (true, true, Low, Always, "environment"), } /// Look up a tool's canonical row by dispatch name. diff --git a/crates/stella-tools/src/contracts.rs b/crates/stella-tools/src/contracts.rs new file mode 100644 index 000000000..d48593f61 --- /dev/null +++ b/crates/stella-tools/src/contracts.rs @@ -0,0 +1,178 @@ +//! Every tool's [`ToolContract`], resolved by name (#2716). +//! +//! This module is one function and one rule, and the rule is the trust +//! boundary: **a name in the canonical catalog gets a reviewed contract; every +//! other name gets an untrusted one.** There is no third case and no +//! configuration that adds one. +//! +//! # Why name lookup is sound +//! +//! Resolving trust by name is only safe because a name cannot be squatted. +//! [`crate::custom::RESERVED_NAMES`] is aliased directly to +//! [`crate::catalog::ALL_NAMES`], so a `.stella/tools/*.toml` manifest cannot +//! register `read_file`, and MCP tools are namespaced `mcp____` +//! and so can never collide with a bare catalog name. Were either of those to +//! stop holding, a third party could inherit a built-in's reviewed grade by +//! choosing its name — which is why the test +//! `a_third_party_cannot_inherit_a_builtins_contract_by_name` asserts the +//! aliasing rather than assuming it. +//! +//! # What "untrusted" costs a tool +//! +//! [`ToolContract::declared`] grades an unreviewed tool +//! [`stella_protocol::RiskLevel::High`] and marks it +//! [`stella_protocol::Provenance::Declared`], which has two +//! consequences that need no rule written about the specific tool: a +//! risk-ceiling gate below `High` refuses it, and its `read_only` claim buys +//! it nothing (`ToolContract::trusted_read_only`). That is deliberate — the +//! honest grade for code nobody reviewed is "unknown", and a ceiling is how +//! you say "unknown" in a way a policy can act on. + +use stella_protocol::{ToolContract, ToolSchema}; + +use crate::catalog; + +/// The contract for an advertised tool. +/// +/// A catalog name resolves to its reviewed row (risk included); anything else +/// — an MCP server's tool, a customer's manifest tool, a name this build has +/// never heard of — resolves to an untrusted contract. +#[must_use] +pub fn contract_for(schema: &ToolSchema) -> ToolContract { + match catalog::get(&schema.name) { + Some(entry) => ToolContract::builtin(schema.clone(), entry.risk), + None => ToolContract::declared(schema.clone()), + } +} + +/// The contract for a name whose schema is not (or no longer) advertised. +/// +/// The fail-closed path: a tool that appears mid-session, a name the model +/// invented, a server that reconnected with a tool the cached view predates. +/// All of them resolve to an untrusted `High` contract rather than skipping +/// authorization, because "I have never heard of this" is the one case where +/// guessing generously is indefensible. +#[must_use] +pub fn unknown_contract(name: &str) -> ToolContract { + ToolContract::declared(ToolSchema { + name: name.to_string(), + description: String::new(), + input_schema: serde_json::json!({}), + read_only: false, + speculation_safe: false, + }) +} + +#[cfg(test)] +mod tests { + use super::*; + use stella_protocol::{Provenance, RiskLevel}; + + fn schema(name: &str) -> ToolSchema { + ToolSchema { + name: name.into(), + description: "d".into(), + input_schema: serde_json::json!({}), + read_only: true, + speculation_safe: false, + } + } + + /// The invariant test the catalog has always had, extended to the new + /// column: every reviewed row must produce a self-consistent contract. + /// This is what stops a future tool being added as `read_only` and + /// `Destructive` at the same time. + #[test] + fn every_catalog_row_produces_a_valid_contract() { + for entry in catalog::CATALOG { + let contract = ToolContract::builtin( + ToolSchema { + name: entry.name.into(), + description: "d".into(), + input_schema: serde_json::json!({}), + read_only: entry.read_only, + speculation_safe: entry.speculation_safe, + }, + entry.risk, + ); + assert_eq!( + contract.validate(), + Ok(()), + "`{}` declares an inconsistent contract", + entry.name + ); + } + } + + /// Anti-vacuity for the row above: the column carries information rather + /// than one repeated value, so a ceiling can actually discriminate. + /// + /// Deliberately **not** asserting that all four grades appear. The twelve + /// built-ins are the task board, the scratch plane and one environment + /// report — nothing among them writes a file or leaves the machine, so + /// `Medium` and `Destructive` are honestly unused here, and demanding + /// them would only invite someone to inflate a grade to satisfy a test. + /// What must hold is that `task` is separated from the rest: it is the + /// one built-in that spends money. + #[test] + fn the_catalog_grades_delegation_above_the_rest() { + let grades: std::collections::BTreeSet = + catalog::CATALOG.iter().map(|entry| entry.risk).collect(); + assert!( + grades.len() > 1, + "one grade for every tool makes the column decorative" + ); + + let task = catalog::get("task").expect("the delegation tool is declared"); + for entry in catalog::CATALOG { + if entry.name == "task" { + continue; + } + assert!( + entry.risk < task.risk, + "`{}` is graded at or above `task`, which is the one built-in \ + that spends real money", + entry.name + ); + } + } + + #[test] + fn a_builtin_resolves_to_its_reviewed_row() { + let contract = contract_for(&schema("get_state")); + assert_eq!(contract.provenance, Provenance::Builtin); + assert_eq!(contract.risk, RiskLevel::Low); + assert!(contract.trusted_read_only()); + } + + #[test] + fn an_mcp_tool_resolves_to_an_untrusted_contract() { + let contract = contract_for(&schema("mcp__vendor__anything")); + assert_eq!(contract.provenance, Provenance::Declared); + assert_eq!(contract.risk, RiskLevel::High); + assert!( + !contract.trusted_read_only(), + "its read_only claim must buy it nothing" + ); + } + + #[test] + fn an_unheard_of_name_is_untrusted_rather_than_unchecked() { + let contract = unknown_contract("something_new"); + assert_eq!(contract.provenance, Provenance::Declared); + assert_eq!(contract.risk, RiskLevel::High); + } + + /// The assumption `contract_for` rests on, asserted instead of assumed: + /// if a manifest could claim a catalog name, it would inherit that + /// built-in's reviewed grade and its trusted read-only bit. + #[test] + fn a_third_party_cannot_inherit_a_builtins_contract_by_name() { + for name in catalog::ALL_NAMES { + assert!( + crate::custom::RESERVED_NAMES.contains(name), + "`{name}` is a catalog name a custom manifest could squat" + ); + } + } +} diff --git a/crates/stella-tools/src/gated.rs b/crates/stella-tools/src/gated.rs new file mode 100644 index 000000000..8281e1207 --- /dev/null +++ b/crates/stella-tools/src/gated.rs @@ -0,0 +1,469 @@ +//! Where authorization actually holds: one decorator over the whole tool +//! stack (#2716). +//! +//! [`stella_core::ports::AuthzGate`] is the seam an operator or an embedding +//! product implements; this is the single place it is *called*. The two are +//! deliberately separate crates — the port is a question the engine asks, and +//! this is the position in the composition where asking it covers everything. +//! +//! # Why a decorator and not registry-internal logic +//! +//! [`crate::registry::ToolRegistry`] only knows about the twelve built-ins. +//! Custom `.stella/tools/*.toml` tools ([`crate::custom::CustomToolSet`]) and +//! every connected MCP server's tools are layered *above* it — and since the +//! surface reduction (#3244) those two are where nearly every capability the +//! agent has now lives. A gate inside the registry would govern the smallest +//! and best-reviewed group while missing all the rest, which is precisely the +//! shape of gap this issue exists to close: the tools most worth governing +//! are the ones a third party supplied. +//! +//! So this composes like [`crate::policy::ToolPolicy`]'s enforcement point +//! does, and belongs **outermost** — above the operator's policy filter, +//! which `stella-cli`'s session driver currently assembles last: +//! +//! ```text +//! GatedToolSet <- authorization: who is asking, and may they? +//! PolicyToolSet <- operator switches: is this tool on at all? +//! CustomToolSet <- .stella/tools/*.toml +//! ToolRegistry / McpToolSet +//! ``` +//! +//! The two layers answer different questions and neither subsumes the other: +//! a policy switch is one operator's session-wide "off", while a gate asks +//! whether *this* principal may make *this* call. Ordering the gate outermost +//! means an operator's "off" short-circuits before a gate is consulted, which +//! is the cheap direction and the correct precedence — the same one +//! [`stella_core::hooks::decision::resolve_precedence`] encodes. +//! +//! # One-sided on purpose +//! +//! [`crate::policy::ToolPolicy`]'s decorator is two-sided — a disabled tool is +//! both hidden from `schemas()` and refused by `execute()`. This one enforces +//! at `execute()` only, and the asymmetry is deliberate rather than an +//! oversight. +//! +//! An operator switch is input-independent: `bash` is off, so hiding it is +//! exactly as true as refusing it. An authorization decision generally is +//! *not* — a gate may allow `write_file` under `src/` and refuse it under +//! `/etc`, and there is no input to hand it while building the advertised +//! list. Filtering `schemas()` would mean asking every gate a question with a +//! fabricated empty input and treating the answer as final, which would hide +//! tools the gate would have allowed. Enforcement therefore lives where the +//! input exists. Narrowing the advertised set for gates that *are* +//! input-independent (a plain risk ceiling) is a real prompt-budget win and is +//! tracked separately — it is an optimization, not the guarantee. +//! +//! # Fail closed on names it does not know +//! +//! A call for a name this view has no schema for is authorized against an +//! untrusted `High` contract ([`crate::contracts::unknown_contract`]) rather +//! than waved through to the inner stack. Mid-session MCP reconnects, a model +//! inventing a name, and a cached view going stale all land here, and the only +//! defensible answer to "I have never heard of this tool" is to treat it as +//! the least trustworthy thing in the session. + +use std::collections::HashMap; +use std::sync::Arc; + +use async_trait::async_trait; +use serde_json::Value; +use stella_core::hooks::decision::{GateVerdict, OperatorPosture}; +use stella_core::ports::authz::authz_verdict; +use stella_core::ports::{AuthzGate, Principal, ToolExecutor}; +use stella_protocol::tool::{ErrorClass, ToolOutput, ToolSchema}; + +use crate::contracts; + +/// The wrapped executor, held either by borrow (a session's per-turn tool +/// chain) or owned via `Arc` (a candidate workspace whose chain is built +/// dynamically and outlives every borrow). Mirrors +/// [`crate::custom::CustomToolSet`]'s shape deliberately — these sit in the +/// same stacks. +enum Inner<'a> { + Borrowed(&'a dyn ToolExecutor), + Owned(Arc), +} + +impl Inner<'_> { + fn get(&self) -> &dyn ToolExecutor { + match self { + Inner::Borrowed(inner) => *inner, + Inner::Owned(inner) => inner.as_ref(), + } + } +} + +/// Wraps a tool surface and asks an [`AuthzGate`] before every dispatch. +pub struct GatedToolSet<'a> { + inner: Inner<'a>, + gate: Arc, + principal: Principal, + /// Contracts for everything the inner stack advertised at construction, + /// resolved once. `execute` is on the hot path of every call and + /// re-materializing the full schema list per call would re-serialize + /// every tool's whole JSON parameter document — the same reason + /// [`stella_core::ports::ReadOnlyTools`] snapshots its name set. A name + /// missing from this map is not trusted for being missing; see + /// [`contracts::unknown_contract`]. + contracts: HashMap, +} + +impl<'a> GatedToolSet<'a> { + /// Gate `inner` for `principal`. + /// + /// The gate is a constructor argument and there is no nullable slot and + /// no default: a session with no authorization plane passes + /// [`stella_core::ports::NoAuthz`] **by name**, so "this deployment does + /// not authorize tool calls" is a decision somebody typed and a reviewer + /// can see. + pub fn new( + inner: &'a dyn ToolExecutor, + gate: Arc, + principal: Principal, + ) -> Self { + let contracts = Self::snapshot(inner); + Self { + inner: Inner::Borrowed(inner), + gate, + principal, + contracts, + } + } + + fn snapshot(inner: &dyn ToolExecutor) -> HashMap { + inner + .schemas() + .into_iter() + .map(|schema| (schema.name.clone(), contracts::contract_for(&schema))) + .collect() + } + + /// The contract this view will authorize `name` against. + fn contract(&self, name: &str) -> stella_protocol::ToolContract { + self.contracts + .get(name) + .cloned() + .unwrap_or_else(|| contracts::unknown_contract(name)) + } +} + +impl GatedToolSet<'static> { + /// Own the inner executor by `Arc` — for callers that hold the whole + /// chain as one value (a boxed candidate workspace). Without this the + /// gate would stop at the candidate boundary, and best-of-N would be a + /// way around authorization. + pub fn new_owned( + inner: Arc, + gate: Arc, + principal: Principal, + ) -> Self { + let contracts = Self::snapshot(inner.as_ref()); + Self { + inner: Inner::Owned(inner), + gate, + principal, + contracts, + } + } +} + +#[async_trait] +impl ToolExecutor for GatedToolSet<'_> { + /// Unfiltered — see the module docs on why this decorator is one-sided. + fn schemas(&self) -> Vec { + self.inner.get().schemas() + } + + async fn execute(&self, name: &str, input: &Value) -> ToolOutput { + let contract = self.contract(name); + let evaluation = self.gate.check(&contract, &self.principal, input); + + // Routed through the shared fold rather than matched here, so the + // fail-closed rule (an `Err` denies whatever any softening flag says) + // has exactly one implementation across the bus chains, the shell-hook + // surface, and this gate. `NoOpinion`/`false` because the operator + // plane is `PolicyToolSet` one layer down — it withholds before a call + // ever reaches here — and no enforcement-softening switch is + // configurable yet. Both are arguments this call site will pass + // through when they exist, which is why they are passed at all. + match authz_verdict(&OperatorPosture::NoOpinion, evaluation, false) { + GateVerdict::Allow => self.inner.get().execute(name, input).await, + GateVerdict::Deny { reason } => { + ToolOutput::classified_error(ErrorClass::RefusedByPolicy, reason) + } + GateVerdict::RequireApproval { reason } => ToolOutput::classified_error( + ErrorClass::RefusedByPolicy, + format!( + "`{name}` needs a human's approval ({reason}), and this session has no \ + approval route attached — it cannot be asked from here" + ), + ), + } + } + + /// Forwarded: a decorator that let the default `0.0` stand would silently + /// drop sub-agent spend out of the parent's budget (see the port's + /// contract). + fn drain_sub_agent_spend_usd(&self) -> f64 { + self.inner.get().drain_sub_agent_spend_usd() + } + + /// Forwarded: a swallowed wait request silently turns parked waits + /// (#1471) back into model-step polling. + fn drain_wait_request(&self) -> Option { + self.inner.get().drain_wait_request() + } + + /// Forwarded: letting the empty default stand would serialize the inner + /// stack's sibling spawns for every session composed through this view. + /// + /// Not narrowed by the gate, unlike [`crate::policy::ToolPolicy`]'s + /// decorator, and for the same reason `schemas` is not: whether a call is + /// authorized can depend on its input, so a name is not withheld here on + /// the strength of a decision made without one. + fn parallel_safe_names(&self) -> std::collections::HashSet { + self.inner.get().parallel_safe_names() + } + + /// Forwarded: the inner stack owns the invocation spans, and live + /// procedure text must survive summarization behind this view too. + fn active_skill_slugs(&self) -> Vec { + self.inner.get().active_skill_slugs() + } + + /// Forwarded: letting the empty default stand would turn the end-of-turn + /// service assertion (#2764) off for every surface composed through this. + fn live_services(&self) -> Vec { + self.inner.get().live_services() + } +} + +#[cfg(test)] +mod tests { + use super::*; + use stella_core::ports::{AuthzDecision, AuthzEvalError, NoAuthz, RiskCeiling}; + use stella_protocol::{RiskLevel, ToolContract}; + + /// A leaf that advertises one built-in and one MCP tool, and records + /// whether it was ever actually reached. + struct Leaf { + reached: std::sync::Mutex>, + } + + impl Leaf { + fn new() -> Self { + Self { + reached: std::sync::Mutex::new(Vec::new()), + } + } + fn reached(&self) -> Vec { + self.reached.lock().unwrap().clone() + } + } + + /// The three tools every test here needs: a reviewed `Low` read, the one + /// reviewed `High` built-in, and a third-party tool nobody reviewed. + const READ: &str = "get_state"; + const SPENDS: &str = "task"; + const THIRD_PARTY: &str = "mcp__vendor__deploy"; + + #[async_trait] + impl ToolExecutor for Leaf { + fn schemas(&self) -> Vec { + [READ, SPENDS, THIRD_PARTY] + .into_iter() + .map(|name| ToolSchema { + name: name.into(), + description: "d".into(), + input_schema: serde_json::json!({}), + read_only: name == READ, + speculation_safe: false, + }) + .collect() + } + async fn execute(&self, name: &str, _input: &Value) -> ToolOutput { + self.reached.lock().unwrap().push(name.to_string()); + ToolOutput::Ok { + content: format!("ran {name}"), + } + } + fn drain_sub_agent_spend_usd(&self) -> f64 { + 2.5 + } + fn parallel_safe_names(&self) -> std::collections::HashSet { + std::collections::HashSet::from(["task".to_string()]) + } + } + + struct DenyAll; + + impl AuthzGate for DenyAll { + fn name(&self) -> &'static str { + "deny-all" + } + fn check( + &self, + contract: &ToolContract, + _principal: &Principal, + _input: &Value, + ) -> Result { + Ok(AuthzDecision::Deny { + reason: format!("`{}` is not permitted here", contract.name()), + }) + } + } + + struct BrokenGate; + + impl AuthzGate for BrokenGate { + fn name(&self) -> &'static str { + "broken" + } + fn check( + &self, + _contract: &ToolContract, + _principal: &Principal, + _input: &Value, + ) -> Result { + Err(AuthzEvalError::new("broken", "policy store unreachable")) + } + } + + fn input() -> Value { + serde_json::json!({}) + } + + /// Anti-vacuity: without a gate in the way, the same call runs. + #[tokio::test] + async fn no_authz_lets_every_call_through() { + let leaf = Leaf::new(); + let gated = GatedToolSet::new(&leaf, Arc::new(NoAuthz), Principal::User); + + assert!(matches!( + gated.execute(SPENDS, &input()).await, + ToolOutput::Ok { .. } + )); + assert_eq!(leaf.reached(), vec![SPENDS.to_string()]); + } + + /// **The #2716 witness.** A `DenyAll` gate blocks a call the default path + /// allows, and the tool is never reached — refusal is enforcement, not a + /// label on a result. + #[tokio::test] + async fn a_deny_all_gate_blocks_a_call_the_default_path_allows() { + let leaf = Leaf::new(); + let gated = GatedToolSet::new(&leaf, Arc::new(DenyAll), Principal::User); + + match gated.execute(SPENDS, &input()).await { + ToolOutput::Error { message, class } => { + assert!(message.contains(SPENDS), "names the tool: {message}"); + assert_eq!( + class, + Some(ErrorClass::RefusedByPolicy), + "a policy refusal must not be counted as a tool defect" + ); + } + other => panic!("expected a refusal, got {other:?}"), + } + assert!( + leaf.reached().is_empty(), + "the tool must not have executed: {:?}", + leaf.reached() + ); + } + + /// **The OXA-2056 witness at the dispatch site.** A gate that cannot + /// evaluate denies — the arm that separates "refused" from "could not + /// tell", and the one oxagen-platform got backwards. + #[tokio::test] + async fn a_gate_that_cannot_evaluate_denies_the_call() { + let leaf = Leaf::new(); + let gated = GatedToolSet::new(&leaf, Arc::new(BrokenGate), Principal::User); + + match gated.execute(READ, &input()).await { + ToolOutput::Error { message, .. } => assert!( + message.contains("failing closed"), + "must say why it refused: {message}" + ), + other => panic!("an unevaluable gate must deny, got {other:?}"), + } + assert!(leaf.reached().is_empty()); + } + + /// The trust boundary reaching dispatch: one ceiling, and the third-party + /// tool is refused while the reviewed read is not — with no rule written + /// about either tool by name. The MCP tool here even *claims* `read_only` + /// (see [`Leaf::schemas`]) and is refused anyway, because nobody reviewed + /// the claim. + #[tokio::test] + async fn a_medium_ceiling_admits_a_reviewed_read_and_refuses_a_third_party_tool() { + let leaf = Leaf::new(); + let gated = GatedToolSet::new( + &leaf, + Arc::new(RiskCeiling::new(RiskLevel::Medium)), + Principal::User, + ); + + assert!( + matches!(gated.execute(READ, &input()).await, ToolOutput::Ok { .. }), + "a reviewed Low-risk read must still run" + ); + assert!( + matches!( + gated.execute(THIRD_PARTY, &input()).await, + ToolOutput::Error { .. } + ), + "an unreviewed tool is graded High and must not clear a Medium ceiling" + ); + assert!( + matches!( + gated.execute(SPENDS, &input()).await, + ToolOutput::Error { .. } + ), + "the one built-in that spends money is graded High too" + ); + assert_eq!(leaf.reached(), vec![READ.to_string()]); + } + + /// The fail-closed path for a name the snapshot never saw — a tool that + /// appeared after construction, or one the model invented. + #[tokio::test] + async fn an_unknown_name_is_authorized_as_untrusted_rather_than_waved_through() { + let leaf = Leaf::new(); + let gated = GatedToolSet::new( + &leaf, + Arc::new(RiskCeiling::new(RiskLevel::Medium)), + Principal::User, + ); + + assert!(matches!( + gated.execute("appeared_mid_session", &input()).await, + ToolOutput::Error { .. } + )); + assert!(leaf.reached().is_empty()); + } + + /// The decorator contract the port documents twice: a wrapper that lets + /// the defaults stand silently breaks budget accounting and dispatch + /// grouping, with no compiler complaint. + #[test] + fn the_decorator_forwards_what_a_decorator_must() { + let leaf = Leaf::new(); + let gated = GatedToolSet::new(&leaf, Arc::new(DenyAll), Principal::User); + + assert_eq!( + gated.drain_sub_agent_spend_usd(), + 2.5, + "sub-agent spend must not vanish through the gate" + ); + assert!( + gated.parallel_safe_names().contains("task"), + "the inner concurrency claim must survive" + ); + assert_eq!( + gated.schemas().len(), + 3, + "this decorator narrows execution, not the advertised set" + ); + } +} diff --git a/crates/stella-tools/src/lib.rs b/crates/stella-tools/src/lib.rs index 748853a2b..93a02a79f 100644 --- a/crates/stella-tools/src/lib.rs +++ b/crates/stella-tools/src/lib.rs @@ -26,12 +26,14 @@ pub mod agent_use; pub mod catalog; +pub mod contracts; pub mod custom; pub mod environment; pub mod exec; pub mod foundry_author; pub mod foundry_gate; pub mod foundry_witness; +pub mod gated; pub mod hook_bridge; pub mod hook_runner; pub mod input; diff --git a/docs/tools/README.md b/docs/tools/README.md index 0cd4243f8..a6866a050 100644 --- a/docs/tools/README.md +++ b/docs/tools/README.md @@ -10,11 +10,12 @@ status: living One TOML page per dispatchable tool — 12 of them — generated from the declarations by `crates/stella-cli/src/tool_docs.rs` and re-derived by the `tool-docs` gate step. A tool added to `crates/stella-tools/src/catalog.rs` without regenerating turns the gate red; there is no path where a new tool ships undocumented. -Each page carries the tool's name, description, input schema, output schema, `read_only`, `available_for_speculation`, category, and a commented example input and output payload. +Each page carries the tool's name, description, input schema, output schema, `read_only`, `available_for_speculation`, `risk_level`, category, and a commented example input and output payload. -Two fields are stated absences rather than values, because inventing them would manufacture a source of truth nobody reviewed: +`risk_level` is a reviewed judgement declared beside the flags it sits with in `crates/stella-tools/src/catalog.rs`, graded against the rubric on `ToolEntry::risk` (#2716, #3060). It answers a different question from `read_only` — what one honest call costs the world, rather than whether the workspace changes — and is deliberately not derived from the booleans above it, which would be a relabelling rather than information. A policy grant is expressed as a ceiling over this grade; every tool that is not a built-in (MCP, custom manifest) is graded `high` for being unreviewed. + +One field remains a stated absence rather than a value, because inventing it would manufacture a source of truth nobody reviewed: -- **`risk_level` is `"undeclared"`.** Nothing in the repository carries a per-tool risk level. Tracked in #3060. - **`output_schema` is the envelope only.** Every tool answers in `ToolOutput { ok | error }`, which is declared and is what the field holds; the shape of the text inside `ok.content` is a per-tool convention with nothing behind it, so the observed example is its only evidence. **Examples are observed, not written.** They come from Terminal-Bench trial traces (stella-events.jsonl), distilled by scripts/build-tool-doc-examples.py, captured 2026-08-11 over 2829 call/result pairs across 10 tasks. 4 of 12 tools carry a real example; the rest say so. 5 tools were never called once across 408 trials and 15304 model calls — the most interesting fact on those pages, and input to #3032. diff --git a/docs/tools/delete_state.toml b/docs/tools/delete_state.toml index 9008a822e..2ded3c8b7 100644 --- a/docs/tools/delete_state.toml +++ b/docs/tools/delete_state.toml @@ -6,9 +6,8 @@ # Where each field comes from: # name / description / input_schema the tool's own ToolSchema # read_only / available_for_speculation / category / availability -# crates/stella-tools/src/catalog.rs +# risk_level crates/stella-tools/src/catalog.rs # output_schema stella_protocol::ToolOutput -# risk_level nothing declares it (see below) name = "delete_state" category = "scratch" @@ -16,15 +15,16 @@ availability = "always" read_only = false available_for_speculation = false -# risk_level: NOT DECLARED. Nothing in this repository carries a per-tool risk -# level — not the catalog, not ToolSchema, not the policy layer (#2716, which -# would have introduced the machinery, was closed wontfix). The two booleans -# above are the only machine-checked safety claims a tool makes. Relabelling -# them "low/medium/high" would add no information while reading as a reviewed -# judgement, and hand-writing one judgement per tool would manufacture a source -# of truth nobody reviewed. Tracked in #3060: put a `risk` column on -# `ToolEntry`, where it is declared once and reviewed like every other column. -risk_level = "undeclared" +# risk_level: how bad one honest call is — a reviewed judgement, declared in +# crates/stella-tools/src/catalog.rs beside the flags above and graded against +# the rubric on `ToolEntry::risk` (#2716, #3060). A DIFFERENT axis from +# `read_only`: `task` mutates no file and spends real money, while +# `task_create` mutates a board that dies with the session. Deliberately not +# derived from the two booleans above — that would be a relabelling, not +# information. A policy grant is expressed as a ceiling over this grade, and +# every non-built-in tool (MCP, custom manifest) is graded `high` for being +# unreviewed. +risk_level = "low" description = ''' Delete one scratch entry by key — use when saved state is stale or superseded so later steps cannot read an invalidated value. The whole scratch directory is deleted automatically at session end; delete_state is for invalidation, not cleanup. diff --git a/docs/tools/get_environment.toml b/docs/tools/get_environment.toml index 745bf5528..9554da176 100644 --- a/docs/tools/get_environment.toml +++ b/docs/tools/get_environment.toml @@ -6,9 +6,8 @@ # Where each field comes from: # name / description / input_schema the tool's own ToolSchema # read_only / available_for_speculation / category / availability -# crates/stella-tools/src/catalog.rs +# risk_level crates/stella-tools/src/catalog.rs # output_schema stella_protocol::ToolOutput -# risk_level nothing declares it (see below) name = "get_environment" category = "environment" @@ -16,15 +15,16 @@ availability = "always" read_only = true available_for_speculation = true -# risk_level: NOT DECLARED. Nothing in this repository carries a per-tool risk -# level — not the catalog, not ToolSchema, not the policy layer (#2716, which -# would have introduced the machinery, was closed wontfix). The two booleans -# above are the only machine-checked safety claims a tool makes. Relabelling -# them "low/medium/high" would add no information while reading as a reviewed -# judgement, and hand-writing one judgement per tool would manufacture a source -# of truth nobody reviewed. Tracked in #3060: put a `risk` column on -# `ToolEntry`, where it is declared once and reviewed like every other column. -risk_level = "undeclared" +# risk_level: how bad one honest call is — a reviewed judgement, declared in +# crates/stella-tools/src/catalog.rs beside the flags above and graded against +# the rubric on `ToolEntry::risk` (#2716, #3060). A DIFFERENT axis from +# `read_only`: `task` mutates no file and spends real money, while +# `task_create` mutates a board that dies with the session. Deliberately not +# derived from the two booleans above — that would be a relabelling, not +# information. A policy grant is expressed as a ceiling over this grade, and +# every non-built-in tool (MCP, custom manifest) is graded `high` for being +# unreviewed. +risk_level = "low" description = ''' Report this session's environment: workspace root, whether it is a git repository (and whether it is a linked worktree), platform/arch, OS release, login shell dialect, and the scratch directory path. Your system prompt's Session environment block already states everything here except the scratch directory — call this only when you need the scratch directory path, or when no such block is in your prompt. Never spend calls on pwd, uname, or shell probing for these facts. diff --git a/docs/tools/get_state.toml b/docs/tools/get_state.toml index 1983fd0fa..351c1533e 100644 --- a/docs/tools/get_state.toml +++ b/docs/tools/get_state.toml @@ -6,9 +6,8 @@ # Where each field comes from: # name / description / input_schema the tool's own ToolSchema # read_only / available_for_speculation / category / availability -# crates/stella-tools/src/catalog.rs +# risk_level crates/stella-tools/src/catalog.rs # output_schema stella_protocol::ToolOutput -# risk_level nothing declares it (see below) name = "get_state" category = "scratch" @@ -16,15 +15,16 @@ availability = "always" read_only = true available_for_speculation = true -# risk_level: NOT DECLARED. Nothing in this repository carries a per-tool risk -# level — not the catalog, not ToolSchema, not the policy layer (#2716, which -# would have introduced the machinery, was closed wontfix). The two booleans -# above are the only machine-checked safety claims a tool makes. Relabelling -# them "low/medium/high" would add no information while reading as a reviewed -# judgement, and hand-writing one judgement per tool would manufacture a source -# of truth nobody reviewed. Tracked in #3060: put a `risk` column on -# `ToolEntry`, where it is declared once and reviewed like every other column. -risk_level = "undeclared" +# risk_level: how bad one honest call is — a reviewed judgement, declared in +# crates/stella-tools/src/catalog.rs beside the flags above and graded against +# the rubric on `ToolEntry::risk` (#2716, #3060). A DIFFERENT axis from +# `read_only`: `task` mutates no file and spends real money, while +# `task_create` mutates a board that dies with the session. Deliberately not +# derived from the two booleans above — that would be a relabelling, not +# information. A policy grant is expressed as a ceiling over this grade, and +# every non-built-in tool (MCP, custom manifest) is graded `high` for being +# unreviewed. +risk_level = "low" description = ''' Read a saved scratch entry by key. Pages by byte offset/limit; a partial read names the remaining bytes. Prefer this over re-deriving the state that produced it. diff --git a/docs/tools/list_state.toml b/docs/tools/list_state.toml index 94f079e3f..5f172aa52 100644 --- a/docs/tools/list_state.toml +++ b/docs/tools/list_state.toml @@ -6,9 +6,8 @@ # Where each field comes from: # name / description / input_schema the tool's own ToolSchema # read_only / available_for_speculation / category / availability -# crates/stella-tools/src/catalog.rs +# risk_level crates/stella-tools/src/catalog.rs # output_schema stella_protocol::ToolOutput -# risk_level nothing declares it (see below) name = "list_state" category = "scratch" @@ -16,15 +15,16 @@ availability = "always" read_only = true available_for_speculation = true -# risk_level: NOT DECLARED. Nothing in this repository carries a per-tool risk -# level — not the catalog, not ToolSchema, not the policy layer (#2716, which -# would have introduced the machinery, was closed wontfix). The two booleans -# above are the only machine-checked safety claims a tool makes. Relabelling -# them "low/medium/high" would add no information while reading as a reviewed -# judgement, and hand-writing one judgement per tool would manufacture a source -# of truth nobody reviewed. Tracked in #3060: put a `risk` column on -# `ToolEntry`, where it is declared once and reviewed like every other column. -risk_level = "undeclared" +# risk_level: how bad one honest call is — a reviewed judgement, declared in +# crates/stella-tools/src/catalog.rs beside the flags above and graded against +# the rubric on `ToolEntry::risk` (#2716, #3060). A DIFFERENT axis from +# `read_only`: `task` mutates no file and spends real money, while +# `task_create` mutates a board that dies with the session. Deliberately not +# derived from the two booleans above — that would be a relabelling, not +# information. A policy grant is expressed as a ceiling over this grade, and +# every non-built-in tool (MCP, custom manifest) is graded `high` for being +# unreviewed. +risk_level = "low" description = ''' List saved scratch entries (key and size in bytes). Check here before re-deriving anything expensive. diff --git a/docs/tools/save_state.toml b/docs/tools/save_state.toml index 414957c1c..7198acac0 100644 --- a/docs/tools/save_state.toml +++ b/docs/tools/save_state.toml @@ -6,9 +6,8 @@ # Where each field comes from: # name / description / input_schema the tool's own ToolSchema # read_only / available_for_speculation / category / availability -# crates/stella-tools/src/catalog.rs +# risk_level crates/stella-tools/src/catalog.rs # output_schema stella_protocol::ToolOutput -# risk_level nothing declares it (see below) name = "save_state" category = "scratch" @@ -16,15 +15,16 @@ availability = "always" read_only = false available_for_speculation = false -# risk_level: NOT DECLARED. Nothing in this repository carries a per-tool risk -# level — not the catalog, not ToolSchema, not the policy layer (#2716, which -# would have introduced the machinery, was closed wontfix). The two booleans -# above are the only machine-checked safety claims a tool makes. Relabelling -# them "low/medium/high" would add no information while reading as a reviewed -# judgement, and hand-writing one judgement per tool would manufacture a source -# of truth nobody reviewed. Tracked in #3060: put a `risk` column on -# `ToolEntry`, where it is declared once and reviewed like every other column. -risk_level = "undeclared" +# risk_level: how bad one honest call is — a reviewed judgement, declared in +# crates/stella-tools/src/catalog.rs beside the flags above and graded against +# the rubric on `ToolEntry::risk` (#2716, #3060). A DIFFERENT axis from +# `read_only`: `task` mutates no file and spends real money, while +# `task_create` mutates a board that dies with the session. Deliberately not +# derived from the two booleans above — that would be a relabelling, not +# information. A policy grant is expressed as a ceiling over this grade, and +# every non-built-in tool (MCP, custom manifest) is graded `high` for being +# unreviewed. +risk_level = "low" description = ''' Save intermediate session state under a key so later steps can reference it instead of re-deriving it (parse results, extracted lists, computed digests). Scratch is session-private and deleted when the session ends; anything the user should keep belongs in the workspace instead. Content is capped at 1 MiB per save. diff --git a/docs/tools/task.toml b/docs/tools/task.toml index 8aca7ccb9..28ab7464d 100644 --- a/docs/tools/task.toml +++ b/docs/tools/task.toml @@ -6,9 +6,8 @@ # Where each field comes from: # name / description / input_schema the tool's own ToolSchema # read_only / available_for_speculation / category / availability -# crates/stella-tools/src/catalog.rs +# risk_level crates/stella-tools/src/catalog.rs # output_schema stella_protocol::ToolOutput -# risk_level nothing declares it (see below) name = "task" category = "task" @@ -16,15 +15,16 @@ availability = "always" read_only = false available_for_speculation = false -# risk_level: NOT DECLARED. Nothing in this repository carries a per-tool risk -# level — not the catalog, not ToolSchema, not the policy layer (#2716, which -# would have introduced the machinery, was closed wontfix). The two booleans -# above are the only machine-checked safety claims a tool makes. Relabelling -# them "low/medium/high" would add no information while reading as a reviewed -# judgement, and hand-writing one judgement per tool would manufacture a source -# of truth nobody reviewed. Tracked in #3060: put a `risk` column on -# `ToolEntry`, where it is declared once and reviewed like every other column. -risk_level = "undeclared" +# risk_level: how bad one honest call is — a reviewed judgement, declared in +# crates/stella-tools/src/catalog.rs beside the flags above and graded against +# the rubric on `ToolEntry::risk` (#2716, #3060). A DIFFERENT axis from +# `read_only`: `task` mutates no file and spends real money, while +# `task_create` mutates a board that dies with the session. Deliberately not +# derived from the two booleans above — that would be a relabelling, not +# information. A policy grant is expressed as a ceiling over this grade, and +# every non-built-in tool (MCP, custom manifest) is graded `high` for being +# unreviewed. +risk_level = "high" description = ''' Delegate a self-contained research question to a sub-agent that investigates with read-only tools and returns only its findings. Its intermediate work never enters this conversation, so use it when answering would otherwise mean reading many files you do not need to keep — 'which of these modules defines X', 'how is Y wired end to end', 'find every caller of Z and summarize the patterns'. Prefer it over running the same searches yourself whenever the evidence is bulky and only the conclusion matters. Independent questions should be dispatched as SEVERAL task calls in the same step — they run concurrently, so three parallel investigations cost the wall-clock of the slowest, not the sum. Not for work that must edit files (the sub-agent cannot write), and not for a single lookup you already know the location of — one direct read is cheaper than a sub-agent. diff --git a/docs/tools/task_assign.toml b/docs/tools/task_assign.toml index efcd48a1c..5a81bd3f7 100644 --- a/docs/tools/task_assign.toml +++ b/docs/tools/task_assign.toml @@ -6,9 +6,8 @@ # Where each field comes from: # name / description / input_schema the tool's own ToolSchema # read_only / available_for_speculation / category / availability -# crates/stella-tools/src/catalog.rs +# risk_level crates/stella-tools/src/catalog.rs # output_schema stella_protocol::ToolOutput -# risk_level nothing declares it (see below) name = "task_assign" category = "task" @@ -16,15 +15,16 @@ availability = "always" read_only = false available_for_speculation = false -# risk_level: NOT DECLARED. Nothing in this repository carries a per-tool risk -# level — not the catalog, not ToolSchema, not the policy layer (#2716, which -# would have introduced the machinery, was closed wontfix). The two booleans -# above are the only machine-checked safety claims a tool makes. Relabelling -# them "low/medium/high" would add no information while reading as a reviewed -# judgement, and hand-writing one judgement per tool would manufacture a source -# of truth nobody reviewed. Tracked in #3060: put a `risk` column on -# `ToolEntry`, where it is declared once and reviewed like every other column. -risk_level = "undeclared" +# risk_level: how bad one honest call is — a reviewed judgement, declared in +# crates/stella-tools/src/catalog.rs beside the flags above and graded against +# the rubric on `ToolEntry::risk` (#2716, #3060). A DIFFERENT axis from +# `read_only`: `task` mutates no file and spends real money, while +# `task_create` mutates a board that dies with the session. Deliberately not +# derived from the two booleans above — that would be a relabelling, not +# information. A policy grant is expressed as a ceiling over this grade, and +# every non-built-in tool (MCP, custom manifest) is graded `high` for being +# unreviewed. +risk_level = "low" description = ''' Delegate a board task to a parallel sub-agent. The task is marked in_progress under the new owner and a dedicated sub-agent is spawned for it; the briefing is passed to that sub-agent VERBATIM, so write it self-contained (context, file paths, definition of done). The sub-agent's results land back in this session. diff --git a/docs/tools/task_cancel.toml b/docs/tools/task_cancel.toml index 7108c32d3..22fbfdbce 100644 --- a/docs/tools/task_cancel.toml +++ b/docs/tools/task_cancel.toml @@ -6,9 +6,8 @@ # Where each field comes from: # name / description / input_schema the tool's own ToolSchema # read_only / available_for_speculation / category / availability -# crates/stella-tools/src/catalog.rs +# risk_level crates/stella-tools/src/catalog.rs # output_schema stella_protocol::ToolOutput -# risk_level nothing declares it (see below) name = "task_cancel" category = "task" @@ -16,15 +15,16 @@ availability = "always" read_only = false available_for_speculation = false -# risk_level: NOT DECLARED. Nothing in this repository carries a per-tool risk -# level — not the catalog, not ToolSchema, not the policy layer (#2716, which -# would have introduced the machinery, was closed wontfix). The two booleans -# above are the only machine-checked safety claims a tool makes. Relabelling -# them "low/medium/high" would add no information while reading as a reviewed -# judgement, and hand-writing one judgement per tool would manufacture a source -# of truth nobody reviewed. Tracked in #3060: put a `risk` column on -# `ToolEntry`, where it is declared once and reviewed like every other column. -risk_level = "undeclared" +# risk_level: how bad one honest call is — a reviewed judgement, declared in +# crates/stella-tools/src/catalog.rs beside the flags above and graded against +# the rubric on `ToolEntry::risk` (#2716, #3060). A DIFFERENT axis from +# `read_only`: `task` mutates no file and spends real money, while +# `task_create` mutates a board that dies with the session. Deliberately not +# derived from the two booleans above — that would be a relabelling, not +# information. A policy grant is expressed as a ceiling over this grade, and +# every non-built-in tool (MCP, custom manifest) is graded `high` for being +# unreviewed. +risk_level = "low" description = ''' Cancel a board task that is no longer worth doing (superseded, out of scope, impossible). Give a short reason. Cancelled tasks keep their row as an audit trail; cancelled is terminal. diff --git a/docs/tools/task_complete.toml b/docs/tools/task_complete.toml index 70e2db5dc..183a10f88 100644 --- a/docs/tools/task_complete.toml +++ b/docs/tools/task_complete.toml @@ -6,9 +6,8 @@ # Where each field comes from: # name / description / input_schema the tool's own ToolSchema # read_only / available_for_speculation / category / availability -# crates/stella-tools/src/catalog.rs +# risk_level crates/stella-tools/src/catalog.rs # output_schema stella_protocol::ToolOutput -# risk_level nothing declares it (see below) name = "task_complete" category = "task" @@ -16,15 +15,16 @@ availability = "always" read_only = false available_for_speculation = false -# risk_level: NOT DECLARED. Nothing in this repository carries a per-tool risk -# level — not the catalog, not ToolSchema, not the policy layer (#2716, which -# would have introduced the machinery, was closed wontfix). The two booleans -# above are the only machine-checked safety claims a tool makes. Relabelling -# them "low/medium/high" would add no information while reading as a reviewed -# judgement, and hand-writing one judgement per tool would manufacture a source -# of truth nobody reviewed. Tracked in #3060: put a `risk` column on -# `ToolEntry`, where it is declared once and reviewed like every other column. -risk_level = "undeclared" +# risk_level: how bad one honest call is — a reviewed judgement, declared in +# crates/stella-tools/src/catalog.rs beside the flags above and graded against +# the rubric on `ToolEntry::risk` (#2716, #3060). A DIFFERENT axis from +# `read_only`: `task` mutates no file and spends real money, while +# `task_create` mutates a board that dies with the session. Deliberately not +# derived from the two booleans above — that would be a relabelling, not +# information. A policy grant is expressed as a ceiling over this grade, and +# every non-built-in tool (MCP, custom manifest) is graded `high` for being +# unreviewed. +risk_level = "low" description = ''' Mark a board task completed the moment its work is done and verified. Complete tasks as you finish them — that is what keeps exactly one task in_progress and the board honest. Completed is terminal. Send it alongside the next task's first real tool call rather than in a step of its own: a step that only moves a card reads nothing and changes nothing, and still costs a full request. diff --git a/docs/tools/task_create.toml b/docs/tools/task_create.toml index 2916a2619..fb8d34e88 100644 --- a/docs/tools/task_create.toml +++ b/docs/tools/task_create.toml @@ -6,9 +6,8 @@ # Where each field comes from: # name / description / input_schema the tool's own ToolSchema # read_only / available_for_speculation / category / availability -# crates/stella-tools/src/catalog.rs +# risk_level crates/stella-tools/src/catalog.rs # output_schema stella_protocol::ToolOutput -# risk_level nothing declares it (see below) name = "task_create" category = "task" @@ -16,15 +15,16 @@ availability = "always" read_only = false available_for_speculation = false -# risk_level: NOT DECLARED. Nothing in this repository carries a per-tool risk -# level — not the catalog, not ToolSchema, not the policy layer (#2716, which -# would have introduced the machinery, was closed wontfix). The two booleans -# above are the only machine-checked safety claims a tool makes. Relabelling -# them "low/medium/high" would add no information while reading as a reviewed -# judgement, and hand-writing one judgement per tool would manufacture a source -# of truth nobody reviewed. Tracked in #3060: put a `risk` column on -# `ToolEntry`, where it is declared once and reviewed like every other column. -risk_level = "undeclared" +# risk_level: how bad one honest call is — a reviewed judgement, declared in +# crates/stella-tools/src/catalog.rs beside the flags above and graded against +# the rubric on `ToolEntry::risk` (#2716, #3060). A DIFFERENT axis from +# `read_only`: `task` mutates no file and spends real money, while +# `task_create` mutates a board that dies with the session. Deliberately not +# derived from the two booleans above — that would be a relabelling, not +# information. A policy grant is expressed as a ceiling over this grade, and +# every non-built-in tool (MCP, custom manifest) is graded `high` for being +# unreviewed. +risk_level = "low" description = ''' Add tasks to the session task board — the board is the session's visible plan, so create tasks BEFORE starting multi-step work, one per concrete deliverable. Pass `tasks` to create a whole plan in ONE call; `subject` creates a single task. New tasks start pending; mark the one you work on with task_start (or delegate it with task_assign). Board updates cost a full model round trip when they travel alone — issue them in the SAME step as the real work they describe, never as a step of their own. diff --git a/docs/tools/task_list.toml b/docs/tools/task_list.toml index 46107b96a..f53976531 100644 --- a/docs/tools/task_list.toml +++ b/docs/tools/task_list.toml @@ -6,9 +6,8 @@ # Where each field comes from: # name / description / input_schema the tool's own ToolSchema # read_only / available_for_speculation / category / availability -# crates/stella-tools/src/catalog.rs +# risk_level crates/stella-tools/src/catalog.rs # output_schema stella_protocol::ToolOutput -# risk_level nothing declares it (see below) name = "task_list" category = "task" @@ -16,15 +15,16 @@ availability = "always" read_only = true available_for_speculation = true -# risk_level: NOT DECLARED. Nothing in this repository carries a per-tool risk -# level — not the catalog, not ToolSchema, not the policy layer (#2716, which -# would have introduced the machinery, was closed wontfix). The two booleans -# above are the only machine-checked safety claims a tool makes. Relabelling -# them "low/medium/high" would add no information while reading as a reviewed -# judgement, and hand-writing one judgement per tool would manufacture a source -# of truth nobody reviewed. Tracked in #3060: put a `risk` column on -# `ToolEntry`, where it is declared once and reviewed like every other column. -risk_level = "undeclared" +# risk_level: how bad one honest call is — a reviewed judgement, declared in +# crates/stella-tools/src/catalog.rs beside the flags above and graded against +# the rubric on `ToolEntry::risk` (#2716, #3060). A DIFFERENT axis from +# `read_only`: `task` mutates no file and spends real money, while +# `task_create` mutates a board that dies with the session. Deliberately not +# derived from the two booleans above — that would be a relabelling, not +# information. A policy grant is expressed as a ceiling over this grade, and +# every non-built-in tool (MCP, custom manifest) is graded `high` for being +# unreviewed. +risk_level = "low" description = ''' Show the session task board: every task as `[status] #id subject (owner)` — [ ] pending, [~] in_progress, [x] completed, [-] cancelled. Use it to re-check the plan or find a task id. diff --git a/docs/tools/task_start.toml b/docs/tools/task_start.toml index b8e45f839..432f4b1af 100644 --- a/docs/tools/task_start.toml +++ b/docs/tools/task_start.toml @@ -6,9 +6,8 @@ # Where each field comes from: # name / description / input_schema the tool's own ToolSchema # read_only / available_for_speculation / category / availability -# crates/stella-tools/src/catalog.rs +# risk_level crates/stella-tools/src/catalog.rs # output_schema stella_protocol::ToolOutput -# risk_level nothing declares it (see below) name = "task_start" category = "task" @@ -16,15 +15,16 @@ availability = "always" read_only = false available_for_speculation = false -# risk_level: NOT DECLARED. Nothing in this repository carries a per-tool risk -# level — not the catalog, not ToolSchema, not the policy layer (#2716, which -# would have introduced the machinery, was closed wontfix). The two booleans -# above are the only machine-checked safety claims a tool makes. Relabelling -# them "low/medium/high" would add no information while reading as a reviewed -# judgement, and hand-writing one judgement per tool would manufacture a source -# of truth nobody reviewed. Tracked in #3060: put a `risk` column on -# `ToolEntry`, where it is declared once and reviewed like every other column. -risk_level = "undeclared" +# risk_level: how bad one honest call is — a reviewed judgement, declared in +# crates/stella-tools/src/catalog.rs beside the flags above and graded against +# the rubric on `ToolEntry::risk` (#2716, #3060). A DIFFERENT axis from +# `read_only`: `task` mutates no file and spends real money, while +# `task_create` mutates a board that dies with the session. Deliberately not +# derived from the two booleans above — that would be a relabelling, not +# information. A policy grant is expressed as a ceiling over this grade, and +# every non-built-in tool (MCP, custom manifest) is graded `high` for being +# unreviewed. +risk_level = "low" description = ''' Mark a board task in_progress — the task you are personally working on right now. Keep exactly ONE task in_progress at a time: complete the current task before starting the next. (task_assign marks delegated tasks in_progress by itself — task_start is for your own work.) Issue this in the SAME step as the first real tool call of that task: alone it costs a whole model round trip to change one character on a board.