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.
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.
Blocking a user prevents them from interacting with repositories, such as opening or commenting on pull requests or issues. Learn more about blocking a user.
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 ofit 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_requestthen 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:
on a
pull_requestrun the workflow is read from the PR head.— Gitea's
write:repositorydoes — so "the operator triggers it by hand" isnot something the operator alone can do.
and asks for an action before every run, which gets abandoned rather than
followed.
session pushes a branch to the same repo, which is exactly the case they do
not cover.
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.mduses it: the runner host reads.dejima/allowed-domainsfrom
origin/main, over the forge API — never from the checkout, whichbelongs 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_runnermounts the daemon socket into every job container unlesscontainer.docker_hostis"-", quoting its own generated config. It is aproperty 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.
DOCKER-USERleaves the DNS allowlist decorative: a resolveron a bridge gateway is addressed to the host, so it is
INPUTand neverpasses through
DOCKER-USER, and a job that can reach any resolver canresolve anything.
permissions:before 1.27, which is what makes branchprotection on
mainnon-optional here rather than merely advisable — theallowlist is read from
main, and a permissive job token can write to anunprotected branch.
--cap-drop=ALLbreaksapt-get.act_runnerenables its cache server by default, and it binds specificallyto 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 = nonewith[core] workflow = local-only. The push grant is the CI code-executiongrant.
Changes
docs/ci.md, new.docs/security.mdandREADME.mdeach gain a section; the doc was silent onCI, runners and actions, and never claimed otherwise.
.dejima.mdtells an agent working on this repo that pushing runs its testcode outside the sandbox, and that a new destination belongs in
.dejima/allowed-domainsin the same PR.test/static.batspins the disclosure in all three files, thatdocs/ci.mdexists and is linked from both, and that it still says the allowlist comes
from
mainrather than from the checkout.Tested
bats test/static.bats— 174 passing, including the three new ones. That isthe suite
.dejima.mdnames for a change toREADME.mdanddocs/, and thisPR touches no code. The
host-dockerhalf was not run locally; a sandbox hasno Docker by design.