diff --git a/docs/ADRs/0073-named-mint-privilege-levels.md b/docs/ADRs/0073-named-mint-privilege-levels.md new file mode 100644 index 0000000000..939bc9760b --- /dev/null +++ b/docs/ADRs/0073-named-mint-privilege-levels.md @@ -0,0 +1,107 @@ +--- +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 + + + +## 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 run-stage 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 `read` level for each built-in role is derived by taking the role's current permission set and downgrading all `*:write` permissions to their `read` counterparts (e.g., `contents: write` becomes `contents: read`). The `write` level is defined as the full 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 + +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"}} +``` + +An alternate shape specifies multiple named levels per role. The mint auto-detects the format per role by checking whether the role's value contains a `levels` key (reserved for format discrimination — role definitions must not use `levels` as a permission name): + +```json +{"my-role": {"levels": {"read": {"contents": "read"}, "write": {"contents": "read", "issues": "write"}}}} +``` + +Mixed format is allowed within a single `CUSTOM_ROLE_PERMISSIONS` value — some roles may use the flat format while others use the multi-level format. The mint detects the format independently for each role. + +### 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 run-stage. + +### Acquisition semantics + +The harness is the sole caller that selects privilege levels. It determines the level for each run-stage from the `privilege_levels` configuration and requests the corresponding token from the mint before that run-stage begins. Agents and scripts within a run-stage do not choose or escalate their own level — they receive the token the harness minted for their run-stage. + +### Harness `privilege_levels` flag + +Harness YAML supports a `privilege_levels` field mapping run-stages to levels: + +```yaml +privilege_levels: + pre_script: write + runtime: read + post_script: write +``` + +A `default` key specifies the level for run-stages not explicitly listed. Run-stages not shown above — such as `validation_loop` — inherit the `runtime` level, since they execute within the runtime run-stage and share its security context. + +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 run-stages. + +## Consequences + +- Agents running in the LLM sandbox can receive read-only tokens, reducing blast radius if the sandbox is compromised. +- The mint API defaults to `read` when `level` is omitted, producing narrower tokens than the current behavior. Non-harness callers that omit `level` — including `reconcileMintToken`, the `mint-token` CLI, and e2e test helpers — will receive read-level tokens instead of write-level tokens. Migration of these callers is coordinated in [#2823](https://github.com/fullsend-ai/fullsend/issues/2823) and [#2826](https://github.com/fullsend-ai/fullsend/issues/2826). +- `CUSTOM_ROLE_PERMISSIONS` supports both the existing flat format and the new multi-level format — including mixed format within a single value — without a breaking change. +- Harnesses that omit `privilege_levels` default to `write` for all run-stages, so existing harness configurations are unaffected — the harness requests `write` from the mint, preserving current token scope. +- `privilege_levels` must coordinate token acquisition with credential expansion for both sandbox delivery paths. Provider credentials (e.g., `${GH_TOKEN}` expanded at provider resolution time) and host files with `expand: true` both require the correct-level token to be in the process environment before expansion runs. For the `runtime` run-stage, the `read`-level token must be set before provider resolution and host file expansion; `pre_script` and `post_script` run-stages receive the `write`-level token via the host environment. +- 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. diff --git a/docs/architecture.md b/docs/architecture.md index 43f934c438..3ddc1262e3 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -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)). +- Run-stage-scoped privilege levels: a `privilege_levels` field in harness config + maps run-stages (`pre_script`, `runtime`, `post_script`) to named mint + privilege levels. A `default` key covers unspecified run-stages. When omitted, + the harness defaults to `write` for all run-stages, preserving backward + compatibility ([ADR 0073](ADRs/0073-named-mint-privilege-levels.md)). **Open questions:** @@ -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__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. `read` for built-in roles is derived by downgrading `*:write` permissions to their `read` counterparts. The mint API accepts an optional `level` field (default `read`); omitting it produces narrower tokens than the current behavior. `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, with mixed format supported per role. The harness `privilege_levels` flag maps run-stages to levels; omitting it defaults to `write`, preserving backward compatibility for existing harness configurations ([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.