Skip to content

approvals: name every gated tool for the operator, and close the seam that hid it - #728

Closed
M3gA-Mind wants to merge 1 commit into
tinyhumansai:mainfrom
M3gA-Mind:fix/706-consequence-tool-labels
Closed

approvals: name every gated tool for the operator, and close the seam that hid it#728
M3gA-Mind wants to merge 1 commit into
tinyhumansai:mainfrom
M3gA-Mind:fix/706-consequence-tool-labels

Conversation

@M3gA-Mind

@M3gA-Mind M3gA-Mind commented Aug 11, 2026

Copy link
Copy Markdown
Collaborator

Summary

Closes #706.

Eleven tools the runtime classifies as Reach::Consequence had no entry in the
console's label maps. Consequence is the reach that parks under the default
supervised mode, so every one of them reaches an operator on an approval card
— and toolAction fell through to the literal "Use one of its tools", which
names nothing. publish_artifact and run_workflow are among the most
consequential things an agent can do; curl and http_request are unbounded
outward reach.

file_write Write a file in its workspace
edit Edit a file in its workspace
apply_patch Apply a set of file changes in its workspace
csv_export Save a spreadsheet to its workspace
read_workspace_state Check the state of its workspace files
http_request Make a web request to any address
curl Transfer data to or from any web address
git_operations Run a version control command that can push
mcp_call_tool Use a tool on one of its connected servers
run_workflow Run one of its saved workflows
publish_artifact Publish one of its documents publicly

The wordings are deliberately distinct from each other and from the existing
entries. The Standing permissions list (#374) renders these labels with no
payload block underneath, so two tools that read alike are two permissions an
operator cannot tell apart — which is why web_fetch stays "a web page" while
curl and http_request say "any address". A test enforces that distinctness
rather than leaving it to review.

It is eleven, not six — and the undercount is the bug in miniature

Corrected figures, read from the typed table rather than from its source text:

declared Consequence tools 22
missing from both label maps 11 (this PR)
covered only by EFFECT_LABELS 4 — composio_authorize, mcp_registry_tool_call, media_generate_image, media_generate_video
covered by TOOL_LABELS 7

The issue reported six of twelve. The reproduction script filed with it matched
38 of the table's 50 entries: it missed all 5 d_grantable(
declarations and 7 entries rustfmt had wrapped across lines — then
reported the smaller answer without a word. The five extra misses it hid —
file_write, edit, apply_patch, csv_export, read_workspace_state — are
precisely the ones a line-oriented scan cannot see.

That is the fail-open defect this issue is about, committed by the script
written to demonstrate it.
A checker that parses the table's source text
cannot be trusted to report its own blind spots, because a regex that matches
nothing and a regex that matches everything produce the same shape of output: a
list, printed confidently. It is the single strongest argument for the design
below, which never looks at source text at all.

The fix that lasts: close the seam, do not patch the drift

The declarations are Rust, the labels are TypeScript, and nothing in either
build compared them. workspace_create sat rendering the generic fallback from
issue #551 until now with every lane green.

Two halves, both reading typed data rather than text:

  1. consequence_tools() (src/policy/consequence.rs) filters DECLARED on
    reach == Reach::Consequence and generates
    frontend/src/lib/gated-tools.generated.ts. A Rust test
    (gated_tool_snapshot_matches) fails if the committed copy drifts, mirroring
    the existing sdl_snapshot_matches pattern for schema.graphql. Regenerate
    with cargo test -- --ignored regenerate_gated_tools.
  2. gated-tool-labels.test.ts imports that generated list and asserts every
    name resolves through the real toolAction resolver to something other than
    the fallback.

They compose: Rust proves the list is current, TypeScript proves the list is
named. Adding a Reach::Consequence tool without a label fails the second;
changing a Reach without regenerating fails the first.

Asserting through toolAction rather than against the label maps is deliberate.
The maps are module-private, and the property that matters is not "a key exists"
but "an operator sees words" — routing through the real resolver also covers the
EFFECT_LABELS-first ordering, which is how composio_authorize and
mcp_registry_tool_call are legitimately named without appearing in
TOOL_LABELS at all.

Both halves fail closed

A check whose own input silently arrives empty reports success having compared
nothing, which is the same shape as the defect being fixed. So each half asserts
its own input is populated (> 10) and contains named anchors
(publish_artifact, run_workflow, shell), rather than trusting that a
non-empty list arrived. the_gated_tool_list_is_never_silently_empty and
is not vacuous: the generated list is populated exist only to fail when the
extraction breaks.

composio_execute is deliberately outside all of this, as it is outside
DECLARED itself: its reach is read from the action slug in its arguments, so
there is no single answer to snapshot. EFFECT_LABELS names it.

i18n: reported rather than changed

These labels are raw English string literals, and this PR keeps them that
way.
There is no i18n infrastructure in this repository — no useT, no
en.ts, no locale directory anywhere under frontend/, and no em-dash lane in
ci.yml. TOOL_LABELS and EFFECT_LABELS have always been plain literals in
language.ts. Introducing a translation layer for eleven strings would be a
separate change with its own argument, so the existing pattern is followed and
flagged here instead of being altered silently. No label value contains U+2014.

API Or Behavior Changes

  • Eleven approval cards and Standing-permissions rows now name their tool
    instead of reading "Use one of its tools". No gating behaviour changes — this
    is presentation only; nothing about what parks, or when, is touched.
  • New public Rust API: policy::consequence::consequence_tools() and
    generated_gated_tools_ts().
  • New generated file frontend/src/lib/gated-tools.generated.ts, committed
    and drift-checked. Regenerate with
    cargo test -- --ignored regenerate_gated_tools.
  • New export UNNAMED_TOOL_ACTION from language.ts, so the test compares
    against the same string the console renders rather than restating it.

Tests

  • cargo fmt --all -- --check — passed.
  • cargo clippy --locked --no-deps --features openhuman,tinycortex --all-targets -- -D warnings — passed, zero lints.
  • cargo build --all-targets --features openhuman,tinycortex — passed. Also cargo check --locked --all-features --all-targets.
  • cargo test --features openhuman,tinycortex3130 passed, 0 failed, 4 ignored (lib), plus 10 / 1 / 11 / 2 / 0 across the other targets.

Frontend, on Node 22 to match the Console job: npm ci, then
npm run typecheck, typecheck:e2e, typecheck:unit all clean, and npm test
40 files / 444 tests passed.

Every added test was checked by reverting its fix

reverted mechanism result test that caught it
all 11 new labels removed 3 ran, 2 failed resolves each declared Consequence tool to a real label (+ distinctness, as the removals collapse onto one fallback)
publish_artifact label alone removed 3 ran, 1 failed resolves each declared Consequence tool to a real label
GATED_TOOLS emptied 3 ran, 1 failed is not vacuous: the generated list is populated
shell's label duplicated onto run_workflow 3 ran, 1 failed gives no two gated tools the same sentence
a Reach flipped without regenerating 11 ran, 2 failed gated_tool_snapshot_matches
consequence_tools extraction returns zero rows 1 ran, 1 failed the_gated_tool_list_is_never_silently_empty

Baselines on the unmodified tree ran green first (frontend 3 passed; Rust 11
passed) so that each red above is attributable to its own sabotage rather than
to a broken tree.

Documentation

The rationale lives next to the code it constrains: consequence_tools() and
gated_tool_snapshot_matches carry the seam argument in Rust, the generated
file states it cannot be hand-edited, and gated-tool-labels.test.ts explains
why it asserts through the resolver. No docs/spec/ change — this adds a check
over an existing contract rather than changing one.

Summary by CodeRabbit

  • New Features

    • Added clearer, plain-language labels for gated tools, including file operations, network access, version-control commands, connected services, and workflows.
    • Added a consistent fallback label for tools without a specific name.
    • Improved the generated tool list so available gated actions are represented consistently in the interface.
  • Bug Fixes

    • Reduced generic or ambiguous tool descriptions, making gated actions easier to understand before use.

Eleven tools the runtime classifies as `Reach::Consequence` had no entry in
the console's label maps, so an approval card for any of them read the generic
"Use one of its tools" and an operator was asked to approve something unnamed.
`publish_artifact` and `run_workflow` are among the most consequential things
an agent can do; `curl` and `http_request` are unbounded outward reach.

The labels are the visible half. The lasting half is that the drift was silent
by construction: the declarations are Rust, the labels are TypeScript, and no
build step compared them -- `workspace_create` sat mislabelled from tinyhumansai#551 until
now with every lane green.

`consequence_tools()` generates `frontend/src/lib/gated-tools.generated.ts`
from the typed table, a Rust test fails if the committed copy drifts, and a
frontend test asserts every name in it resolves to real words. Both halves
fail closed: each asserts its own input is non-empty and names anchors, so a
broken extraction is loud rather than green.

Reading the typed table rather than parsing its source text is deliberate. A
script over `DECLARED` would rot on the first formatting change and fail open
-- and it undercounts today: the reproduction in the issue misses every
`d_grantable(` and multi-line entry, which is why tinyhumansai#706 reports six missing
tools where there are eleven.
@M3gA-Mind

Copy link
Copy Markdown
Collaborator Author

@coderabbitai review

@coderabbitai

coderabbitai Bot commented Aug 11, 2026

Copy link
Copy Markdown
⚠️ Action not completed

Already reviewed.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

@coderabbitai

coderabbitai Bot commented Aug 11, 2026

Copy link
Copy Markdown

Review Change Stack

Important

Review skipped

No new commits to review since the last review.

⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: 6495250a-8eee-4a57-861a-064d8e69bb5c

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: e8135dd6-ba7e-4c1a-b71f-69366d18dfc5

📥 Commits

Reviewing files that changed from the base of the PR and between 9af6d67 and d8a9ea9.

⛔ Files ignored due to path filters (1)
  • frontend/src/lib/gated-tools.generated.ts is excluded by !**/*.generated.*
📒 Files selected for processing (3)
  • frontend/src/lib/language.ts
  • frontend/test/unit/gated-tool-labels.test.ts
  • src/policy/consequence.rs

📝 Walkthrough

Walkthrough

The PR generates the frontend gated-tool list from Rust consequence declarations, adds labels for previously unlabeled tools, exports the generic fallback string, and adds Rust and frontend validation for coverage, population, and label uniqueness.

Changes

Gated tool label coverage

Layer / File(s) Summary
Generate consequence tool list
src/policy/consequence.rs
Rust extracts and sorts statically declared Reach::Consequence tools, excludes argument-dependent composio_execute, generates the TypeScript list, and validates its snapshot and required entries.
Map gated tools to operator labels
frontend/src/lib/language.ts, frontend/test/unit/gated-tool-labels.test.ts
The frontend adds operator-facing labels for gated tools, shares the exported UNNAMED_TOOL_ACTION fallback, and tests label coverage and uniqueness.

Estimated code review effort: 3 (Moderate) | ~20 minutes

Possibly related issues

Possibly related PRs

Suggested reviewers: oxoxdev

Poem

A rabbit checks each tool by name,
No silent label slips remain.
Rust lists the gates, TypeScript sings,
Tests catch duplicates and missing things.
Hop, hop—clear approvals bloom! 🐇

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 inconclusive)

Check name Status Explanation Resolution
Linked Issues check ❓ Inconclusive The summary covers the requested labels and typed drift check, but the generated TypeScript file was excluded by path filters. Review frontend/src/lib/gated-tools.generated.ts and confirm its contents and drift-check integration.
✅ Passed checks (4 passed)
Check name Status Explanation
Out of Scope Changes check ✅ Passed The listed changes support issue #706 through labels, generated tool data, drift checks, and focused tests.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly summarizes the two main changes: naming gated tools for operators and preventing the declaration-to-label drift seam.

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@oxoxDev oxoxDev left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

The best thing here is the paragraph about the issue's own reproduction script — it matched 38 of the table's 50 entries, missing all five d_grantable( declarations and seven rustfmt-wrapped ones, then reported the smaller answer without a word. "That is the fail-open defect this issue is about, committed by the script written to demonstrate it." A regex that matches nothing and one that matches everything produce the same shape of output, so a source-text checker cannot report its own blind spots. Finding that while fixing the thing it under-reported, and correcting six to eleven, is the whole argument for generating from the typed table.

And the loop is genuinely closed at both ends — I checked rather than assumed:

  • gated_tool_snapshot_matches is a non-ignored test that include_str!s the checked-in gated-tools.generated.ts and compares it against generated_gated_tools_ts(). Adding a Consequence tool to the Rust table without regenerating fails the Rust lane. That is the drift seam shut.
  • the_gated_tool_list_is_never_silently_empty guards the generator against returning nothing through a formatting change — the repro script's failure mode, applied to the replacement. That is the right lesson to draw from it.
  • gated-tool-labels.test.ts closes the other half, so a generated name with no label fails the frontend lane.

The wording discipline is also right: web_fetch staying "a web page" while curl and http_request say "any address" matters precisely because the Standing permissions list renders these with no payload underneath, so two labels that read alike are two permissions an operator cannot tell apart. Enforcing distinctness with a test rather than in review is the correct call for copy that is load-bearing.

0 major. 1 question, and it is the same shape as the bug. Approving.

Question — consequence_tools() enumerates DECLARED; declared_tools() enumerates DECLARED plus COMPOSIO_EXECUTE

pub fn consequence_tools() -> Vec<&'static str> {
    let mut tools: Vec<&'static str> = DECLARED
        .iter()
        .filter(|d| d.reach == Reach::Consequence)
        ...

composio_execute is not in DECLARED — it is chained on separately by declared_tools(), because its consequence is a property of the action in its arguments rather than of its name. So the generated list does not contain it, and the crate now holds two enumerations of "every tool" that disagree by exactly one entry, with the new one narrower.

This may well be correct: a Composio approval card presumably names the action rather than the tool, so composio_execute may never need a toolAction label. If so, the exclusion deserves a sentence at consequence_tools() saying why it does not chain what its sibling chains — because the failure mode you have just spent this PR closing is precisely "an entry that lives outside the array nobody thought to look past".

If it is not correct, then a Composio send — one of the more consequential things in the product — is the twelfth tool falling through to "Use one of its tools", and the generator would be reproducing the structural blind spot rather than the textual one. Worth confirming which, and pinning it either way: a test asserting the two enumerations differ only by COMPOSIO_EXECUTE, with the reason, would make the divergence deliberate instead of incidental.

Before merging: base is current and the branch is CLEAN.

@M3gA-Mind

Copy link
Copy Markdown
Collaborator Author

Closing this as superseded by #721, which shipped the same fix first and better.

#721 ("name the eleven gated tools on the approval card", issue #701) landed
both halves this PR set out to deliver: the eleven labels, and a Rust guard —
every_consequence_tool_has_a_console_label — that keeps DECLARED and the
console's label tables in step. TOOL_LABELS on main now carries 25 keys,
where it had 12.

Three specific concessions, because "duplicate" undersells it — two of its
labels are better than the ones here, on grounds I agree with after reading the
tool descriptions rather than the tool names:

  • curl — this PR wrote "Transfer data to or from any web address", which
    reads the name rather than the tool. fix(console): name the eleven gated tools on the approval card (#701) #721 has "Download a file from the
    internet", which is what it does: it takes a url and streams the body to a
    file in the workspace. It is not the arbitrary-method tool its name suggests;
    http_request is the one carrying POST/PUT/DELETE with headers and a body.
  • publish_artifact — this PR wrote "Publish one of its documents
    publicly". fix(console): name the eleven gated tools on the approval card (#701) #721 has "Publish a file it produced" and argues against
    "publicly" explicitly: the tool hands a finished file over, and an agent's
    sandbox being private does not make the destination public. Saying "publicly"
    over a hand-off to the operator is a misleading label on the one card that
    has to be trusted. That is the stronger reading and this PR's was the
    misleading one.

The design objection in label_keys's doc comment also lands, and is worth
conceding rather than leaving implicit. This PR generated a checked-in
gated-tools.generated.ts and drift-tested it. As #721 notes, that "would give
the contract two failure sites and a window between the declaration commit and
the regenerate commit where nothing is wrong" — and it offers no local signal
to the Rust contributor adding the next Reach::Consequence line, who is the
person who caused all three instances of this defect (#372, #551#671, #701)
and who has no reason to open the frontend. An in-Rust test that reads
language.ts directly is a better answer to the same problem.

One thing here is not covered by #721 and may be worth a small follow-up: its
distinctness assertion is scoped to the four grantable tools, whereas the #374
Standing-permissions argument — no payload block underneath, so two rows
reading alike are two permissions an operator cannot choose between — applies
to all 22 Reach::Consequence tools. Happy to open a ~15-line PR adding that
assertion on top of the merged guard if it is wanted; it conflicts with
nothing.

Verified independently against upstream/main before closing: 22 declared
Reach::Consequence tools, 0 now missing from both label maps.

Branch kept for now; no code from this PR should land.

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.

approvals: six declared Consequence tools have no operator label, so the card reads "Use one of its tools"

2 participants