Skip to content

fix(mcp): read a scoped overlay teammate's own grant, and bound add_agent by its minter - #745

Merged
oxoxDev merged 4 commits into
tinyhumansai:mainfrom
M3gA-Mind:fix/661-mcp-overlay-reach
Aug 12, 2026
Merged

fix(mcp): read a scoped overlay teammate's own grant, and bound add_agent by its minter#745
oxoxDev merged 4 commits into
tinyhumansai:mainfrom
M3gA-Mind:fix/661-mcp-overlay-reach

Conversation

@M3gA-Mind

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

Copy link
Copy Markdown
Collaborator

Summary

Closes #740. Completes the remaining half of #619referenced deliberately without a closing keyword; see Issue linkage below.

This builds on #661 (PR #713), which landed the foundation: OverlayAgent.tools, the overlay_fingerprint hash and its test, the harness carry through overlay_agent_to_manifest, the team-detail read, and scope-at-creation via POST …/team and add_agent. None of that is re-done here.

Three things that foundation left open:

1. The third reader — a live defect on main (#740)

roster_grants in src/server/ops/mcp.rs computes every MCP server row's reachableBy. #661 taught two of the three readers to honour the new field; this one still passed an empty grant:

// :283 — the manifest half, correct
agent_effective_grants(allow, &agent.tools)
// :299 — the overlay half, stale
agent_effective_grants(allow, &[])

So a teammate scoped to one server reported as reaching every enabled server — the console asserting a connection the harness does not grant, which is exactly the divergence #264 exists to prevent and which #661 was careful about in the other two readers.

The comment that stood there ("no manifest tools row → the company's standard grant") was true before #661 and read as a deliberate decision rather than an assumption that had expired — which is the likeliest reason it was missed.

Read-only surface, so nothing was over-granted: the harness builds the agent correctly. The defect was that the operator-facing answer disagreed with it. It also silently degrades a second signal — reachableBy is what flags an enabled, healthy server no teammate can reach as a probable misconfiguration, and with every overlay teammate reporting universal reach that check can no longer fire for a company whose teammates are all overlay-defined.

2. add_agent was bounded by the company, not by the minter

#661 clamps an explicit tools argument to the company grant, and its own comment says so: "this can only narrow the new teammate below the company grant." That leaves #619's original defect intact — omitting tools still yields the company's entire grant, so an agent scoped to a corner of the company can mint a teammate holding everything the company holds. add_agent is Reach::Nothing and sits in INTRINSIC_TOOLS, so it is always present and never asks; nothing else in the path would catch it.

The ceiling is now the minter:

  • Omitting tools copies the minter's own line. Copying the line rather than its resolved grant is deliberate — an unscoped minter still mints an unscoped teammate that keeps tracking [tools].allow, instead of freezing today's allow-list into the record as an explicit scope a later company-wide narrowing would not reach.
  • An explicit list is narrowed against the minter's effective grant.
  • A list that narrows to nothing is a clean tool error, not a stored empty list. This is the sharp edge: an empty list means inherit everything, so storing the empty result of a narrowing would turn the single most deliberate narrowing an agent can ask for into the widest grant in the company. That inversion is the whole defect approvals: an overlay teammate cannot be scoped, so it silently holds the company's widest grant #619 was filed about, and it must not be reachable through the fix for it.

3. The mint is now observable

Every mint logs the minter, the teammate and the resolved scope, naming inheritance explicitly when the grant is inherited. This was the condition attached to sanctioning the narrowing at all: the tool never asks, so the log is the only place the decision is visible, and a narrowing nobody can observe is the defect repeated one layer down.

4. A teammate can be narrowed after it exists

#661 made the scope writable at creation. Correcting one afterwards had no route, so it meant deleting and recreating the teammate — which orphans its workspace folder, budget row, desk memberships and inbox.

PATCH …/team/{agent_id} now accepts tools, and editable names it. An omitted key leaves the scope alone; [] is the deliberate way back to the standard grant, so null never has to mean a third thing. A blank glob is a 400"" matches nothing an operator meant and would read as a scope that grants nothing while looking like a scope that was set. Globs are stored verbatim, exactly like a manifest line, so the allow ceiling applies at read time and an uncovered glob surfaces as asked-for-but-not-granted rather than vanishing on save. A manifest teammate stays a 409.

5. Why tools is the one admin-only field on that route

Found in review by @oxoxDev, and it was the same inversion this PR exists to close — reintroduced through the path added to fix it.

edit_agent is open to any signed-in member, deliberately: defining a teammate was never admin-only, so correcting one is not either. That reasoning covers what a teammate is. It does not cover what a teammate may do.

The sharp edge is that an empty tools list means "the company's standard grant" — the widest grant the company has. So {"tools": []} is not a small edit, it is the widest possible widening, and left member-open any signed-in member could hand a deliberately-scoped teammate the whole company grant back with one call.

The honest description of the defect: the invariant existed in my head and was enforced in only one of the two places I wrote it. add_agent already refuses its own version — a narrowing that lands empty is a hard error there, precisely because an empty list inherits everything — and edit_agent, one file away, applied tools with no check at all.

The check is therefore conditional on the field being present, in the same shape and for the same reason as the budget cap on add_member: a member editing a name or a role keeps working exactly as before, because adding a field must not quietly take an existing capability away. Matching that existing pattern is deliberate, so asymmetric authority on one field reads as an established shape rather than a special case.

Two other designs were considered and rejected. Both are recorded here so a later "simplification" does not reintroduce them:

  • Reuse add_agent's guard directly. The most attractive option, since two enforcement sites are exactly how this gap appeared. It does not transfer, for a reason sharper than the layering: the two empties mean different things. In add_agent an empty set is the accidental residue of an intersection, which is why it must be refused. In edit_agent, [] is a deliberate, documented request for the standard grant. Collapsing them would either forbid a legitimate operation or introduce a rule that reads as arbitrary. What genuinely transfers is the principle — an empty list is a widening — and the authority check is where that principle belongs. (add_agent also clamps to the minting agent's grant; edit_agent has no minting agent, the actor is a person.)
  • Narrow-only for members (allow tools when the new set is a subset of the current effective grant). This makes the scope a one-way ratchet: a teammate scoped too tightly could never be loosened by anyone, and the only route back is delete-and-recreate — which orphans the workspace folder, budget row, desk memberships and inbox that this very route exists to preserve. A concrete harm, not a preference.

editable is actor-dependent for the same reason (a gap not raised in review): it is the host stating the rule so the console does not re-derive it, so advertising tools to a member whose save is a 403 is exactly the drift that list exists to remove. It resolves authority through the same may_administer predicate as the enforcement path, so the two cannot disagree.

API Or Behavior Changes

  • reachableBy now reflects a scoped overlay teammate's real grant instead of always reporting the company's. Bug fix; an unscoped teammate is unchanged.
  • add_agent's default scope is the minter's line, not the company's. For an unscoped minter — the overwhelmingly common case, and every company before workflows: authoring & validation gaps let broken graphs (incl. shipped seeds) save clean and fail silently #661 — behaviour is identical. It changes only for a scoped minter, which is the fix. A tools request outside the minter's grant is now narrowed, and one entirely outside it is a tool error.
  • PATCH …/team/{agentId} accepts toolsadmin-only, conditionally: an edit carrying tools requires admin, one carrying only name/role/description stays open to any member exactly as before. editable names "tools" for an admin and omits it for a member. Manifest teammates still 409.
  • No console change and no i18n: the existing summarizeGrants().standardGrant rendering already distinguishes inherited from explicit, and now tells the truth for overlay teammates on every surface. Zero frontend files changed.

Tests

Ten tests added or changed.

Rebased onto green main (d1d01ae5), so nothing below is inherited:

cargo fmt --all -- --check                                                                     → 0
cargo check  --locked --all-features --all-targets                                             → 0
cargo clippy --locked --no-deps --features openhuman,tinycortex --all-targets -- -D warnings   → 0
cargo test   --features openhuman,tinycortex   → 3336 + 10 + 1 + 11 + 2 = 3360 passed, 0 failed, 3 ignored

Every new test was revert-checked — its own fix removed, the named test re-run, confirmed to fail. Counts quoted so a run that silently executed nothing cannot pass for a pass:

revert test result
mcp reads &[] again a_scoped_overlay_teammate_does_not_read_back_as_reaching_everything FAILED — 0 passed; 1 failed
add_agent defaults to Vec::new() a_minted_teammate_is_bounded_by_its_minter_not_the_company FAILED — 0 passed; 1 failed
explicit scope clamped to ["*"] an_explicit_scope_is_narrowed_to_what_the_minter_holds FAILED — 0 passed; 1 failed
empty-narrowing refusal removed a_scope_entirely_outside_the_minters_grant_is_refused FAILED — 0 passed; 1 failed
OVERLAY_EDITABLE back to 3 the_host_states_which_fields_are_editable FAILED — 0 passed; 1 failed
PATCH apply removed an_overlay_teammate_can_be_scoped_after_creation FAILED — 0 passed; 1 failed
trimmed_globs never refuses a_blank_tool_glob_is_refused FAILED — 0 passed; 1 failed
require_admin guard removed a_member_cannot_widen_a_teammates_scope FAILED — 0 passed; 1 failed
editable made actor-independent editable_names_tools_only_for_an_admin FAILED — 0 passed; 1 failed
guard made unconditional a_member_may_still_edit_a_teammates_name_and_role FAILED — 0 passed; 1 failed

The last row is deliberate: it pins the conditionality in both directions, so over-correcting into a blanket admin check — silently taking the name/role edit away from members — fails too. The widening test drives the route with two accounts; the harness signs every request in as an admin, so a check verified only as an admin passes identically against no check at all.

Not claimed as covered: the tracing::info! observability has no revert-check. Nothing in the suite asserts tracing output, so I cannot prove that line fails when removed, and it is not in the table above.

One of #661's own tests needed a fixture fix, and it is worth saying why rather than leaving it to look like a weakened assertion. add_agent_tool_persists_a_tool_grant began failing under the new minter ceiling because my test helper modelled an "unscoped" minter with an arbitrary narrow grant (["fs:*", "web:*"]) — which is incoherent: an unscoped minter's effective grant is whatever the company allows, not some third list. The helper now passes ["*"], which grant_matches short-circuits to true, so an unscoped minter has a genuinely open ceiling and a test about other behaviour is not accidentally a test about the #619 clamp.

That distinction matters and would not be obvious on review: the fix is not "loosen the fixture until it passes", it is "model the thing the fixture claims to model". No assertion of #661's was weakened — and the two control tests (add_agent_tool_empty_tools_is_the_standard_grant, an_unscoped_minter_mints_an_unscoped_teammate) pin that the common path is unchanged.

  • cargo fmt --all -- --check
  • cargo clippy --all-targets -- -D warnings — run as cargo clippy --locked --no-deps --features openhuman,tinycortex --all-targets -- -D warnings; --no-deps keeps it off pre-existing vendor/tinyagents lints CI never sees
  • cargo build --all-targets — N/A — covered by cargo check --locked --all-features --all-targets, which is strictly wider (it compiles the mongodb/sqlite row-built CompanyRecord paths the default feature set never reaches). Deliberately not run as a separate full build: the machine is at 31Gi free and a redundant build is ~15G of target/.
  • cargo test — run as cargo test --features openhuman,tinycortex

Documentation

No doc change. docs/spec/runtime/api.md's team section was updated by #661 to describe the field, the empty-means-inherit rule and scope-at-creation, and all of that remains accurate. This PR adds no new concept to document — it makes an existing documented rule true on a third reader, tightens a ceiling the docs never claimed was the company's, and adds a PATCH field to a route whose patch semantics are already described.

Issue linkage

Supersedes #717

#717 was the original #619 build, opened before #661 merged. Roughly 60% of it is now duplicate. It is being closed rather than rebased: honest conflict resolution would mean taking main's side on most of fifteen files, which is hand-deleting the PR. What carried over is exactly the four items above; what was dropped is the field, the fingerprint and its test, the harness carry, and the detail read — all now on main via #713.

Summary by CodeRabbit

  • New Features

    • Administrators can manage teammate tool access, with editable fields tailored to each person’s permissions.
    • Newly created teammates inherit appropriate tool access by default, while explicitly requested access is limited to what the creator can grant.
    • Agent details now display relevant tool-scope information.
  • Bug Fixes

    • Tool access is now enforced consistently when connecting to available services.
    • Invalid, blank, duplicate, or overly broad tool permissions are rejected or cleaned up automatically.
    • Restricted teammates can no longer modify settings beyond their authorization.

@coderabbitai

coderabbitai Bot commented Aug 12, 2026

Copy link
Copy Markdown

Review Change Stack

Caution

Review failed

The pull request is closed.

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: 4415405a-3d1f-41b3-af8e-63b4eca59c1b

📥 Commits

Reviewing files that changed from the base of the PR and between 3fa3338 and 18fd2b1.

📒 Files selected for processing (5)
  • src/harness/build.rs
  • src/harness/mod.rs
  • src/harness/orchestrator.rs
  • src/server/ops/mcp.rs
  • src/server/ops/team_agent.rs

📝 Walkthrough

Walkthrough

Agent creation and editing now enforce tool scopes from minter grants and actor permissions. MCP reachability uses each overlay agent’s configured scope, with company-level inheritance for empty scopes.

Changes

Agent scope propagation

Layer / File(s) Summary
Scoped teammate creation
src/harness/orchestrator.rs, src/harness/build.rs, src/harness/mod.rs
AddAgentTool inherits or narrows scopes against the minter’s grants, rejects empty results before persistence, and reports the resulting scope. Tests cover scoped and unscoped minting.
Actor-aware tool-scope editing
src/server/ops/team_agent.rs
Overlay-agent tool scopes are editable according to actor authority. Submitted scopes are normalized, validated, persisted, and reflected in detail responses.
Scoped MCP reachability
src/server/ops/mcp.rs
Overlay-agent MCP grants use configured tools. Empty scopes continue to inherit company grants. Tests cover both cases.

Estimated code review effort: 4 (Complex) | ~45 minutes

Possibly related issues

Possibly related PRs

Suggested reviewers: oxoxdev

Poem

A rabbit grants tools with a careful paw,
Narrow scopes follow each minter’s law.
MCP paths bloom where permissions agree,
Blank scopes inherit the company tree.
“Hop!” says the rabbit—“the grants now align!” 🐇


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.

@M3gA-Mind

Copy link
Copy Markdown
Collaborator Author

@coderabbitai review

@coderabbitai

coderabbitai Bot commented Aug 12, 2026

Copy link
Copy Markdown
⚠️ Action not completed

Review rate limited.

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.

@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 split from #717 is the right call — the field and the fingerprint landed in 1171afcc, and this carries the reader half. Both things I checked on the earlier version survive it intact:

  • roster_grants's overlay half is fixed. It was agent_effective_grants(allow, &[]) — hard-coding every overlay teammate to the full company grant, so a teammate scoped away from mcp:* still showed as reaching every enabled server. Narrowing each by its own line makes console reachability equal what the agent is actually granted.
  • The narrow-to-empty guard in add_agent is intact, with its actionable error naming what the minter holds. That guard is the reason for everything below.

1 major. Requesting changes.

Major — PATCH …/team/{agent_id} lets any member widen a teammate's scope

edit_agent takes ScopedCompany and says so deliberately:

Open to any signed-in member, matching POST …/team: defining a teammate was never admin-only, so correcting one it defined is not either.

That reasoning is sound for name, role and description. It does not transfer to tools, and the handler applies it with no field-level check:

if let Some(tools) = tools {
    agent.tools = tools;
}

An empty tools list means inherit the company's standard grant — that is the semantics this feature is built on, and it is exactly why add_agent treats a narrowing that lands empty as a hard error rather than storing it. So a member can PATCH {"tools": []} onto a teammate an admin deliberately scoped to ["file_read"] and restore the full company [tools].allow.

It is bounded — [tools].allow is the ceiling and this route does not touch it — so this is not escalation past what the company granted. But it defeats an admin's deliberate narrowing, and that narrowing is the capability #619/#661 shipped. A scope any member can remove is not a scope.

The asymmetry is the clearest way to see it: an agent minting a teammate is bounded by its own effective grant and refused if the result is empty; a human member editing the same field over HTTP is bounded by nothing and can set empty as the documented way to widen. The tighter rule is on the automated path.

Two fixes, either fine:

  • Gate the field, not the route. Keep edit_agent member-open for the cosmetic fields and require admin when tools is present — the same shape POST …/team uses for budgets, which the doc already notes stays admin-only "on its own route".
  • Refuse [] here. Make widening its own explicit verb rather than a value, so the route cannot express "restore everything" by accident, and narrowing stays member-safe.

I would take the first: it keeps one route and puts the check where the privilege actually is.

Worth noting this is the same extractor class #729 just fixed — a bare ScopedCompany on a handler whose payload turned out to be an authority question. That one was a read; this is a write.

Also worth confirming

OVERLAY_EDITABLE now lists tools beside name / role / description. Once the gate above exists, the editable array a client reads should reflect who may actually edit what, or the console will offer a control the host will refuse.

`roster_grants` computes every MCP server row's `reachableBy`. tinyhumansai#661 gave
`OverlayAgent` a `tools` list and taught two of the three readers to
honour it; this one still passed an empty grant, so a teammate scoped to
one server reported as reaching every enabled server — the console
asserting a connection the harness does not grant, which is the exact
divergence tinyhumansai#264 exists to prevent. Issue tinyhumansai#740.

The comment that stood here ("no manifest tools row → the company's
standard grant") was true before tinyhumansai#661 and read as a deliberate decision
rather than an assumption that had expired.

Read-only surface, so nothing was over-granted: the harness builds the
agent correctly from `overlay_agent_to_manifest`. The defect was that
the operator-facing answer disagreed with it — and that an enabled,
healthy server no teammate can reach could no longer be flagged as a
misconfiguration for a company whose teammates are all overlay-defined.
tinyhumansai#661 gave `add_agent` a `tools` argument clamped to the company grant.
That leaves tinyhumansai#619's original defect intact: omitting `tools` still yields
the company's whole grant, so an agent scoped to a corner of the company
can mint a teammate holding everything the company holds. `add_agent` is
`Reach::Nothing` and sits in `INTRINSIC_TOOLS`, so it is always present
and never asks — nothing else in the path would catch it.

The ceiling is now the minter, not the company. Omitting `tools` copies
the minter's own line, so an unscoped minter still mints an unscoped
teammate that keeps tracking `[tools].allow` rather than freezing a copy
of it. An explicit list is narrowed against the minter's effective
grant, and a list that narrows to nothing is a clean tool error rather
than a stored empty list — an empty list means inherit everything, so
storing one would turn the most deliberate narrowing an agent can ask
for into the widest grant in the company.

Every mint is logged with the minter, the teammate and the resolved
scope. That was the condition attached to sanctioning the narrowing:
the tool never asks, so the log is the only place the decision is
visible, and a narrowing nobody can observe is the defect repeated.
tinyhumansai#661 made the scope writable at creation — `POST …/team` and
`add_agent`. Narrowing a teammate that already exists had no route, so
correcting a grant meant deleting and recreating the teammate, which
orphans its workspace folder, budget row, desk memberships and inbox.

`PATCH …/team/{agent_id}` now accepts `tools`, and `editable` names it.
An omitted key leaves the scope alone; `[]` is the deliberate way back
to the company's standard grant, so `null` never has to mean a third
thing. A blank glob is a 400 — `""` matches nothing an operator meant
and would read as a scope that grants nothing while looking like a scope
that was set.

Globs are stored verbatim, exactly like a manifest line: the
`[tools].allow` ceiling is applied at read time, so a glob the company
does not cover surfaces as asked-for-but-not-granted rather than
vanishing on save. A manifest teammate stays a 409 — its line lives in
the version-controlled `company.toml`.

@tinysweeper tinysweeper Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

tinysweeper found nothing blocking. Approving.

             $0.0748 · 68,341 in / 23,311 out · 51,910 cached (76%) · z-ai/glm-5.2
critique:    $0.0317 · 19,367 in / 11,217 out · 15,358 cached (79%) · z-ai/glm-5.2
security:    $0.0167 · 19,262 in / 4,887 out  · 15,290 cached (79%) · z-ai/glm-5.2
tests:       $0.0147 · 13,441 in / 4,455 out  · 9,799 cached (73%)  · z-ai/glm-5.2
description: $0.0117 · 16,271 in / 2,752 out  · 11,463 cached (70%) · z-ai/glm-5.2

@tinysweeper

tinysweeper Bot commented Aug 12, 2026

Copy link
Copy Markdown

What this change touches

5 files, +792 -43 across 2 components. It reaches 6 untouched components (60 graph nodes walked). 4 further components left out to keep the diagram readable.

flowchart LR
  n0["src/server/ops<br/>2 files +505 -19"]:::changed
  n1["src/harness<br/>3 files +287 -24"]:::changed
  n2["src/company<br/>3 files reached"]:::impacted
  n3["src/harness<br/>3 files reached"]:::impacted
  n4["src/ports<br/>3 files reached"]:::impacted
  n5["src<br/>2 files reached"]:::impacted
  n6["src/runtime<br/>2 files reached"]:::impacted
  n7["frontend/src/views<br/>1 file reached"]:::impacted
  n3 -->|26 refs| n4
  n0 -->|19 refs| n2
  n3 -->|15 refs| n2
  n3 -->|10 refs| n5
  n0 -->|9 refs| n5
  n2 -->|8 refs| n4
  n2 -->|7 refs| n5
  n0 -->|5 refs| n3
  n0 -->|5 refs| n7
  n3 -->|5 refs| n6
  n3 -->|4 refs| n7
  n2 -->|2 refs| n7
  n6 -->|2 refs| n2
  n6 -->|2 refs| n3
  n6 -->|2 refs| n4
  n0 -->|1 ref| n4
  n4 -->|1 ref| n2
  n4 -->|1 ref| n5
  n5 -->|1 ref| n2
  n5 -->|1 ref| n3
  n5 -->|1 ref| n4
  n6 -->|1 ref| n5
  classDef changed fill:#0d4429,stroke:#238636,color:#e6edf3
  classDef impacted fill:#161b22,stroke:#6e7681,color:#c9d1d9
  classDef flagged fill:#5a1e02,stroke:#d93f0b,color:#ffffff
  classDef blocking fill:#67060c,stroke:#f85149,color:#ffffff
Loading

Green: changed. Grey: untouched, reached through an import or a call. Orange: has findings. Red: has a finding that blocks the merge.

Component Files Lines Findings
src/server/ops changed 2 +505 -19
src/harness changed 3 +287 -24
src/company reached 3
src/harness reached 3
src/ports reached 3
src reached 2
src/runtime reached 2
frontend/src/views reached 1
Changed files

src/server/ops

  • src/server/ops/mcp.rs
  • src/server/ops/team_agent.rs

src/harness

  • src/harness/build.rs
  • src/harness/mod.rs
  • src/harness/orchestrator.rs

tinysweeper 0.1.0

@tinysweeper tinysweeper Bot added the priority: p3 Whenever. Cosmetic, a nicety, or a cleanup with no user visible effect. label Aug 12, 2026
@oxoxDev oxoxDev added cluster:mcp MCP tool servers: console tab, registry, agent bridge cluster:approvals Human-in-the-loop boundary, policy, and approval surfaces labels Aug 12, 2026
`edit_agent` is open to any signed-in member, and that is right for
`name`, `role` and `description`: defining a teammate was never
admin-only, so correcting one is not either. It does not transfer to
`tools`.

An empty `tools` list means "the company's standard grant". So
`{"tools": []}` is not a small edit — it is the widest possible
widening, and left member-open any signed-in member could hand a
deliberately-scoped teammate the company's whole grant back. That is the
same inversion `add_agent` already refuses, where a narrowing that lands
empty is a hard error precisely because an empty list inherits
everything. The invariant was enforced in one of the two places it
applies. Found in review of tinyhumansai#745.

The check is conditional on the field being present, in the same shape
and for the same reason as the cap on `add_member`: a member editing a
name or a role keeps working exactly as before, and adding this field
must not quietly take an existing capability away.

Narrow-only-for-members was considered and rejected — it makes the scope
a one-way ratchet, so a teammate scoped too tightly could never be
loosened by anyone, and the only way back would be delete-and-recreate,
which orphans the workspace folder, budget row, desk memberships and
inbox this route exists to preserve.

`editable` is now actor-dependent too. It is the host stating the rule
so the console does not re-derive it, so advertising `tools` to a member
whose save would be a 403 is exactly the drift it exists to remove. The
read resolves authority through the same `may_administer` predicate the
enforcement path uses, so the two cannot disagree.
@M3gA-Mind
M3gA-Mind force-pushed the fix/661-mcp-overlay-reach branch from c781297 to 18fd2b1 Compare August 12, 2026 08:57
@M3gA-Mind

Copy link
Copy Markdown
Collaborator Author

@oxoxDev — you're right, and thank you for catching it. Fixed in 18fd2b13, on a rebase onto green main (d1d01ae5).

The finding, restated so the fix is judged against the real thing: edit_agent applied tools with no field-level check, and because an empty list means the company's standard grant, PATCH {"tools": []} was the widest possible widening — available to any signed-in member. You identified the asymmetry exactly: I made a narrowing that lands empty a hard error in add_agent because empty inherits everything, and then didn't carry that reasoning one file across. The invariant lived in my head and was enforced in one of the two places it applies.

What I changed, and why not the other options

Conditional admin on the field, in the same shape POST …/team already uses for its budget cap:

if body.tools.is_some() {
    require_admin(&headers, &state, &company.runtime).await?;
}

I weighed the two alternatives seriously rather than taking the easiest:

  • Reusing add_agent's guard directly was the most attractive, since two enforcement sites are precisely how this appeared. It does not transfer. add_agent's ceiling is the minting agent's effective grant, and edit_agent has no minting agent — the actor is a person. And the two empties are different things: in add_agent an empty set is the accidental residue of an intersection, which is why it must be refused; here [] is a deliberate, documented request for the standard grant. Collapsing them would either forbid a legitimate operation or introduce a rule that reads as arbitrary. What genuinely transfers is the principle — an empty list is a widening — and that is what the authority check is placed on.
  • Narrow-only for members makes the scope a one-way ratchet: a teammate scoped too tightly could never be loosened by anyone, and the only way back is delete-and-recreate — which orphans the workspace folder, budget row, desk memberships and inbox that this route exists to preserve.

The check is conditional on purpose: a member editing a name or role keeps working exactly as before. That is the stated reason add_member's cap check is conditional rather than blanket — adding a field must not quietly remove an existing capability.

One more gap, which you did not raise

editable was still advertising "tools" to everyone, so a member would be offered a field whose save is a 403 — exactly the console/host drift that list exists to remove. It is now actor-dependent, resolved through the same may_administer predicate as the enforcement path so the two cannot disagree.

Verification

Rebased onto d1d01ae5 (green main), so nothing here is inherited:

cargo fmt --all -- --check                                                                     → 0
cargo check  --locked --all-features --all-targets                                             → 0
cargo clippy --locked --no-deps --features openhuman,tinycortex --all-targets -- -D warnings   → 0
cargo test   --features openhuman,tinycortex   → 3336 + 10 + 1 + 11 + 2 = 3360 passed, 0 failed

All 10 mechanisms revert-checked — each fix removed, the named test re-run, confirmed failing. The three for this fix:

revert test result
the require_admin guard removed a_member_cannot_widen_a_teammates_scope FAILED — 0 passed; 1 failed
editable made actor-independent editable_names_tools_only_for_an_admin FAILED — 0 passed; 1 failed
the guard made unconditional a_member_may_still_edit_a_teammates_name_and_role FAILED — 0 passed; 1 failed

The third is there deliberately: it fails if I over-correct and take the name/role edit away from members, so the conditionality is pinned in both directions rather than just the refusal.

The widening test drives the route with two accounts — the harness signs every request in as an admin, so a check verified only as an admin passes identically against no check at all.

@M3gA-Mind

Copy link
Copy Markdown
Collaborator Author

@coderabbitai review

@coderabbitai

coderabbitai Bot commented Aug 12, 2026

Copy link
Copy Markdown
⚠️ Action not completed

Review rate limited.

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.

@tinysweeper tinysweeper Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

tinysweeper found nothing blocking. Approving.

             $0.0929 · 102,045 in / 27,062 out · 78,207 cached (77%) · z-ai/glm-5.2
critique:    $0.0513 · 39,113 in  / 17,235 out · 30,976 cached (79%) · z-ai/glm-5.2
security:    $0.0169 · 23,694 in  / 4,325 out  · 18,225 cached (77%) · z-ai/glm-5.2
tests:       $0.0162 · 17,873 in  / 4,701 out  · 13,700 cached (77%) · z-ai/glm-5.2
description: $0.0086 · 21,365 in  / 801 out    · 15,306 cached (72%) · z-ai/glm-5.2

) -> Result<Json<AgentDetailDto>, Response> {
// Authority before the write lock: a refused edit must not hold the lock,
// and must not have looked at the record either.
if body.tools.is_some() {

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

priority medium critique likely

The admin check runs before the agent exists

This check runs before the lookup that turns a non-existent id into a 404. A non-admin requesting PATCH /{jamie} with tools will get 403 Forbidden instead of 404 Not Found. That is the wrong order: a 404 is a pre-condition to any authority check. As written, an empty tools request skips the check and correctly returns 404, which is consistent — but a non-admin who does send tools is now blocked before the lookup. This also means non-admins can probe for which agent ids exist vs. which they cannot edit, by observing the status code. Reorder: resolve the record first, then run the admin check. Note that moving the lookup inside the write lock would change the lock semantics; the check can stay outside the lock, but it must come after the 404 is known. Alternatively, return 404 for missing ids before checking admin. Alternatively, this may be acceptable if your API deliberately treats id enumeration as sensitive and prefers a 403, but that would be a surprising rule. The repo-standard rule is that a resource check precedes an authority check.

[RULE] Reorder: resolve the record first, then run the admin check. ·

@tinysweeper tinysweeper Bot added priority: p2 Soon. Real but survivable — a rough edge, a gap, a thing that will bite later. and removed priority: p3 Whenever. Cosmetic, a nicety, or a cleanup with no user visible effect. labels Aug 12, 2026
@M3gA-Mind
M3gA-Mind requested a review from oxoxDev August 12, 2026 09:41
@M3gA-Mind

Copy link
Copy Markdown
Collaborator Author

@oxoxDev — re-requesting review. Your CHANGES_REQUESTED was submitted at 08:01 against c7812973; the fix landed at 18fd2b13, so the block is standing against a head that no longer exists. Full detail is in this comment and in section 5 of the PR body — the short version:

Your finding was right. edit_agent applied tools with no field-level check, and because an empty list means the company's standard grant, PATCH {"tools": []} was the widest possible widening, available to any signed-in member. You spotted the asymmetry exactly: I made a narrowing that lands empty a hard error in add_agent because empty inherits everything, then didn't carry that one file across. The invariant lived in my head and was enforced in one of the two places it applies.

The fixtools requires admin, conditionally, in the same shape POST …/team already uses for its budget cap:

if body.tools.is_some() {
    require_admin(&headers, &state, &company.runtime).await?;
}

Conditional on purpose: a member editing a name or role keeps working exactly as before, because adding a field must not quietly remove an existing capability.

Why not the two obvious alternatives (both now recorded in the PR body so a later simplification doesn't reintroduce them):

  • Reusing add_agent's guard — the most attractive option, since two enforcement sites are how this appeared. It doesn't transfer, because the two empties mean different things: in add_agent an empty set is the accidental residue of an intersection, which is why it must be refused; in edit_agent, [] is a deliberate, documented request for the standard grant. Collapsing them would either forbid a legitimate operation or introduce a rule that reads as arbitrary. What transfers is the principle — an empty list is a widening — and that's what the authority check is placed on. (add_agent also clamps to the minting agent's grant; there is no minting agent here, the actor is a person.)
  • Narrow-only for members — makes scope a one-way ratchet: a teammate scoped too tightly could never be loosened by anyone, and the only route back is delete-and-recreate, orphaning the workspace folder, budget row, desk memberships and inbox this route exists to preserve.

One gap you didn't raise, also fixed: editable was still advertising "tools" to every actor, so a member would be offered a field whose save is a 403 — the exact console/host drift that list exists to remove. It's now actor-dependent, resolved through the same may_administer predicate as enforcement so the two can't disagree.

Verification — rebased onto green main, so nothing is inherited: fmt/check --all-features --all-targets/clippy all 0, and cargo test --features openhuman,tinycortex3360 passed, 0 failed. All 10 mechanisms revert-checked with counts quoted, including one that fails if I over-correct into a blanket admin check and silently take the name/role edit away from members.

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

Re-reviewed at 18fd2b13. The major is closed, and the fix goes further than what I asked for. Restoring my approval.

The enforcement is right, and placed thoughtfully:

// Authority before the write lock: a refused edit must not hold the lock,
// and must not have looked at the record either.
if body.tools.is_some() {
    require_admin(&headers, &state, &company.runtime).await?;
}

Gated on tools being present rather than on the route, so a member correcting a name or role is unaffected — which was the property worth preserving. Some([]) is covered, which is the case that mattered: an empty list means the company's standard grant, so the widening path is the one now behind admin. And doing the check before taking the write lock, so a refused edit neither holds the lock nor reads the record, is a detail I would not have asked for and is correct on both counts.

editable is now actor-dependent, which was my secondary point and you have implemented the whole of it rather than the letter:

a console renders a field read-only exactly when the host says it is, so offering tools to a member who would meet a 403 on save is precisely the drift editable exists to remove

Resolving an unknown principal as not-admin — under-claiming what the caller may edit rather than over-claiming it — is the right direction for a field whose whole purpose is telling a client what it may offer.

Your framing of the axis is better than mine. I called tools a privilege boundary; you named the distinction the codebase already draws:

That reasoning covers what a teammate is. It does not cover what a teammate may do … a write that settles something on behalf of the company rather than one a member makes for themselves.

That is a rule the next person can apply to a field I have not thought of, which "this one is sensitive" is not.

0 major. Approving. Thirteen checks green, MERGEABLE / CLEAN.

The two things I verified on the earlier revision both still hold: roster_grants narrows each overlay teammate by its own line instead of hard-coding &[], and add_agent's narrow-to-empty guard is intact with its actionable error.

Before merging: the branch is 38 commits behind main. main has gone red twice today from semantic collisions between branches that were each green against their own base, and this one touches orchestrator.rs and harness/mod.rs, which several PRs have landed in since you branched. Worth the rebase and a fresh run rather than trusting the current thirteen.

@oxoxDev
oxoxDev dismissed their stale review August 12, 2026 10:05

Superseded — tools is now admin-only, editable is actor-dependent; approved above.

@oxoxDev
oxoxDev merged commit 6f4ccfb into tinyhumansai:main Aug 12, 2026
14 checks passed
M3gA-Mind added a commit to M3gA-Mind/opencompany that referenced this pull request Aug 12, 2026
Placing the conditional admin check at the top of `edit_agent` made one
route give two answers about whether a teammate exists: `{"name": "x"}`
on an unknown id returned 404, while `{"tools": […]}` on the same id
returned 403. An unrelated field decided whether the thing was there.
Found in review of tinyhumansai#745; introduced by the admin check itself.

The ordering is forced rather than preferred. The non-`tools` path
cannot be moved to match — a name edit is member-open and has no
authority check to run first — so authorising after existence is the
only order in which the two agree.

The usual reason to authorise first, refusing to confirm a resource
exists, does not apply here: `GET {scope}/team/{agent_id}` is open to
any signed-in member and already 404s on an unknown id, so 403-before-
404 would hide nothing from the caller it inconveniences.

Deliberately unlike `set_budget`, which authorises first: that route is
admin-only in full, so admin-first is self-consistent there. This one is
admin-only per field, which is what makes the position load-bearing.

The test pins the invariant rather than the choice — an unknown id must
answer the same way whether or not `tools` is present — and is driven as
a member, the only actor for whom the two orderings differ.
@M3gA-Mind

Copy link
Copy Markdown
Collaborator Author

@tinysweeper — good catch, fixed in 8f1ab438. The finding was against the check I added in 18fd2b13, not against the original code, and you were right that the two paths disagreed.

The internal inconsistency was the decisive part, as you framed it: same route, same non-existent id, and {"name": "x"} answered 404 while {"tools": […]} answered 403. An unrelated field decided whether the teammate existed.

The ordering turns out to be forced, not preferred. The non-tools path cannot be moved to match — a name edit is member-open and has no authority check to run first — so authorising after existence is the only order in which the two agree. The check now sits after the 409/404 lookups and before field validation.

I weighed the counter-argument (403-first avoids leaking existence to an unauthorised caller) and it does not apply here: GET {scope}/team/{agent_id} is open to any signed-in member and already 404s on an unknown id, so ordering 403 ahead of 404 would hide nothing from the very caller it inconveniences.

Deliberately unlike set_budget, which authorises first. That route is admin-only in full, so admin-first is self-consistent there; this one is admin-only per field, which is exactly what makes the position load-bearing. Both facts are now in the code comment so the divergence is not "harmonised" away later.

The test pins the invariant, not the choicean unknown id answers the same way whether or not tools is present — so it survives a future decision to flip the ordering, provided both paths flip together. It is driven as a member, the only actor for whom the orderings differ: an admin passes the check either way and sees 404 regardless, so an admin-driven test would pass against the broken ordering too.

Revert-checked, and worth noting because my first attempt was worthless: I initially re-inserted the check in a position that was still after the 404, so nothing changed and the test passed — proving nothing. Redone by genuinely hoisting it above the write lock and record load:

test …::an_unknown_teammate_is_a_404_whether_or_not_tools_are_sent ... FAILED
assertion `left == right` failed: an unrelated field must not change whether a
teammate is reported as existing    left: 403   right: 404

That reproduces your exact 403/404 disagreement.

Lanes: fmt / check --locked --all-features --all-targets / clippy --no-deps --features openhuman,tinycortex -D warnings all 0; cargo test --features openhuman,tinycortex3361 passed, 0 failed.

@M3gA-Mind

Copy link
Copy Markdown
Collaborator Author

@coderabbitai review

@coderabbitai

coderabbitai Bot commented Aug 12, 2026

Copy link
Copy Markdown
⚠️ Action not completed

Pull request is closed.

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.

@senamakel senamakel self-assigned this Aug 12, 2026
@tinysweeper tinysweeper Bot added priority: p3 Whenever. Cosmetic, a nicety, or a cleanup with no user visible effect. and removed priority: p2 Soon. Real but survivable — a rough edge, a gap, a thing that will bite later. labels Aug 12, 2026
@M3gA-Mind

Copy link
Copy Markdown
Collaborator Author

@tinysweeper — your finding on src/server/ops/team_agent.rs:382 was not fixed by this PR, despite my earlier comment saying it was. Chasing it in #764.

What happened: I pushed the fix (8f1ab438) and replied here, but GitHub had not yet advanced this PR's head off 18fd2b13. #745 merged at 10:09 against the stale head, so the merge carried your finding and not its fix. My reply above described a commit that never landed on main.

Verified after the fact:

git merge-base --is-ancestor 8f1ab438 upstream/main   → not in main
grep an_unknown_teammate_is_a_404_whether_or_not_tools_are_sent  → 0 occurrences
team_agent.rs:382  require_admin(...)   ← still above the record load at :391

So the 403/404 disagreement you identified is live on main right now, exactly as you described it: on an unknown id a non-admin gets 404 for {"name": "x"} and 403 for {"tools": […]}.

#764 cherry-picks that commit onto current main, with the reasoning from my earlier reply intact — the ordering is forced rather than preferred, since the member-open path has no authority check to move; the existence-leak counter-argument does not apply because GET …/team/{agent_id} already 404s for any member; and the divergence from set_budget (admin-only in full vs admin-only per field) is recorded in the code comment.

How the escape was caught: re-running this area's tests on main afterwards, I passed 11 filters and only 10 ran — the missing one being the test from the commit that never landed. A filter matching nothing reports ok, so comparing filters-passed against tests-actually-run is what surfaced it.

Apologies for the misleading reply above — it was accurate about what I had pushed and wrong about what had merged, and those are not the same fact.

M3gA-Mind added a commit that referenced this pull request Aug 12, 2026
Placing the conditional admin check at the top of `edit_agent` made one
route give two answers about whether a teammate exists: `{"name": "x"}`
on an unknown id returned 404, while `{"tools": […]}` on the same id
returned 403. An unrelated field decided whether the thing was there.
Found in review of #745; introduced by the admin check itself.

The ordering is forced rather than preferred. The non-`tools` path
cannot be moved to match — a name edit is member-open and has no
authority check to run first — so authorising after existence is the
only order in which the two agree.

The usual reason to authorise first, refusing to confirm a resource
exists, does not apply here: `GET {scope}/team/{agent_id}` is open to
any signed-in member and already 404s on an unknown id, so 403-before-
404 would hide nothing from the caller it inconveniences.

Deliberately unlike `set_budget`, which authorises first: that route is
admin-only in full, so admin-first is self-consistent there. This one is
admin-only per field, which is what makes the position load-bearing.

The test pins the invariant rather than the choice — an unknown id must
answer the same way whether or not `tools` is present — and is driven as
a member, the only actor for whom the two orderings differ.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

cluster:approvals Human-in-the-loop boundary, policy, and approval surfaces cluster:mcp MCP tool servers: console tab, registry, agent bridge priority: p3 Whenever. Cosmetic, a nicety, or a cleanup with no user visible effect.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

mcp: a scoped overlay teammate still reports as reaching every enabled server

3 participants