Skip to content

feat(agents-server): add permission enforcement#4475

Merged
icehaunter merged 1 commit into
mainfrom
codex/agents-permissions
Jun 3, 2026
Merged

feat(agents-server): add permission enforcement#4475
icehaunter merged 1 commit into
mainfrom
codex/agents-permissions

Conversation

@icehaunter
Copy link
Copy Markdown
Contributor

@icehaunter icehaunter commented Jun 2, 2026

Motivation

Agents Server needs authorization at every entity access path so tenants can limit which principals can spawn types and access individual entity instances. The default model should keep entity instances owner-private while still allowing common sharing flows through explicit grants, principal-kind grants, spawn-time inheritance, and Electric-visible effective permissions.

fixes #4464

What changed

  • Added permission storage for entity type spawn grants, entity instance grants, effective permissions, lineage, and shared-state links.
  • Added owner-default permission checks plus middleware enforcement for entity routes, spawn, durable streams, attachments, schedules, event-source subscriptions, tags, inbox edits, and shared-state streams.
  • Made new entity type registration available to any authenticated principal by default. Runtime entity definitions can publish their default spawn grants during registerTypes().
  • Added default built-in spawn grants for principal_kind=user on Horton and Worker.
  • Materialized descendant and copy-at-spawn entity grants into entity_effective_permissions for point decisions and Electric visibility.
  • Scoped entity list, entity type list, Electric shape predicates, and entities(tags) observation streams by the authenticated principal. Entity manage grants are included in read visibility, and entity-type manage grants are included in spawn/type visibility.
  • Added spawn-time initial grants for the new entity. Direct root-spawn grants are allowed because the caller becomes the owner of the new entity; broad parented-spawn grants require manage on the parent before they can be delegated.
  • Added an optional authorization hook for deployments that need custom point decisions; webhook decisions are not injected into Electric predicates and should be materialized into effective rows for shape visibility.
  • Enabled Electric subqueries in packages/agents-server/docker-compose.dev.yml with ELECTRIC_FEATURE_FLAGS=allow_subqueries.

Permissions model

Principals are still defined outside Agents Server. The server consumes the authenticated principal from the request context/header and stores grants against either a concrete principal URL, such as /principal/user%3Aalice, or a principal kind, such as user, agent, service, or system. Native group management is intentionally out of scope for this version; principal-kind grants cover the common broad-access cases.

Type grants control type-level actions only. A type-level spawn grant decides whether a principal may create an instance of a registered entity type; type-level manage is used for type mutation and is treated as a superset for type visibility/spawn checks. Type grants never grant access to existing entity instances. New type creation is open to authenticated principals so developers can register new entity definitions, while mutation of an existing type definition remains guarded by manage.

Entity grants control access to individual entity instances. Instance access defaults to created_by ownership, plus explicit/effective grants for verbs such as read, write, delete, signal, fork, schedule, and parent spawn. Entity manage is treated as a superset for point checks and visibility.

The permission verbs are intentionally granular. write covers content mutation paths such as send, tags, inbox edits, attachments, and event-source subscription edits. It does not imply delete, signal, fork, schedule, parent spawn, or manage; those require their own grants or manage. UI sharing presets like “read-only”, “read/write”, and “full control” should expand into the appropriate set of granular grants.

Spawn links the new entity into the permission graph:

  • The caller must have a type-level spawn grant for the target type.
  • If the spawn has a parent, the caller must also have spawn on that parent entity.
  • The new entity records created_by as the caller principal.
  • Parent/child lineage is recorded so descendant grants can be materialized for current and future descendants.
  • Grants marked copy_to_children are copied into the child as direct grants during spawn.
  • The spawn body may include initial direct grants for the new entity.
  • For parented spawns, broad or propagating initial grants require manage on the parent. This covers principal_kind grants, manage grants, propagation=descendants, and copy_to_children=true.
  • Manifest-discovered shared-state links connect shared-state streams back to the linked entity permissions.

Key decisions

  • Electric predicates use only IN (SELECT ...) subqueries with no correlated outer references.
  • Entity instance access defaults to created_by ownership plus explicit/effective grants.
  • Type grants are not used for instance visibility or instance access; entity grants are the source of truth there.
  • Workspace-wide sharing is represented as subject_kind=principal_kind and subject_value=user. This assumes an Agents Server tenant/service maps to a workspace boundary, and only workspace members can authenticate as user principals in that tenant.
  • Groups are not modeled in this version; deployments that need richer policy can use the authorization hook and/or materialize decisions into effective permission rows.
  • Public or “anyone with the URL” sharing is a follow-up. The model leaves room for a later public/link subject kind, synthetic anonymous/link principals, or Cloud share-link records that resolve into an authorization context with matching Electric visibility behavior.
  • Expired grants are pruned/deactivated server-side rather than using now() in Electric predicates.
  • Pre-permission entity observation bridges are rebuilt after upgrade because old bridge rows do not include principal attribution.

Coverage

Added focused coverage for permission service behavior, route middleware, durable stream access, shared-state authorization, Electric SQL predicate generation, principal-scoped observation streams, runtime registration grant payloads, spawn-time grant materialization and parented-spawn delegation checks, and built-in Horton/Worker spawn grants.

A local smoke run also registered a linked /tmp runtime, spawned an entity as user:test, confirmed user:other was denied list/send/view before grants, then confirmed user:other could list/send/view after explicit read and write grants using ELECTRIC_AGENTS_SERVER_HEADERS with Electric-Principal.

@icehaunter icehaunter force-pushed the codex/agents-permissions branch from b79dbd1 to 8d5b80c Compare June 2, 2026 13:47
@github-actions
Copy link
Copy Markdown
Contributor

github-actions Bot commented Jun 2, 2026

Electric Agents Desktop Builds

Build artifacts for commit be8e3f9.

Platform Status Artifact
macOS Apple Silicon Passed DMG
macOS Intel Passed DMG
Windows x64 Passed Installer
Linux x64 Passed AppImage / deb

Workflow run

@github-actions
Copy link
Copy Markdown
Contributor

github-actions Bot commented Jun 2, 2026

Electric Agents Mobile Build

Android preview build for commit be8e3f9.

Platform Profile Status Build
Android preview Passed EAS build

Workflow run

@icehaunter icehaunter force-pushed the codex/agents-permissions branch from 8d5b80c to 6367362 Compare June 2, 2026 14:02
@KyleAMathews
Copy link
Copy Markdown
Contributor

I read through this against the main sharing flows we want to support in Desktop/Mobile:

  1. Desktop/mobile apps spawn agents/sessions that are private to the creator by default, but overridable at spawn time.
  2. A user can share a session with a specific teammate, or with everyone in the workspace, choosing granular permissions like read, write, schedule, manage, etc.
  3. A user can eventually share a session globally to anyone with the URL. This likely needs a separate PR because it touches Cloud/link auth, but this PR should leave a clear path for it.

Overall this is a strong foundation: private-by-default via created_by, explicit entity grants, principal-kind grants, scoped lists/Electric predicates, and built-in spawn grants for Horton/Worker line up well with the first two flows. I think there are a few changes/clarifications we should make before merging.

1. Spawn-time grants need authority validation

The issue explicitly calls out validating spawn-time grants so the spawner cannot grant beyond their authority. Right now withSpawnPermission validates that the caller can spawn the type, and can spawn from the parent when one is supplied, but the actual initial grants are then inserted without further validation:

for (const grant of parsed.grants ?? []) {
  await ctx.entityManager.registry.createEntityPermissionGrant({
    entityUrl: entity.url,
    permission: grant.permission,
    subjectKind: grant.subject_kind,
    subjectValue: grant.subject_value,
    propagation: grant.propagation,
    copyToChildren: grant.copy_to_children,
    expiresAt: parseExpiresAt(grant.expires_at),
    createdBy: principal.url,
  })
}

So a caller with type-level spawn can currently create an entity and attach broad grants like:

{
  "subject_kind": "principal_kind",
  "subject_value": "user",
  "permission": "manage",
  "propagation": "descendants",
  "copy_to_children": true
}

If the intended rule is “the spawner owns the newly created entity and therefore may grant any permission on it”, that should be documented and tested explicitly. But I think we likely want a validation step, especially for:

  • permission: "manage"
  • subject_kind: "principal_kind"
  • propagation: "descendants"
  • copy_to_children: true

At minimum, broad/propagating grants should require some explicit grant authority, e.g. manage on the parent/source context, or another clearly-defined “can delegate/share” rule. We should also add tests for invalid/escalating spawn-time grants, since that was part of the linked issue’s acceptance criteria.

2. Electric entity visibility should match server-side manage semantics

Server-side entity checks treat manage as a superset of the requested permission:

const permissions = [permission, `manage`] as const

But the Electric predicate for readable entities currently only checks:

permission = 'read'

That means a user with manage on an entity can access it through HTTP routes, but may not see it in Electric-backed entity lists unless they also have a separate read grant.

Suggested fix:

permission IN ('read', 'manage')

and add a test covering a manage-only grant being visible through both HTTP and Electric predicates.

3. Electric entity-type visibility should match server-side manage semantics

Same issue for entity types. Server-side type permission checks treat manage as a superset of spawn, but the Electric predicate only checks:

permission = 'spawn'

Suggested fix:

permission IN ('spawn', 'manage')

with a test for manage-only type grants.

4. Document the permission lattice / sharing semantics

The issue talks in terms of read and read/write, while this PR introduces a richer permission set:

  • read
  • write
  • delete
  • signal
  • fork
  • schedule
  • spawn
  • manage

That seems useful, but we should document what write does and does not imply. For example, today routes like signal/delete/fork/schedule/parent-spawn require separate permissions rather than being implied by write.

This matters for Desktop/Mobile UX: if the UI says “share read/write”, users may expect broader control than the server grants. Either the UI should expose the granular permissions, or we should provide a helper/API that expands common sharing presets like “read-only”, “read/write”, and “full control” into the right grants.

5. Workspace-wide sharing is currently principal_kind=user; that’s OK only if tenant == workspace

For “share with everyone in the workspace”, this PR can approximate that with:

{
  "subject_kind": "principal_kind",
  "subject_value": "user",
  "permission": "read"
}

But this is really “all user principals in this tenant/service”, not a first-class workspace/team/group grant. That’s probably fine if Cloud maps one agents-server tenant to one workspace and only workspace members can authenticate as user principals in that tenant. We should document that assumption. If tenant != workspace, this is too broad.

6. Global URL sharing can be a separate PR, but this PR should not block it

True “anyone with the URL” sharing is not implemented here, which is fine because it likely needs Cloud/link-auth work. Current requests require a principal, and grants only target principal or principal_kind; there is no public, link, or anonymous subject/capability model yet.

I don’t think this PR needs to implement global URL sharing, but it should leave the model extensible for it. For example, a follow-up may need one of:

  • a new grant subject kind like public or link
  • a synthetic principal kind like link/anonymous
  • capability/share-link records that Cloud resolves into a principal or authorization context
  • matching Electric predicate behavior for link/public visibility

It would be good to avoid baking in assumptions that make that hard later, and maybe add a TODO/design note around this.

Demo/test suggestion

Once the above is tightened up, let’s add Desktop/Mobile support for these flows so we can demo and test them end-to-end:

  1. Spawn a Horton/Worker session from Desktop/Mobile and confirm it is private to the creator by default.
  2. Share that session with a specific teammate using a principal grant and verify the teammate can see/use it according to the selected permissions.
  3. Share that session with the workspace using a principal_kind=user grant and verify another workspace member can see/use it.
  4. Verify an ungranted user cannot list/read/write the session.
  5. Later, in a Cloud/link-sharing PR, add “anyone with URL” sharing and verify public/link access works through both HTTP and Electric-backed views.

@icehaunter icehaunter force-pushed the codex/agents-permissions branch from 6367362 to be8e3f9 Compare June 3, 2026 07:55
@icehaunter icehaunter merged commit 6434774 into main Jun 3, 2026
18 checks passed
@icehaunter icehaunter deleted the codex/agents-permissions branch June 3, 2026 10:24
msfstef added a commit that referenced this pull request Jun 3, 2026
…nfig (#4495)

## What

Started as a fix for three CI failures observed on `main`; reviving the
TS suite (the first fix) then surfaced pre-existing typecheck breakage
that had been hidden while CI was dark, which this PR also cleans up.

### CI / build fixes

| Job | Verdict | Fix |
|-----|---------|-----|
| **TS tests** | 🔴 Real — startup-failing on *every* run since #4450 |
Job-scoped `packages: write` on the `ensure_sync_service_image` caller |
| **Agents Desktop Canary** | 🔴 Real — fails every run since #4441 |
`-c.channel` → `-c.publish.channel` |
| **Changesets** | 🟡 Flake — intermittent `agents-runtime` dts race |
Promote `skills/types` to a tsdown entry |

### Typecheck fixes (pre-existing breakage exposed by re-enabling TS CI)

| File | Issue | Fix |
|------|-------|-----|
| `agents-runtime/src/pi-adapter.ts` | merged assistant `content` typed
`unknown[]` (#4449) | annotate the block-array type |
| `agents-runtime/src/webhook-signature.ts` | `node:crypto` no longer
exports `JsonWebKey` | cast input to `JsonWebKeyInput` |
| `agents-runtime/src/sandbox/docker.ts` | `isDockerAvailable` not on
the subpath the tsconfig wildcard resolves | re-export it (cascade fix
for `electric-ax` / conformance) |
| `agents-server-ui/tsconfig.json` | UI typechecked agents-runtime's
node-only sandbox source via `paths` | resolve the index via built
`.d.ts`; keep only the browser-safe `client` source-mapped — UI stays
node-free |

## Why (CI fixes)

- **TS tests:** #4450 downgraded the workflow's top-level token to
`packages: read`, but `ts_tests.yml` is the sole caller of the reusable
`ensure_sync_service_image.yml`, whose job requests `packages: write` to
push the sync-service image to GHCR. A called reusable workflow cannot
elevate permissions above the caller's token, so GitHub failed the
**entire run at startup** — meaning the TS test suite had not run on any
commit (main or PR) since. Fixed by granting `packages: write` only on
the caller job, keeping the top-level token at `read` per #4450's
hardening.
- **Agents Desktop Canary:** electron-builder 26.8.1 rejects the config
with `unknown property 'channel'`. `channel` is not a valid root
property — moved under the publish provider (`-c.publish.channel=beta`,
alongside the existing `-c.publish.url`).
- **Changesets:** `agents-runtime` dts build intermittently fails with
`UNLOADABLE_DEPENDENCY: Could not load src/skills/types.d.ts` under CI's
parallel build. Promoting `src/skills/types.ts` to a first-class tsdown
entry makes its `.d.ts` a stable named output instead of a raced chunk.

## Validation

- Workflow files parse as valid YAML.
- All previously-failing typecheck packages (`agents-runtime`,
`electric-ax`, `agents-server-ui`, `agents-server-conformance-tests`)
verified green via CI-faithful isolated (`--frozen-lockfile`) install +
build + typecheck.

## Not in scope (pre-existing, flagged separately)

- `runtime-dsl.test.ts` (92 tests, `401 UNAUTHORIZED: Principal is not
allowed to spawn`) — from #4475's permission enforcement (@icehaunter);
test fixtures need spawn permission seeded. Not a build/type issue.
- An `agents-mcp` dts-race flake in the conformance build (same class as
the `skills/types` one).

## Note for reviewer

The canary maps channel input `canary` → publish channel `beta`.
Preserved the existing value, but flagging in case it should be
`canary`.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
kevin-dp added a commit that referenced this pull request Jun 4, 2026
…registry changes

After rebasing on top of main, two main-side changes that landed since
this branch's base broke a handful of tests that were green at branch
time. Aligning the mocks so CI doesn't false-alarm on this PR.

1. `add permission enforcement` (#4475) made the spawn route 401 for
   any non-bypass principal that doesn't hold the required
   entity-type grant. The runtime-dsl test framework was using
   `system:runtime-dsl-test`, which isn't a built-in bypass — switch
   to `system:dev-local` (the same bypass principal the desktop's
   local runtime uses). 70 snapshots auto-regenerated; the diff is
   purely the `from` field, nothing semantic. The dispatch-policy-
   routing test setup gets the same swap, plus its mocked runner's
   `owner_principal` flipped to match (so the
   `assertDispatchPolicyAllowed` owner check passes).

2. A new `registry.replaceSharedStateLink` call landed in
   `syncManifestLinks` without updating the registry mocks in
   agents-server's status / write-validation / server-start tests.
   Added `replaceSharedStateLink: vi.fn()` (or a no-op method on the
   fake registry class) to each.

None of these tests are exercised by the recent main commits' CI
matrix (TS tests are scoped by affected workspace, and main has only
touched agents-desktop / agents-mobile recently), which is why they
appear to "pass on main" but red on this PR's CI. The fixes above
keep them passing and let our actual change get a clean signal.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
kevin-dp added a commit that referenced this pull request Jun 4, 2026
…ute tests

Same pattern as the previous rebase-fixup commit: another test file
landed on main with a non-bypass principal (`system:test`), and the
new permission-enforcement code (#4475) now reaches for
`registry.hasEntityPermission` via canAccessEntity — which the test's
mock registry doesn't expose.

Switching the principal to `system:dev-local` (a built-in bypass)
sidesteps the entity-permission check, same way the desktop's local
runtime and the dispatch-policy-routing tests do. These tests assert
subscription routing, not authz, so the bypass is the minimal fix and
matches the convention established in the previous commit on this
branch.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
kevin-dp added a commit that referenced this pull request Jun 4, 2026
…registry changes

After rebasing on top of main, two main-side changes that landed since
this branch's base broke a handful of tests that were green at branch
time. Aligning the mocks so CI doesn't false-alarm on this PR.

1. `add permission enforcement` (#4475) made the spawn route 401 for
   any non-bypass principal that doesn't hold the required
   entity-type grant. The runtime-dsl test framework was using
   `system:runtime-dsl-test`, which isn't a built-in bypass — switch
   to `system:dev-local` (the same bypass principal the desktop's
   local runtime uses). 70 snapshots auto-regenerated; the diff is
   purely the `from` field, nothing semantic. The dispatch-policy-
   routing test setup gets the same swap, plus its mocked runner's
   `owner_principal` flipped to match (so the
   `assertDispatchPolicyAllowed` owner check passes).

2. A new `registry.replaceSharedStateLink` call landed in
   `syncManifestLinks` without updating the registry mocks in
   agents-server's status / write-validation / server-start tests.
   Added `replaceSharedStateLink: vi.fn()` (or a no-op method on the
   fake registry class) to each.

None of these tests are exercised by the recent main commits' CI
matrix (TS tests are scoped by affected workspace, and main has only
touched agents-desktop / agents-mobile recently), which is why they
appear to "pass on main" but red on this PR's CI. The fixes above
keep them passing and let our actual change get a clean signal.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
kevin-dp added a commit that referenced this pull request Jun 4, 2026
…ute tests

Same pattern as the previous rebase-fixup commit: another test file
landed on main with a non-bypass principal (`system:test`), and the
new permission-enforcement code (#4475) now reaches for
`registry.hasEntityPermission` via canAccessEntity — which the test's
mock registry doesn't expose.

Switching the principal to `system:dev-local` (a built-in bypass)
sidesteps the entity-permission check, same way the desktop's local
runtime and the dispatch-policy-routing tests do. These tests assert
subscription routing, not authz, so the bypass is the minimal fix and
matches the convention established in the previous commit on this
branch.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
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.

Implement initial agents permissions model

3 participants