Skip to content

Check agent scopes on content and the calls-per-period cap - #2512

Merged
pournasserian merged 5 commits into
v2from
2507-agent-scopes
Oct 18, 2026
Merged

pournasserian merged 5 commits into
v2from
2507-agent-scopes

Conversation

@pournasserian

Copy link
Copy Markdown
Member

Closes #2507

What

The scope checks on content and the calls-per-period cap, both failing closed (MCP-05, SEC-04). They build on IAgentPermissions from #2503 and on the tool call log from #2490.

  • Core (IAgentPermissions, AgentPermissions):
    • CheckContentAsync(type, operation, fields?) checks the operation on the content type and, for a write, the fields it changes.
    • CheckDefinitionAsync(type, operation) checks a type's definition, which also needs a scope that does not narrow the type to some of its fields.
    • ReadableTypesAsync filters lists.
    • CheckToolCallAsync applies the calls cap.
    • A scope refusal fails with agent_scope_denied and names the part of the scope that refused: the operation, with what the scope allows; the type, with the types in scope; or the fields, with the fields allowed.
    • People pass everything, and a refused credential is refused as in Refuse agents' review moves and enforce change set caps #2503.
  • Reads need read on the type, including:
    • a content type and its schema document
    • an entry, its versions and its history
    • a resolved view
    • where an entry or an entity field is used
  • Lists leave out types the agent may not read: the content type list, the site's schema document, deleted entries, and where-used answers.
  • Entry writes:
    • Each write needs its operation on the type. Create sets fields, update changes them, and delete and restore both need delete.
    • Every field the write changes must be allowed. A create names the fields it sets. An update names those whose values differ from its base version, compared as JSON with ValueDiff.Compare.
  • Definitions: creating or revising a content type, and a change set's schema operation, need the operation on the type through CheckDefinitionAsync.
  • Calls per period (ToolCallRecording):
    • Before a tool runs, the filter asks CheckToolCallAsync, which counts the credential's calls in its period through IAgentActivity.ToolCallsSinceAsync.
    • Past the cap, the tool does not run. The agent gets an error result that names the cap, its value and the period. agent.calls_per_period_exceeded is appended (SEC-08), and the call is recorded with the new outcome Refused.
  • No migration: outcomes are stored as names, so Refused needs no schema change.
  • CONTRIBUTING.md: how scopes and the calls cap apply.

Why

MCP-05 gives each credential a scope of types, fields, operations and sites. SEC-04 says a call past a cap fails closed. ADR-0007 puts both in Core, so tools and forms cannot diverge. The epic's acceptance needs an agent that creates types, entries and entities within its scope and nothing outside it.

Choices worth a look:

  • Reads take whole entries: the field list narrows writes only. Narrowing reads would mean redacting stored documents and resolved views, and the glossary's "may touch" reads as writing. Fact values a resolved view inlines from another type belong to that view, as CNT-04 intends.
  • Writes need read too: an entry write is checked against the definition it reads through IContentTypeDirectory, so read on the type is part of writing. The refusal says so.
  • Definitions are whole: a definition covers every field, so a credential narrowed to product.price cannot create, revise or propose the product definition, but it can for types its field list leaves whole.
  • Restore takes delete, since it undoes a delete.
  • Lists filter rather than fail, because a list has no single type to name, and an agent sees only what it may read. A direct read of a type outside the scope fails and names the type, so the agent learns why.
  • Refused calls do not count: otherwise an agent retrying at its cap would keep itself there. "Per period" is a sliding window, like the change set cap in Refuse agents' review moves and enforce change set caps #2503. The count is made before the call and records are written after it, so concurrent calls can exceed the cap by at most the number in flight.
  • A refused tool call is a tool error result, not a protocol error, so the model reads the message, as UX-03 asks.
  • Not in this PR: LLM spend per period (needs the model gateway, F-CHK F-CHK: Check engine with deterministic and LLM-judged checks #2420), which change sets an agent may read, and usage views (ADM-03).

Tests

Integration, each on SQLite and PostgreSQL:

  • An agent scoped to category is refused 11 reads and writes of product and its entries, each with agent_scope_denied naming the type and the types in scope.
    • The refused calls are: the type, its schema document, entry list, get, version, history, resolved view, where-used, create, update and delete.
    • Nothing is written, category works, and the type list, the site schema document and deleted entries leave product out.
  • An agent with read alone is refused create, update, delete, restore, type create and type revise, each naming the operation, and nothing is written. An agent with create alone sees no types and is told a write needs read. With every operation all of these succeed.
  • An agent limited to product.price:
    • changes the price while sending the name back unchanged
    • creates a product with only a price, writes category in full, and revises the category definition
    • is refused, with the allowed field named, a rename, a dropped name, and a create that sets the name
    • is refused revising the product definition and proposing it in a change set
    • The refused writes leave no entry, version or event behind. The only new event is the change set it opened.
  • Over HTTP with a cap of two calls an hour:
    • The third and fourth calls are refused with a message naming the cap. Each appends agent.calls_per_period_exceeded for the credential and is recorded as Refused.
    • An hour after the first call, one more call runs, because refused calls do not count, and the next is refused again.

The #2503 permission tests, the change set and entry tests, and the tool call tests pass unchanged. dotnet build -warnaserror, the full suite against a postgres:17 container as well as SQLite, and markdownlint pass locally. No migration, no new package and no new project reference.

Checklist

  • Linked to an issue a maintainer has confirmed
  • Targets v2
  • Tests or docs updated where behaviour changed
  • No tool-generated co-author trailers or footers

@pournasserian pournasserian added this to the M1 Governance core milestone Oct 18, 2026
@pournasserian pournasserian added type:feature New capability v2 FluentCMS v2 (v2 branch) area:mcp labels Oct 18, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

type:feature New capability v2 FluentCMS v2 (v2 branch)

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Agent scopes on content and the calls-per-period cap, failing closed

1 participant