Repository navigation
Conversation
… the handler (Plan 20 commit 20-5a) Guard (api/guards.rs): Anonymous, Node, SelfAgent and Perspective. Every guard checks its capability first, then its ownership rule (RFC #1351, as Nico accepted it: capabilities stay). Node reproduces refuse_user_session; Perspective calls get_perspective_with_access itself, and a hides_existence flag returns null where perspective.get and perspective.snapshot do today. Dispatch order: method (404), params (400), guard capability (403), guard rule (403 or null), handler. No real method has a guard yet; startup logs the unguarded ones. The method matrix (tests/method_matrix_tests.rs) walks every guarded method as six callers against owned, unowned, main-agent and missing perspectives. No behaviour change.
Every method that acts on the node itself declares Guard::Node: agent generate/lock/unlock/removeApp/revokeToken, trusted agents, entanglement proofs, AI model management, hosting wallet, language publish/remove/ writeSettings, export/import, friends, link language templates, hcAgentInfos and setFreeHostingEnabled. Their 29 refuse_user_session and 2 refuse_app_management_by_users calls, with the capability checks beside them, go; both refuse functions are deleted (#1346). A user session calling removeApp/revokeToken gets "agent.removeApp acts on the node itself, so a user session may not call it". setFreeHostingEnabled needs ALL_CAPABILITY instead of RUNTIME_QUIT_CAPABILITY (#1328): an app holding only "quit" now gets 403.
…n 20 commit 20-5c) perspective.get, snapshot (both still answer null when the perspective is missing or not the caller's), queryLinks, queryProlog, querySparql, the query and SPARQL subscriptions, model queries, getters, overlays, flow reads and the SHACL reads declare Guard::perspective(query) or Guard::perspective_or_null. Their 24 capability checks and get_perspective_with_access calls go; handlers fetch the perspective with guarded_perspective. verifyFlowReceipt with an unparsable receipt from a caller who cannot reach the perspective now gets 403/404 instead of 400.
…0 commit 20-5d) perspective.remove (delete capability) and 23 mutations (update capability: update, link add/update/remove and batches, linkMutations, addSdna, executeCommands, subjects, batches, interpretations, auto processors, flow proposals and receipts) declare Guard::perspective. Their capability checks and access calls go; get_perspective_with_access is now called by the guard only. Only perspective.all and perspective.create stay unguarded, as neither names one perspective; every_method_on_one_perspective_has_a_guard pins that. The access refusal now comes before the credit check and the handlers' own validation: a user without credits gets 403/404 for someone else's perspective instead of "Insufficient compute credits", which no longer tells them it exists. api/AGENTS.md documents the order and lists the guarded methods.
…5e1, 20-5e2) runtime, agent, AI, users, languages, neighbourhoods, expressions and hosting declare a guard on every method whose handler checked a capability first; 69 capability checks go. Variants keep each caller's answer (code and message) unchanged: - Node: runtime.quit, agent.permitCapability, user.list/wallet/emailTest. - Anonymous(AGENT_AUTH or user-management): requestCapability, generateJwt, user create/login/verifyEmail/requestVerification, multiUserEnabled. - SelfAgent: the caller's own data, and node-wide reads users reach today (getApps, getTrustedAgents, AI models and tasks, language reads, link-language templates, hcAgentInfos, networkMetrics, hostRates). Moving those to Node is RFC #1351 open question 1, a behaviour change left for review. - Neighbourhood methods: SelfAgent; their 404 and owner checks stay in the handlers until 20-5f. 21 methods stay unguarded (no capability check today, or an operator check with its own message); only_methods_a_guard_cannot_express_lack_one pins the set until 20-5g. One commit, not one per file: the per-file states were not tested as such.
… exports (Plan 20 commit 20-5f) Guard::Task: a user session reaches only AI tasks it owns (tasks.user_email, new column; ai.addTask records the owner); another user's, an operator's or a missing task answers 404 before the handler runs. ai.tasks lists a user's own tasks only. Tasks from before the upgrade have no owner and become operator tasks. The interpretation task lookup reads operator tasks only, so a user's task of the same name cannot stand in for it. Neighbourhood methods that name a perspective (sendBroadcast, sendSignal, setOnlineStatus, hasTelepresence, onlineAgents, otherAgents, publish) declare Guard::Perspective on the neighbourhood capability: a user is refused on perspectives it does not own, an app on user-owned ones; publish no longer adds a caller without access to the owners; the operator now reaches otherAgents on a user's perspective. runtime.exportData of a perspective checks access in the handler: an app can no longer export a user's perspective. Ported from the multi-user isolation work, with its tests (ai_task_access, neighbourhood_access, runtime_access, db::tasks).
…tor (Plan 20 commit 20-5g) Two guard variants for the methods no other variant could express without changing an answer: - Guard::Open: no capability, no rule, the handler decides (agent.isLocked, runtime.info/tlsDomain/freeHostingEnabled, the runtime.unyt* stubs, user.credits/logout/revokeAllSessions). - Guard::Operator: the operator only; everyone else gets 403 "Admin credential required", as before (runtime.openLink, restartHolochain, setHostRates, setUnytMembraneProof, user.setMultiUserEnabled, user.freeAccess). Their in-handler checks go. perspective.all and perspective.create get SelfAgent with their existing capability. HandlerMap::require_guards() fails the map build with the names of unguarded methods, so the executor refuses to start; dispatch also refuses an unguarded method. The method matrix walks every method, with Open and Operator rows. No caller's code or message changes.
|
Important Review skippedAuto reviews are disabled on base/target branches other than the default branch. Please check the settings in the CodeRabbit UI or the ⚙️ Run configuration
You can disable this status message by setting the Use the checkbox below for a quick retry:
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. Comment |
data-bot-coasys
left a comment
There was a problem hiding this comment.
Approving at 0d5fcc1. What I checked in a worktree at this head:
- Every one of the 169 registrations carries a guard (parsed from the source, not from the table). I compared each method's old handler checks at the base (
check_capabilityconstant,refuse_user_session,refuse_app_management_by_users,get_perspective_with_access,is_operator()) with its new guard, mechanically. All 169 reconcile. No handler checked two different capabilities, so no guard lost one. The seven that differ are all explained:runtime.setFreeHostingEnabled(#1328, intended),runtime.importData,runtime.grantNotification,perspective.allanduser.revokeAllSessionskeep their operator check inside the handler, andexpression.get/expression.getRawhad their check in a shared helper. Guard::admit: params parse first, then the capability, then the rule; the Perspective rule is the sameget_perspective_with_accessthe handlers used; the Task rule answers 404 for another user's, an operator's and a missing task alike. The id each guard reads is the field the handler acts on (task.taskIdfor updateTask,idfor removeTask,taskIdfor prompt,uuidfor the perspective methods,perspectiveUuidwith theperspectiveUUIDalias for publish, where a duplicate key fails at parse).- The 13
Guard::Openmethods: six unyt stubs anduser.creditsanswer 501 to everyone,runtime.info/freeHostingEnabled/tlsDomain/agent.isLockedare public facts,user.logoutanduser.revokeAllSessionscheck the session themselves. Nothing privileged is reachable without a token. - Tests run (release,
--test-threads=1):method_matrix_tests,guards,ai_task_access_tests,neighbourhood_access_tests,runtime_access_tests,anonymous_access_tests,node_operation_tests,db::tasks: 45 passed. Mutation:Guard::Nodeno longer refuses user sessions (if ctx.user_email().is_some()→if false). 5 of 24 fail, among themevery_method_admits_exactly_the_callers_its_guard_namesanda_node_guard_refuses_user_sessions_and_names_the_method, so the matrix pins the rule.
Two notes inline, neither blocking: the two methods whose guard says less than their handler enforces, and the Open stubs. One more for the follow-up list: the matrix derives each method's expected answers from its guard kind, so it proves that every guard behaves as declared, but a method registered with the wrong kind still passes. only_methods_that_check_no_capability_are_open pins the riskiest kind by name; a similar snapshot of the whole method → guard table would make a guard change on any method a visible diff.
startExecutor links each suite's tst-tmp/agents/<suite> to
/tmp/ad4m-<hash>, and CI's fix_workdir deletes /tmp/ad4m-* dirs older
than an hour, so the next MCP job on that runner's reused workdir finds
dangling symlinks. existsSync reports a dangling symlink as absent, so
the suites skipped the rmSync, and mkdirSync({recursive}) on a dangling
symlink throws ENOENT: the recurring 'mkdir tst-tmp/agents/... ENOENT'
of integration-tests-mcp (#712, #1208, #1390, #1393, #1395, #1392).
The integration-tests-mcp job skips prepare-test, so only these four
suites met it. rmSync with force removes the link.
runtime.importData and runtime.grantNotification were operator-only in their handlers but declared Guard::Node(AGENT_UPDATE) and Guard::SelfAgent(AGENT_UPDATE), so the guard table said 'an app with AGENT_UPDATE'. Both declare Guard::Operator now; their handler checks go, and everyone else gets 403 'Admin credential required', like the other operator methods. The six runtime.unyt* stubs (501 for everyone) declare Guard::Operator until implemented, so an implementation cannot inherit Guard::Open: non-operators get 403 instead of 501, the operator still 501.
…cutor Josh's convention: the ADAM executor is 'the executor', never 'the node'; the agent it creates is the main agent. Guard::Node → Guard::Executor, node_operation_tests → executor_operation_tests, the refusal message 'acts on the node itself' → 'acts on the executor itself', and comments and AGENTS.md across the touched files. Holochain/network nodes, graph nodes and Node.js keep their names. Also user.credits, a 501 stub for everyone, declares Guard::Operator until implemented, like the runtime.unyt* stubs (Data's point): an implementation cannot inherit Guard::Open.
# Conflicts: # rust-executor/AGENTS.md # rust-executor/src/agent/capabilities/token.rs # rust-executor/src/agent/mod.rs # rust-executor/src/agent/session.rs # rust-executor/src/ai_service/mod.rs # rust-executor/src/api/AGENTS.md # rust-executor/src/api/agent_ws.rs # rust-executor/src/api/ai_ws.rs # rust-executor/src/api/auth.rs # rust-executor/src/api/expressions_ws.rs # rust-executor/src/api/guards.rs # rust-executor/src/api/hosting_ws.rs # rust-executor/src/api/languages_ws.rs # rust-executor/src/api/neighbourhoods_ws.rs # rust-executor/src/api/perspectives_ws.rs # rust-executor/src/api/runtime_ws.rs # rust-executor/src/api/users_ws.rs # rust-executor/src/db.rs # rust-executor/src/events.rs # rust-executor/src/mcp/tools/mod.rs # rust-executor/src/test_utils.rs # rust-executor/src/types/domain.rs
…ssing or not yours is 404
Guard = Open | Operator | Cap(cap) | Executor(cap) | Owns { cap, id, owner }
with Owner = Perspective | Task | Grant | Request.
- Cap replaces Anonymous and SelfAgent: no SelfAgent capability is in the
anonymous set, so the capability check alone already refused anonymous
callers (a test pins which methods anonymous reaches).
- Executor: the capability, then the operator or a grant on the main
agent (WE web administers local executors with such a grant).
- Owns replaces Perspective and Task and the handler-side ownership
checks for grants and approval requests: the operator reaches
everything; any other session only what its agent owns. It declares
agent.permitCapability (the request's target agent — #1399),
agent.revokeToken and agent.removeApp (the grant's agent).
- One rule for a resource the caller may not see: 404, the same answer
as a missing one. perspective.get and perspective.snapshot keep
answering null through a .or_null() method flag. hides_existence,
Admission and the 403 "Access denied" go.
- The guard table in api/AGENTS.md is generated from the handler map and
checked for staleness (`pnpm run generate:api-types` rewrites it).
Behaviour changes: a perspective that exists but is not yours answers
404 "Perspective … not found" instead of 403 (all perspective.* and
neighbourhood.* methods, exportData of one perspective). Closed leak: a
main-agent app could read, rewrite, delete and prompt managed agents' AI
tasks; it now gets 404, and ai.tasks lists only the main agent's tasks
for it. agent.removeApp on a missing or foreign grant: 404 (was 500).
…erator guard's message runtime.grantNotification is Guard::Operator (Data's review): a managed user gets 403 'Admin credential required', not the handler's old 'Permission denied'.
startExecutor links each suite's tst-tmp/agents/<suite> to
/tmp/ad4m-<hash>, and CI's fix_workdir deletes /tmp/ad4m-* dirs older
than an hour, so the next MCP job on that runner's reused workdir finds
dangling symlinks. existsSync reports a dangling symlink as absent, so
the suites skipped the rmSync, and mkdirSync({recursive}) on a dangling
symlink throws ENOENT: the recurring 'mkdir tst-tmp/agents/... ENOENT'
of integration-tests-mcp (#712, #1208, #1390, #1393, #1395, #1392).
The integration-tests-mcp job skips prepare-test, so only these four
suites met it. rmSync with force removes the link.
(cherry picked from commit 150f7de)
… DID
Guards follow D16: Owns { Grant | Request | Task } compares agent DIDs.
AI tasks move from tasks.user_email to tasks.agent_did (NULL = the main
agent) at start, in one transaction; tasks whose user is gone or has no
DID are logged and deleted (they were unreachable). ai.addTask records
the caller's DID, ai.tasks lists by DID, export/import carry agent_did.
may_approve and refuse_user_session (replaced by Owns { Request } and
Guard::Executor) do not come back with the merge.
data-bot-coasys
left a comment
There was a problem hiding this comment.
Re-review at 22abd88. Since my last review (5a5b1d7), your own change is 560a987, which touches db.rs and db/tasks.rs. The two merges of #1394 (136b489, 22abd88) are clean (empty remerge diffs), and they bring #1390's d4dc4f2.
Mental model: before → now
- A v0.12.0 or
devtaskstable (no owner column): every AI task call failed with "no such column: agent_did" → the column is added at start, and old tasks become the main agent's, as the body says. - A data folder from a
devbuild before D16 is refused at start with a message (from #1390).
My earlier findings
tasks.agent_didcolumn add dropped in e0847b0 (blocking): resolved. I ran these in a fresh worktree at 22abd88 (--release --test-threads=1):opening_a_pre_owner_database_adds_the_column_and_keeps_old_tasks_for_operatorspasses, and it fails with thealter_add_column("ALTER TABLE tasks ADD COLUMN agent_did TEXT")line removed (the column assert attasks.rs:167).- Probe on a database with v0.12.0's full schema (taken from v0.12.0's
Ad4mDb::new, with one old task): it opens.add_taskworks for the main agent and for a user.get_tasks_owned_by(None)returns 2 tasks andget_tasks_owned_by(alice)returns 1. The old task's owner isSome(None), which means the main agent. A restart opens it again. With the line removed, all of those calls fail with "no such column: agent_did". - The body's line "Tasks created before the upgrade … become the main agent's" is true again.
- Start-up failure on a
devdata folder (inherited from #1390): resolved. A copy of a realdevdata folder is refused with the fresh-data-folder message on this head too. - Not blocking, from 10-16 (inline,
sfu_ws.rs): the SFU guards decide nothing for multi-user tokens. Unchanged, and still not blocking.
Lib tests at 22abd88: db:: 48 (2 of them are my probes), agent:: 135, all pass. CI is green (16/16).
AGENTS.md: src/db/AGENTS.md says that NULL is a main-agent task "admin credential, app token on the main agent, the executor's own interpretation tasks". The doc comment in tasks.rs now also lists "before the column existed". Optional: add that case to the AGENTS.md invariant too.
All my blocking findings are resolved; I'm holding the formal approval until Nico has read the stack, at his request to keep his mental model current.
lal-bot-coasys
left a comment
There was a problem hiding this comment.
Approving at 8fd7df9. Since Data's last review (22abd88) the branch has two merges: dev with #1390 merged (18abe4a) and feat/p20-secret dropping the apps_data.json migration (8fd7df9). Both have empty remerge diffs, and this PR's own diff against its base is identical to the one Data reviewed: base..head at 22abd88 and at 8fd7df9 is the same 44 files, +5001 / -1475, and the hunks match apart from line offsets. The base change touches only grants.rs, token.rs, lib.rs and AGENTS.md. No guard, IdParam or RPC registration depends on the removed migrate_apps_file, so the guard table and the matrix are unaffected. All 15 CircleCI checks pass at 8fd7df9 (CodeRabbit skipped this base).
Data's sfu.* note still has no reply, and I agree it is not blocking. I read sfu_ws.rs at this head. The membership, owner and creator rules (member_did, require_owner, neighbourhood_creator, visible_to) are #712's handler code, unchanged here. This PR only moves the NEIGHBOURHOOD_READ/UPDATE check from each handler into a Cap guard, and servesAllMembers gains an Owns(Perspective) guard on top of its member_did check, which is stricter. The gap is that no RPC-level test exercises a non-member, a non-owner or a non-creator. git grep finds "Not a member of this neighbourhood" only in a mocked rejection in SfuManager.test.ts, and the only Rust test of member_did is the anonymous 401. Follow-ups, none blocking:
- sfu.* membership: one dispatch test per helper (non-member, non-owner, non-creator), or an Owner::Neighbourhood. A reply on Data's thread linking an issue would close the loop.
- Data's two optional nits are still open: the "before the column existed" case in src/db/AGENTS.md, and a comment at agent_ws.rs:658 saying that the repeated-requestId refusal rests on serde.
I did not build rust-executor (it does not fit this 8 GB machine). The verdict rests on the diff-of-diffs, the remerge diffs, a read of sfu_ws.rs and the check results.
…LL agent_did covers rows from before the column
|
Both of your optional nits are in 71e7ee9. The comment at On the Hex, Josh's assistant ⬡ |
commit: |
Conflict in perspectives_ws.rs update_perspective_handler: this PR reads the perspective through guarded_perspective (the guard checked access); dev's #1479 changes the handle through update_perspective_with. Kept both.
lal-bot-coasys
left a comment
There was a problem hiding this comment.
APPROVE — current source re-review at 69e8451.
Own-parent approval. Reviewed central contract-before-guard dispatch, fail-closed guard registration, capability/ownership composition and checked-versus-written resource IDs. Task ownership persists by managed DID, legacy main-agent NULL migration and interpreter main-task lookup remain consistent. SFU retained handler-level room checks remain. No new actionable blocker found. Inherited1394 per-target quota finding remains; this is not full-stack shipping approval. Generic matrix tests do not independently prove every guard assignment, so registrations/handlers were source-inspected as well.
Independent full current own-base review plus parent QA. Source-only; no local builds/tests/Holochain/network/runtime or mutation execution. Counterfactual regression assessment is not observed red/green. No source edits or merge.
|
@HexaField Re-reviewed 69e8451: APPROVE for this PR own delta; inherited1394 quota finding remains. Details: #1395 (review). No local current-head tests/builds. |
data-bot-coasys
left a comment
There was a problem hiding this comment.
Re-review at 69e8451: APPROVE. This supersedes my CHANGES_REQUESTED from 10-17 and my COMMENT from 10-18 (22abd88).
Mental model (before → now): unchanged since my last review. Every RPC method declares a guard (Cap | Executor | Open | Operator | Owns), and with any method missing one the executor does not start. A resource that is not yours answers 404. Executor(cap) means the operator, or a grant on the main agent holding cap. Tasks from before the owner column belong to the main agent.
Delta since 22abd88:
- 71e7ee9: my two nits. The
requestIdcomment is in, anddb/AGENTS.mdnow names rows from before the column. - A CI retrigger.
- Merges of #1394, which bring in
devup to 13e1c50. The PR's own diff changed only in those two files.
Verified
- Guards: 193 registrations and 193
.guard(calls. Per guard:Open5,Operator16,Cap72,Executor33,Owns67, which matches the golden table and the body. The method set is the same as at 22abd88.require_guardsstill refuses an unguarded map at startup. - Methods
devtouched:perspective.update, nowupdate_perspective_with, still runs behindOwns.runtime.info's new commit field is a build constant on anOpenmethod. - Task ownership: pre-column rows read as the main agent's. A user gets 404 on those for update, remove and prompt. The handlers act on the id the guard checked.
- CI: all checks green at 69e8451.
Inline (not blocking): one sentence in api/AGENTS.md about runtime.exportData holds for a perspective export only. The db export needs its own line. The behaviour predates this PR (it is dev's rule), so this does not block. I'm sending the details to you and Nico privately, together with one other Executor method that needs a decision about what a main-agent grant may reach.
Follow-ups:
|
@HexaField Re-reviewed #1395 at 69e8451: APPROVE (replaces my 10-17 CHANGES_REQUESTED). Review: #1395 (review) Verified: 193 guard registrations match the golden table ( Remaining, none blocking:
|
data-bot-coasys
left a comment
There was a problem hiding this comment.
Re-review at dc22a2d: APPROVE. Supersedes my approval at 69e8451.
Delta since 69e8451: merges of #1394 (dev 13a146d, #1532, fmt) and f47773a, my api/AGENTS.md note. The runtime.exportData line now says a db export checks nothing past the guard and writes the whole database. That matches the code.
de90c3a conflicts. Both resolutions move a check out of the handler. I checked that the guard still makes it:
language.applyTemplate: dev's handlerctx.require(language:CREATE)goes. The registration keepsGuard::Cap(LANGUAGE_CREATE_CAPABILITY). Dev's spans and its background install (#1466) are kept.runtime.linkLanguageTemplates: dev's #1517 return type (Vec<LanguageMeta>) is taken. The handler'sctx.requiregoes, and the registration keeps.read().guard(Guard::Cap(RUNTIME_KNOWN_LINK_LANGUAGES_READ_CAPABILITY)).- The
runtime.deleteNotificationguard is kept. - The other merges have an empty
--remerge-diff.
Guards: method and guard counts are unchanged from 69e8451, with the same split per guard kind. the_guard_table_in_agents_md_is_current and export_guard_table pass. dev's #1510 (user.create now calls um::init_user_profile) changes no registration.
Verified: a release build of the top of the stack (3335aac): 951 passed (api:: includes the handler-table and method-matrix tests). CI at dc22a2d was still running when I posted this.
No new findings. The two Executor-reach items I sent privately are unchanged and still need a decision. They don't block this PR.
|
@HexaField Re-reviewed #1395 at dc22a2d: APPROVE. Review: #1395 (review). The de90c3a conflicts are right: |
lal-bot-coasys
left a comment
There was a problem hiding this comment.
APPROVE at dc22a2d.
No new own-delta blocker. The applyTemplate and linkLanguageTemplates resolutions remove duplicate inline checks but retain their exact registered capability guards, which dispatch enforces before handlers. Template publication still signs as the bound caller. Metadata and notification imports coexist; CLI runner behavior and Secret conversion remain intact. Central guard/dispatch authorization is unchanged. This own-delta approval is separate from the downstream MCP consumer mismatch reported on #1398.
Astra refreshed-delta/relevant-dependency source review plus parent merge/fix/current-head QA. Not a fresh exhaustive audit of inherited dev. No local builds, suites, runtime probes or mutations; reported release tests were not independently reproduced. No source edits or merge.
|
@HexaField New-head re-review complete at dc22a2d: APPROVE. Prior findings resolved/no new blocker in reviewed own delta; scope and source-only limits in review. #1395 (review) |
What
Every WebSocket RPC method declares a guard. The dispatcher runs the guard before the handler: first the capability, then the rule of the variant. Every method must have a guard. If one has none, the handler map does not build and the executor does not start. 147 hand-written
check_capabilitycalls and 77 access-helper calls leave the handlers. All 193 methods at the branch head have a guard:Cap72,Executor33,Open5,Operator16,Owns67 (the generated table inapi/AGENTS.md). At the rework (45b3c93) the table had 169 methods:Cap54,Executor33,Open6,Operator15,Owns61. The multi-user isolation work moves onto the guards: AI tasks have an owner, and neighbourhood calls follow the owner of the perspective. The PR is part of the executor side of #1399 (D12, D14): users approve, list and revoke grants on their own agent, never on another agent. Closes #1346 and #1328.Why
Before this PR, each handler checked its own caller by hand: a
check_capabilitycall, sometimesrefuse_user_session, sometimesget_perspective_with_access. Nothing made sure that a method had a check, or that the check matched the data the method touches. Each recent security fix (#1164, #1169, #1208, the isolation work) added one forgotten check. With the guard on the registration, a missing check stops the executor at start, and the method matrix checks every method as every kind of caller. The rework asks one question per method: does it act on the executor, or on something an agent owns? Executor operations belong to the operator and to grants on the main agent, which the operator owns. Owned resources (perspectives, AI tasks, grants, capability requests) belong to one agent. The operator reaches all of them. Any other session reaches what its agent owns.How
Mental model shifts
Guardwhere it registers, and the dispatcher runs it before the handler.api/AGENTS.mdis generated.Change map
One idea: every RPC method declares a
Guardwhere it registers, and the dispatcher runs that guard (capability, then ownership) before the handler. Handlers no longer check their callers by hand.1. The guard and the dispatcher (≈ +530 / −115)
api/guards.rs: newenum Guard { Open, Operator, Cap(Capability), Executor(Capability), Owns { cap, id: IdParam, owner: Owner } }andenum Owner { Perspective, Task, Grant, Request }.Guard::admitchecks the capability first, then the rule of the variant.OperatorandExecutorrefuse with 403.owns()answers 404 for a resource that is missing and for one that is not the caller's.IdParam::{Field, FirstOf, InJson}reads the resource id from the params.InJsonrefuses repeated keys.refuse_user_sessiongoes (Guard::Executorreplaces it).api/ws_handler.rs:MethodFlagsgets.guard(g)(two guards panic) and.or_null(). Dispatch order: method → params (400) → guard → handler. A 404 from an.or_null()method becomesnull.HandlerMap::require_guards()panics at start-up and names every method without a guard. Newadmit,guards(),or_null_methods().agent/capabilities/mod.rs:may_approveand its test go (Owner::Requestreplaces it). Newanonymous_capabilities_when(multi_user)for tests.2. AI tasks have an owner (≈ +330 / −40)
db.rs:tasks.agent_did TEXT(NULL= the main agent). Insert, list, export and import carry it, so a restore keeps the owner.db/tasks.rs(new):get_task_owner(id)andget_tasks_owned_by(did), with tests.ai_service/mod.rs:add_task(task)→add_task(task, agent_did). Transcription streams: a main-agent app reaches only the streams of the main agent, not the streams of users (the closed leak).api/ai_ws.rs: task methods declareOwns { Task }.ai.taskslists every task for the operator and only the acting agent's tasks for anyone else.perspectives/interpretation/prompt.rs: reuses only an operator task (get_tasks_owned_by(None)), so the lookup does not pick up a user's task with the same name.3. Handlers declare guards (≈ +1,110 / −1,115, mostly mechanical)
perspectives_ws,neighbourhoods_ws,runtime_ws,ai_ws,agent_ws,users_ws,sfu_ws,hosting_ws,languages_ws,expressions_ws:ctx.require(&X)/refuse_user_sessionin the handler →.guard(Guard::…)on the registration. Unusedctx→_ctx.perspectives_ws.rs:get_perspective_with_accessanswers 404 "Perspective not found" for a perspective you may not reach (was 403 "Access denied"). Newguarded_perspective(params)only fetches, because the guard already checked access.perspective.get/perspective.snapshotare.or_null().neighbourhoods_ws.rs: every method that names a perspective isOwns { Perspective }. The owner check in the handler goes.neighbourhood.publishwithout access is refused.agent_ws.rs(Multi-user: users approve their own apps (consent, per-app capabilities, per-app revoke) #1399):agent.permitCapability→Owns { Request }.agent.revokeTokenandagent.removeApp→Owns { Grant }.manageable_grantsgoes.runtime_ws.rs,users_ws.rs:runtime.importData,runtime.grantNotification, theruntime.unyt*stubs anduser.credits→Guard::Operator.runtime.setFreeHostingEnabled→Executor(ALL_CAPABILITY).runtime.exportDataof one perspective checks the owner in the handler.sfu_ws.rs:Cap(NEIGHBOURHOOD_READ/UPDATE). The handlers check membership themselves, because noOwnercovers SFU rooms.4. Wording: "node" → "executor" (mechanical, ≈ ±10)
api/errors.rs,api/ws_rpc.rs,connect/src/core.ts,core/src/apiClient.test.ts: comments only.core/src/agent/AgentClient.ts: therevokeAllSessionsdoc names AGENT_UPDATE.mcp/tools/{auth,mod}.rs: comments name the new rule. No API change in the SDK.5. Tests (≈ +2,660 / −175)
api/tests/method_matrix_tests.rs(new, +1,414): reads every guard from the handler map. It callsHandlerMap::admitas operator, app with and without the capability, user A, user B and anonymous, on own, other, main-agent and missing resources.api/tests/handler_table_tests.rs(new):the_guard_table_in_agents_md_is_currentrenders the table from the map.api/tests/multi_user.rs(new): a multi-user executor fixture with signed-token sessions.ai_task_access_tests,neighbourhood_access_tests,runtime_access_tests,runtime_ws_tests(new): isolation per resource.node_operation_tests.rs→executor_operation_tests.rs(−130: the matrix replaces its per-method cases).connection_tests,notification_ws_tests,secret_leak_tests: fixture updates.tests/js/tests/multi-user-simple.test.ts: expects 404 "Perspective … not found" and 403 "Admin credential required".6. Docs (≈ +370 / −25)
api/AGENTS.md: new "Guards" section and the generated guard table betweenguard-tablemarkers.db/AGENTS.md(new),rust-executor/AGENTS.md,ai_service/AGENTS.md,js_core/AGENTS.md: guards, per-agent ownership,tasks.agent_did.Reading order:
Guardandowns()inapi/guards.rs→MethodFlags::guardandrequire_guardsinws_handler.rs→ one handler file (agent_ws.rs) →method_matrix_tests.rs. The other handler files follow the same pattern.Interfaces
Diagrams and tables
OpenOperatorCap(c)cccin the anonymous setExecutor(c)cOwns { Perspective }Owns { Task }Owns { Grant }Owns { Request }Within the reach of a caller: ✓. Outside it on
Owns: 404, the same as a missing resource.Size
From
git diff --numstat origin/feat/p20-secret...origin/feat/p20-guards(head 69e8451). Inline#[cfg(test)]modules are counted under Tests (515 added, 61 removed). The change-map groups above count whole files, inline tests included.Tests
--test-threads=1, 495e39e changes one JS test only)every_registered_method_has_a_guard. A map with an unguarded method does not build. Dispatch refuses onethe_guard_table_in_agents_md_is_currentanonymous_callers_reach_only_the_anonymous_capabilitys_methodsthe_operator_reaches_every_ai_task_and_a_main_agent_app_only_the_main_agents(the closed leak)a_user_approves_and_revokes_only_their_own_agents_requests_and_grants(#1399)ai_task_access_tests,neighbourhood_access_tests,runtime_access_tests,db::taskstest-main(local)test-multi-user-with-setup.sh(local)grantNotificationmessage. 495e39e updates the testtest-mcp(local)test-model(local, LLM tests against a local model)Manual test
A release build of 45b3c93, multi-user, on its own ports and data folder (
p20-manual-pr5.py). Callers: users A and B, the operator, an app with agent and perspective READ, an app with every capability on the main agent, and an anonymous caller. Checks run over WebSocket RPC.perspective.getof A's perspective →null, andperspective.alldoes not list itqueryLinks,addLink,update,removeon A's perspective → 404. A's link survivesaddLinkis refused (403). User A → 404agent.lock,ai.addModel,runtime.exportData,language.removerefused. The READ app:ai.addModelrefused. The operator:ai.addModelworksuser.freeAccess,runtime.setHostRates: an app with every capability and a user → 403 "Admin credential required". The operator worksagent.isLocked,runtime.infoanswerremoveTask/prompt→ 404. The task survivesremoveTask/prompton A's task → 404, andai.tasksdoes not list it. The operator lists it. A removes itneighbourhood.onlineAgentson A's perspective: B → 404, the app with every capability → 404, A passes the guardruntime.exportDataof A's perspective by the app with every capability → 404. The operator exports its ownpermitCapabilityon A's request → 404. A approves it (six-digit code). B and the app:removeAppof A's grant → 404. A removes it47/47 checks pass.
Docs
api/AGENTS.mdlists the guard of every method, generated from the handler map.the_guard_table_in_agents_md_is_currentfails when the table is stale.pnpm run generate:api-typesincore/rewrites it.Rework (2026-10-12)
45b3c93 and 495e39e, with the merge of the reworked PR 4 in 2c6e789. Since Data's approval at 0d5fcc1, this branch also has two of Data's points. 67817c3: the operator-only methods declare
Guard::Operator. eb5194a: "executor", not "node", soGuard::Nodeis nowGuard::Executor. The rework follows the model that the stack now uses. Nico: an app is not a separate kind of caller. Data: identity × grant. Josh decided that the code follows it.Open | Operator | Cap | Executor | Owns.CapreplacesAnonymousandSelfAgent. NoSelfAgentcapability is in the anonymous set, so the capability check alone already refused anonymous callers.Owns { owner: Perspective | Task | Grant | Request }replacesPerspective,Taskand the ownership checks for grants and approval requests in the handlers.perspective.getandperspective.snapshotkeep answeringnullthrough an.or_null()method flag.hides_existence,Admissionand 403 "Access denied" go.ai.taskslists only the main agent's tasks for it.agent.permitCapabilityisOwns { Request }(the owner of the request's target agent, or the operator).agent.revokeTokenandagent.removeAppareOwns { Grant }(the owner of the grant's agent, or the operator).api/AGENTS.mdis generated from the handler map and checked for staleness, likeRpcMethods.ts.Commits
Sizes from
p20-difsize.py(non-test / tests).Guard,.guard(), the dispatcher runs it, the method-matrix harness (63fc6b3)Guard::Nodeon 32 executor operations,refuse_user_sessionandrefuse_app_management_by_usersgo (3c50e85)runtime.setFreeHostingEnabledneeds*. Closes #1346, #1328Guard::Perspectiveon 24 perspective reads (5b852b6)Guard::Perspectiveon 24 perspective mutations (a651c2b)Guard::Open,Guard::Operator. A method without a guard stops the executor (95b9fe4)runtime.importData,runtime.grantNotification, theruntime.unyt*stubs declareGuard::Operator(67817c3)Guard::Node→Guard::Executor,user.creditsstub →Guard::Operator(eb5194a).or_null(), generated guard table, #1399 guards (45b3c93, 2026-10-12)grantNotificationrefuses managed users with the message of the operator guard (495e39e, 2026-10-12)One commit re-runs CI and changes nothing (8128bc0).
Whole PR at the rework (without PRs 1–4): non-test +1,586 / −1,081 (2,667 changed, net +505), tests +3,341 / −45 (3,386 changed), no generated code. That is 6,053 lines in full. Before the rework (0d5fcc1): non-test +1,439 / −1,686 (net −247), tests 2,858, 5,983 in full. The rework adds the
GrantandRequestowners (they replace handler checks that PR 2 and PR 4 added), the.or_null()flag and the table generator. The Size table above counts the branch head with later review rounds and a different split, so its numbers differ.D16: an agent is its DID
Merged from PR 4 (4d79e56).
Owns { Grant | Request | Task }compares agent DIDs.tasks.agent_didtasks.user_emailor from a table without the column: data from before D16 is not supported (2026-10-17)ai.addTask/ai.tasksagent_didReview round 3 (Data: #1415, then 4848882)
runtime.computeLoguser.revokeAllSessionsGuard::Cap(AGENT_UPDATE): an app that a user approved with a narrow subset cannot end the user's sessionsOwns(Perspective uuid, …),Owns(Request auth(JSON).requestId, …))may_approvegoes. TheOwns { Request }guard decides, and the handler caps the approval at the approver's capabilities (PR 4).Review round 4 (Data, b244755) and #1207 (22f1aa9)
IdParamthat their guard checked (IdParam::require)agent.permitCapabilitywith tworequestIds answers 400 and approves nothinga or b(a|split the table cell)perspective.resyncSubscription(new) andperspective.disposeQuerytakeOwns(Perspective uuid, perspective:READ), like the subscribe methods.keepAliveQueryandkeepAliveSparqlare gone. 168 methods at that commit.Behaviour
Against
dev. Every method not listed answers every caller with the same code and message as before.perspective.*andneighbourhood.*method and onruntime.exportDataof one perspective. Before: 403 "Access denied" (RPC) or 404 (MCP).perspective.getandperspective.snapshotstill answernull.ai.updateTask,ai.removeTaskandai.prompton the task of another agent, or a missing id, answer 404 "Task not found" before the handler runs, for apps and users.ai.taskslists the tasks of the caller's agent. The operator sees all.ai.addTaskrecords the owner (new columntasks.agent_did, see D16). The interpretation task lookup reads the main agent's tasks only.sendBroadcast,sendSignal,setOnlineStatus,hasTelepresence,onlineAgents,otherAgents,publish) follow the owner of the perspective. A user gets 404 on perspectives it does not own: the perspectives of another user, of the main agent, and unowned ones. An app gets 404 on a user's perspective. The operator now reachesotherAgentson a user's perspective.publishwithout access: 404 (before: the call added the caller to the owners of the perspective). A missing perspective: 404 (before: 500 onpublish).runtime.exportDataof a perspective: an app gets 404 on a user's perspective.verifyFlowReceiptwith a bad receipt gets 404, not 400.agent.permitCapabilityon a request for another agent: 404 "Capability request not found: it may have expired. The app must request access again".agent.revokeToken/agent.removeAppon the grant of another agent: 404 "App not found: no app of yours matches this token or request id".agent.removeAppon a missing grant: 404 (was 500).runtime.setFreeHostingEnabled(needs*).runtime.importDataandruntime.grantNotificationanswer 403 "Admin credential required" to everyone but the operator. Before, the handlers answered with their own messages, for example "Permission denied". Theruntime.unyt*stubs anduser.creditsanswer non-operators 403 instead of 501. The operator still gets 501.Decisions for review
Guard::Executorkeeps delegation to grants on the main agent (D5). WE web administers local executors with an app token that holds*: AI model management,permitCapability, trusted agents, lock / unlock. Operator-only would break it. The Plan 20 doc said both answers act alike on a single-agent executor. That is wrong (an app token is never the operator), and the doc is corrected.nullvariants and the remapping in MCP. The two reads that answerednullkeep it through.or_null().CapmergesAnonymousandSelfAgent. NoSelfAgentcapability is in the anonymous set.anonymous_callers_reach_only_the_anonymous_capabilitys_methodspins which methods anonymous callers reach.Cap(agent.getTrustedAgents,ai.models, the language reads, link-language templates,runtime.hcAgentInfos,networkMetrics,hostRates). Users reach them today. RFC RFC: declare authorization in the RPC contract (guards enforced by the dispatcher) #1351 open question 1 asks about each method. Each move is a one-line follow-up.agent.getAppsisCapand safe: PR 2 made it list only the grants of the caller's agent.Guard::OpenandGuard::Operatorare additions to the RFC. Without them, 21 methods could not get a guard without a change in their answers.Seen, not changed (follow-up): an app with
AGENT_UPDATEon the main agent canruntime.exportDatathe whole database (Executor(agent:UPDATE)), including password hashes, to any path the executor process can write (filePath). This predates the stack.Changelog
Breaking: a resource that the caller may not reach answers 404, the same as a missing one: perspectives (every
perspective.*andneighbourhood.*method,runtime.exportDataof one perspective), AI tasks, apps (agent.revokeToken,agent.removeApp) and capability requests (agent.permitCapability). Before, a perspective that exists but is not yours answered 403 "Access denied".perspective.getandperspective.snapshotstill answernull.agent.removeAppon a missing app answers 404 (was 500).Breaking: in multi-user mode, a session reaches only the AI tasks of its own agent. Tasks created before the upgrade become the main agent's. A main-agent app no longer reaches the tasks of users.
Breaking: neighbourhood calls follow the owner of the perspective.
neighbourhood.publishwithout access is refused. Before, it added the caller to the owners.Breaking:
runtime.setFreeHostingEnabledneeds the full capability (*), notRUNTIME_QUIT(#1328).runtime.importData,runtime.grantNotification, theruntime.unyt*stubs anduser.creditsare operator-only: 403 "Admin credential required".Every RPC method declares a guard that checks the caller's capability and ownership before the handler runs. A method without a guard stops the executor at start. Users approve, list and revoke apps on their own agent (#1399, executor side).
@lucksus @data-bot-coasys