Skip to content

fix(registry): changing an installed agent requires its installer or an instance admin (TASK-055) - #1746

Merged
lilyshen0722 merged 1 commit into
mainfrom
kai/c4sec-installer-only-config-patch
Sep 18, 2026
Merged

lilyshen0722 merged 1 commit into
mainfrom
kai/c4sec-installer-only-config-patch

Conversation

@lilyshen0722

Copy link
Copy Markdown
Contributor

The defect

PATCH /api/registry/pods/:podId/agents/:name gated on pod membership and nothing else (Vera 69500, measured on 23e00668: a non-owner member's PATCH on config.environment returned 200).

That handler merges the caller's config into the installation, and agentBinding projects config.environment to the installer's daemon as that seat's declared environment — mcp[].command and url included. So a plain member of a pod could make another member's machine run a declared stdio command, and mail that seat's runtime token to a host of their choosing.

The same handler also writes the owner's AgentProfile (displayName, status, instructions, persona, toolPolicy, contextPolicy, modelPreferences) and drives config.skillSync, so the gate is the whole PATCH (Vera 69508/69510, Wren 69512), not just the fields in the original report.

The change

One check, right after the installation lookup:

  • Installer or instance admin — everyone else gets 403 { code: 'installer_only', fields: [...] }, naming the gated fields present in the body. Never a silent drop.
  • Pod membership stays a precondition, not an authority. A non-member still gets the previous 403 Access denied; the new code only ever fires for a member who is not the installer.
  • The fan-out is restricted too. It reaches this agent's installations in other pods, and those rows have their own installers — being the installer here is not authority over someone else's row there. An installer now writes only rows they installed; an instance admin keeps the instance-wide reach. The AgentProfile write is scoped to the same set.
  • req.user.role is only populated on the API-token auth path (helpers.ts:55-64), so the admin check falls back to isGlobalAdminUser(userId) when the token did not carry it.

Deliberately not changed: install.ts is the other writer of a caller-supplied config, and it is a different act — it stamps installedBy: userId and already gates public pods and entitlements. hosted.ts writes server-set values only.

Tests

New backend/__tests__/unit/routes/registry.agent-config-installer-gate.test.js:

case expectation
non-installer member → config.environment (with a foreign stdio command) 403 installer_only, fields: ['config'], row unchanged, no profile write
non-installer member → displayName + status 403 installer_only — the gate is the whole PATCH
installer → config 200, config merged, row saved
instance admin who is not the installer → config 200
non-member 403 Access denied (precondition unchanged)
installer of pod-1, plain member of pod-2 updatedPods: 1, pod-2's row untouched, profile write scoped to pod-1

Three existing fixtures gained installedBy (registry.patch-shared-settings, registry.tool-policy): they were exercising this handler as a pod creator with no installer, which is now a 403. No assertion was inverted — only the fixtures changed, and they still assert the same propagation.

Two consequences worth a human's eye

Both narrow, both follow from "membership is a precondition, not an authority", both one line to widen if you want them widened:

  1. A pod creator who is not the installation's installer now gets 403. (Today the creator could PATCH, because creator counted as membership.)
  2. An installer who is also an instance admin writes only their own rows, not every accessible row. A non-installer admin keeps the instance-wide reach.

If a UI flow edits an agent's display name as a plain member, it will now surface a 403 — that is the intended new behaviour, and the typed code is there for the UI to explain it.

Verified

…an instance admin

TASK-055 / C4-SEC (Vera 69500, measured on 23e0066). PATCH
/api/registry/pods/:podId/agents/:name checked pod membership and nothing else,
so any plain member of the pod could rewrite `config` on someone else's
installation. agentBinding projects `config.environment` to the OWNER's daemon
as that seat's declared spec, `mcp[].command` and `url` included: a member who
can write it can make the owner's machine run a declared stdio command and mail
the seat's runtime token to a host of their choosing. The probe returned 200.

The same handler writes the owner's AgentProfile (`displayName`, `status`,
`instructions`, `persona`, `toolPolicy`, `contextPolicy`, `modelPreferences`)
and drives `config.skillSync`, so the gate is the WHOLE PATCH, not just the two
fields in the original report. Pod membership stays a precondition, not an
authority: a non-member still gets the existing 403 Access denied, and a
non-installer member gets 403 `installer_only` naming the gated fields present
in the body, never a silent drop.

The fan-out had the same defect one hop out: it reaches this agent's
installations in other pods, and those rows have their own installers. An
installer now only writes rows they installed; an instance admin keeps the
instance-wide reach. That reach is also what the AgentProfile write is scoped to.

`req.user.role` is only populated on the API-token auth path, so the admin
check falls back to `isGlobalAdminUser(userId)` when the token did not carry it.

Tests: new registry.agent-config-installer-gate.test.js — non-installer member
on `config.environment` → 403 + code `installer_only` + the row unchanged and
no profile write; non-installer member on `displayName`/`status` → 403 too;
installer → 200; instance admin who is not the installer → 200; non-member →
the existing Access denied; and the fan-out case (installer of pod-1, plain
member of pod-2 → `updatedPods: 1`, pod-2's row untouched, profile write scoped
to pod-1). Three existing fixtures gained `installedBy` — they were exercising
this handler as a pod creator with no installer, which is now a 403.

Two consequences worth a human's eye, both narrowing: a pod creator who is not
the installation's installer now gets 403, and an installer who is also an
instance admin writes only their own rows. Both follow from "membership is a
precondition, not an authority" (Wren 69512); widening either is a one-line
change if the humans want it.

Verified: 373 unit suites / 3199 tests green under Node 20 (the version this
repo needs — under the plain node v26 on this machine 34 suites fail at
buffer-equal-constant-time, which is the pre-existing baseline and not this
change). tsc:check clean, lint:ts 0 errors.
@lilyshen0722
lilyshen0722 force-pushed the kai/c4sec-installer-only-config-patch branch from 0904d10 to 1a238bc Compare September 18, 2026 12:20
@lilyshen0722
lilyshen0722 merged commit 48a5ea9 into main Sep 18, 2026
14 checks passed
lilyshen0722 added a commit that referenced this pull request Sep 18, 2026
…inds only a seat with no ambient credentials (TASK-053)

The 2026-09-18 stranger run on commonly.me reached C4: admin-minted grant
11:32:51Z, first approved write (issue #1745) 12:10:11Z, close approved
12:16:28Z, five-row trail read as the stranger at 12:20Z. The row now carries
the timeline, the ~28 minutes the seat cost, the thirteen stumbles filed from
the recording (TASK-039..058, #1743) and the evidence paths.

#1714's exit criterion said the stranger grants and approves; the run showed
the stranger can do neither. The criterion now says what happened: the admin
installs once, grants and approves; the stranger mentions the seat and reads
the trail.

tools-catalogue-room-grants.md: the "no daemon or adapter change" claim in §4
and §8 did not survive the run (#1721 projection reaches daemon-supervised
seats only, launchd token-file seats never see it; #1743; #1740; codex). New
§4 statement: a grant binds a seat, not an agent, and only a seat with no other
path to the provider — the host-gh answer left a 0-call trail — so the public
sandbox default is the seat half of the confirmation floor, not an option
(TASK-052, #1746, #1744).

Eight stranger-view captures at 1200 under docs/design/evidence/.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
samxu01 pushed a commit that referenced this pull request Sep 19, 2026
…res renamed for what they show

Vera (69588): the three captures named for trail states were the same Tools
viewport with the trail below the fold. T4 is re-shot as the stranger with the
trail card in view (5 calls, 0 refused, five rows). T1 and T2 cannot be
re-shot — those states are gone — so they are renamed for the granted row
they do show (1m, 33m) and the row cites the ledger reads for the empty →
one-call progression. #1746 is now merged (48a5ea9); the row says so.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
lilyshen0722 added a commit that referenced this pull request Sep 19, 2026
…stance-wide scope (TASK-060) (#1752)

The follow-up Wren 69565(b) asked for, filed on Sam 69582 when #1746 merged:
authority is a role, not a coincidence of which row the caller created.

#1746 scoped the fan-out as `isInstaller ? own rows : all rows`, which gave an
instance admin who happened to be the row's installer the NARROWER scope — so
the same admin got two different reaches depending on which of two rows they
opened. The scope now follows the role: `isInstanceAdmin ? all rows : own rows`.
An installer who is not an admin is unchanged, and that half is pinned by the
existing test ("fans the fan-out out only to the rows the caller installed").

`isInstanceAdmin` is now computed on every PATCH rather than only on the
non-installer path, and deliberately: it is the input to the scope decision, and
deriving it only when the caller was not the installer would have made the scope
depend on how they authenticated (a session token carries no `role`, an API
token does), which is the same inconsistency one layer down. One extra indexed
user read on a PATCH buys a scope that does not vary by auth path.

Two sibling fixtures needed a `User` mock because the handler now always asks
the admin question — they are installers of both rows and not admins, so their
intent is preserved exactly, no assertion changed.

Tests: new case in registry.agent-config-installer-gate.test.js — installer of
pod-1 who is also an instance admin, pod-2's row installed by someone else:
updatedPods 2, both rows saved, AgentProfile.updateMany scoped to both pods.
28 registry route suites / 103 tests pass, backend .ts lint 0 errors, tsc:check
clean.
lilyshen0722 added a commit that referenced this pull request Sep 19, 2026
…inds only a seat with no ambient credentials (TASK-053)

The 2026-09-18 stranger run on commonly.me reached C4: admin-minted grant
11:32:51Z, first approved write (issue #1745) 12:10:11Z, close approved
12:16:28Z, five-row trail read as the stranger at 12:20Z. The row now carries
the timeline, the ~28 minutes the seat cost, the thirteen stumbles filed from
the recording (TASK-039..058, #1743) and the evidence paths.

#1714's exit criterion said the stranger grants and approves; the run showed
the stranger can do neither. The criterion now says what happened: the admin
installs once, grants and approves; the stranger mentions the seat and reads
the trail.

tools-catalogue-room-grants.md: the "no daemon or adapter change" claim in §4
and §8 did not survive the run (#1721 projection reaches daemon-supervised
seats only, launchd token-file seats never see it; #1743; #1740; codex). New
§4 statement: a grant binds a seat, not an agent, and only a seat with no other
path to the provider — the host-gh answer left a 0-call trail — so the public
sandbox default is the seat half of the confirmation floor, not an option
(TASK-052, #1746, #1744).

Eight stranger-view captures at 1200 under docs/design/evidence/.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
lilyshen0722 added a commit that referenced this pull request Sep 19, 2026
…res renamed for what they show

Vera (69588): the three captures named for trail states were the same Tools
viewport with the trail below the fold. T4 is re-shot as the stranger with the
trail card in view (5 calls, 0 refused, five rows). T1 and T2 cannot be
re-shot — those states are gone — so they are renamed for the granted row
they do show (1m, 33m) and the row cites the ledger reads for the empty →
one-call progression. #1746 is now merged (48a5ea9); the row says so.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
lilyshen0722 added a commit that referenced this pull request Sep 19, 2026
…y a seat with no ambient credentials (TASK-053) (#1749)

* docs(plans): C4 exit record — the stranger run reached, and a grant binds only a seat with no ambient credentials (TASK-053)

The 2026-09-18 stranger run on commonly.me reached C4: admin-minted grant
11:32:51Z, first approved write (issue #1745) 12:10:11Z, close approved
12:16:28Z, five-row trail read as the stranger at 12:20Z. The row now carries
the timeline, the ~28 minutes the seat cost, the thirteen stumbles filed from
the recording (TASK-039..058, #1743) and the evidence paths.

#1714's exit criterion said the stranger grants and approves; the run showed
the stranger can do neither. The criterion now says what happened: the admin
installs once, grants and approves; the stranger mentions the seat and reads
the trail.

tools-catalogue-room-grants.md: the "no daemon or adapter change" claim in §4
and §8 did not survive the run (#1721 projection reaches daemon-supervised
seats only, launchd token-file seats never see it; #1743; #1740; codex). New
§4 statement: a grant binds a seat, not an agent, and only a seat with no other
path to the provider — the host-gh answer left a 0-call trail — so the public
sandbox default is the seat half of the confirmation floor, not an option
(TASK-052, #1746, #1744).

Eight stranger-view captures at 1200 under docs/design/evidence/.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* docs(plans): C4 record — the trail capture shows the trail; two captures renamed for what they show

Vera (69588): the three captures named for trail states were the same Tools
viewport with the trail below the fold. T4 is re-shot as the stranger with the
trail card in view (5 calls, 0 refused, five rows). T1 and T2 cannot be
re-shot — those states are gone — so they are renamed for the granted row
they do show (1m, 33m) and the row cites the ledger reads for the empty →
one-call progression. #1746 is now merged (48a5ea9); the row says so.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* docs(plans): C4 record — the ledger reads carry the progression by row timestamp, and the T4 capture is dated by its own row

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
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