Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
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
102 changes: 102 additions & 0 deletions docs/ADRs/0073-named-mint-privilege-levels.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
---
title: "73. Named mint privilege levels under agent roles"
status: Accepted
relates_to:
- security-threat-model
- agent-architecture
topics:
- mint
- least-privilege
- harness
- roles
---

# 73. Named mint privilege levels under agent roles

Date: 2026-07-19

## Status

Accepted

<!-- ADRs are point-in-time records, but not fully frozen after acceptance.
Minor annotations are welcome: cross-references to related ADRs, short
notes linking to newer decisions, or clarifying remarks. However, do not
substantially rewrite the Context, Decision, or Consequences sections. If
the decision itself needs to change, write a new ADR that supersedes this
one. For evolving design narrative, use docs/architecture.md. -->

## Context

The mint maps each agent role to a single hardcoded permission set ([ADR 0007](0007-per-role-github-apps.md)). Every token minted for a role gets the same ceiling. The threat model establishes least privilege as a cross-cutting principle (see [security-threat-model.md](../problems/security-threat-model.md)), but the current model cannot differentiate between a phase that only needs read access and one that needs write — both receive write-level tokens.

[#2821](https://github.com/fullsend-ai/fullsend/issues/2821) (human-gated permission adjustments) and the least-privilege topic ([#5312](https://github.com/fullsend-ai/fullsend/issues/5312)) depend on a general mechanism for minting tokens at differentiated privilege levels within a role. Without this mechanism, downstream work risks encoding use-case-specific designs instead of a reusable model.

## Decision

### Role levels

Each role defines an ordered set of **named privilege levels**. Each level's permissions are a superset of all preceding levels. The first level is always `read` — generally read-only — and is the default when no level is requested.

The `write` level is defined as the permission set currently granted by each built-in role. For roles where `write` is not explicitly defined, requesting `write` falls back to `read` automatically.

### Mint API

Comment thread
ifireball marked this conversation as resolved.
Resolved
Token requests accept an optional `level` field:

```json
{"role": "coder", "level": "write"}
```

When `level` is omitted, the mint defaults to `read`. The mint validates that the requested level exists for the role and returns an error for unknown levels.

### Custom roles

`CUSTOM_ROLE_PERMISSIONS` continues to accept the current JSON shape — a flat role-to-permissions map — interpreted as the `read` permissions for each role:

```json
{"my-role": {"contents": "read", "issues": "write"}}
```
Comment thread
ifireball marked this conversation as resolved.
Resolved

An alternate shape specifies multiple named levels per role. The mint auto-detects the format by checking whether each role's value contains a `levels` key:

```json
{"my-role": {"levels": {"read": {"contents": "read"}, "write": {"contents": "read", "issues": "write"}}}}
```

### Clients

`mintclient` and CLI paths that mint tokens accept an optional `level` parameter, defaulting to `read` when unset. The harness passes the level derived from the `privilege_levels` configuration (see below) to the mint client for each phase.

### Acquisition semantics

The harness is the sole caller that selects privilege levels. It determines the level for each run phase from the `privilege_levels` configuration and requests the corresponding token from the mint before that phase begins. Agents and scripts within a phase do not choose or escalate their own level — they receive the token the harness minted for their phase.

### Harness `privilege_levels` flag

Harness YAML supports a `privilege_levels` field mapping run phases to levels:
Comment thread
ifireball marked this conversation as resolved.
Resolved

```yaml
privilege_levels:
pre_script: write
runtime: read
post_script: write
```

A `default` key specifies the level for phases not explicitly listed. When `privilege_levels` is omitted entirely, the harness behaves as if:

```yaml
privilege_levels:
default: write
```

This preserves backward compatibility — existing harnesses continue to receive write-level tokens for all phases.

## Consequences
Comment thread
ifireball marked this conversation as resolved.
Resolved

- Agents running in the LLM sandbox can receive read-only tokens, reducing blast radius if the sandbox is compromised.
- The mint API remains backward-compatible; omitting `level` produces the same tokens as today.
- `CUSTOM_ROLE_PERMISSIONS` supports both the existing flat format and the new multi-level format without a breaking change.
- Harnesses that omit `privilege_levels` default to `write` for all phases, so existing configurations are unaffected.
- Implementation of role levels in the mint, clients, and harness is tracked in [#2823](https://github.com/fullsend-ai/fullsend/issues/2823) and [#2826](https://github.com/fullsend-ai/fullsend/issues/2826).
- [#2821](https://github.com/fullsend-ai/fullsend/issues/2821) (human-gated permission adjustments) is unblocked and can build on this mechanism.
6 changes: 6 additions & 0 deletions docs/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -134,6 +134,11 @@ repo baseline and overrides)
URL-resolved providers are validated against `allowed_remote_resources`
and merged with local definitions at resolution time
([ADR 0070](ADRs/0070-portable-provider-profile-resolution.md)).
- Phase-scoped privilege levels: a `privilege_levels` field in harness config
maps run phases (`pre_script`, `runtime`, `post_script`) to named mint
privilege levels. A `default` key covers unspecified phases. When omitted,
the harness defaults to `write` for all phases, preserving backward
compatibility ([ADR 0073](ADRs/0073-named-mint-privilege-levels.md)).

**Open questions:**

Expand Down Expand Up @@ -176,6 +181,7 @@ Identity is not the same as trust. An agent's identity lets it authenticate to e
- Cross-org mint authorization: workflows may request tokens for a different org via optional `target_org` when the target org installs the role App and sets `FULLSEND_FOREIGN_<role>_REPOS`. Empty `repos` yields installation-wide tokens on either path; cross-org adds FOREIGN gating, same-org relies on WIF/OIDC enrollment ([ADR 0060](ADRs/0060-cross-org-mint-authorization-via-org-variables.md)).
- Standalone mint deployment: `cmd/mint/` provides a self-contained HTTP server that uses direct JWKS verification and filesystem PEM storage instead of GCP infrastructure. It shares the `internal/mintcore/` library with the GCF mint and adds support for custom role permissions and a fallback proxy to an upstream mint. Custom role permissions live in mintcore (not `cmd/mint/`) so that `RolePermissionsFor`, `HasRole`, and `CreateInstallationToken` return a unified view without callers needing to distinguish built-in from custom roles. The GCF mint never calls `RegisterCustomRolePermissions`, so the code is inert there. See the [standalone mint guide](guides/infrastructure/standalone-mint.md).
- Hosted public community mint: steady-state deployment on Cloudflare Workers (JWKS + WAF + single ops console), with interim GCP Cloud Function acceptable until the Worker port is production-ready. Trust policy (`ALLOWED_ORGS=*`, upstream-only workflow provenance) is in [ADR 0059](ADRs/0059-public-mint-mode-with-wildcard-allowlists.md); deployment, edge security, monitoring, and phasing are in [ADR 0068](ADRs/0068-public-community-mint-architecture.md). Enrollment is installing the shared Apps—no per-org mint env registration ([#1145](https://github.com/fullsend-ai/fullsend/issues/1145)).
- Named privilege levels: each role defines ordered named levels (`read`, `write`), where each level's permissions are a superset of preceding levels. The mint API accepts an optional `level` field (default `read`). `write` is defined as the current max permission set for each built-in role. `CUSTOM_ROLE_PERMISSIONS` auto-detects a multi-level JSON shape alongside the existing flat format. The harness `privilege_levels` flag maps run phases to levels; omitting it defaults to `write` for backward compatibility ([ADR 0073](ADRs/0073-named-mint-privilege-levels.md)).

One concrete implementation option is [`oidcx`](https://github.com/oxidecomputer/oidcx): a service that accepts OIDC identity tokens and exchanges them for short-lived access tokens. It can mint tokens scoped to selected GitHub repositories and permissions, or to selected Oxide silos and permissions, and it also ships with a GitHub Action wrapper. In a Fullsend deployment, this can be used by the sandbox entrypoint to narrow a broad GitHub App identity down to only the specific permissions an agent needs for the current run.

Expand Down
Loading