Skip to content

Threat model

llms.txtlists every page for an agent
Optional: hand this page to your coding agentThe steps work by hand too.
Show the whole prompt
Read https://intentius.io/terragucci/reference/threat-model/.
For this repo's forge, compare the branch protection, the merge methods and the apply role's trust policy with the page's "Branch protection" tab, reading them through the forge's API or CLI.
List each setting that differs and what it leaves open; do not change settings, tokens or trust policies yourself.
Do not run `terragucci override`.
Never apply, approve (a pull request review or `terragucci approve`), override a policy denial (`terragucci override`), use `--mode apply`, or merge; never touch `.chant/allowed_signers` or `chant/lifecycle`.

The pipeline runs as jobs in your CI, so its trust is your forge’s.

Who Trusted to Kept in check by
Anyone with write access run CI jobs, and the plan role through a pull request the plan role is read-only; its trust names your repo
Reviewers decide what reaches the default branch branch protection
Approvers: under approval: ledger anyone who can push to chant/lifecycle, under sealed the people .chant/allowed_signers lists release a gated wave the plan digest the approval names, and under sealed its seal
Overriders: the people policy.override lists at base let one denied plan of one root through tf-apply the root, plan digest and rules the override names, and under sealed its seal
The default branch’s pipeline apply with the apply role the apply role’s trust, set to the default branch’s subject alone
On GitLab, anyone allowed to run a pipeline on the default branch start mr-apply with any variables mr-apply reads the merge request, the note and the head from GitLab, and refuses a head the merge request does not have
The review command, with review.agent read a change and write a note about it no forge token and no cloud role in its step; the note approves nothing (The review)
The drift agent’s command, with agent.drift edit files after the drift job opens the drift issue no forge token and no cloud role in its step; its change comes back as a pull request, refused when it touches a guarded path, that plans and applies like any other
An agent connected to terragucci mcp read the estate, reports, state versions, audit trail and DORA figures the server’s tools only read, and it holds the reports bucket’s read credentials from its own environment, never from the agent
A fork’s author nothing forks get no plan job and never apply
The chat relay, in your cloud under ledger and pr-review, record an approval of a waiting digest in the name of a chat user the signers file lists Slack’s or Teams’ signature on each request, the signers file on the default branch, the digest waiting, and an approve-only token

“Change” means code from a branch or a pull request that has not merged. A pull request or merge request from a branch of the repo runs the pipeline file as the change has it, so its author can edit any job that runs on it.

Job Runs on Code Forge token Cloud identity
check a push to any branch; a fork’s pull request the pushed branch none none
fmt a failed check of a push to a branch other than the default the pushed branch’s files, formatted, never run github.token, contents: write none
plan a pull request from the repo the change github.token, statuses: write, only in the step before the checkout plan role
plan-note after plan none of the change’s; the plan job’s note and status read as data github.token: statuses and pull requests write none
replan /terragucci plan the change’s head, from the default branch’s workflow github.token: statuses and pull requests write, only in the step that reads the comment, before the head is checked out plan role
replan-note after replan as plan-note as plan-note none
apply-wave-<k> a push to the default branch merged code github.token, contents: write apply role
apply-wave-<k>-share-<s> with waves.jobs, after its wave’s deciding job merged code github.token, contents: write apply role
apply-done with waves.jobs, after the last wave’s share jobs none github.token, statuses: write none
apply-comment /terragucci apply the merge commit; with apply.when: pull-request, the open change’s head github.token, contents: write apply role
pr-merge apply.merge: auto none of the change’s the merge token none
pr-lock pull_request_target and lock comments, with locks: plan none of the change’s; its diff read as data, from the default branch’s workflow github.token, contents: write, statuses and pull requests write none
confirm a push, with apply.when: pull-request merged code contents read, statuses write plan role
approval a review of a pull request from the repo, with approval: pr-review none of the change’s; the head’s plan note read as data github.token: statuses write, pull requests read none
drift the drift schedule the default branch contents: write, issues plan role
tips a push to the default branch merged code contents: write, pull requests none
version-bump after the last apply, with respond.version-bump: suggest merged code; it opens release pull requests github.token, contents: write, pull requests; the decide key when set none
publish after the last apply, with modules.publish merged code; it pushes module tags github.token, contents: write; the registry secrets, and with modules.attest COSIGN_PRIVATE_KEY and COSIGN_PASSWORD, which no other job holds none
resume the resume schedule, with apply.resume merged code: the default branch’s commit github.token, contents: write apply role
rollout the rollout schedule, with rollouts: the default branch; it edits pins and opens pull requests, and runs none of their code the token_env secret, else github.token; contents: write, pull requests none
agent /terragucci agent the change’s head, with no credentials in the checkout the job’s own, kept from the agent’s step none
agent-push after agent a patch, never run the agent.token_env secret none
drift-agent after drift opens the drift issue, with agent.drift the default branch, with no credentials in the checkout; the drift report as data in a prompt github.token, contents: read, kept from the agent’s step none
drift-agent-push after drift-agent a patch, never run the agent.token_env secret none
review workflow_run after the pipeline’s run of a pull request from the repo, with review.agent; the review workflow is the default branch’s the change’s diff, title and description, and its run’s plan report, as data in a prompt; the command and the instructions from the default branch github.token: contents, actions and pull requests read, kept from the command’s step none
review-note after review none of the change’s; the review read as data github.token, pull-requests: write none
OIDC subject Jobs
repo:<owner>/<repo>:pull_request plan
the default branch’s ref every job a comment, a schedule or a push to the default branch starts

With reports.bucket set and no reports.role, the bucket keys also reach plan, replan and drift (Credentials).

Whatever root a job runs, its role reaches every state the role’s IAM policy grants. One plan_role and one apply_role means every root runs as the same pair: a dev root’s plan can read prod’s state and a dev apply can write it.

With oidc.roles (or terragrunt.credentials for units), each root runs with the pair of the first glob it matches. Grant each pair only its own environment’s state keys. Then nothing a root runs (init, plan, apply or a step) holds an identity that reaches another environment’s state. A mistake in one environment’s code, or a backend pointed at the wrong key, is refused by the cloud.

Guard Covers Does not cover
a pair per environment, scoped to its state keys a root reading or writing another environment’s state with the identity it runs as code that reads the job’s token file and assumes another environment’s role itself: one job’s token is good for every role whose trust names the job’s subject, so the subject separates plan from apply, not environments
config check warns when one role is two environments’ role, when a root reads another environment’s state through terraform_remote_state (its roles must reach that state), and when a root is left with no role; lists each role’s roots and the state keys it needs the IAM policy itself: config check reads the roots’ code, never the cloud, so a policy wider than the keys it lists goes unseen
state contents never in the reports bucket reports, states.json, edges.json and the estate page hold version ids and root paths only a provider or step that prints state values into a job log

terragucci state export downloads one version of a root’s state to the machine of whoever runs it, and nowhere else.

Guard Stops Does not stop
a request on chant/lifecycle, and an approval of its digest by someone other than the requester an export nobody else agreed to, through terragucci someone whose own identity reads the bucket downloading the object with the cloud’s CLI; scope who holds such an identity
under approval: sealed, the approval’s seal a forged approval line a listed approver’s stolen key
the record in _gates/tf-state-export/done.jsonl, written before the file an export the audit trail does not name a requester name chosen by the person running it: under ledger it is git’s user.name or --actor
no job artifact, no bucket copy the state reaching everyone with read access to the repo, or the bucket’s readers the requester’s own copy once written

The gate policy decides when a wave waits: always, on-destructive (the default) or never. When it waits, the approval key in terragucci.yml at base decides which approvals count; with no key, chant.workspace.json at base does. Base is the applied commit’s first parent.

Mode Set by An approval counts when Stops Does not stop
No gate gate: never no wave waits nothing past review and branch protection any merged change applying
ledger, the default approval: ledger at base, or no key and no gate under identity.gates a line on chant/lifecycle approves the wave’s set digest, newer than its pending fact a wave whose plans moved after approval anyone who can push to chant/lifecycle writing that line: every writer, and the apply jobs’ token; on GitLab without gitlab.token: protected, a merge request’s code through that token
pr-review approval: pr-review at base as under ledger, or the merged pull request’s head was approved by a writer other than its author (on GitLab, an approval by a Developer or higher after the merge request’s latest push) and the wave plans the digest its plan note recorded as under ledger, a review standing for plans that moved after it, and an approval by the pipeline token’s own user as under ledger; anyone who can edit the plan note’s comment can change the digest it records
sealed approval: sealed at base, or no key and identity.gates listing gates at base; then every wave gate needs a seal as above, and terragucci approve --sign sealed it with a key the signers file at base lists for that approver forged lines from writers and job tokens; a change editing the approval key, the signers file, .chant/trust.json or chant.workspace.json to pass itself; signed fields edited later; moved plans a listed approver’s stolen key; an approver releasing their own change; a rebase merge of several commits, whose base is the change’s own commit

A wave about to apply under an approval marks it used in _gates/tf-apply/applied.jsonl on chant/lifecycle. A used approval of other plans makes the wave wait (exit 3) where it would refuse (exit 4); it approves nothing, so a forged line there releases no wave.

No approval counts when sealing is on and base has no signers file. A reviewed change that sets approval: ledger, or with no key empties identity.gates, turns sealing off for the merges after it. Which commit the rule is read from.

An override lets tf-apply apply one root’s denied plan. It never edits the policy, and tf-plan still fails the root. policy.override in the config at base lists who may write one; with no key there, no override counts (steps).

Mode An override counts when Stops Does not stop
ledger or pr-review a line on chant/lifecycle names the root, its plan digest and the rules that denied it, is newer than the recorded denial, gives a reason, and names someone listed at base a change adding its author to policy.override to pass itself; an override standing for a plan or rules that changed; a line by someone unlisted; a line a job recorded (via) anyone who can push to chant/lifecycle writing that line in a listed name, the apply jobs’ token included
sealed as above, and sealed by a key the signers file at base lists for that person as above, and forged lines from writers and job tokens a listed person’s stolen key; a listed person overriding their own change

No generated job runs terragucci override or writes an override line: the wave records the denial, and before it applies under an override it marks that override used in _gates/policy-override/applied.jsonl. A used override of another plan makes the wave record the new denial and exit 1 where it would exit 4; it lets nothing through, so a forged line there applies nothing. A root the policy could not check is never overridden.

With apply.when: pull-request, the open change’s head runs with the apply role. The reviewer who approves the head is the guard. On GitLab the comments job runs the checks, and mr-apply runs them again.

Check before any credential Stops
Forks never apply outside code meeting the apply role
A reviewer other than the author approved the head; no reviewer’s last review asks for changes (on GitLab, an approval after the latest push) unreviewed code meeting the apply role
On GitLab, the head the pipeline’s variable names is the merge request’s head now a pipeline started by hand for code nobody approved
The head did not move while the comment was read, and contains the default branch as it is now applying code nobody reviewed, or undoing merged changes
The change does not edit the pipeline file a change rewriting the job that applies it
Waves, gate, signers, policy, config and steps come from the default branch a change loosening its own gate, or adding a step that runs with the apply role
No other open pull request holds a lock on its roots two changes applying the same root

After the checks the change’s providers, modules and external data sources run with the apply role and the job’s contents: write token; review them before approving the head (what they can reach).

terragucci relay turns a Slack click or a Teams reply into an approval line on chant/lifecycle. It runs in your cloud, and terragucci hosts no part of it.

Guard Stops
Each request is verified: Slack’s signing secret over the timestamp and the body, within five minutes; the Teams outgoing webhook’s HMAC over the body anyone else posting a click, and a Slack click replayed later
The chat user maps to a principal only through their line of the signers file on the default branch, which a reviewed merge changes an unlisted chat user approving, and a pull request adding its author’s chat id to pass its own wave
The approval names the digest the message showed, and only a wave waiting for that digest takes it a click approving plans that moved after the message
Under approval: sealed the relay records nothing a relay, or whoever holds its token, releasing a sealed gate
The token is approve-only: the relay refuses one that administers the repo or may push to the default branch, and on GitLab one with the api scope; it uses the token only to fetch the repo and push chant/lifecycle, and starts no job a stolen relay token merging, applying or changing settings
The answer goes to Slack’s own response_url hosts only the relay posting to an address a request names
It does not stop Because
A stolen chat account approving as its owner the chat platform’s login is the identity the relay checks
Whoever holds the relay’s token or its secrets writing approval lines in any listed name under ledger and pr-review an approval line is the relay’s word, as any writer’s is; sealed does not count them
A Teams reply replayed while the same digest still waits Teams signs the body with no timestamp; the replay approves only that digest, in the same person’s name
A Slack retry within five minutes the second click finds the wave approved and records nothing

The relay’s token pushes chant/lifecycle like any approver’s, so it is one more writer the approval modes account for.

steps are commands the stage runs in the plan, apply and drift jobs, with the job’s cloud identity (the apply role on an apply wave) and without its forge tokens. The stage reads them from terragucci.yml at base only.

Run Steps read at So a change cannot
a pull request’s plan the target branch run a step it adds with the plan role, or skip one it removes
an apply after a merge the applied commit’s first parent, as the approval mode run a step it adds with the apply role on its own merge, or drop an on_failure: approve step to pass its own gate
apply.when: pull-request the base the head applies onto run a step it adds with the apply role before it merges
drift the default branch’s checkout (drift runs no change)

If the base’s file cannot be read, the stage fails when the checkout names steps and runs none otherwise; it never runs the change’s. An edit to steps takes effect for the changes after it merges.

This does not stop a step from running whatever its command runs at the time: a step that calls a script in the repo runs the change’s copy. Keep what a step runs out of the roots a change can edit, or call a pinned tool. A change can still run code with the plan role through its providers, modules and external data sources, as without steps. An on_failure: approve step holds a wave at the same gate as the gate policy, so the approval mode decides who can release it.

The review command reads text the change’s author wrote: the title, the description and the diff. A model can follow an instruction hidden in them.

Guard What it stops
the review jobs are a workflow of their own, which the forge runs from the default branch: GitHub on workflow_run, Forgejo on pull_request_target; the head is checked out without credentials and nothing in it runs a pull request editing the review jobs it gets
the command’s step holds the model’s key and no forge token: the step clears the runner’s token variables, and on GitHub the job’s token only reads the model posting, pushing or approving anything itself
neither review job has a cloud role the model reaching the cloud through the pipeline
the instructions are read from the default branch with git show, and the command runs in the default branch’s files a pull request rewriting what the model is told, or the script the command names
review-note posts one issue comment, with every HTML comment in the review turned into text a review that approves, or that carries a marker another job reads, such as the plan note’s waves
input.review is read from the terragucci-review-<head> artifact only when the forge says a run of the default branch’s review workflow kept it: on GitHub a workflow_run run of terragucci-review.yml; on Forgejo a pull_request_target run of it whose event names the default branch as base and the merged pull request and head. The artifact must also say it reviewed that pull request and head against the default branch an artifact of the same name kept by any other run, the pull request’s own pipeline or its edited copy of the review workflow included; a review of the head against another base
no note is read for input.review a note, posted by any user or by any run’s token (another branch’s run included), setting the risk a policy reads

A hidden instruction can still steer the note, such as risk low for a risky change. The note informs reviewers and never approves. A policy on input.review.risk holds back what the model flags and cannot vouch for what the model misses. The plan note and policy results in the prompt come from the plan job of the pull request’s own run, which the pull request can edit so it shows a plan it does not make, as for every job in Job access. Git itself supplies the review job’s diff, so such an edit shows in what the model reads and in the merged head’s diff, where reviewers see it. On Forgejo the review waits up to 30 minutes for that plan before reviewing without it. Anything posted with the pipeline’s token from any run changes what people read on the pull request and leaves the policy’s input alone.

The review job keeps the verdict as the command’s output plus the pull request, head and base that the previous step wrote. A review command that runs tools could rewrite them in the job; the default command runs no tools.

Forgejo runs a pull_request_target workflow from the pull request’s base, which may be a branch other than the default. A writer who opens a pull request against a branch whose review workflow they edited gets a run of that copy; its artifact is passed over, since the run’s event names another base. There is no workflow_run on Forgejo.

A Forgejo pull_request_target run’s token can write, and the runner gives it to every step of the review job but the command’s. Only a command that runs tools and leaves a process behind could read it, in the step that keeps the review (Token scrub limits).

terragucci starts the binary, Terragrunt and the policy engine with every forge token removed by name and by value (the list). On GitHub and Forgejo, jobs that run a pull request’s code also keep the token from the steps that run it (which steps). Elsewhere a change’s code can still reach one.

Where Forge token reachable
The binary, Terragrunt and the policy engine, started by terragucci stage, respond, check-root and check-policy no; a TF_ variable passes as set, even holding a token
On GitHub and Forgejo, the steps of check, plan and replan that run the change’s code, their parent processes and their checkout’s git config no
On GitHub, a process the change’s code leaves running in the check, plan or replan job’s container no API token: the later steps hold only ACTIONS_RUNTIME_TOKEN, which the artifact upload uses
On Forgejo, a process the change’s code leaves running in the check, plan or replan job’s container yes: Forgejo’s runner gives the run’s token to every later step of the job, such as the one that keeps the report
GitLab’s check job no variable once its script starts; the runner’s job token stays in the checkout’s remote
GitLab’s plan job yes: GITLAB_TOKEN, api scope, and the job token in the checkout’s remote; with gitlab.token: protected, only the job token
apply-comment and mr-apply with apply.when: pull-request yes: the job’s token, in the job’s environment and the checkout, which the stage pushes the locks and the gate’s records with
The jobs that run merged code: the apply waves and their share jobs, confirm, drift, tips, version-bump, publish, resume, rollout yes: the job’s token, and in publish the registry and signing secrets; the code there was reviewed and merged, and rollout runs none of the pull requests it opens
A change that edits its own pipeline file whatever that pipeline asks for

Each of these is a writer’s own code in a job the forge gives a token to; branch protection keeps that token from reaching the default branch.

Where Setting Relied on for
Default branch Require a pull request, with one approval every push there applies; contents: write tokens can push
Default branch Dismiss stale approvals when new commits are pushed the reviewed code is the merged code
Default branch Require the terragucci/plan status only planned changes merge
Default branch Block force pushes and deletion base, the first parent, stays the reviewed history
Repo settings Allow merge commits or squash; turn off rebase merging a rebase of several commits makes base the change’s own commit
chant/lifecycle A ruleset blocking force pushes and deletion, with pull requests and status checks off approval history stays; approvers and jobs push records
Apply role Trust only repo:<owner>/<repo>:ref:refs/heads/<default branch> no branch or pull request assumes it

terragucci

These docs count page views and clicks with PostHog. They set no cookies, store nothing in your browser, and send nothing when your browser asks not to be tracked.