Document what push access grants a session on your CI host #74

Merged
branderson merged 1 commits from ci-hardening-docs into main 2026-08-04 23:04:22 -07:00
Owner

Summary generated by LLM agent

First of three. This one is documentation only, so it can ship immediately and
start warning people; the ci/ example bundle and this repo's own adoption of
it follow separately.

Addresses direction 4 of #72, and sets up the reasoning directions 2 and 3 will
implement.

What this is about

A dejima session cannot reach outside its allowlist. It can push a branch and
open a PR, and the briefing tells it to do that without asking. on: pull_request then makes CI check out that commit and execute it, on a host,
with no firewall and no human in between. It does not take an edited workflow
file to get there — a workflow that runs a test suite runs every file in it, so
a plausible-looking helper in a test file is executed just as surely and far
more quietly. The gate we otherwise rely on, that the operator merges and the
agent does not, sits strictly downstream: by the time anyone reads the diff, it
has already run.

This applies to any project developed in a sandbox, not just to this repo.

Why there is no approval gate in here

I went looking for one first. Each version is reachable from the branch,
covered by a credential the session already holds, or not a gate at all:

  • The trigger and the workflow file live in the tree the session controls, and
    on a pull_request run the workflow is read from the PR head.
  • A forge token that can open a PR generally also covers the dispatch endpoint
    — Gitea's write:repository does — so "the operator triggers it by hand" is
    not something the operator alone can do.
  • Leaving the runner stopped fails as soon as a shared pool has spare capacity,
    and asks for an action before every run, which gets abandoned rather than
    followed.
  • Gitea's approval requirement and read-only token apply to fork PRs. A dejima
    session pushes a branch to the same repo, which is exactly the case they do
    not cover.
  • Filtering egress from inside the workflow is a step in a file the branch can
    edit.

Running unattended from a plan through to a PR is most of what this is for, so
the document does not try to stop the run. It bounds what a run can do, and
says which of the three things a run adds each control is aimed at: execution
on a host you own, egress from a network position you own, and the credentials
the run is handed.

The part worth generalising

A dejima project already carries a version-controlled statement of where it may
reach. docs/ci.md uses it: the runner host reads .dejima/allowed-domains
from origin/main, over the forge API — never from the checkout, which
belongs to the branch. A branch that can write its own allowlist has no
allowlist.

So widening what CI can reach becomes a diff on main: reviewed, attributable,
revertible, and made in the same file and by the same act that widens what a
session may reach. A prompt injection needing one more destination has to get
it past a human in a PR first.

Two consequences are stated rather than left to be discovered — the CI base set
should be narrower than the sandbox's built-ins, since a name judged safe for a
firewalled container is a different proposition on a host on your network; and
one file now grants in two places.

Pitfalls it documents

Included because they are easy to miss rather than obscure:

  • act_runner mounts the daemon socket into every job container unless
    container.docker_host is "-", quoting its own generated config. It is a
    property of the runner, not of the job, so a job asking for nothing
    unusual still gets whatever the runner grants everything it runs — and
    nothing in a job's log mentions it.
  • Filtering only DOCKER-USER leaves the DNS allowlist decorative: a resolver
    on a bridge gateway is addressed to the host, so it is INPUT and never
    passes through DOCKER-USER, and a job that can reach any resolver can
    resolve anything.
  • Gitea ignores permissions: before 1.27, which is what makes branch
    protection on main non-optional here rather than merely advisable — the
    allowlist is read from main, and a permissive job token can write to an
    unprotected branch.
  • --cap-drop=ALL breaks apt-get.
  • act_runner enables its cache server by default, and it binds specifically
    to be reachable from job containers.

Where none of this is available

Said plainly instead of papered over: on hosted CI there is no reliable
pre-execution gate for a same-repo branch and the egress section does not
apply, because every candidate gate is in a file the branch can edit and the
network is not yours to program. What still works there is branch protection,
credential hygiene, and declining the grant — [core] forge = none with
[core] workflow = local-only. The push grant is the CI code-execution
grant.

Changes

  • docs/ci.md, new.
  • docs/security.md and README.md each gain a section; the doc was silent on
    CI, runners and actions, and never claimed otherwise.
  • .dejima.md tells an agent working on this repo that pushing runs its test
    code outside the sandbox, and that a new destination belongs in
    .dejima/allowed-domains in the same PR.
  • test/static.bats pins the disclosure in all three files, that docs/ci.md
    exists and is linked from both, and that it still says the allowlist comes
    from main rather than from the checkout.

Tested

bats test/static.bats — 174 passing, including the three new ones. That is
the suite .dejima.md names for a change to README.md and docs/, and this
PR touches no code. The host-docker half was not run locally; a sandbox has
no Docker by design.

**Summary generated by LLM agent** First of three. This one is documentation only, so it can ship immediately and start warning people; the `ci/` example bundle and this repo's own adoption of it follow separately. Addresses direction 4 of #72, and sets up the reasoning directions 2 and 3 will implement. ## What this is about A dejima session cannot reach outside its allowlist. It can push a branch and open a PR, and the briefing tells it to do that without asking. `on: pull_request` then makes CI check out that commit and execute it, on a host, with no firewall and no human in between. It does not take an edited workflow file to get there — a workflow that runs a test suite runs every file in it, so a plausible-looking helper in a test file is executed just as surely and far more quietly. The gate we otherwise rely on, that the operator merges and the agent does not, sits strictly downstream: by the time anyone reads the diff, it has already run. This applies to any project developed in a sandbox, not just to this repo. ## Why there is no approval gate in here I went looking for one first. Each version is reachable from the branch, covered by a credential the session already holds, or not a gate at all: - The trigger and the workflow file live in the tree the session controls, and on a `pull_request` run the workflow is read from the PR head. - A forge token that can open a PR generally also covers the dispatch endpoint — Gitea's `write:repository` does — so "the operator triggers it by hand" is not something the operator alone can do. - Leaving the runner stopped fails as soon as a shared pool has spare capacity, and asks for an action before every run, which gets abandoned rather than followed. - Gitea's approval requirement and read-only token apply to fork PRs. A dejima session pushes a branch to the same repo, which is exactly the case they do not cover. - Filtering egress from inside the workflow is a step in a file the branch can edit. Running unattended from a plan through to a PR is most of what this is for, so the document does not try to stop the run. It bounds what a run can do, and says which of the three things a run adds each control is aimed at: execution on a host you own, egress from a network position you own, and the credentials the run is handed. ## The part worth generalising A dejima project already carries a version-controlled statement of where it may reach. `docs/ci.md` uses it: the runner host reads `.dejima/allowed-domains` from **`origin/main`**, over the forge API — never from the checkout, which belongs to the branch. A branch that can write its own allowlist has no allowlist. So widening what CI can reach becomes a diff on `main`: reviewed, attributable, revertible, and made in the same file and by the same act that widens what a session may reach. A prompt injection needing one more destination has to get it past a human in a PR first. Two consequences are stated rather than left to be discovered — the CI base set should be narrower than the sandbox's built-ins, since a name judged safe for a firewalled container is a different proposition on a host on your network; and one file now grants in two places. ## Pitfalls it documents Included because they are easy to miss rather than obscure: - `act_runner` mounts the daemon socket into every job container unless `container.docker_host` is `"-"`, quoting its own generated config. It is a property of the **runner**, not of the job, so a job asking for nothing unusual still gets whatever the runner grants everything it runs — and nothing in a job's log mentions it. - Filtering only `DOCKER-USER` leaves the DNS allowlist decorative: a resolver on a bridge gateway is addressed to the host, so it is `INPUT` and never passes through `DOCKER-USER`, and a job that can reach any resolver can resolve anything. - Gitea ignores `permissions:` before 1.27, which is what makes branch protection on `main` non-optional here rather than merely advisable — the allowlist is read from `main`, and a permissive job token can write to an unprotected branch. - `--cap-drop=ALL` breaks `apt-get`. - `act_runner` enables its cache server by default, and it binds specifically to be reachable from job containers. ## Where none of this is available Said plainly instead of papered over: on hosted CI there is no reliable pre-execution gate for a same-repo branch and the egress section does not apply, because every candidate gate is in a file the branch can edit and the network is not yours to program. What still works there is branch protection, credential hygiene, and declining the grant — `[core] forge = none` with `[core] workflow = local-only`. The push grant *is* the CI code-execution grant. ## Changes - `docs/ci.md`, new. - `docs/security.md` and `README.md` each gain a section; the doc was silent on CI, runners and actions, and never claimed otherwise. - `.dejima.md` tells an agent working on this repo that pushing runs its test code outside the sandbox, and that a new destination belongs in `.dejima/allowed-domains` in the same PR. - `test/static.bats` pins the disclosure in all three files, that `docs/ci.md` exists and is linked from both, and that it still says the allowlist comes from `main` rather than from the checkout. ## Tested `bats test/static.bats` — 174 passing, including the three new ones. That is the suite `.dejima.md` names for a change to `README.md` and `docs/`, and this PR touches no code. The `host-docker` half was not run locally; a sandbox has no Docker by design.
branderson added 1 commit 2026-08-04 22:07:39 -07:00
docs: disclose that push access is CI code execution, and how to bound it
tests / sandbox (proxy) (pull_request) Successful in 2m5s
tests / sandbox (providers) (pull_request) Successful in 3m56s
tests / sandbox (remote) (pull_request) Successful in 4m44s
tests / sandbox (project) (pull_request) Successful in 5m17s
tests / sandbox (config) (pull_request) Successful in 5m21s
tests / host-docker (pull_request) Successful in 5m49s
tests / sandbox (audit) (pull_request) Successful in 7m31s
tests / sandbox (static) (pull_request) Successful in 8m5s
a23d8145a5
A session cannot reach outside its allowlist, but it can push a branch and
open a PR - and that makes CI run what it wrote, on a host, with no firewall
and no human in between. It does not take an edited workflow file: a workflow
that runs a test suite runs every file in it. The operator-merges gate sits
strictly downstream of that, so by the time anyone reads the diff, it has run.

Nothing here claimed this was handled; the docs were simply silent on it. This
adds the disclosure in all three places a reader could reasonably start from -
README, docs/security.md, and .dejima.md for an agent working on this repo -
and a full runbook in docs/ci.md.

The runbook deliberately offers no approval gate. Every version of one is
reachable from the branch, covered by a credential the session already holds,
or not a gate at all: the trigger and the workflow file are in the tree, a
forge token that can open a PR generally covers the dispatch endpoint, a
stopped runner just means the job lands in someone else's pool, and the forge's
own approval settings apply to fork PRs rather than to same-repo branches. So
it bounds what a run can do instead: a runner host you are willing to lose,
job containers with no Docker socket, deny-by-default egress, and no secret a
run does not need.

The part worth generalising is the egress allowlist. A dejima project already
carries a version-controlled statement of where it may reach, so CI reads
.dejima/allowed-domains from origin/main over the forge API - never from the
checkout, which belongs to the branch. Widening what CI can reach becomes a
diff on main: reviewed, attributable and revertible, in the same file and by
the same act that widens what a session may reach.

Also documented, because they are easy to miss rather than obscure: act_runner
mounts the daemon socket into every job container unless container.docker_host
is "-", and that is a property of the runner rather than of the job; filtering
only DOCKER-USER leaves a job free to use any resolver it likes, because the
resolver on a bridge gateway is INPUT and not FORWARD; and Gitea ignores
`permissions:` before 1.27, which is what makes branch protection on main
non-optional rather than merely advisable.

Where none of it is available - CI you do not administer, a shared pool - the
document says so plainly and points at declining the grant instead:
[core] forge = none with [core] workflow = local-only.

static.bats pins the disclosure in all three files, that docs/ci.md exists and
is linked from both, and that it still says the allowlist comes from main
rather than from the checkout.
branderson merged commit d417f31def into main 2026-08-04 23:04:22 -07:00
Sign in to join this conversation.