Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
70 commits
Select commit Hold shift + click to select a range
63fc6b3
feat(api): methods can declare a guard; the dispatcher runs it before…
HexaField Oct 11, 2026
3c50e85
feat(api)!: Guard::Node on the 32 node operations (Plan 20 commit 20-5b)
HexaField Oct 11, 2026
5b852b6
feat(api): Guard::Perspective on the 24 perspective read methods (Pla…
HexaField Oct 11, 2026
a651c2b
feat(api): Guard::Perspective on the 24 perspective mutations (Plan 2…
HexaField Oct 11, 2026
95abb50
Merge branch 'feat/p20-secret' into feat/p20-guards
HexaField Oct 11, 2026
e0c9f08
feat(api): guards on the remaining handler files (Plan 20 commits 20-…
HexaField Oct 11, 2026
25422e0
feat(api)!: multi-user isolation on guards: AI tasks, neighbourhoods,…
HexaField Oct 11, 2026
95b9fe4
feat(api): every method has a guard; an unguarded one stops the execu…
HexaField Oct 11, 2026
37e4caa
Merge branch 'feat/p20-secret' into feat/p20-guards
HexaField Oct 11, 2026
8128bc0
chore: re-run CI (integration-tests-mcp: runner tst-tmp ENOENT)
HexaField Oct 11, 2026
0d5fcc1
Merge branch 'feat/p20-secret' into feat/p20-guards
HexaField Oct 11, 2026
3505c20
Merge branch 'feat/p20-secret' into feat/p20-guards
HexaField Oct 11, 2026
e770e95
Merge branch 'feat/p20-secret' into feat/p20-guards
HexaField Oct 11, 2026
67817c3
fix(api): declare the operator-only methods Guard::Operator (Data)
HexaField Oct 11, 2026
9d8a8fe
Merge branch 'feat/p20-secret' into feat/p20-guards
HexaField Oct 11, 2026
eb5194a
refactor(api): say executor, not node; Guard::Node becomes Guard::Exe…
HexaField Oct 11, 2026
2c6e789
Merge branch 'feat/p20-secret' into feat/p20-guards
HexaField Oct 12, 2026
45b3c93
refactor(api)!: five guards — Open, Operator, Cap, Executor, Owns; mi…
HexaField Oct 12, 2026
495e39e
test(multi-user): grantNotification refuses managed users with the op…
HexaField Oct 12, 2026
1e32b98
Merge branch 'feat/p20-secret' into feat/p20-guards
HexaField Oct 12, 2026
4d79e56
Merge PR 4 (DID-keyed agents, D16) into PR 5; AI tasks keyed by agent…
HexaField Oct 12, 2026
68cdf5e
Merge branch 'feat/p20-secret' into feat/p20-guards
HexaField Oct 12, 2026
8d8168f
test(guards): a main-agent app reads no user's compute log; a narrow …
data-bot-coasys Oct 12, 2026
bfa2c4a
fix(guards): only the operator names a user's compute log; revokeAllS…
data-bot-coasys Oct 12, 2026
2342e4d
Merge branch 'feat/p20-secret' into feat/p20-guards
HexaField Oct 12, 2026
67d2b18
Merge branch 'fix/p20-5-guards-review' of github.com:coasys/ad4m into…
HexaField Oct 12, 2026
6e9baf0
Merge PR 4 (Data's review: approver cap, flood, read-only lookup) int…
HexaField Oct 12, 2026
4848882
review(guards): a main-agent app reaches no user's transcription stre…
HexaField Oct 12, 2026
e0c9a45
Merge PR 4 (dev: #1381 multi-class model query) into PR 5; model quer…
HexaField Oct 12, 2026
179a0ad
Merge branch 'feat/p20-secret' into feat/p20-guards
HexaField Oct 12, 2026
404e68e
Merge branch 'feat/p20-secret' into feat/p20-guards
HexaField Oct 12, 2026
b6fb7ec
chore: re-run CI (integration-tests-model: flow-task-handover propose…
HexaField Oct 12, 2026
6820f7a
chore: re-run CI (integration-tests-multi-node-holochain: 1500-link s…
HexaField Oct 12, 2026
81bfe78
Merge PR 4 (Data's round-4 review: #1422 fullest-target eviction, 429…
HexaField Oct 13, 2026
b244755
review(guards): handlers read owner ids through their guard's IdParam…
HexaField Oct 13, 2026
f8b7269
Merge PR 4 (dev: #1207) into PR 5; live-query methods keep the perspe…
HexaField Oct 13, 2026
22f1aa9
Merge PR 4 (test fixes) into PR 5
HexaField Oct 13, 2026
0f9c417
Merge PR 4 (dev: #1425) into PR 5; flowStates and flowFired take the …
HexaField Oct 13, 2026
43e6eb3
Merge branch 'feat/p20-secret' into feat/p20-guards
HexaField Oct 13, 2026
51a2dea
Merge branch 'feat/p20-secret' into feat/p20-guards
HexaField Oct 13, 2026
481cb37
Merge branch 'feat/p20-secret' into feat/p20-guards
HexaField Oct 14, 2026
5849772
Merge branch 'feat/p20-secret' into feat/p20-guards
HexaField Oct 14, 2026
cc4d001
Merge branch 'feat/p20-secret' into feat/p20-guards
HexaField Oct 14, 2026
c987610
Guard #712's sfu.* methods and neighbourhood.ownOnlineStatus
HexaField Oct 14, 2026
9235f04
Merge branch 'feat/p20-secret' into feat/p20-guards
HexaField Oct 15, 2026
34ddf15
Merge PR 4 (dev: #1440, #1450, #1459, #1407, #1383, #1406) into PR 5
HexaField Oct 15, 2026
7e076e9
docs(api): the guard table counts dev's updateSubject and removeSubject
HexaField Oct 15, 2026
cf4d339
Merge feat/p20-secret into feat/p20-guards
HexaField Oct 16, 2026
e0d304d
Merge feat/p20-secret (#1476) into feat/p20-guards
HexaField Oct 16, 2026
e0847b0
Merge feat/p20-secret (D16 migrations dropped) into feat/p20-guards
HexaField Oct 17, 2026
5a41f92
Merge branch 'feat/p20-secret' into feat/p20-guards
HexaField Oct 17, 2026
5a5b1d7
Merge branch 'feat/p20-secret' into feat/p20-guards
HexaField Oct 17, 2026
560a987
fix(db): restore the tasks.agent_did column add for v0.12.0 data folders
HexaField Oct 18, 2026
136b489
Merge branch 'feat/p20-secret' into feat/p20-guards
HexaField Oct 18, 2026
22abd88
Merge branch 'feat/p20-secret' into feat/p20-guards
HexaField Oct 18, 2026
18abe4a
Merge dev (#1390 merged) into feat/p20-guards
HexaField Oct 18, 2026
8fd7df9
Merge feat/p20-secret (drop the apps_data.json migration)
HexaField Oct 19, 2026
330801a
Merge feat/p20-secret (flow-state-changed audience fix)
HexaField Oct 19, 2026
71e7ee9
docs: Data's nits (#1395): the requestId duplicate rests on serde; NU…
HexaField Oct 19, 2026
8e985ba
Merge feat/p20-secret (dev update)
HexaField Oct 20, 2026
6ccbbfa
Merge feat/p20-secret (revoke-all retry fix)
HexaField Oct 20, 2026
245eab8
Merge feat/p20-secret (dev 79ffe5d3f)
HexaField Oct 21, 2026
145b471
Merge branch 'feat/p20-secret' into feat/p20-guards
HexaField Oct 21, 2026
e3a3565
Merge branch 'feat/p20-secret' into feat/p20-guards
HexaField Oct 22, 2026
69e8451
ci: retrigger (sfu-e2e crossed audio after rejoin, SFU bug being fixe…
HexaField Oct 22, 2026
de90c3a
Merge feat/p20-secret (dev 13a146d27, #1532)
HexaField Oct 23, 2026
f47773a
docs(api): exportData's db export checks nothing past the guard (Data…
HexaField Oct 23, 2026
71c62b7
Merge feat/p20-secret (#1445 test port)
HexaField Oct 23, 2026
ca17a11
Merge feat/p20-secret (fmt)
HexaField Oct 23, 2026
dc22a2d
Merge feat/p20-secret (#1532 update)
HexaField Oct 23, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion connect/src/core.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ const LOCKED_WALLET = "Cannot extractByTags from a ciphered wallet. You must unl
/** How the executor's close reason (code 1013) and 503 begin when its wallet is locked. */
const EXECUTOR_LOCKED = "Executor is locked";

/** Whether `error` says the node's wallet is locked: a state of the node, so the token stays. */
/** Whether `error` says the executor's wallet is locked: a state of the executor, so the token stays. */
const saysLocked = (error: any): boolean => {
const text = errorText(error);
return text === LOCKED_WALLET || (typeof text === 'string' && text.startsWith(EXECUTOR_LOCKED));
Expand Down
2 changes: 1 addition & 1 deletion core/src/agent/AgentClient.ts
Original file line number Diff line number Diff line change
Expand Up @@ -297,7 +297,7 @@ export class AgentClient {
/**
* Revokes every token of a user issued up to now, this client's own included, and closes
* their sockets. A user leaves out `email` (or names themselves); the operator names the
* user. Tokens from a later login work.
* user. Needs AGENT_UPDATE, which a login holds. Tokens from a later login work.
*/
async revokeAllSessions(email?: string): Promise<boolean> {
return this.#apiClient.call('user.revokeAllSessions', email === undefined ? {} : { email });
Expand Down
2 changes: 1 addition & 1 deletion core/src/apiClient.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -441,7 +441,7 @@ describe('ApiClient when the executor refuses the token', () => {
expect(authFailed).not.toHaveBeenCalled()
})

// A locked wallet is a state of the node: the token may be good. 1013 is not an auth failure.
// A locked wallet is a state of the executor: the token may be good. 1013 is not an auth failure.
it('treats close code 1013 (executor locked) as an ordinary close that carries its reason', async () => {
const LOCKED = 'Executor is locked: ask the operator to unlock it, then reconnect'
open()
Expand Down
18 changes: 13 additions & 5 deletions rust-executor/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,7 @@ Some tests in `perspectives/*_e2e.rs` and `flow_context/real_llm_e2e.rs` call re
| `languages/` + `js_core/` | Language runtime: one Deno isolate per Language on its own thread; install, templates, expressions | `src/languages/AGENTS.md`, `src/js_core/AGENTS.md` |
| `holochain_service/` | Embedded Holochain conductor behind an actor channel; signal fan-in | `src/holochain_service/AGENTS.md` |
| `agent/` | Agent keys/DID, signing, multi-user (managed agents by DID; a user's email is only the login name), `capabilities/` (auth tokens, capability defs) | — |
| `db.rs` | All SQLite access (`rusqlite`), single `impl Ad4mDb`, accessed via `Ad4mDb::with_global_instance(closure)`. Perspective **links** are not here (they are in `perspectives/sparql_store.rs`); `db` holds handles, diffs, agent data, AI models/tasks, users, billing, notifications. | — |
| `db.rs`, `db/` | All SQLite access (`rusqlite`): `impl Ad4mDb` in `db.rs` plus child modules in `db/`, accessed via `Ad4mDb::with_global_instance(closure)`. Perspective **links** are not here (they are in `perspectives/sparql_store.rs`); `db` holds handles, diffs, agent data, AI models/tasks, users, billing, notifications. | `src/db/AGENTS.md` |
| `db_backend.rs` | `SharedDb` HTTP backend for stateless/hosted mode; only used when `config.db_backend == "shared"` | — |
| `pubsub.rs` | Global broadcast bus: one channel of typed events (`events.rs`: `Topic`, `Audience`, `Event`) for every event (live query updates too, as `Event::for_connection`); feeds `api/events_ws.rs` | `src/api/AGENTS.md` |
| `types/` | `core.rs` = domain types (Link, Expression, Perspective…), `domain.rs` = wire/input types; some duplicates, see spec item 5 | — |
Expand Down Expand Up @@ -101,11 +101,19 @@ Some tests in `perspectives/*_e2e.rs` and `flow_context/real_llm_e2e.rs` call re
- **Billing** is per agent: `billing::bill_compute(agent_did, ..)`, `compute_log.agent_did`,
`payment_requests.agent_did`. Credits stay on the user's `users` row, reached by
DID (`get_agent_credits`, `deduct_credits_and_log`).
- **Capabilities**: every WS handler checks `ctx.require(&X_CAPABILITY)` itself
(`agent/capabilities/defs.rs`); `Session::may` answers the same question as a `bool`. MCP tools
check via `Ad4mMcpHandler::get_*_perspective`.
- **Capabilities**: every WS method declares a guard where it registers (`api/guards.rs`); the
dispatcher checks its capability (`agent/capabilities/defs.rs`) and ownership rule before the
handler runs. A handler calls `ctx.require(&X_CAPABILITY)` only for a check its guard does not
make; `Session::may` answers the same question as a `bool`. MCP tools check via
`Ad4mMcpHandler::get_*_perspective`.
- **Per-agent objects**: a session reaches only its agent's; the operator reaches all.
Perspectives are owned by DID (`owners`; none = the main agent), AI tasks by DID
(`tasks.agent_did`; `NULL` = the main agent), grants and capability requests by their agent's
DID. A WS method that names one declares `Guard::Owns` (`api/AGENTS.md`); missing and not
yours are both 404. The capability check alone does not
separate users: user tokens hold wildcard perspective, neighbourhood and AI capabilities.
- **Capability requests** (#1399, executor side): `agent.requestCapability(authInfo, agent?)`
names a target agent (a managed user's email; absent = the main agent) and records a
names a target agent (a managed agent's DID, or its user's email, resolved to the DID at once; absent = the main agent) and records a
`PendingRequest` in memory only (`agent/capabilities/pending.rs`: never on disk, anonymous
callers create them; 1024 at most, unapproved evicted first). The approval request event goes to
the target agent's DID with `AGENT_PERMIT`. `agent.permitCapability` (the operator, or a
Expand Down
3 changes: 2 additions & 1 deletion rust-executor/src/agent/capabilities/defs.rs
Original file line number Diff line number Diff line change
Expand Up @@ -512,7 +512,8 @@ pub fn get_user_default_capabilities() -> Vec<Capability> {
AGENT_SIGN_CAPABILITY.clone(),
AGENT_SUBSCRIBE_CAPABILITY.clone(),
// Approving apps' requests for this user's own agent (#1399, D12):
// `capabilities::may_approve` limits it to requests that name this agent.
// `agent.permitCapability`'s guard (`Owns { Request }`) limits it to requests that
// name this agent.
AGENT_PERMIT_CAPABILITY.clone(),
// Note: Excluding LOCK, UNLOCK as these are admin operations

Expand Down
72 changes: 13 additions & 59 deletions rust-executor/src/agent/capabilities/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,6 @@ pub use token::*;
pub use types::*;

use crate::agent::grants::now;
use crate::agent::session::Session;
use crate::agent::AgentContext;
use crate::events::Event;
use crate::pubsub::get_global_pubsub;
Expand Down Expand Up @@ -295,8 +294,13 @@ pub fn capabilities_from_token(
/// so anonymous callers cannot list them.
pub fn anonymous_capabilities() -> Vec<Capability> {
use crate::db::Ad4mDb;
let multi_user_enabled =
Ad4mDb::with_global_instance(|db| db.get_multi_user_enabled().unwrap_or(false));
anonymous_capabilities_when(Ad4mDb::with_global_instance(|db| {
db.get_multi_user_enabled().unwrap_or(false)
}))
}

/// [`anonymous_capabilities`] with multi-user mode on or off.
pub fn anonymous_capabilities_when(multi_user_enabled: bool) -> Vec<Capability> {
if multi_user_enabled {
vec![
AGENT_AUTH_CAPABILITY.clone(),
Expand Down Expand Up @@ -486,32 +490,20 @@ pub fn request_target(request_id: &str) -> Option<RequestTarget> {
PendingRequests::global().target(request_id, now())
}

/// Whether `session` may approve a capability request for `target` (D12): the operator, or a
/// session that acts as `target` (the same DID) and holds AGENT_PERMIT (a user on their own
/// agent; on the main agent, an app the operator gave AGENT_PERMIT). Nobody approves for
/// another agent; only the operator approves a request for [`RequestTarget::NoAgent`], and
/// its code redeems nothing. An anonymous caller approves nothing, whatever it holds: it acts
/// as the main agent only in name.
pub fn may_approve(session: &Session, target: &RequestTarget) -> bool {
session.is_operator()
|| session.is_authenticated()
&& target.agent().is_some_and(|agent| {
session.acts_as() == *agent && session.may(&AGENT_PERMIT_CAPABILITY)
})
}

/// Approves a pending request with `auth_info_extended.auth` as the operator, which holds
/// every capability: [`permit_capability_within`] with no limit beyond the request's agent.
/// For the operator and dev-mode auto-permit; a session approves with its own capabilities.
pub fn permit_capability(auth_info_extended: AuthInfoExtended) -> Result<String, String> {
permit_capability_within(auth_info_extended, &[ALL_CAPABILITY.clone()])
permit_capability_within(auth_info_extended, std::slice::from_ref(&*ALL_CAPABILITY))
}

/// Approves a pending request with `auth_info_extended.auth`, which may grant less than the
/// app asked for, and returns the code the app redeems: for the approver only. The request's
/// agent stays the one it named. The caller has checked [`may_approve`]. An approver grants
/// no more than it holds itself (`approver`, its session's capabilities): an app with
/// AGENT_PERMIT approves only within its own grant, so it cannot mint a wider one.
/// agent stays the one it named. The caller has checked that the approver owns that agent
/// (`agent.permitCapability`'s guard, `api::guards::Owner::Request`; MCP's
/// `caller_can_permit`). An approver grants no more than it holds itself (`approver`, its
/// session's capabilities): an app with AGENT_PERMIT approves only within its own grant, so
/// it cannot mint a wider one.
pub fn permit_capability_within(
auth_info_extended: AuthInfoExtended,
approver: &[Capability],
Expand Down Expand Up @@ -1333,44 +1325,6 @@ mod app_token_tests {
assert_eq!(caps_text(&caps), caps_text(&reader));
}

// D12: the operator approves anything; a session approves only for the agent it acts as,
// and only while it holds AGENT_PERMIT.
#[test]
fn only_the_operator_or_the_agent_with_permit_may_approve() {
use crate::agent::session::{Principal, Session};
let main = AgentContext::main_agent();
let alice = AgentContext::managed("did:key:z6MkAlice".into());
let bob = AgentContext::managed("did:key:z6MkBob".into());
let permit = vec![
AGENT_PERMIT_CAPABILITY.clone(),
AGENT_READ_CAPABILITY.clone(),
];
let no_permit = vec![AGENT_READ_CAPABILITY.clone()];
let session = |agent: &AgentContext, caps: &Vec<Capability>| {
Session::for_test(Principal::Agent(agent.clone())).with_caps(caps.clone())
};
let targets: [RequestTarget; 4] = [
main.clone().into(),
alice.clone().into(),
bob.clone().into(),
RequestTarget::NoAgent,
];

let operator = Session::for_test(Principal::Operator);
assert!(targets.iter().all(|t| may_approve(&operator, t)));

let anonymous = Session::for_test(Principal::Anonymous).with_caps(permit.clone());
assert!(targets.iter().all(|t| !may_approve(&anonymous, t)));

for (agent, own) in [(&main, &targets[0]), (&alice, &targets[1])] {
assert!(may_approve(&session(agent, &permit), own), "{agent:?}");
assert!(!may_approve(&session(agent, &no_permit), own), "{agent:?}");
for other in targets.iter().filter(|t| *t != own) {
assert!(!may_approve(&session(agent, &permit), other), "{agent:?}");
}
}
}

#[tokio::test]
async fn redeeming_caps_a_wider_approval_at_the_managed_agents_capabilities() {
setup();
Expand Down
5 changes: 3 additions & 2 deletions rust-executor/src/ai_service/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,8 +16,9 @@ Split plan: spec item 9 (`mod.rs`), item 7 (`harness/` → `agentic/`).
| rest | tests |

Persistence of models/tasks is in `db.rs` (`models`, `tasks`, `model_status`,
`default_models` tables). Billing goes through `crate::billing` with the caller's
`AgentContext`/email.
`default_models` tables). Tasks have an owner (`tasks.user_email`, `NULL` for operator
tasks; see `src/db/AGENTS.md`): `add_task` takes it, and the WS guards enforce it.
Billing goes through `crate::billing` with the caller's `AgentContext`/email.

## `harness/`

Expand Down
Loading
Loading