Skip to content

Make tool:call imply tool:read for tool listing - #6903

Open
rkb32 wants to merge 1 commit into
stacklok:mainfrom
rkb32:fix/4048-tool-call-implies-tool-read
Open

rkb32 wants to merge 1 commit into
stacklok:mainfrom
rkb32:fix/4048-tool-call-implies-tool-read

Conversation

@rkb32

@rkb32 rkb32 commented Oct 21, 2026 •

Copy link
Copy Markdown

Summary

Closes #4048.

tools/list filtered tools by tool:call, so a caller could not be allowed to discover a tool without also being allowed to invoke it. This PR filters by tool:read instead, as agreed in #4048. A caller can now see a tool without being allowed to call it.

tool:call implies tool:read, one way only. The implication is a decorator around Authorizer (pkg/authz/authorizers/tool_read.go). It checks tool:read first and falls back to tool:call when read is denied. Existing policies that allow tool:call keep the same tool list.

The decorator is applied once, where the authorization middleware is built (Middleware) and in the vMCP admission (newCedarAdmission). vMCP FilterTools checks tool:read. vMCP AllowToolCall still checks tool:call.

When a tool is listed only through the tool:call fallback, the tools/list audit event records discovery_fallback_count, the number of tools listed that way.

No new ingress verb. No signed or replayable audit receipts.

Type of change

  • New feature

Test plan

  • Unit tests (task test)
  • Linting (task lint)
  • E2E tests (focused run, see below)

Unit tests and lint pass on Linux, with Docker available. One unrelated timing test in pkg/transport/proxy/streamable (TestStandaloneSSE_StopWhileStreamOpenReturnsCleanly, "connection refused" on its own local port) failed in the last full run, and this PR does not touch that package.

E2E: an earlier version of the authz and audit labelled specs passed 22 of 22 (ginkgo --label-filter="authz || audit") against a built thv. The new spec Tool discovery through tool:call fallback (test/e2e/tool_read_fallback_authz_test.go) uses a mock HTTP PDP over streamable-http and checks that the PDP is asked for tool:read first. The spec was trimmed after that run and not re-run locally. The full task test-e2e suite was not run.

New unit tests:

  • pkg/authz/authorizers/tool_read_test.go: TestToolCallImpliesRead (including the audit marker) and TestToolCallImpliesRead_ErrorPaths.
  • pkg/authz/authorizers/cedar/core_test.go: TestAuthorizer_ToolReadIsAPlainDeny.
  • pkg/authz/tool_filter_test.go: a tool:read-only row in TestAuthorizedToolIndexes, and TestAuthorizedToolIndexes_SharedDeadlineStopsTheLoop.
  • pkg/authz/response_filter_test.go: TestFindToolResponseFilter_ListsToolWithReadGrant.
  • pkg/audit/discovery_marker_test.go: TestAddMetadata_DiscoveryFallback and TestDiscoveryFallback_FlowsFromDecoratorToEvent.
  • pkg/vmcp/core/admission_test.go: TestCedarAdmission_ReadGrantListsButDoesNotAllowCall and TestCedarAdmission_FilterToolsStopsAtSharedDeadline.

Updated: pkg/authz/tool_filter_test.go now expects tool:read, and the response filter tests build the filter through a helper that wraps the authorizer the way Middleware does. pkg/vmcp/core/admission_test.go expects tool:read from FilterTools. The vMCP factory path (newAdmission) is also covered by the Cedar subtest cedar config lists tools through the tool:read fallback.

Does this introduce a user-facing change?

  • Cedar: no change in which tools are listed. Policies that allow call_tool list the same tools. Cedar denies tool:read without resolving claims, so the new read check adds no extra claim or upstream-token resolution per tool.
  • HTTP PDP: a tool is listed if the PDP allows tool:read or tool:call. Each tool now makes up to two PDP requests (a read check, then a call check when read is denied), where it made one before. Tools that are not listed cost two as well. A PDP that allows tool:read for all tools makes every tool visible, including a tool its tool:call rule denies. Example: a rule allowing mcp:tool:read for all tools, with mcp:tool:call denied for delete_repo, now lists delete_repo to every caller. The call is still denied. Narrow the read rule before upgrading if that tool must stay hidden.
  • vMCP: tools/list now uses the tool:read check, so tool:read grants and HTTP PDP read allows list tools there. The same up-to-two-request cost applies. tools/call is unchanged.
  • Audit: tools/list events gain discovery_fallback_count only when a fallback happened. The optimizer's find_tool response is filtered the same way, so its tools/call event can carry the count too. Other fields are unchanged.

Documented in docs/authz.md.

Special notes for reviewers

  • Any HTTP PDP rule that allows mcp:tool:read makes tools visible, in both the single-server and vMCP paths. Check those rules before upgrading.
  • tools/list runs under one shared 30s deadline on both paths (maxDerivedAuthzBudget for the single-server filter, toolFilterBudget for vMCP FilterTools). Tools checked after it expires are hidden. A tool:read error, including a timeout, is returned without a tool:call check, so a slow PDP cannot double the wait.
  • The HTTP PDP must answer mcp:tool:read. A tool:read error is final, so a PDP that rejects or errors on that operation hides the tool for every caller, including callers who hold mcp:tool:call. This follows the review request to drop the "ignore the read error" fallback, and the failure is closed. Falling back to tool:call on non-timeout read errors would avoid it if reviewers prefer.
  • The audit event records a count, not tool names. Authz runs after the tool filter, so recorded names could differ from what the client sees.
  • Deliberate deviation: the decorator is applied in Middleware rather than CreateMiddlewareFromConfig. Middleware is the one place every caller passes through, and it is called once per middleware, not per request.
  • Test fakes: each package keeps one small hand-written fake (pkg/authz, pkg/vmcp/core, pkg/authz/authorizers, pkg/audit), following the existing pattern in those packages. A shared or generated mock for Authorizer would replace all four and could be a separate change.
  • Known limitations: Cedar can't grant tool:read alone yet, since it has no read_tool action (Add a read_tool action so Cedar can grant tools/list visibility without invocation #6924). tools/list is also not filtered on the SSE transport on main (tools/list is not filtered on the SSE transport #6926), and this PR doesn't change that.

🤖 Generated with Claude Code

@chatgpt-codex-connector

chatgpt-codex-connector Bot commented Oct 21, 2026 •

Copy link
Copy Markdown

Codex Review Summary

This comment shows the latest Codex review activity on this pull request.

Review Status Commit Review trigger
📝 Code Review ⚠️ Failed 2026-10-21T22:50:43.127016Z
We couldn’t complete this review. Comment @codex review to try again.
370d03f PR opened
ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review" or "@codex security review".

Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings.

Comment thread docs/authz.md Outdated

- `tools/list` shows only tools the user can call (based on `call_tool` policies); a result in which a tool repeats `name`, `annotations`, or the annotation `title` or a hint such as `readOnlyHint`, or spells one with a case-insensitive alias, fails closed with a generic internal JSON-RPC error, so policies evaluate exactly the hints the client receives.
- `tools/list` shows only tools the user can read (the `tool:read` check). `tool:call` implies `tool:read`, so a user allowed to call a tool also sees it, and existing `call_tool` policies keep their `tools/list` behavior. `tool:read` never implies `tool:call`. Cedar policies cannot grant `tool:read` alone yet, so for Cedar a tool is visible only when a `call_tool` policy allows it. An HTTP PDP receives `mcp:tool:read` and can allow it alone. This changes what an HTTP PDP deployment shows, so review it before upgrading:
- **Before:** a tool appeared in `tools/list` only if the PDP allowed `mcp:tool:call` for it, with one PDP decision per tool.

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.

I'm not sure it's relevant to document a before and after in this doc. Architecture docs should represent the "today" and not be a changelog. Unless an explicit migration is needed.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Thanks, you were right. I've rewritten the section to describe current behavior only and moved the Cedar log migration into its own section under the claim-source docs. The before-and-after is in the PR description. The docs now also cover the error handling and the audit fields. Pushed as e0dacd2.

@codecov

codecov Bot commented Oct 22, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 79.84%. Comparing base (16ffc0b) to head (370d03f).
⚠️ Report is 38 commits behind head on main.

Additional details and impacted files
@@            Coverage Diff             @@
##             main    #6903      +/-   ##
==========================================
+ Coverage   79.71%   79.84%   +0.12%     
==========================================
  Files         818      828      +10     
  Lines       83583    84585    +1002     
==========================================
+ Hits        66631    67537     +906     
- Misses      16947    17043      +96     
  Partials        5        5              

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

@jhrozek jhrozek 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.

Thanks for picking this up. The shape matches what we agreed on in #4048, and I checked that nothing that leads to a tools/call now goes through a read check, so the security story holds. Most of my comments are about making it smaller and putting the decorator where it belongs.

One thing I want to call out because it isn't obvious from the code. With Cedar, the tool:read check errors with "unsupported feature/operation combination" and the decorator drops that error, so visibility is decided by call_tool. That turns out to be the right answer, but only by accident. I wrote a quick test against the real Cedar authorizer with this policy set:

permit(principal, action, resource);
forbid(principal, action == Action::"call_tool", resource == Tool::"t");

On main, t is hidden from tools/list. With this PR it's still hidden. If Cedar evaluated a real Action::"read_tool", t would show up, because the broad permit matches and the call-only forbid doesn't. That's what #6924 proposes, so I've left a comment there. It needs a design for this case before it goes ahead. What I'd ask for here instead is that the Cedar authorizer returns false, nil for tool/read before it resolves claims. Today every tool on every tools/list goes through the full claim and upstream-token processing twice, and the first pass only exists to produce an error we throw away. With a plain deny, combineToolReadDecision and the "ignore the read error" logic can go away too.

The same combination affects the HTTP PDP (see the inline comment on docs/authz.md).

Comment thread pkg/authz/authorizers/tool_read.go Outdated
// tool:read check, so surfacing it would hide every tool for every Cedar user.
// The tool:call answer is authoritative: an allow grants visibility, and a
// callErr is returned so the caller fails closed.
func combineToolReadDecision(_ error, callAllowed bool, callErr error) (bool, error) {

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.

This function ignores its first argument and returns the other two unchanged, so the comment above it describes a decision it doesn't make. Can we drop it and return callAllowed, callErr directly? If Cedar returns a plain deny for tool/read (see the review body), there's no read error to explain anymore.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

@jhrozek Thanks. Here's what I'm doing:

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

@jhrozek thanks for the review. I squashed this to a single commit on top of current main, so the earlier inline comments show as outdated. Everything is addressed except the points marked below.

  • Cedar: tool:read is now a plain deny before claims are resolved, so call_tool policies decide visibility, as before. A tool:read error is returned as is, with no tool:call fallback, so combineToolReadDecision and the ignore-the-error logic are gone. The read_tool action design stays in Add a read_tool action so Cedar can grant tools/list visibility without invocation #6924.
  • Wrap once: the decorator is applied in Middleware (what CreateMiddlewareFromConfig builds, and the one place every caller goes through) and in newCedarAdmission. authorizeToolRead calls the authorizer directly. vMCP FilterTools now checks tool:read and AllowToolCall stays on tool:call, so Filter vMCP tools/list with tool:read, as the single-server path does #6925 isn't needed. I put it in Middleware instead of CreateMiddlewareFromConfig.
  • Audit: only discovery_fallback_count remains. The tool names and the flag are gone. This replaces my earlier reply that said the names would stay.
  • Deadline: tools/list runs under one 30s deadline on both paths, and a tool:read error or timeout skips the tool:call check.
  • Tests: one table with a wantMarked column, error-path tests, the nil and absent-marker tests dropped, and the rows that pass on main removed. The fakes are one per package and still hand-written, because test types can't be shared across packages. I can generate a shared mock for Authorizer as a follow-up if you'd prefer.

Also in the PR body: a PDP must answer mcp:tool:read, because an error from it hides the tool. @JAORMX the docs/authz.md section now describes current behavior only, and the upgrade example is in the PR body.

CI hasn't started on this PR since it comes from a fork. Could one of you approve the workflow run? That also runs the new e2e spec, which I trimmed after my last local run.

Comment thread pkg/authz/tool_filter.go Outdated
return a.AuthorizeWithJWTClaims(
// Wrap here, so every caller gets the tool:call implication, including callers
// that pass a raw authorizer. The wrap is cheap: the fallback runs only on denial.
return authorizers.WithToolCallImpliesRead(a).AuthorizeWithJWTClaims(

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.

Wrapping on every call means the decorator only ever sees tool:read, so the pass-through branch, the nil guard in WithToolCallImpliesRead and the exported ToolCallImpliesRead type never run in production. The issue asked for the decorator to sit in front of the authorizer. Could we wrap once where authorizers are built, in CreateMiddlewareFromConfig (pkg/authz/config.go:42) and newAdmission (pkg/vmcp/core/admission.go:99), and have authorizeToolRead call the authorizer directly the way the prompts and resources filters do?

That would also fix vMCP in this PR, so #6925 isn't needed as a follow-up. cedarAdmission.FilterTools still asks tool:call on a raw authorizer (admission.go:160). With the decorator applied in newAdmission, FilterTools can ask tool:read. AllowToolCall (admission.go:186) has to stay on tool:call, since it's the invocation check. The comments at admission.go:40, 44 and 172 also still refer to authorizeToolCall, which this PR renames.

Comment thread pkg/authz/authorizers/tool_read.go Outdated
// The marker is written and read by the single request goroutine, so no
// synchronization is needed.
type DiscoveryMarker struct {
ViaCallFallback bool

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.

ViaCallFallback is always the same as len(FallbackTools) > 0, and the discovery_fallback audit key is the same as discovery_fallback_count > 0. I'd also reconsider the tool names. Authz runs inside the tool filter middleware (pkg/runner/middleware.go, authz is appended after the tool filter), so these names are recorded before the allowlist and overrides are applied. With an allowlist of only weather, the client gets [weather] and the audit event says ["weather", "calculator"]. Under Cedar the list is also every visible tool on every call, so it doesn't add much. A tools/list event is already a discovery event because of its method. Could we keep just a flag (or a count) and drop the names?

Comment thread docs/authz.md Outdated
- `tools/list` shows only tools the user can call (based on `call_tool` policies); a result in which a tool repeats `name`, `annotations`, or the annotation `title` or a hint such as `readOnlyHint`, or spells one with a case-insensitive alias, fails closed with a generic internal JSON-RPC error, so policies evaluate exactly the hints the client receives.
- `tools/list` shows only tools the user can read. The read check is `tool:read`, and a `tool:call` grant also satisfies it, so a user who can call a tool can also see it. `tool:read` never grants `tool:call`.
- **Cedar:** Cedar has no `tool:read` action. Its read check returns an "unsupported" error, which is ignored, so the `tool:call` check decides visibility. In policies this is `call_tool`, so a tool is listed only when a `call_tool` policy allows it.
- **HTTP PDP:** the PDP receives `mcp:tool:read` as well as `mcp:tool:call`, and a tool is listed when it allows either one. Each listed tool costs up to two PDP decisions: a read check, then a call check when read is denied. A PDP that allows `mcp:tool:read` broadly lists every tool to every caller, so review read rules before deploying.

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.

Worth spelling out one more case here. A PDP that allows mcp:tool:read broadly and denies mcp:tool:call only for one tool will now list that tool, where before it was hidden. That's a behavior change for existing PDP rules, so it belongs in the upgrade notes and the PR body.

Also, the 2N PDP calls aren't covered by maxDerivedAuthzBudget, so a slow PDP makes tools/list up to twice as slow with no overall deadline. Could the authorizedToolIndexes loop run under a shared deadline, or at least skip the call fallback when the read check failed with a context or timeout error?

Comment thread docs/authz.md Outdated
- `tools/list` shows only tools the user can read. The read check is `tool:read`, and a `tool:call` grant also satisfies it, so a user who can call a tool can also see it. `tool:read` never grants `tool:call`.
- **Cedar:** Cedar has no `tool:read` action. Its read check returns an "unsupported" error, which is ignored, so the `tool:call` check decides visibility. In policies this is `call_tool`, so a tool is listed only when a `call_tool` policy allows it.
- **HTTP PDP:** the PDP receives `mcp:tool:read` as well as `mcp:tool:call`, and a tool is listed when it allows either one. Each listed tool costs up to two PDP decisions: a read check, then a call check when read is denied. A PDP that allows `mcp:tool:read` broadly lists every tool to every caller, so review read rules before deploying.
- **vMCP:** filtering uses `tool:call` only, so a `tool:read`-only grant has no effect there.

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.

This is only true for the stateless vMCP core. Legacy vMCP goes through CreateMiddlewareFromConfig and the response filter, so it gets the decorator. If the decorator moves to construction (see tool_filter.go comment), both paths behave the same and this bullet can go.

Comment thread docs/authz.md Outdated
always resolved to the first upstream, so the mirror and the pinned provider's
assertion cannot diverge there.

#### Upgrading the Cedar DEBUG log values

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.

This section and the INFO-line sentence under "Tokens with no upstream login" are about Cedar claim-source logging, not #4048. Could they go in a separate PR? Two things to fix when they do: the removed paragraph mentioned that the session-less path now logs request:no-upstream-session, which the new mapping drops, and the quoted INFO message is only the start of the real text in cedar/core.go:681, so an exact-match parser would miss it.

Comment thread pkg/authz/tool_filter_fallback_test.go Outdated
want []int
}{
{
name: "tool:call alone makes every tool visible",

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.

The "tool:call alone" and "tool:read error" rows pass on main too, since main asks tool:call. Only "tool:read alone" at :72 fails if the change is reverted. Could that row go into the existing TestAuthorizedToolIndexes table and this file be dropped? Same for TestFindToolResponseFilter_FallsBackToToolCall, which goes through the same authorizedToolIndexes path.

Comment thread pkg/authz/authorizers/tool_read_test.go Outdated
assert.Nil(t, WithToolCallImpliesRead(nil))
}

func TestToolCallImpliesRead_DiscoveryMarker(t *testing.T) {

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.

This table repeats the inputs from TestToolCallImpliesRead at :32. A wantMarked column on the first table would cover it. TestWithToolCallImpliesRead_NilPassesThrough (:112) tests a branch production never reaches, and TestDiscoveryMarkerFromContext_Absent (:184) only tests a context lookup. What's missing is the error paths: opsAuthorizer can't return errors, so "read errors, call allows" and "call errors" aren't covered at unit level. There are also now three hand-written fakes for the same interface (opsAuthorizer, opFilterAuthorizer, mockAuthorizer). One mock, generated or shared, would do.

for _, tool := range result.Tools {
names = append(names, tool.Name)
}
Expect(names).To(ContainElement("query_vulnerability"),

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.

This assertion passes on main as well. The reads > 0 check further down is the one that proves the change. The CLI It goes through the same tools/list path, so I don't think it adds coverage.

@rkb32
rkb32 requested a review from jerm-dro as a code owner October 23, 2026 14:54
tools/list filtered tools by tool:call. It now filters by tool:read, and a
tool:call grant also satisfies the read check through a decorator around the
Authorizer (pkg/authz/authorizers/tool_read.go). The implication is one way:
tool:read never grants tool:call, and there is no new ingress verb.

The decorator is applied once, where the authorization middleware is built
(Middleware) and in the vMCP admission. vMCP FilterTools now checks tool:read;
AllowToolCall still checks tool:call. Cedar denies tool:read without
evaluating claims, so call_tool policies decide visibility, as before.

tools/list runs under one shared deadline. A tool:read error, including a
timeout, is returned without a tool:call check, so a slow PDP cannot double
the wait. A tools/list entry listed only through the tool:call fallback is
counted in the audit event as discovery_fallback_count.

HTTP PDP rules that allow mcp:tool:read now list a tool even when the matching
mcp:tool:call rule denies it. This is documented in docs/authz.md.

Closes stacklok#4048

Signed-off-by: rkb32 <177729309+rkb32@users.noreply.github.com>
@rkb32
rkb32 force-pushed the fix/4048-tool-call-implies-tool-read branch from a60a7c1 to 0e44ae5 Compare October 23, 2026 15:59
@rkb32
rkb32 requested review from JAORMX and jhrozek October 23, 2026 16:06

This branch has not been deployed

No deployments
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.

Distinguish tool discovery from tool invocation in authorization and audit

3 participants