Skip to content

ToolAccessGrants: server-aware grants + missing tests + LLM-readable response#304

Merged
odk- merged 3 commits into
fix/280-union-allow-always-grantsfrom
fix/280-followups
May 13, 2026
Merged

ToolAccessGrants: server-aware grants + missing tests + LLM-readable response#304
odk- merged 3 commits into
fix/280-union-allow-always-grantsfrom
fix/280-followups

Conversation

@odk-
Copy link
Copy Markdown
Contributor

@odk- odk- commented May 13, 2026

Summary

Stacked on #302. Three follow-ups identified during that PR's review:

  1. Test gap — bypass-skip non-invocation, grant-read-failure warn-and-continue, and the hasNameAllowlist === false shape (action without a tools: array).
  2. allow_once vs allow_always is now distinguishable to the LLMpersistent is always present in the response envelope; grantType lands in the operator log; SKILL.md teaches the LLM to read it.
  3. Server-aware grants + eager load — closes the "stale-grant for a server the action doesn't declare" gap. ToolAccessGrantSchema gains serverId?; new grants record it; buildTools eagerly loads grant-referenced workspace MCP servers before createMCPTools; the union now does serverId-aware attribution.

Commits

  • 879113efsm-engine: cover bypass-skip + grant-read-failure + no-narrowing test branches
  • fa97fc8request_tool_access: distinguish allow_once vs allow_always for LLM + logs
  • 965c859ToolAccessGrants: record source serverId + eagerly load referenced MCP servers

Each is self-contained; can be cherry-picked independently if the stack splits.

Compat

  • Schema change is additive. Existing grants deserialize cleanly; read-path infers serverId from a qualified toolName when feasible.
  • hasGrant key derivation unchanged — lookups by the original LLM-facing string keep resolving both legacy and new grants.
  • request_tool_access response gains persistent: boolean on every "answered / bypass / persistent_allow" shape. Previously present only when true; now first-class. Existing toEqual({...}) tests updated; no production caller depends on the field's absence.

Test plan

  • deno task typecheck — 0 errors
  • deno task test — 6495 / 6514 pass, no regressions
  • deno run -A npm:@biomejs/biome check — 0 errors
  • Storage tests cover serverId derivation + explicit-override + ListedGrant projection
  • fsm-engine tests cover: bypass-skip non-invocation, grant-read-failure warn-and-continue, no-tools: shape, serverId-aware widening, serverId-mismatch defense, eager-load happy path, eager-load skipped for stale grant

Known limitations now resolved

The "strictly-qualified action + grant for a different server's tool" gap called out in #302's PR description is closed: the action no longer needs to pull in the granted-tool's server via a bare-name declaration. Grants whose source server has been dropped from the workspace config are silently skipped to avoid surfacing a confusing MCP error — the action still loads its declared servers normally.

Base

Targets fix/280-union-allow-always-grants so the diff is just the follow-up delta. Once #302 lands, rebase onto main and re-target there.

odk- added 3 commits May 13, 2026 16:25
…t branches

Adds three test cases to grant-union.test.ts that lock the contract:

- Bypass mode skips the grant query entirely (spy assertion on
  listForWorkspace, mirroring the "redundant under bypass" claim from
  the originating PR).
- Grant store read failure → warn-and-continue with un-widened toolset
  (no crash, no escalation). KV down / deserialize-edge-case path.
- Action without a `tools:` array (hasNameAllowlist === false) → union
  is a no-op because every loaded tool already flowed through. Most
  common ad-hoc LLM action shape; previously untested.

Refactors the mock for listForWorkspace from a closure-bound function
into a vi.fn() so call-count assertions are possible. RunOptions grows
two parameters (`bypass`, full `grants` envelope) without changing the
existing happy-path test shapes.
… logs

After #302 the two grant flavors have meaningfully different runtime
semantics: `allow_always` widens future actions in the workspace via the
grant union; `allow_once` is an approval signal only. The tool surface
collapsed both into the same `granted: true` shape, leaving the LLM
without a programmatic way to tell "this will keep working" from "this
is a one-shot".

- Always include `persistent: boolean` in answered / bypass / persistent
  responses. Previously present only when true; now first-class on every
  shape so the LLM branches on a single field instead of parsing `answer`.
- Add `grantType` to the `info` log lines (`allow_once` / `allow_always`
  / `bypass` / `persistent_allow` / `deny`) so operators reading
  `~/.atlas/logs/global.log` can audit choices without joining against
  the elicitation store.
- Mirror both changes in the chat-side parallel
  (`packages/system/agents/workspace-chat/tools/request-tool-access.ts`).
- SKILL.md gains a "Reading the response" subsection that teaches the
  LLM to branch on `persistent` and explains the `grantType` log field.
- Tool descriptions surface the `persistent` semantics in the prompt
  so the LLM sees the new behavior at registration time.

Existing exact-match `toEqual` tests updated; no behavioural regression.
…P servers

#302 left a known limitation: a grant for `gmail/send_email` only worked
when the action that benefits from it happened to load gmail anyway —
typically because it declared a bare tool name (forcing all workspace
MCP servers to load). For strictly-qualified actions like
`tools: [google-calendar/list_events]`, the gmail server stays unloaded
and the grant union finds nothing to widen.

This closes the gap end-to-end:

- `ToolAccessGrantSchema` gains an optional `serverId` field. Additive
  schema change — legacy grants keep deserializing; the read path
  infers serverId from a qualified `toolName` when feasible.
- `grantAlways` derives `serverId` from the LLM-facing tool name when
  the caller doesn't pass one explicitly. KV key derivation is
  unchanged (still based on `toolName`) so `hasGrant` lookups by the
  original LLM-facing string keep working for both old and new grants.
- `listForWorkspace` returns `ListedGrant[]` with `{ toolName,
  bareToolName, serverId? }`. `bareToolName` is what `mcpResult.tools`
  is keyed by; `serverId` is the source MCP server when known.
- `fsm-engine.buildTools` hoists the grant fetch above the
  `effectiveConfigs` choice. Any grant-referenced server that the
  workspace still declares is eagerly added to the MCP load. Stale
  grants (server dropped from workspace config) are silently skipped to
  avoid surfacing a confusing MCP error.
- The union step now does serverId-aware attribution: a grant with
  `serverId: "gmail"` only widens when `mcpResult.toolsByServer.gmail`
  actually contains the bare tool name. Defense in depth against
  cross-server bare-name collisions. Legacy bare-name grants fall
  through to the existing bareToolName-in-filtered check.
- `request_tool_access` (MCP-server side) passes the parsed serverId
  through to `grantAlways` so new grants land with the field set.

Two new fsm-engine tests cover the eager-load happy path
(grant-referenced server is loaded; the granted tool reaches the LLM)
and the stale-grant defensive path (server not in workspace config →
silently skipped). The earlier `serverId mismatch must not widen` test
covers the cross-server attribution check.
@odk- odk- merged commit 965c859 into fix/280-union-allow-always-grants May 13, 2026
5 checks passed
@odk- odk- deleted the fix/280-followups branch May 13, 2026 14:43
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.

1 participant