Skip to content

feat(stella-tools): ToolContract, AuthzGate port and the gate decorator — the governance half of tool-first - #3281

Open
macanderson wants to merge 3 commits into
mainfrom
feat/2716-tool-contract-authz
Open

feat(stella-tools): ToolContract, AuthzGate port and the gate decorator — the governance half of tool-first#3281
macanderson wants to merge 3 commits into
mainfrom
feat/2716-tool-contract-authz

Conversation

@macanderson

@macanderson macanderson commented Aug 14, 2026

Copy link
Copy Markdown
Owner

Closes #3060
Refs #2716, #2694, #2793, #3246

What this is

The governance half of tool-first Stella: a tool call can now be authorized against who is asking and how dangerous the tool is, at one seam that covers built-ins, MCP servers and custom manifests alike.

#2716 was closed NOT_PLANNED in the 2026-08-12 mission-scope sweep. The close invited a reopen "with a mission tie", and the plugin-platform review (#3246) is that tie: without an authority vocabulary, a paid plugin and a hostile one have identical authority, so there is no defensible way to let third-party code participate in the loop. This PR lands the vocabulary and the enforcement point. It is a subset of #2716's definition of done — see "What is not here" — and the rest is filed as issues rather than left implied.

The three axes, kept separate

The defect this design most wants to avoid is a conflated axis. read_only is the closest thing the tree had to a risk grade and it is the wrong axis twice over: a metered web call 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. So:

Field Question Plane
ToolSchema::read_only does the workspace change? dispatch
RiskLevel how bad is the worst honest outcome? policy input
ToolContract::requires_approval must a human say yes? its own boolean

Risk does real work — a grant is expressed as a ceiling over it — but it never silently becomes an approval prompt. (oxagen-platform's docs promise riskLevel: high forces approval while its code keys only on a separate boolean, so the grade is written down, displayed, and enforced nowhere. RiskCeiling is the counter-example: the grade has exactly one job and does it.)

What landed

stella-protocol::contractRiskLevel, Provenance, ToolContract, ContractError.

ToolContract contains ToolSchema rather than restating its fields. That is load-bearing for invariant #7: the bytes advertised to the model are literally today's ToolSchema, serialized by today's code, so contracts cannot perturb the prompt-cache prefix. A 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 types separate is what makes the difference visible in review. Pinned by the_advertised_half_serializes_exactly_as_a_bare_schema.

RiskLevel::Destructive carries #[serde(other)], so a grade minted by a newer build reads as the maximum rather than failing the decode — the ErrorClass forward-compat posture pointed the one direction that is safe for a security field. Degrading an unknown risk to Low would let a newer emitter's most dangerous tools through an older reader's ceiling; degrading to the maximum can only refuse too much.

stella-core::ports::authzPrincipal, AuthzGate, AuthzDecision, AuthzEvalError, NoAuthz, RiskCeiling, authz_verdict.

  • check() returns Result<AuthzDecision, AuthzEvalError> and the arms are not interchangeable. Ok(Deny) is a decision an operator running softened may downgrade; Err is the absence of a decision and is never softenable. This is oxagen-platform's OXA-2056 defect encoded at the type level.
  • authz_verdict is a thin adapter over the existing resolve_precedence, so the gate joins the ladder the bus chains and shell hooks already fold through — three producers, one precedence order, rather than a second place for the order to be wrong. resolve_precedence's doc comment already named AuthzGate as a future producer; this fills that slot.
  • NoAuthz is chosen by name, never a nullable slot defaulting open. oxagen-platform carries five module-level gate slots initialized to null, and a bootstrap path that forgot one shipped as a live no-authz surface. Here somebody has to type NoAuthz, and that word appears in review.

stella-toolsrisk becomes a declared catalog column; contracts::contract_for; GatedToolSet.

The trust boundary is one function: a name in the catalog gets a reviewed contract with its declared grade; every other name — MCP, custom manifest, or a name this build has never heard of — gets ToolContract::declared, graded High for being unreviewed, with trusted_read_only() false. Two consequences follow with no rule written about any specific tool: a Medium ceiling refuses every third-party tool, and a manifest's read_only = true claim buys it no dispatch privileges.

That name lookup is only sound because a name cannot be squatted (RESERVED_NAMES is aliased to ALL_NAMES). a_third_party_cannot_inherit_a_builtins_contract_by_name asserts that rather than assuming it.

Why the decorator, and why one-sided

A gate inside ToolRegistry would govern the twelve built-ins and miss custom + MCP tools entirely — and since #3244 cut the surface to twelve, those two are where nearly every capability now lives. GatedToolSet therefore wraps the whole stack, outermost:

GatedToolSet        <- authorization: who is asking, and may they?
  PolicyToolSet     <- operator switches: is this tool on at all?
    CustomToolSet   <- .stella/tools/*.toml
      ToolRegistry / McpToolSet

Unlike PolicyToolSet it enforces at execute() only, not at schemas(). An operator switch is input-independent, so hiding is as true as refusing; an authorization decision generally is not — a gate may allow write_file under src/ and refuse it under /etc. Filtering the advertised set would mean asking every gate a question with a fabricated empty input and treating the answer as final, hiding tools it would have allowed. Enforcement lives where the input exists.

A call for a name the view has no schema for is authorized against an untrusted High contract rather than waved through — mid-session MCP reconnects and invented names both land there.

Witnesses

New-seam witnesses: the types do not exist on main, so these are "the feature is genuinely absent" rather than a fail→pass on unchanged code. Each has an explicit anti-vacuity partner so the assertions are not trivially true.

Test Proves
a_deny_all_gate_blocks_a_call_the_default_path_allows a gate blocks a call, and the tool is never reached — refusal is enforcement, not a label
no_authz_lets_every_call_through anti-vacuity for the above: same call, no gate, runs
a_gate_that_cannot_evaluate_denies_the_call OXA-2056 at the dispatch site
an_eval_error_denies_even_with_enforcement_softened the same, asserted for both values of the softening flag
a_medium_ceiling_admits_a_reviewed_read_and_refuses_a_third_party_tool the trust boundary, with no rule naming either tool
a_declared_tools_read_only_claim_buys_it_nothing would fail if someone "simplified" trusted_read_only to schema.read_only
an_unknown_risk_token_reads_as_the_maximum forward-compat degrades safely, not permissively
the_advertised_half_serializes_exactly_as_a_bare_schema invariant #7

risk_level in the generated docs (closes #3060)

docs/tools/*.toml printed risk_level = "undeclared" with a note saying nothing in the repository carried a per-tool risk and that #2716 had been closed wontfix. It now prints the declared grade, and the drift test got stronger rather than disappearing: each page must match its catalog row, not merely carry the field.

Grades across the twelve built-ins: 11 low, 1 high (task — the one built-in that spends real money and hands a child a whole tool surface).

Which is worth stating plainly: with a twelve-tool surface, only two rungs are populated, so RiskCeiling's discriminating power among built-ins is "delegation versus everything else". The four-rung scale earns its keep on the untrusted side, where every MCP and manifest tool is graded High. the_catalog_grades_delegation_above_the_rest asserts exactly that and deliberately does not demand all four grades appear — that would only invite someone to inflate a grade to satisfy a test.

What is not here

GatedToolSet is not yet wired into the shipped session stack — it is constructed by its tests only. Wiring it means extracting the tool-chain assembly out of agent.rs, which is a grandfathered god file closed to growth, and that extraction is its own logical change. #3283 is that handoff, filed per AGENTS.md's rule that scaffolding ships with its wiring issue in the same breath. Until it lands, this PR adds a seam and changes no session's behaviour.

Also deferred from #2716's DoD, each filed as a handoff:

Issue Deferred slice
#3283 wire the decorator into the real session stack
#3284 ToolCtx + in-loop tool.call.progress
#3285 output-schema validation (needs ToolOutput::Ok { data })
#3286 contracts over the serve wire + the stella-parity row
#3287 manifest/MCP annotation ingestion as untrusted claims
#3288 an approval route for a gate's RequireApproval — today it refuses with a grant-path message, matching the documented headless posture
#3289 the rule-by-rule AuthzTrace

The version, idempotent and events contract fields are deliberately omitted rather than added unwired — a subset of the oxagen-platform shape is forward-compatible, an unread field is not. Each lands with the slice that consumes it: events in #3284, version in #3286, idempotent in #3287.

Note on the base

This branch carries one commit that is not mine in spirit: main's stella-core test target does not compile (a duplicated use std::sync::Mutex plus an unconstructed NoTools in driver/restore.rs), so cargo test -p stella-core and cargo clippy -p stella-core --all-targets fail on main and on every branch cut from it. #3277 is that fix on its own; it is cherry-picked here so this PR's CI reflects this PR. If #3277 merges first the change is identical and merges cleanly.

Verification run locally

cargo fmt --all --check; cargo clippy -p stella-protocol -p stella-core -p stella-tools --all-targets -- -D warnings; RUSTDOCFLAGS="-D warnings" cargo doc --no-deps on the same three; make guards-fast; make tool-docs; make wire-schema. Tests: stella-protocol 148 passed, stella-core 1222 passed, stella-tools 232 passed, all 0 failed.

Not run locally: cargo test --workspace and stella-cli's integration binaries (this machine OOMs linking them) — left to CI.

Deleted tests

None. NoTools in the cherry-picked fix is a test helper struct, not a #[test].

The governance half of tool-first Stella (#2716): a tool call can now be
authorized against *who is asking* and *how dangerous the tool is*, at one
seam that covers built-ins, MCP servers and custom manifests alike.

- stella-protocol: RiskLevel (ordered, unknown tokens read as the maximum)
  and ToolContract, which *contains* ToolSchema rather than restating it so
  the advertised prompt bytes are structurally unchanged (invariant 7).
  Provenance splits reviewed claims from self-declared ones.
- stella-core::ports::authz: Principal, AuthzGate, AuthzEvalError, NoAuthz
  and RiskCeiling. An Err from a gate is an unconditional deny, folded
  through the existing resolve_precedence so there is one fail-closed rule,
  not a second ladder.
- stella-tools: risk becomes a declared catalog column, contracts resolve by
  name (catalog = reviewed, everything else = untrusted High), and
  GatedToolSet enforces at the outermost position.
- docs/tools: risk_level stops reading "undeclared"; the drift test now
  asserts each page agrees with its catalog row.

Refs #2716
Closes #3060
`driver/restore.rs`'s test module carried a duplicated `use
std::sync::Mutex` (E0252) and a `NoTools` executor that nothing
constructs, so `cargo clippy -p stella-core --all-targets` and
`cargo test -p stella-core` both failed on main — every branch cut from
it inherits the red.

`NoTools` is deleted rather than allowed: `SkillTools { active: vec![] }`
is the same tool-less executor and is what all five tests in the module
actually use.

No test is removed by this change.

@sourcery-ai sourcery-ai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Sorry @macanderson, you have reached your weekly rate limit of 500000 diff characters.

Please try again later or upgrade to continue using Sourcery

@vercel

vercel Bot commented Aug 14, 2026

Copy link
Copy Markdown
Contributor

The latest updates on your projects. Learn more about Vercel for GitHub.

1 Skipped Deployment
Project Deployment Actions Updated (UTC)
stella-cli-docs Ignored Ignored Preview Aug 14, 2026 6:59pm

@sourcery-ai

sourcery-ai Bot commented Aug 14, 2026

Copy link
Copy Markdown
Contributor

Reviewer's Guide

Adds a governance-aware tool authorization layer: introduces ToolContract and risk/provenance types in the protocol, a pluggable AuthzGate port and GatedToolSet decorator in core/tools that authorize every tool call by principal and risk, derive contracts from the catalog with a risk column, and update generated tool docs and tests to enforce safe risk handling and trust boundaries across built-ins and third-party tools.

Sequence diagram for gated tool execution via AuthzGate

sequenceDiagram
    actor Principal
    participant GatedToolSet
    participant AuthzGate
    participant authz_verdict
    participant InnerToolExecutor as ToolExecutor

    Principal->>GatedToolSet: execute(name, input)
    GatedToolSet->>GatedToolSet: contract(name)
    GatedToolSet->>AuthzGate: check(contract, principal, input)
    AuthzGate-->>GatedToolSet: Result<AuthzDecision, AuthzEvalError>

    GatedToolSet->>authz_verdict: authz_verdict(operator, evaluation, enforcement_softened)
    authz_verdict-->>GatedToolSet: GateVerdict

    alt GateVerdict::Allow
        GatedToolSet->>InnerToolExecutor: execute(name, input)
        InnerToolExecutor-->>GatedToolSet: ToolOutput::Ok
        GatedToolSet-->>Principal: ToolOutput::Ok
    else GateVerdict::Deny
        GatedToolSet-->>Principal: ToolOutput::classified_error(RefusedByPolicy, reason)
    else GateVerdict::RequireApproval
        GatedToolSet-->>Principal: ToolOutput::classified_error(RefusedByPolicy, reason)
    end
Loading

File-Level Changes

Change Details Files
Introduce protocol-level ToolContract and risk/provenance model for tool governance and validation.
  • Add stella-protocol::contract module defining RiskLevel, Provenance, ToolContract, and ContractError with serde-first design and forward-compatible risk decoding.
  • Implement ToolContract constructors for builtin and declared tools, trusted_read_only computation, and validate() enforcing speculation/read_only and risk/read_only invariants.
  • Export ContractError, Provenance, RiskLevel, and ToolContract from stella-protocol root; add unit tests covering serde behavior, forward-compat, trust boundary, and advertised schema invariants.
crates/stella-protocol/src/contract.rs
crates/stella-protocol/src/lib.rs
Add a pluggable authorization port and precedence folding in stella-core for governing tool calls by principal and risk.
  • Introduce Principal, AuthzDecision, AuthzEvalError, AuthzGate, NoAuthz, RiskCeiling, and authz_verdict in stella-core ports::authz, modeling caller identity and gate outcomes.
  • Implement RiskCeiling gate that denies tools above a configured RiskLevel ceiling and treats unreviewed tools as High; NoAuthz explicitly allows all calls by name.
  • Wire authz module into ports, re-export AuthzGate-related types; implement authz_verdict as adapter to hooks::decision::resolve_precedence and add tests for fail-closed behavior, precedence, and evaluation errors.
crates/stella-core/src/ports/authz.rs
crates/stella-core/src/ports.rs
Decorate the tool execution stack with an authorization-aware GatedToolSet that consults AuthzGate and contracts before each call.
  • Create stella-tools::gated module with GatedToolSet wrapping any ToolExecutor via Inner enum (borrowed or owned) and an AuthzGate plus Principal.
  • Snapshot ToolSchema list into a name->ToolContract map using contracts::contract_for, and fall back to unknown_contract for names without schemas to fail closed.
  • In execute(), evaluate gate.check(contract, principal, input), fold through authz_verdict, and either dispatch to inner or return classified policy errors; forward all budgeting and metadata methods (spend, parallel_safe_names, live_services, etc.).
  • Add tests verifying deny-all enforcement, NoAuthz passthrough, BrokenGate fail-closed behavior, risk ceiling trust boundary (reviewed vs third-party tools), unknown-name handling, and decorator forwarding invariants.
crates/stella-tools/src/gated.rs
Derive per-tool contracts from the catalog with an explicit risk column and enforce catalog/contract consistency and reserved-name trust boundary.
  • Extend ToolEntry with a RiskLevel risk field and document the rubric for grading built-ins and untrusted tools; adjust catalog! macro to include risk column and set concrete grades for all twelve built-ins.
  • Implement contracts::contract_for to build ToolContract::builtin for catalog entries and ToolContract::declared for non-catalog tools; unknown_contract for names with no schema.
  • Add tests ensuring every catalog row yields a valid contract, that delegation tool is graded higher than others, MCP/custom tools are untrusted High, unknown names are High/Declared, and RESERVED_NAMES matches ALL_NAMES so third parties cannot inherit built-in contracts by name.
crates/stella-tools/src/catalog.rs
crates/stella-tools/src/contracts.rs
Update generated tool documentation to surface risk_level from the catalog and strengthen drift checks between docs and catalog.
  • Modify tool_docs generator to pull ToolEntry::risk, render risk_level values instead of "undeclared", and update RISK_NOTE to describe the declared rubric and governance semantics.
  • Strengthen generated_pages_parse_and_carry_every_promised_field to assert each docs/tools/.toml risk_level matches its catalog row, not just presence.
  • Update docs/tools/.toml pages and README to include risk_level from catalog and document its meaning, while keeping output_schema as the only stated absence.
crates/stella-cli/src/tool_docs.rs
docs/tools/*.toml
docs/tools/README.md
Clean up stella-core test helper and fix duplicate import to align with #3277.
  • Remove unused NoTools ToolExecutor test helper from restore.rs tests.
  • Deduplicate std::sync::Mutex import in restore.rs test module so stella-core test target compiles.
  • Leave functional test behavior unchanged aside from build fix.
crates/stella-core/src/driver/restore.rs

Assessment against linked issues

Issue Objective Addressed Explanation
#3060 Introduce a per-tool risk level in the canonical catalog (ToolEntry), using a typed enum (not a string), document a rubric for the risk rungs, and add catalog-level tests that enforce consistency of the declared risks.
#3060 Update the tool-doc generator and generated pages so that risk_level is read from the catalog declaration (instead of the literal "undeclared"), adjust the explanatory note to reflect the new source of truth, and strengthen the drift test to assert alignment between docs and catalog.

Tips and commands

Interacting with Sourcery

  • Trigger a new review: Comment @sourcery-ai review on the pull request.
  • Continue discussions: Reply directly to Sourcery's review comments.
  • Generate a GitHub issue from a review comment: Ask Sourcery to create an
    issue from a review comment by replying to it. You can also reply to a
    review comment with @sourcery-ai issue to create an issue from it.
  • Generate a pull request title: Write @sourcery-ai anywhere in the pull
    request title to generate a title at any time. You can also comment
    @sourcery-ai title on the pull request to (re-)generate the title at any time.
  • Generate a pull request summary: Write @sourcery-ai summary anywhere in
    the pull request body to generate a PR summary at any time exactly where you
    want it. You can also comment @sourcery-ai summary on the pull request to
    (re-)generate the summary at any time.
  • Generate reviewer's guide: Comment @sourcery-ai guide on the pull
    request to (re-)generate the reviewer's guide at any time.
  • Resolve all Sourcery comments: Comment @sourcery-ai resolve on the
    pull request to resolve all Sourcery comments. Useful if you've already
    addressed all the comments and don't want to see them anymore.
  • Dismiss all Sourcery reviews: Comment @sourcery-ai dismiss on the pull
    request to dismiss all existing Sourcery reviews. Especially useful if you
    want to start fresh with a new review - don't forget to comment
    @sourcery-ai review to trigger a new review!

Customizing Your Experience

Access your dashboard to:

  • Enable or disable review features such as the Sourcery-generated pull request
    summary, the reviewer's guide, and others.
  • Change the review language.
  • Add, remove or edit custom review instructions.
  • Adjust other review settings.

Getting Help

…ate layouts

Each crate README carries a Layout table enumerating its modules; three new
ones landed without rows, which is how those tables go stale.

Refs #2716
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

tools: no per-tool risk level is declared anywhere — docs/tools/ carries risk_level = "undeclared" on all 78 pages

1 participant