You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
13 #[NoAdminRequired] endpoints act on a caller-supplied agentId / skill id
/ template id with no ownership check. The worst converts a one-shot write into persistent, fan-out prompt injection against other users' agents.
gate-7 reports 0 for hermiq and always has.
⚠️This is NOT the #353/#360 false-positive class. I checked every
candidate against it first. canUserAccessAgent() exists twice
(lib/Controller/AgentsController.php:650, lib/Controller/AgentVersionController.php:271) and is called at AgentsController.php:164,221, AgentVersionController.php:251, ChatStreamController.php:621. None of the 13 controllers below calls it — MemoryController, SkillController and AgentTemplateController do not even
inject a service that could. Gate package measured is ConductionNL/.github @ 112d4c9, i.e. post-#368.
Why this was never reported
.github#365 — gate-7 accepts Http::STATUS_UNAUTHORIZED / 401 as an
authorisation guard; 28 of hermiq's 43 controller files carry that preamble.
Compounding (.github#372): lib/Settings/hermiq_register.json declares 29
schemas, 1 with an authorization block — Agent: {"read": ["authenticated"]}. AgentTemplate, Skill, Memory, Budget, SkillDraft are all null, so
OpenRegister grants open read and write on all of them
(ConductionNL/openregister#1955, #2011).
checker
findings
canonical (what CI reports today)
0
same run, UNAUTHORIZED/401 dropped from _GUARD_BODY_RE
35
20 take a caller-supplied id; hand-verification confirmed 13 REAL, 7
downgraded (listed at the bottom, in the interest of not repeating .github#365's over-count).
Positive control — hermiq DOES own-check Skills, just not on these endpoints
SkillMaturityController::qualify gates on that. SkillController::update / install / uninstall mutate the same Skill object with no such call. These
are omissions, not a missing convention.
🔴 Worst — PUT /api/skills/{id} (SkillController::update, :233-250)
No guard in the controller; lib/Service/SkillService.php:286-307 updateSkill() does getSkill() then saveObject(...) and never reads owner/createdBy.
Skill bodies are not inert documents.lib/Service/Engine/Engine.php:
The rewritten skill text is folded straight into the system-prompt preamble of
every run of every agent that has the skill installed. So this is not a
one-shot write IDOR — it is persistent, fan-out prompt injection. The
attacker never touches the victim's agent object (which OR would refuse, since Agent declares read but no write action); they poison a shared dependency the
victim's agent pulls in at run time.
Two things make it worse rather than better: the app already proves it knows the
correct predicate (SkillMaturityController.php:209) and omits it on the write
path, and GET /api/skills hands every authenticated user the complete list of
targets to choose from.
The rest
route
method
verdict
evidence
POST /api/agents/{agentId}/memory/consolidate
MemoryController::consolidate
REAL — destructive
:204-224 no guard; MemoryService.php:263 $data['entries'] = $normalised; — the caller-supplied entries array replaces the agent's entire memory. Wipe-or-rewrite of any agent's memory
PUT /api/agent-templates/{id}
AgentTemplateController::update
REAL — write
:231-247 no guard; AgentTemplateService.php:206-221 merges the caller payload and saveObjects it. createdBy is only written (:184), never compared
DELETE /api/agent-templates/{id}
AgentTemplateController::destroy
REAL — hard delete
:263-275; AgentTemplateService.php:235-242 is a bare deleteObject(uuid: $templateId, …). ⚠️ Contrast AgentTemplateController.php:404, where approvedoes gate via requireAction('agenttemplate.approve-quarantined')
POST /api/agents/{agentId}/memory
MemoryController::addMemory
REAL — write
:114-131 no guard; MemoryService.php:146 getMemory() filters on ['agentId' => $agentId] only. Attacker-authored "facts" injected into another user's agent run loop
POST /api/skills/{id}/install
SkillController::install
REAL — write
:272-294; SkillService.php:369-398 installOnAgent() mutates installedOn with no owner check. The agent-side syncAgentSkillInstalls (:395) is best-effort and OR would deny it for a foreign agent — but the Skill-side write lands
DELETE /api/skills/{id}/install/{agentId}
SkillController::uninstall
REAL — write
:310-330; SkillService.php:475-503array_filters the agent out of installedOn, no owner check
POST /api/agents/{id}/run-on-object
AgentRunController::runOnObject
REAL — split
The object half is properly guarded: :251-257 find(id: $objectId, …, _rbac: true, _multitenancy: true) → :264 return null → :164 STATUS_NOT_FOUND. The agent half is not: :292 return $this->agentMapper->findByUuid($ref);, no canUserAccessAgent(). Any authenticated user can invoke a private agent — its prompt, model policy, tools and budget — they were never invited to
GET /api/agent-templates/from-agent/{agentId}/export
AgentTemplateController::export
REAL — read
:291-303 no guard; AgentTemplateService.php:273 'systemPrompt' => (string) ($data['prompt'] ?? '') plus tools/skillRefs. Dumps any agent's system prompt — including an isPrivate:true agent that AgentsController.php:221 would have refused
GET /api/agents/{agentId}/user-profiles
MemoryController::userProfiles
REAL — read
:147-160; listUserProfiles() filters on agentId only. Per-subject-user learned profiles — the most PII-dense object in the app
GET /api/agents/{agentId}/recall?q=
MemoryController::recall
REAL — read
:245-259 → recallSessions(agentId, query), agentId-only filter. Free-text search across another agent's conversation turns
Recorded explicitly so this issue is not another over-count:
GET /api/agent-templates/{id} — ::show. AgentTemplateService.php:130-145 list() is an unfiltered findAll(config: ['limit' => 200]), so index already returns every template to every authenticated user. show crosses no new boundary.
GET /api/agent-templates/{id}/export — ::exportPackage. :299-307 serialises the same object show returns.
GET /api/skills/{id}/export — SkillController::export. skill#index (:143) already returns full skill objects. (Also mis-typed in my first pass as a write — it is GET, routes.php:308.)
GET /api/skills/{id}/drafts — SkillDraftController::index. Subsumed by the org-open Skill catalog. Flagged as the least confident downgrade — it is only "not a new leak" because SkillController::index is already open.
GET /api/analytics?agentId= — AnalyticsController::index. agentId is a pure narrowing filter: AnalyticsService.php:221 findAll(config: ['limit' => 1000]) (tenant-scoped, _multitenancy: true by default) then :232 if ($agentId !== null && … !== $agentId) { continue; }. Omitting it returns a superset.
GET /api/agents/{agentId}/budget-estimate — BudgetController::estimate. Same narrowing path via BudgetService.php:412.
GET /api/budgets/status — BudgetController::status. GUARDED-BELOW by OR multitenancy: BudgetService.php:796-799 findAll() runs _multitenancy: true, so the candidate set is already the caller's organisation; :811 can only narrow. A foreign org id yields the 'configured' => false stub at :508-518.
Also correcting my own first pass: none of these endpoints is unrouted. All 20
resolve in appinfo/routes.php @ a4eca493 (memory :201-211, analytics :214,
budget :265,270, skills :308,323,324,326, drafts :354, templates :386-405, agentRun :77-109). My route parser missed them; the endpoints are
live.
Suggested remediation shape (measurement pass — not implemented here)
SeedCustodyService::actsAsOwner() already encodes the model for Skills. Call it on update / install / uninstall, not just on qualify.
canUserAccessAgent() already encodes it for Agents. Call it in MemoryController (all five methods), AgentTemplateController::export and AgentRunController::resolveAgent (:292).AgentRunController already
guards the object half correctly — the agent half is one line.
Treat PUT /api/skills/{id} as a prompt-injection vector, not just an
IDOR — whatever guard lands should be accompanied by a test that a
non-owner's skill edit cannot reach another agent's run preamble.
Summary
13
#[NoAdminRequired]endpoints act on a caller-suppliedagentId/ skill id/ template id with no ownership check. The worst converts a one-shot write into
persistent, fan-out prompt injection against other users' agents.
gate-7 reports
0for hermiq and always has.#353/#360false-positive class. I checked everycandidate against it first.
canUserAccessAgent()exists twice(
lib/Controller/AgentsController.php:650,lib/Controller/AgentVersionController.php:271) and is called atAgentsController.php:164,221,AgentVersionController.php:251,ChatStreamController.php:621. None of the 13 controllers below calls it —MemoryController,SkillControllerandAgentTemplateControllerdo not eveninject a service that could. Gate package measured is
ConductionNL/.github@112d4c9, i.e. post-#368.Why this was never reported
.github#365— gate-7 acceptsHttp::STATUS_UNAUTHORIZED/401as anauthorisation guard; 28 of hermiq's 43 controller files carry that preamble.
Compounding (
.github#372):lib/Settings/hermiq_register.jsondeclares 29schemas, 1 with an
authorizationblock —Agent: {"read": ["authenticated"]}.AgentTemplate,Skill,Memory,Budget,SkillDraftare allnull, soOpenRegister grants open read and write on all of them
(ConductionNL/openregister#1955, #2011).
UNAUTHORIZED/401dropped from_GUARD_BODY_RE20 take a caller-supplied id; hand-verification confirmed 13 REAL, 7
downgraded (listed at the bottom, in the interest of not repeating
.github#365's over-count).Positive control — hermiq DOES own-check Skills, just not on these endpoints
SkillMaturityController::qualifygates on that.SkillController::update/install/uninstallmutate the same Skill object with no such call. Theseare omissions, not a missing convention.
🔴 Worst —
PUT /api/skills/{id}(SkillController::update,:233-250)No guard in the controller;
lib/Service/SkillService.php:286-307updateSkill()doesgetSkill()thensaveObject(...)and never readsowner/createdBy.Skill bodies are not inert documents.
lib/Service/Engine/Engine.php:The rewritten skill text is folded straight into the system-prompt preamble of
every run of every agent that has the skill installed. So this is not a
one-shot write IDOR — it is persistent, fan-out prompt injection. The
attacker never touches the victim's agent object (which OR would refuse, since
Agentdeclaresreadbut no write action); they poison a shared dependency thevictim's agent pulls in at run time.
Two things make it worse rather than better: the app already proves it knows the
correct predicate (
SkillMaturityController.php:209) and omits it on the writepath, and
GET /api/skillshands every authenticated user the complete list oftargets to choose from.
The rest
POST /api/agents/{agentId}/memory/consolidateMemoryController::consolidate:204-224no guard;MemoryService.php:263 $data['entries'] = $normalised;— the caller-suppliedentriesarray replaces the agent's entire memory. Wipe-or-rewrite of any agent's memoryPUT /api/agent-templates/{id}AgentTemplateController::update:231-247no guard;AgentTemplateService.php:206-221merges the caller payload andsaveObjects it.createdByis only written (:184), never comparedDELETE /api/agent-templates/{id}AgentTemplateController::destroy:263-275;AgentTemplateService.php:235-242is a baredeleteObject(uuid: $templateId, …).AgentTemplateController.php:404, whereapprovedoes gate viarequireAction('agenttemplate.approve-quarantined')POST /api/agents/{agentId}/memoryMemoryController::addMemory:114-131no guard;MemoryService.php:146 getMemory()filters on['agentId' => $agentId]only. Attacker-authored "facts" injected into another user's agent run loopPOST /api/skills/{id}/installSkillController::install:272-294;SkillService.php:369-398 installOnAgent()mutatesinstalledOnwith no owner check. The agent-sidesyncAgentSkillInstalls(:395) is best-effort and OR would deny it for a foreign agent — but the Skill-side write landsDELETE /api/skills/{id}/install/{agentId}SkillController::uninstall:310-330;SkillService.php:475-503array_filters the agent out ofinstalledOn, no owner checkPOST /api/agents/{id}/run-on-objectAgentRunController::runOnObject:251-257 find(id: $objectId, …, _rbac: true, _multitenancy: true)→:264 return null→:164 STATUS_NOT_FOUND. The agent half is not::292 return $this->agentMapper->findByUuid($ref);, nocanUserAccessAgent(). Any authenticated user can invoke a private agent — its prompt, model policy, tools and budget — they were never invited toGET /api/agent-templates/from-agent/{agentId}/exportAgentTemplateController::export:291-303no guard;AgentTemplateService.php:273 'systemPrompt' => (string) ($data['prompt'] ?? '')plustools/skillRefs. Dumps any agent's system prompt — including anisPrivate:trueagent thatAgentsController.php:221would have refusedGET /api/agents/{agentId}/user-profilesMemoryController::userProfiles:147-160;listUserProfiles()filters onagentIdonly. Per-subject-user learned profiles — the most PII-dense object in the appGET /api/agents/{agentId}/recall?q=MemoryController::recall:245-259→recallSessions(agentId, query), agentId-only filter. Free-text search across another agent's conversation turnsGET /api/agents/{agentId}/memoryMemoryController::memory:81-95→MemoryService.php:148 findOne(schema: MEMORY_SCHEMA, filters: ['agentId' => $agentId])GET /api/agents/{agentId}/sessionsMemoryController::sessions:175-188→listSessions(agentId), agentId-only filterDowngraded — 7 candidates that are NOT IDORs
Recorded explicitly so this issue is not another over-count:
GET /api/agent-templates/{id}—::show.AgentTemplateService.php:130-145 list()is an unfilteredfindAll(config: ['limit' => 200]), soindexalready returns every template to every authenticated user.showcrosses no new boundary.GET /api/agent-templates/{id}/export—::exportPackage.:299-307serialises the same objectshowreturns.GET /api/skills/{id}/export—SkillController::export.skill#index(:143) already returns full skill objects. (Also mis-typed in my first pass as a write — it isGET, routes.php:308.)GET /api/skills/{id}/drafts—SkillDraftController::index. Subsumed by the org-open Skill catalog. Flagged as the least confident downgrade — it is only "not a new leak" becauseSkillController::indexis already open.GET /api/analytics?agentId=—AnalyticsController::index.agentIdis a pure narrowing filter:AnalyticsService.php:221 findAll(config: ['limit' => 1000])(tenant-scoped,_multitenancy: trueby default) then:232 if ($agentId !== null && … !== $agentId) { continue; }. Omitting it returns a superset.GET /api/agents/{agentId}/budget-estimate—BudgetController::estimate. Same narrowing path viaBudgetService.php:412.GET /api/budgets/status—BudgetController::status. GUARDED-BELOW by OR multitenancy:BudgetService.php:796-799 findAll()runs_multitenancy: true, so the candidate set is already the caller's organisation;:811can only narrow. A foreign org id yields the'configured' => falsestub at:508-518.Also correcting my own first pass: none of these endpoints is unrouted. All 20
resolve in
appinfo/routes.php@a4eca493(memory:201-211, analytics:214,budget
:265,270, skills:308,323,324,326, drafts:354, templates:386-405, agentRun:77-109). My route parser missed them; the endpoints arelive.
Suggested remediation shape (measurement pass — not implemented here)
SeedCustodyService::actsAsOwner()already encodes the model for Skills.Call it on
update/install/uninstall, not just onqualify.canUserAccessAgent()already encodes it for Agents. Call it inMemoryController(all five methods),AgentTemplateController::exportandAgentRunController::resolveAgent(:292).AgentRunControlleralreadyguards the object half correctly — the agent half is one line.
authorizationblocks onSkill,Memory,AgentTemplate,SkillDraft(wave-12 follow-up: fleet sweep of *_register.json to add authorization blocks ahead of default-closed flip openregister#2011).Agentalready shows thepattern works: it declares
readand therefore correctly denies writes.PUT /api/skills/{id}as a prompt-injection vector, not just anIDOR — whatever guard lands should be accompanied by a test that a
non-owner's skill edit cannot reach another agent's run preamble.
Confirmed by: canonical
ConductionNL/.github@112d4c9(post-#368);hermiq@origin/developmenta4eca493. Full harness and reproduction:/home/rubenlinde/fleet-board/findings/gate7-fleet-reaudit.md.Related: ConductionNL/.github#365, ConductionNL/.github#372,
ConductionNL/openregister#1955, ConductionNL/openregister#2011.