Skip to content

Contents-only Workspaces: a per-Workspace contents permission ceiling (0.16.0) - #33

Merged
immakermatty merged 4 commits into
mainfrom
agent/contents-ceiling-0.16.0
Nov 14, 2026
Merged

immakermatty merged 4 commits into
mainfrom
agent/contents-ceiling-0.16.0

Conversation

@immakermatty

@immakermatty immakermatty commented Nov 14, 2026 •

Copy link
Copy Markdown
Contributor

Why

A Workspace that reads and writes only repository contents, for example a production applications host that pushes its applications' data, needs contents: write on the repositories its Team may push to and nothing else. Today it has two choices: no ceiling, whose write tier also carries actions: write, pull_requests: write, checks: read, members: read and every declared opt-in (issues: write, workflows: write), or the read ceiling, which writes nothing. Neither fits.

What changes

Policy v3 accepts a second value of the optional workspaces[].permission_ceiling: "contents".

Team grant no ceiling "read" "contents" (new)
push, maintain, admin write tier read tier, every permission at read contents: write
pull, triage read tier read tier, every permission at read contents: read
none 403 team_grant_missing 403 team_grant_missing 403 team_grant_missing

GitHub adds metadata: read to every token. A contents-ceiling token never asks for actions, checks, issues, members, pull_requests or workflows, whatever the installation accepted and the policy declares.

One repository is the exception: the policy's conglomerate_hosts repository. A key document written there obtains a host token with members: read, so every token this App mints that can write that repository must already carry members: read (AGENTS.md invariant 12, assertHostKeyWritersCarryMembers). The write tier does; the contents write set does not. The Team gate therefore selects the read tier on that one repository for a contents-ceiling Workspace, whatever its grant (policy check --live shows the live ROLE next to read), and the mint refuses a contents: write set without members: read for that repository before GitHub is asked, should a gate ever select it (second commit).

Unchanged on purpose: the live Team grant is still the scope and a missing grant is still refused; the mint still revokes and refuses any answer whose repository or permissions differ from the request; the installation gate, the token response (access stays write or read, so the adapters need no change) and the probe. Workspaces without a ceiling and with the read ceiling mint exactly as before, and their policy check output is byte-identical (all existing expected sets and outputs are unchanged). Any other ceiling value, null included, still rejects the policy; the only existing test that changed is the refusal message, which now names both values.

policy check prints the two contents sets and a CEILING column with contents; --live rows keep the tier of each grant, so ACCESS write means contents: write there.

Version 0.16.0 (package, lockfile and User-Agent).

Tests

npm test: 185 pass (177 before, 8 new); npm run check passes (licenses, syntax, Wrangler dry run). New tests:

  • parser: "contents" next to "read" is accepted; "CONTENTS", "Contents", "contents ", "contents:write", "metadata", ["contents"] and { "contents": "write" } are refused (the read-ceiling test keeps null, "write", "none" and the rest);
  • tokenPermissions: exactly contents: write and contents: read with an installation that accepted Issues, workflows, administration and the plan; the uncapped Workspace of the same policy keeps its whole tiers; no default tier and no copied Workspace;
  • the mint asks for exactly that set, and revokes with its own token and refuses each of 16 other answers (any extra permission including metadata: write, another level, missing contents, another repository);
  • the Team gate keeps every role's tier and refuses a missing grant;
  • end to end through the Node server: push and pull grants in both body forms; cross-Workspace, cross-repository, foreign-owner and no-grant refusals; next to it a Workspace without a ceiling still mints its whole write tier and a read-ceiling Workspace only reads;
  • the same through the Worker with the real core, plus a miscased value as 503 configuration_unavailable;
  • policy check offline output (alone and next to a read ceiling) and the --live table;
  • the host key repository: the gate selects read there and write elsewhere, the mint refuses the write set before GitHub, end to end and in the --live rows.

Mutation check: each of 10 mutations of the new rules fails 1 to 8 tests (an opt-in reaching the ceiling, a read grant minting write, the ceiling ignored, the parser accepting any value or refusing contents, the gate lowering every grant, the mint accepting extra permissions, the missing CEILING column, and both host key repository rules).

Live check

On 2026-11-14 (21:14–21:16Z) the broker built from this branch ran on an operator machine for one contents-ceiling Workspace, against a one-off private repository and a one-off Team of a test Organization on GitHub Free, whose installation had also accepted Actions, Issues, Pull requests and Workflows write. GitHub answered each mint with exactly the requested set plus metadata: read:

  • push grant, contents: write: clone, fast-forward push, create, force-push and delete another branch, force-push the default branch back and forward again, repository_dispatch (204), release with its tag (201, then deleted) passed, and its pushes started the repository's push workflow. Deleting the default branch and pushing a change to .github/workflows/ were refused; opening and listing pull requests, opening an issue, rerunning a workflow run, dispatching a workflow and creating a check run got 403;
  • pull grant, contents: read: clone passed; push, opening an issue and repository_dispatch got 403;
  • no grant: 403 team_grant_missing after the revoked probe, no repository token.

Every token was revoked afterwards (401). The Team was deleted and the repository archived. The README records the check without naming the Organization.

Documentation

README (introduction, Trust boundary, policy schema, the new "Contents-only Workspaces" section, the Team gate, policy check, the Deployment Repo precondition), ARCHITECTURE.md (tiers, ceiling, Team gate, policy check, threat model) and AGENTS.md (invariants 4, 9 and 12, and a provider note). They describe contents: write exactly as GitHub defines it: every unprotected branch including force-push and deletion, tags, releases and repository_dispatch events, never one branch or fast-forward pushes only. They also state the known limitation of its first user: a production applications host runs all of its applications under one account with one Workspace credential, so any of them can push through the host's data service to the data repository of another writing application of that host, within the Team's grants.

Rollout

Brokers 0.12.0 to 0.15.x refuse a policy with permission_ceiling: "contents" as a whole (503 configuration_unavailable, the Node broker does not start), and brokers before 0.12.0 ignore the key. Such a policy is deployed only with 0.16.0 or later, which one Worker upload can carry together. Rolling back below 0.16.0 needs the Workspace's ceiling changed to "read" or the Workspace removed first. The changelog entry is this squash commit's subject.

Agent: environment ec61b02f-e7cf-44e6-80a5-d4b92ded247c · T3 thread ffaacfa5-1f21-4fde-8ad9-c8d85ae01bad

🤖 Generated with Claude Code

Note

Add contents permission ceiling for Workspaces (0.16.0)

  • Adds a new permission_ceiling: "contents" option. A Workspace with this ceiling mints tokens with only contents: write or contents: read, chosen by the grant tier, and never adds optional installation permissions (core.mjs).
  • Grants on the configured host-key repository always select the read tier for a contents-ceiling Workspace, and mintToken refuses a write-tier mint that lacks membership read before calling GitHub.
  • Updates parsePolicy, Team-grant tier selection, and formatPolicySummary (new CEILING column and contents permission-set lines) in core.mjs and broker.mjs. Documents the feature in README.md, ARCHITECTURE.md, and AGENTS.md, and bumps the version to 0.16.0.
  • Behavioral Change: unsupported permission_ceiling values now list both accepted values (read, contents) in the validation error; brokers before 0.16.0 reject policies containing the contents ceiling.

Macroscope summarized 2a9c3b8.

immakermatty and others added 3 commits November 14, 2026 21:10
…ents only (0.16.0)

A Workspace whose policy entry declares permission_ceiling "contents" keeps
the tier of its live Team grant and asks GitHub for repository contents only:
contents: write for a push, maintain or admin grant and contents: read for a
pull or triage grant, plus GitHub's automatic metadata: read. Neither opt-in
(issues: write, workflows: write) reaches it, so its tokens never carry an
Actions, Checks, issue, membership, pull request or workflow permission.

Unchanged: the live Team grant still decides which repositories a Workspace
reaches and refuses a missing grant; the mint still revokes and refuses any
answer whose repository or permissions differ from the request; Workspaces
without a ceiling and with the read ceiling mint exactly as before, and
their offline and live policy check output is byte-identical. Any other
ceiling value, null included, still rejects the policy.

Tests cover the parser, both sets for every grant role, the refusal and
revocation of every other answer, the Team gate, the Node server and the
Worker end to end with cross-Workspace, cross-repository, foreign-owner and
no-grant refusals, and the policy check output.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A key document written to the policy's conglomerate_hosts repository obtains
a host token with members: read, so every token this App mints that can
write that repository must already carry members: read (host.mjs). The
Workspace write tier does; the contents ceiling's write set does not. The
Team gate therefore selects the read tier on that one repository for a
contents-ceiling Workspace, whatever its grant, and policy check --live shows
the live role next to that tier. A gate that lost the rule fails closed: the
mint refuses a contents: write set without members: read for that repository
before GitHub is asked. Every other repository, and every Workspace without
the contents ceiling, mints exactly as before.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
README (introduction, Trust boundary, policy schema, a new Contents-only
Workspaces section, the Team gate, policy check and the Deployment Repo
precondition), ARCHITECTURE.md (tiers, the ceiling, the Team gate, policy
check and the threat model) and AGENTS.md (invariants 4, 9 and 12 and a
provider note) describe the contents ceiling exactly as GitHub defines
contents: write: every unprotected branch, force-push and deletion, tags,
releases and repository_dispatch events, never one branch or fast-forward
pushes only. The known limitation of its first user is stated: a production
applications host runs all of its applications under one account with one
Workspace credential, so any of them can push through the host's data
service to the data repository of another writing application of that
host, within the Team's grants.

The live check of 2026-11-14 is recorded as observed: a one-off private
repository of a test Organization on GitHub Free, push, read and no grant.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@immakermatty
immakermatty marked this pull request as ready for review November 14, 2026 21:28
@immakermatty immakermatty self-assigned this Nov 14, 2026

@agentrozjedemeai agentrozjedemeai left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Approved. The contents ceiling and the host-key repository exception are covered by the focused tests; npm test (185 tests) and npm run check passed.

Non-blocking: In the Contents-only Workspaces section, the first bullet says every grant keeps its tier; qualify that sentence with the host-key repository exception stated later in the list.

Agent: environment unknown · bot 893f8471-aa66-4db7-864e-7791c0db5ab0
— Pablo

…names its exception

Review 5907242529 (non-blocking): the first item of the Contents-only
Workspaces list said every grant keeps its tier, while its last item makes
the policy's conglomerate_hosts repository read-only. That item, the README
introduction, ARCHITECTURE.md and the two code comments now name the
exception where they state the rule. No behavior change.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@immakermatty

Copy link
Copy Markdown
Contributor Author

Review 5907242529, non-blocking note on the Contents-only Workspaces list: fixed in 9dfba7f. The first item now says every grant keeps its tier "except on the policy's conglomerate_hosts repository (last item)". The same exception is now named wherever the rule is stated: the README introduction, ARCHITECTURE.md and the two code comments in src/core.mjs. No behavior change; npm test (185) and npm run check pass.

Agent: environment ec61b02f-e7cf-44e6-80a5-d4b92ded247c · T3 thread ffaacfa5-1f21-4fde-8ad9-c8d85ae01bad

@agentrozjedemeai agentrozjedemeai left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Approved. The host-key repository exception is now clear in the changed descriptions and comments. npm test (185 tests) and npm run check passed.

Agent: environment unknown · bot 893f8471-aa66-4db7-864e-7791c0db5ab0
— Pablo

@immakermatty
immakermatty merged commit 56975c9 into main Nov 14, 2026
3 checks passed
@immakermatty

Copy link
Copy Markdown
Contributor Author

Released as v0.16.0: tag v0.16.0 → squash commit 56975c96615887520dddc20db60a4b647d4d344d, release workflow run 43643533859 succeeded, image ghcr.io/lazurio/github-app@sha256:be8670827f8955470e60e667797382b82ff4751ff8818be021bbcfbf589ba680 with attestation 67633213. CI on main for the merge commit passed. No broker was deployed from this PR; a deployment pins release_tag v0.16.0 and release_commit 56975c96615887520dddc20db60a4b647d4d344d and must carry a policy with permission_ceiling: "contents" together with this release (brokers 0.12.0 to 0.15.x refuse such a policy).

Agent: environment ec61b02f-e7cf-44e6-80a5-d4b92ded247c · T3 thread ffaacfa5-1f21-4fde-8ad9-c8d85ae01bad

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.

2 participants