Enabling the governed git read + write capability on a tenant
github.commit_files is the first UpsQuad action that can change a customer's
source of truth. It ships catalogued but authorised nowhere: nothing in the
platform seeds a policy for it, so on every tenant — including the ones running the
SDLC golden flow today — a run that reaches such a node fails closed with
GovernanceDenied until an operator writes an explicit grant.
This runbook is how that grant is written, what it enables, and what to check first.
Issue: #2568 (design, founder-approved 2026-08-12) · ADR-0031 (#2595, the (A)/(B)/(b)/(c) decision record) · related: #2566 (the gap report), #2564 (why the layer you grant at matters), #2397 (the idempotency anchor).
1. What the three actions are
| Action | Class | Clearance floor | What it can do |
|---|---|---|---|
github.get_file_contents | read | 0 | Read one file, at a ref, from the node's pinned repo. Capped at 128 KiB, truncation and binary flagged. |
github.get_branch | read | 0 | Read a branch's head SHA, its protected flag and the repo's default branch. |
github.commit_files | write | 4 | Commit a set of file contents to a non-default, unprotected branch of the node's pinned repo, compare-and-swap'd on expected_head_sha. |
The floor is enforced at save: a commit_files step declaring
required_clearance < 4 is rejected by the definition validator.
2. What is fenced without any policy at all
These hold on a tenant with zero governance rows, and they hold if the guardrails store is unreachable. They are not what authorises the action — they are what bounds it once it is authorised.
- Repo pin.
with.repomust be an author-committed literal (no${...}), and the invoker denies any resolved repo outside it before resolving the credential. - Protected-ref deny, three independent arms: a conventional integration-branch
name (
main,master,trunk,develop,release,production,staging, …) with no network call; the repository's livedefault_branch; and GitHub's ownprotectedflag on the branch. - Path fence. Traversal / absolute / URL-metacharacter paths are refused, and no
commit may touch
.github/or.git/— a workflow file committed to a branch executes with the repository's CI credentials, which is a strictly larger capability than the commit. - Compare-and-swap.
expected_head_shais required; there is no blind-write mode. The ref update additionally sendsforce: false, so GitHub rejects a non-fast-forward. There is no force-push capability and no way to compose one. - Bounds. ≤ 50 files, ≤ 256 KiB per file, ≤ 1 MiB total, ≤ 8 KiB message.
- Idempotency. The commit message carries an
Upsquad-Idempotency-Key: run:step@laptrailer and the arm reads the branch's recent history for its own trailer before writing, so an at-least-once retry returns the prior commit instead of landing a second one.
guardrails.Engine.EvaluateArguments is fail-open — it allows when no policy
matches. What authorises a commit_files node is the node's entry Govern, where
action_type = tool_action is in governance.FailsClosedOnNoMatch, so a cascade
matching no policy at any layer resolves to deny. Never reason about this
capability from a guardrail rule.
The .github/ fence stops the agent committing new workflow definitions, but it
does not stop the code it commits from being run by workflows you already have.
A repository with an existing on: push or (worse) pull_request_target workflow
executes the agent's committed content — build scripts, Makefiles, test files,
package.json lifecycle hooks — with whatever credentials that workflow carries, the
moment commit_files moves the branch. pull_request_target runs on the base
repository's secrets and is the classic privilege-escalation surface.
Before enabling commit_files on a repo, audit its .github/workflows/ for triggers
that run on the branches this agent will target, and prefer a branch-scoped or
manual-approval CI posture for agent-authored branches. This capability grants "may
land a commit", and on a repo with automatic CI that is transitively "may run code in
CI" — which is exactly the escalation the .github/ write-fence is designed to keep
narrow, not to eliminate.
3. Before you grant: the pre-enablement audit
Check for a blanket wildcard first. cascade_query.go returns target = '*'
rows for every target, so a tenant that already carries a
tool_action / * / allow row has the write enabled the moment it ships, with no
per-target decision by anyone. The platform never seeds such a row (the provisioner
is deliberately per-target), so it can only have arrived by hand.
-- Any wildcard tool_action grant on this org? Expect ZERO rows.
SET LOCAL app.org_id = '<ORG_ID>';
SELECT id, target, effect, min_clearance
FROM org_governance_policies
WHERE org_id = '<ORG_ID>' AND action_type = 'tool_action' AND target = '*';
Also check the tighter layers (org_unit_governance_policies,
member_governance_policies) for target = '*'. If a wildcard exists, narrow it
before granting anything here — otherwise the per-target grant below is decoration.
Then confirm the rest of the prerequisites:
- The tenant's SCM credential has write scope. The token comes from the vault resolution chain (member → team → org). A read-only token fails at the ref update with a GitHub 403; that is a legible failure, not a silent one, but it is a wasted run. Check before granting.
- The target repo is the one you think. The grant is per action, not per repo —
the repo bound is the node's literal pin in the workflow definition. Read the
definition's
with.repobefore enabling. - The branch strategy produces non-protected branches. A flow that ships to
maincannot be made to work by granting harder; it has to open a PR instead.
4. Writing the grant
The governance target strings are exactly:
tool:github.get_file_contents
tool:github.get_branch
tool:github.commit_files
(TestGitCapability_GovTargetIsTheStringAnOperatorGrants pins these against the
runtime's own derivation, so a typo here is a grant that is written and never read.)
4a. Org scope — the layer that matches every member (recommended)
Governance keys on the triggering human's identity: the org_unit layer only
matches units on that member's ancestor path, so a unit-scoped grant is invisible to
every run triggered from outside that unit (this is the core#2564 finding, measured
on the live tenant). For a capability the whole tenant is meant to have, grant at
org scope.
There is no PutOrgPolicy RPC today — GovernanceService exposes PutOrgUnitPolicy
and PutMemberPolicy only — so an org-scope grant is a direct, RLS-scoped upsert:
BEGIN;
SET LOCAL app.org_id = '<ORG_ID>'; -- required: FORCE ROW LEVEL SECURITY
INSERT INTO org_governance_policies (org_id, action_type, target, effect, min_clearance)
VALUES
('<ORG_ID>', 'tool_action', 'tool:github.get_file_contents', 'allow', 0),
('<ORG_ID>', 'tool_action', 'tool:github.get_branch', 'allow', 0),
('<ORG_ID>', 'tool_action', 'tool:github.commit_files', 'allow', 4)
ON CONFLICT (org_id, action_type, target)
DO UPDATE SET effect = EXCLUDED.effect,
min_clearance = EXCLUDED.min_clearance,
updated_at = now();
COMMIT;
min_clearance = 4 on the write mirrors the catalog floor: the node presents its
declared required_clearance, and the engine denies when the presented clearance is
below the policy's minimum. Raise it above 4 to require a higher-clearance node;
never lower it below the floor, which would be a policy the validator makes
unreachable.
Grant the reads without the write to get a genuinely read-only agent posture:
omit the third row. Per-target scoping is enforced in SQL
(AND (target = $4 OR target = '*')), so a read grant does not reach the write.
4b. Unit or member scope — for a single team or a single person
Use the RPCs (both require guardrails.edit on the target scope):
PutOrgUnitPolicy { org_unit_id, action_type: "tool_action",
target: "tool:github.commit_files", effect: "allow", min_clearance: 4 }
PutMemberPolicy { member_id, action_type: "tool_action",
target: "tool:github.commit_files", effect: "allow", min_clearance: 4 }
⚠ Remember the cascade rule: an org_unit grant only applies to runs triggered by a
member on that unit's ancestor path. If the person who triggers the run is not in
that unit, the grant is dead and the run fails closed.
5. Verifying
EvaluateDryRun renders the full cascade trace without mutating anything, and since
core#2565 its no-match default is derived from the same FailsClosedOnNoMatch the
enforcement path uses, so preview and enforcement agree:
EvaluateDryRun { org_id, member_id: <the human who triggers the run>,
action_type: "tool_action",
target: "tool:github.commit_files", clearance: 4 }
Expect verdict = allow with the matching layer named in the trace. Before the
grant, expect verdict = deny with platform default deny.
Then run the flow end to end on a sandbox repo first, and check:
workflow_actions.outputfor the step carriescommit_sha,previous_head_shaandidempotent: false;coordinator_audit_logcarries atool_resulthop for the step whosedetailholdscommit_sha,previous_head_shaandidempotency_key;- the branch in GitHub has exactly ONE new commit, and its message ends with the
Upsquad-Idempotency-Key:trailer.
6. Revoking
Set the row's effect to deny rather than deleting it — a deny is explicit and
survives a later re-seed, whereas a deleted row simply returns the target to
"no match", which is also a deny today but relies on the fail-closed default
staying in place.
BEGIN;
SET LOCAL app.org_id = '<ORG_ID>';
UPDATE org_governance_policies
SET effect = 'deny', updated_at = now()
WHERE org_id = '<ORG_ID>' AND action_type = 'tool_action'
AND target = 'tool:github.commit_files';
COMMIT;
Revocation takes effect on the next Govern check. A run already past its entry Govern for that node is not interrupted; a run parked at a gate re-governs when it resumes.
7. Failure modes and what they mean
| Symptom | Cause | Action |
|---|---|---|
Run fails GovernanceDenied at the tool node | No allow policy at any layer, or a deny at a tighter layer | Grant per §4, or find the tighter deny |
refusing to write to branch "X" | Protected-ref deny (name, default branch, or GitHub protection) | Ship to a feature branch and open a PR |
ToolCASConflict … branch moved | Another writer moved the head since get_branch | Retries automatically (3 attempts); if persistent, the branch has a concurrent writer |
path … is under ".github" | The agent tried to write a workflow/action definition | Deliberate fence — widening it is a separate review |
github 403 on the ref update | The tenant SCM credential has no write scope | Re-issue the credential with write scope |
requires required_clearance >= 4 at save | The node declares a clearance below the floor | Raise the node's required_clearance |
8. Related
internal/workflow/tool_catalog.go— the catalog rows and the "membership is not authorisation" note.cmd/agent-orchestrator/github_commit.go— the six fences, in the order they run.cmd/agent-orchestrator/github_repo_read.go— why neither read is observation-safe.internal/goldenflow/git_capability_seed_2568_test.go— the test that fails if any provisioner path ever starts seeding these targets.