Skip to main content

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​

ActionClassClearance floorWhat it can do
github.get_file_contentsread0Read one file, at a ref, from the node's pinned repo. Capped at 128 KiB, truncation and binary flagged.
github.get_branchread0Read a branch's head SHA, its protected flag and the repo's default branch.
github.commit_fileswrite4Commit 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.

  1. Repo pin. with.repo must be an author-committed literal (no ${...}), and the invoker denies any resolved repo outside it before resolving the credential.
  2. 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 live default_branch; and GitHub's own protected flag on the branch.
  3. 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.
  4. Compare-and-swap. expected_head_sha is required; there is no blind-write mode. The ref update additionally sends force: false, so GitHub rejects a non-fast-forward. There is no force-push capability and no way to compose one.
  5. Bounds. ≤ 50 files, ≤ 256 KiB per file, ≤ 1 MiB total, ≤ 8 KiB message.
  6. Idempotency. The commit message carries an Upsquad-Idempotency-Key: run:step@lap trailer 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.
The T5 guardrails are NOT the authorisation for this action

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 agent's committed code can execute in your CI on the next push

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.repo before enabling.
  • The branch strategy produces non-protected branches. A flow that ships to main cannot 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.)

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.output for the step carries commit_sha, previous_head_sha and idempotent: false;
  • coordinator_audit_log carries a tool_result hop for the step whose detail holds commit_sha, previous_head_sha and idempotency_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​

SymptomCauseAction
Run fails GovernanceDenied at the tool nodeNo allow policy at any layer, or a deny at a tighter layerGrant 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 movedAnother writer moved the head since get_branchRetries automatically (3 attempts); if persistent, the branch has a concurrent writer
path … is under ".github"The agent tried to write a workflow/action definitionDeliberate fence — widening it is a separate review
github 403 on the ref updateThe tenant SCM credential has no write scopeRe-issue the credential with write scope
requires required_clearance >= 4 at saveThe node declares a clearance below the floorRaise the node's required_clearance
  • 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.