fix(registry): changing an installed agent requires its installer or an instance admin (TASK-055) - #1746
Merged
Conversation
…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
force-pushed
the
kai/c4sec-installer-only-config-patch
branch
from
September 18, 2026 12:20
0904d10 to
1a238bc
Compare
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>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
The defect
PATCH /api/registry/pods/:podId/agents/:namegated on pod membership and nothing else (Vera 69500, measured on23e00668: a non-owner member's PATCH onconfig.environmentreturned 200).That handler merges the caller's
configinto the installation, andagentBindingprojectsconfig.environmentto the installer's daemon as that seat's declared environment —mcp[].commandandurlincluded. 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 drivesconfig.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:
403 { code: 'installer_only', fields: [...] }, naming the gated fields present in the body. Never a silent drop.403 Access denied; the new code only ever fires for a member who is not the installer.AgentProfilewrite is scoped to the same set.req.user.roleis only populated on the API-token auth path (helpers.ts:55-64), so the admin check falls back toisGlobalAdminUser(userId)when the token did not carry it.Deliberately not changed:
install.tsis the other writer of a caller-suppliedconfig, and it is a different act — it stampsinstalledBy: userIdand already gates public pods and entitlements.hosted.tswrites server-set values only.Tests
New
backend/__tests__/unit/routes/registry.agent-config-installer-gate.test.js:config.environment(with a foreign stdio command)installer_only,fields: ['config'], row unchanged, no profile writedisplayName+statusinstaller_only— the gate is the whole PATCHconfigconfigAccess denied(precondition unchanged)updatedPods: 1, pod-2's row untouched, profile write scoped to pod-1Three 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:
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
nodev26 on this machine 34 suites fail atbuffer-equal-constant-time(30 test failures) — the pre-existing baseline, unchanged by this branch.npm run tsc:checkclean;npm run lint:ts0 errors (1206 pre-existing warnings).