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
Open
feat(stella-tools): ToolContract, AuthzGate port and the gate decorator — the governance half of tool-first#3281macanderson wants to merge 3 commits into
macanderson wants to merge 3 commits into
Conversation
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.
Contributor
There was a problem hiding this comment.
Sorry @macanderson, you have reached your weekly rate limit of 500000 diff characters.
Please try again later or upgrade to continue using Sourcery
Contributor
|
The latest updates on your projects. Learn more about Vercel for GitHub. 1 Skipped Deployment
|
Contributor
Reviewer's GuideAdds 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 AuthzGatesequenceDiagram
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
File-Level Changes
Assessment against linked issues
Tips and commandsInteracting with Sourcery
Customizing Your ExperienceAccess your dashboard to:
Getting Help
|
This was referenced Aug 14, 2026
Open
…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
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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_PLANNEDin 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_onlyis 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'sread_only: falseis a self-report from a party the workspace has no reason to trust. So:ToolSchema::read_onlyRiskLevelToolContract::requires_approvalRisk 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: highforces approval while its code keys only on a separate boolean, so the grade is written down, displayed, and enforced nowhere.RiskCeilingis the counter-example: the grade has exactly one job and does it.)What landed
stella-protocol::contract—RiskLevel,Provenance,ToolContract,ContractError.ToolContractcontainsToolSchemarather than restating its fields. That is load-bearing for invariant #7: the bytes advertised to the model are literally today'sToolSchema, 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 bythe_advertised_half_serializes_exactly_as_a_bare_schema.RiskLevel::Destructivecarries#[serde(other)], so a grade minted by a newer build reads as the maximum rather than failing the decode — theErrorClassforward-compat posture pointed the one direction that is safe for a security field. Degrading an unknown risk toLowwould 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::authz—Principal,AuthzGate,AuthzDecision,AuthzEvalError,NoAuthz,RiskCeiling,authz_verdict.check()returnsResult<AuthzDecision, AuthzEvalError>and the arms are not interchangeable.Ok(Deny)is a decision an operator running softened may downgrade;Erris the absence of a decision and is never softenable. This is oxagen-platform's OXA-2056 defect encoded at the type level.authz_verdictis a thin adapter over the existingresolve_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 namedAuthzGateas a future producer; this fills that slot.NoAuthzis 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 typeNoAuthz, and that word appears in review.stella-tools—riskbecomes 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, gradedHighfor being unreviewed, withtrusted_read_only()false. Two consequences follow with no rule written about any specific tool: aMediumceiling refuses every third-party tool, and a manifest'sread_only = trueclaim buys it no dispatch privileges.That name lookup is only sound because a name cannot be squatted (
RESERVED_NAMESis aliased toALL_NAMES).a_third_party_cannot_inherit_a_builtins_contract_by_nameasserts that rather than assuming it.Why the decorator, and why one-sided
A gate inside
ToolRegistrywould 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.GatedToolSettherefore wraps the whole stack, outermost:Unlike
PolicyToolSetit enforces atexecute()only, not atschemas(). An operator switch is input-independent, so hiding is as true as refusing; an authorization decision generally is not — a gate may allowwrite_fileundersrc/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
Highcontract 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.a_deny_all_gate_blocks_a_call_the_default_path_allowsno_authz_lets_every_call_througha_gate_that_cannot_evaluate_denies_the_callan_eval_error_denies_even_with_enforcement_softeneda_medium_ceiling_admits_a_reviewed_read_and_refuses_a_third_party_toola_declared_tools_read_only_claim_buys_it_nothingtrusted_read_onlytoschema.read_onlyan_unknown_risk_token_reads_as_the_maximumthe_advertised_half_serializes_exactly_as_a_bare_schemarisk_levelin the generated docs (closes #3060)docs/tools/*.tomlprintedrisk_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, 1high(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 gradedHigh.the_catalog_grades_delegation_above_the_restasserts 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
GatedToolSetis not yet wired into the shipped session stack — it is constructed by its tests only. Wiring it means extracting the tool-chain assembly out ofagent.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:
ToolCtx+ in-looptool.call.progressToolOutput::Ok { data })stella-parityrowRequireApproval— today it refuses with a grant-path message, matching the documented headless postureAuthzTraceThe
version,idempotentandeventscontract 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:eventsin #3284,versionin #3286,idempotentin #3287.Note on the base
This branch carries one commit that is not mine in spirit:
main'sstella-coretest target does not compile (a duplicateduse std::sync::Mutexplus an unconstructedNoToolsindriver/restore.rs), socargo test -p stella-coreandcargo clippy -p stella-core --all-targetsfail onmainand 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-depson 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 --workspaceandstella-cli's integration binaries (this machine OOMs linking them) — left to CI.Deleted tests
None.
NoToolsin the cherry-picked fix is a test helper struct, not a#[test].