Skip to content

fix(kernel): scope self:agent:list to the caller's own row - #848

Merged
joshuajbouw merged 1 commit into
mainfrom
fix/self-agent-list-roster-filter
Jun 5, 2026
Merged

fix(kernel): scope self:agent:list to the caller's own row#848
joshuajbouw merged 1 commit into
mainfrom
fix/self-agent-list-roster-filter

Conversation

@joshuajbouw

@joshuajbouw joshuajbouw commented Jun 5, 2026

Copy link
Copy Markdown
Member

Linked Issue

Closes #847

Summary

AgentList always resolves to AuthorityScope::Self_, so the required cap is self:agent:list (held by the agent builtin via self:*). That cap-lowering is intentional, but agent_list returned every principal's profile regardless of the caller — so any ordinary agent could enumerate every other principal's id / groups / grants / revokes via GET /api/sys/principals. The gateway already documents "the kernel filters server-side"; the kernel never implemented it. Found by the CLI/API sysadmin parity audit.

Changes

  • handlers.rs: agent_list now takes the caller and filters to the caller's own row unless the caller holds the global agent:list capability. New helper caller_has_global_agent_list (fail-secure: unresolvable profile → self-only).
  • GroupList intentionally left a full read — system config, needed for cap-inheritance resolution, not per-principal data.
  • State-test fixture() now admin-seeds the default principal to mirror production (Kernel::new's seed_default_principal_admin_profile).
  • New test agent_list_filters_to_self_for_non_admin_caller; the existing agent_list_returns_every_home_dir_principal still passes.
  • CHANGELOG ### Security entry.

Precision note (from adversarial review)

The full roster requires both halves: the AgentList preamble independently requires self:agent:list, and a bare global agent:list grant does not satisfy it (the grammar does not make a global cap imply its self:-scoped form — capability_matches("agent:list","self:agent:list") is false). So in practice only the admin group's * (which matches both) sees everyone. This is pre-existing and fail-closed; the surprising 'a bare agent:list grant can't list' interaction is filed separately as a design question in #849 (not addressed here).

Test Plan

  • cargo test -p astrid-kernel --lib136/136 pass (sandbox off; under the sandbox the only 2 failures are socket::tests UnixListener::bind PermissionDenied).
  • cargo test -p astrid-kernel admin:: — 68/68.
  • cargo clippy --workspace --all-features -- -D warnings (CI-exact) — clean for the lib changes.
  • cargo fmt -p astrid-kernel --check — clean.

⚠️ Behavioural change — please confirm

This narrows what an API caller holding only self:agent:list sees on GET /api/sys/principals (and the client-side GET /api/sys/principals/:id): the full roster → just their own row. Fail-secure, matches the gateway's documented contract, CLI unaffected (default is admin-seeded). Flagged because it changes a response shape for self-scoped callers.

@gemini-code-assist

Copy link
Copy Markdown
Contributor

Summary of Changes

Hello, I'm Gemini Code Assist1! I'm currently reviewing this pull request and will post my feedback shortly. In the meantime, here's a summary to help you and other reviewers quickly get up to speed!

This pull request addresses an information disclosure vulnerability where the AgentList handler was returning the full principal roster to any caller, regardless of their authorization level. By implementing server-side filtering, the kernel now restricts non-admin callers to seeing only their own principal data, aligning the implementation with the documented gateway contract.

Highlights

  • Security Fix: Agent Roster Information Disclosure: The AgentList handler now correctly filters results to the caller's own profile unless they possess the global agent:list capability, preventing unauthorized enumeration of other principals.
  • Capability Enforcement: Introduced caller_has_global_agent_list to verify administrative permissions, ensuring that self-scoped agents (who only hold self:agent:list) cannot access the full roster.
  • Test Infrastructure Update: Updated the state-test fixture() to properly seed the default principal with admin privileges, ensuring tests accurately reflect production behavior and authority-scope filtering.
Using Gemini Code Assist

The full guide for Gemini Code Assist can be found on our documentation page, here are some quick tips.

Invoking Gemini

You can request assistance from Gemini at any point by creating a comment using either /gemini <command> or @gemini-code-assist <command>. Below is a summary of the supported commands on the current page.

Feature Command Description
Code Review /gemini review Performs a code review for the current pull request in its current state.
Pull Request Summary /gemini summary Provides a summary of the current pull request in its current state.
Comment @gemini-code-assist Responds in comments when explicitly tagged, both in pull request comments and review comments.
Help /gemini help Displays a list of available commands.

Customization

To customize the Gemini Code Assist for GitHub experience, repository maintainers can create a configuration file and/or provide a custom code review style guide (such as PEP-8 for Python) by creating and adding files to a .gemini/ folder in the base of the repository. Detailed instructions can be found here.

Limitations & Feedback

Gemini Code Assist may make mistakes. Please leave feedback on any instances where its feedback is incorrect or counterproductive. You can react with 👍 and 👎 on @gemini-code-assist comments. If you're interested in giving your feedback about your experience with Gemini Code Assist for GitHub and other Google products, sign up here.


The roster was open to all, An agent could see every wall. But now it is tight, Filtered by right, And safety is back in the hall.

Footnotes

  1. Review the Privacy Notices, Generative AI Prohibited Use Policy, Terms of Service, and learn how to configure Gemini Code Assist in GitHub here. Gemini can make mistakes, so double check it and use code with caution.

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

This pull request addresses an information disclosure vulnerability where any ordinary agent could enumerate all other principals' profiles via the AgentList request. The kernel router's agent_list handler has been updated to check if the caller holds the global agent:list capability; if not, the returned list is filtered to only include the caller's own profile. Additionally, the test fixture was updated to seed the default admin profile, and a new integration test was added to verify the filtering behavior for non-admin callers. There are no review comments to address, and the implementation is clean and well-tested.

Important

The consumer version of Gemini Code Assist on GitHub is being sunset. Starting June 18, 2026, new organization installations will be blocked, and all code review activity will officially cease on July 17, 2026.
For more details on the timeline and next steps, please review the Help Documentation.

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR fixes an information disclosure in the kernel’s AgentList admin handler by enforcing server-side filtering consistent with the gateway contract: callers with only self:agent:list can no longer enumerate other principals’ profiles, while callers with global agent:list (effectively admin via *) still see the full roster.

Changes:

  • Pass the authenticated caller into agent_list and filter results to the caller’s own row unless the caller also holds global agent:list.
  • Update state-test setup to mirror production by seeding the default principal with the admin group, and add a regression test asserting self-only listing for non-admin callers.
  • Add a ### Security changelog entry describing the disclosure and the new behavior.

Reviewed changes

Copilot reviewed 3 out of 3 changed files in this pull request and generated no comments.

File Description
crates/astrid-kernel/src/kernel_router/admin/handlers.rs Adds a caller-aware agent_list implementation with a global-capability gate to prevent principal roster disclosure.
crates/astrid-kernel/src/kernel_router/admin/state_tests.rs Seeds default as admin in the fixture and adds a regression test proving non-admin callers only see themselves.
CHANGELOG.md Documents the security fix and the resulting behavioral change for self:agent:list callers.
Comments suppressed due to low confidence (1)

crates/astrid-kernel/src/kernel_router/admin/handlers.rs:473

  • For callers without global agent:list, agent_list still scans etc/profiles/ and resolves every principal profile before retain(...) narrows the output to a single row. That makes the self-scoped path more expensive than necessary and allows low-privilege agents to trigger repeated full-profile directory scans even though they can only ever see themselves.

Consider short-circuiting when the caller lacks global agent:list: resolve the caller profile and return a single AgentSummary without iterating the directory.

fn agent_list(kernel: &Arc<crate::Kernel>, caller: &PrincipalId) -> AdminResponseBody {
    // Source of truth: `etc/profiles/{principal}.toml`. Iterating the
    // home directory was the pre-#672 approach but stopped working
    // when profiles moved out — and was always wrong in spirit since
    // a principal's home dir can outlive its policy file (e.g. after
    // `agent.delete`, where home stays as an ops concern but the
    // profile is removed).
    let profiles_dir = kernel.astrid_home.profiles_dir();

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

AgentList always resolves to AuthorityScope::Self_, so the required
cap is self:agent:list — which the agent builtin holds via self:*.
That lowering is deliberate (an agent resolves its own group-inherited
caps), but agent_list returned EVERY principal's profile regardless of
the caller: any ordinary agent could enumerate every other principal's
id, groups, grants and revokes via GET /api/sys/principals. The
gateway already documented 'the kernel filters server-side' — the
kernel never implemented it.

agent_list now filters to the caller's own row unless the caller also
holds the global agent:list capability (agent:list / agent:* / *).
self:* does not match agent:list, so self-scoped callers are narrowed;
an unresolvable profile fails secure to self-only. GroupList is left a
full read (system config, needed for cap-inheritance resolution).

The state-test fixture now admin-seeds the default principal to mirror
production (Kernel::new seeds it), so dispatch-through-default keeps
admin authority. CLI is unaffected. Behavioural change for API callers
holding only self:agent:list.

Surfaced by the CLI/API sysadmin parity audit.

Closes #847
@joshuajbouw
joshuajbouw force-pushed the fix/self-agent-list-roster-filter branch from 130ba79 to 5583ea6 Compare June 5, 2026 17:02
@joshuajbouw
joshuajbouw merged commit 7282a70 into main Jun 5, 2026
13 checks passed
@joshuajbouw
joshuajbouw deleted the fix/self-agent-list-roster-filter branch June 5, 2026 17:12
@joshuajbouw joshuajbouw mentioned this pull request Jun 10, 2026
joshuajbouw added a commit that referenced this pull request Jun 10, 2026
## Linked Issue

Closes #853

## Summary

`chore: release` — bumps all workspace crates **0.7.0 → 0.8.0** and
rolls `CHANGELOG [Unreleased]` into `[0.8.0] - 2026-06-10`. Consolidates
the ~50 PRs landed since v0.7.0:

- **HTTP admin gateway** (#756 + follow-ups) — `astrid-gateway`:
principals/caps/quotas/groups/invites/env over HTTP, audit SSE +
historical queries, agent-prompt SSE, OpenAPI emission, bus-direct admin
path (285× throughput), rustls TLS, CORS, metrics, invite + keypair CLI
verbs.
- **Runtime concurrency overhaul** (#813/#816/#819/#820) — routed
per-(capsule, topic, principal) IPC with DRR fairness, async Wasmtime,
dynamic instance pools, split blocking/IO semaphores, per-principal fuel
+ memory ledgers with enforcement and usage reporting.
- **Host process + introspection surface** — `astrid:process` persistent
tier (#866/#867/#871/#873), capability introspection (#868/#869),
`astrid mcp serve` (#879/#880), `astrid-emit` (#814/#842).
- **Security** — macOS 15+ sandbox no longer silently disabled
(#855/#857), audit-feed principal scoping (#850/#851), failed-redeem
audit rows (#846), `self:agent:list` roster leak (#848), pair-device
redeem rate-limit (#844), bearer revocation + wire format v2 (#772).

Breaking: `Capsule.toml` `[publish]`/`[subscribe]`-only IPC surface
(#858/#864/#865, `[[interceptor]]` / `ipc_*` arrays / `[[topic]]`
removed), bearer v2, MSRV 1.95, `astrid-openclaw` removed (#833).

## Changes

- `[workspace.package].version` `0.7.0` → `0.8.0` + the 23 in-workspace
path-dependency pins (now including `astrid-emit`; `axum`/`axum-server`
stay on their external 0.7).
- CHANGELOG: `[Unreleased]` → `[0.8.0] - 2026-06-10` with a release
**synopsis** (extracted into the GitHub release body by release.yml on
tag). Keeps the earlier roll's curation (canonical section order,
bullets verbatim) and merges the 13 entries added to `[Unreleased]` by
the 12 PRs that landed after the branch was first cut (#851#885) —
verified both directions that no bullet was lost.

Branch history note: rebuilt on current `main` (was `CONFLICTING` and 12
commits behind); previous tip was `54c7294`.

## Release steps (maintainer)

1. Merge this PR.
2. Tag `v0.8.0` on the merge commit → release.yml creates the GitHub
release from the CHANGELOG block.
3. Publish/distribute the new `astrid` binaries as usual.

## Test Plan

Release PR — no code changes. `cargo check --workspace` passes on the
rebuilt branch; full test/clippy/MSRV matrix runs in CI. The changelog
merge was verified bullet-by-bullet in both directions: every
`[Unreleased]` bullet on `main` appears in the rolled `[0.8.0]` section,
and every bullet from the earlier curated roll is preserved verbatim.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

kernel: self:agent:list returns the full roster (information disclosure)

2 participants