Skip to content

Hosted Terraform MCP is read-only; no MCP-triggered TFC runs

Date: 2026-07-03 Status: Accepted — AMENDED 2026-08-01 (see § Amendment) Decision: hl-iu87 Deciders: Sean

⚠ AMENDED 2026-08-01 — one factual claim in the Decision below is false. The TFE_TOKEN is not a scoped read-only team token. No such credential exists, none was ever built, and the approach does not work in HCP Terraform. The read-only intent still holds; the mechanism that enforces it is different and is recorded in § Amendment. Read that section before acting on anything below it.

The hosted Terraform MCP server (hashicorp/terraform-mcp-server) can expose three capability tiers: registry documentation (no credentials), HCP Terraform observation (--toolsets=registry,terraform + a TFE_TOKEN), and — only when ENABLE_TF_OPERATIONS=true — run creation (create_run, whose run_type includes plan_and_apply / is_destroy / auto_approve). The initial ask was “read + plan_only runs.”

The hosted Terraform MCP is strictly READ-ONLY: registry docs plus run/workspace observation (list_runs, get_run_details, get_workspace_details, list_workspaces). ENABLE_TF_OPERATIONS stays unset, so run-creation tools are never registered. The TFE_TOKEN is a dedicated tfe_team with custom access runs=read, state_versions=none, variables=none. Registry docs run as a separate zero-credential deployment.

  • tf/CLAUDE.md already mandates “MUST NOT manually trigger TFC runs via API or MCP tools — GitOps only.” create_run, even plan_only, triggers a run via MCP and violates it.
  • HashiCorp documents that TFC “plan” permission is equivalent to “write” from a security standpoint — “intended to prevent accidental runs, not malicious access.” A plan-scoped token is a UX rail, not a confused-deputy boundary.
  • state_versions=none prevents the token from downloading state or reading sensitive outputs — closing the “plan grants full state read” gap.
  • Apply stays on the GitOps path (PR merge → HCP Terraform auto-apply); the MCP never mutates.
  • Read + plan_only (rejected): violates the tf/CLAUDE.md MUST-NOT, and plan-permission is not a security boundary — negligible safety gain for real blast radius.
  • Full, including apply/destroy (rejected): bypasses HCP Terraform’s auto-apply-on-merge, the analog of the “no kubectl apply” invariant.
  • Positive: no confused-deputy write path; the token cannot read state/variables; the GitOps apply invariant is fully preserved.
  • Negative: agents cannot trigger even speculative plans via MCP — planning stays on the PR path.
  • Neutral: registry docs need no TFC credential and are served by a separate deployment open to any authenticated caller.

Amendment — 2026-08-01: the read-only team token does not exist

Section titled “Amendment — 2026-08-01: the read-only team token does not exist”

The Decision above states, in the present indicative, that “the TFE_TOKEN is a dedicated tfe_team with custom access runs=read, state_versions=none, variables=none.” That credential was never built and, per the operator who tried to build it, cannot be: HCP Terraform has no read-only team token. A read-only team does exist in the fzymgc-house organisation with 0 members and no workspace grants — the residue of that attempt, not a working control.

Measured 2026-08-01:

Check Result
tfe_team / tfe_team_token / tfe_team_access anywhere in tf/ none
tfe_* resources declared in tf/hcp-terraform/ 18 — org, project, workspaces, variables, notifications; no team resource
read-only team in TFC exists, 0 users, no organization-access, no grant on ws-7WU8odS8KxcceMY6
Consequence a token from it could read nothing; the only usable credential is a personal, write-capable one

tf/cloudflare/CLAUDE.md compounded this by telling agents the ADR “establishes” the team and that “its read-only TFE_TOKEN is the credential to use” — instructing them to reach for something unobtainable. Corrected in the same change.

This ADR contained its own refutation. The Rationale correctly cites HashiCorp that TFC plan permission “is equivalent to write from a security standpoint — intended to prevent accidental runs, not malicious access,” and concludes that a plan-scoped token is “a UX rail, not a confused-deputy boundary.” That reasoning applies to any TFC permission scoping, including the one this Decision then rested its safety argument on. Having argued that credential scoping is not a boundary, it made credential scoping the boundary.

Structural, in the harness code, not in the credential. Built during v1.5 Phase 2 plan 02-08 before this defect was noticed, and it supersedes the token-scoping claim:

  • No code in this repository reads HCP runs any more: the verifier’s HCP module and the parsed-module test that held it to GET-only requests were removed with its read-path phase. Any future module that reads a run must carry the same structural assertion, because the credential cannot provide it.
  • Any plan JSON fetched must go to a temporary directory removed on exit; only a redacted action extract may reach the tree. (PR #1750 exists because a harness once persisted a plan JSON into a tracked directory — it is workspace-scoped, not change-scoped, and carries every credential in the workspace in cleartext.)
  • tf/CLAUDE.md:84’s “MUST NOT manually trigger TFC runs — GitOps only” remains the standing instruction, and apply stays on the PR-merge path.

This is the stronger control by this ADR’s own argument: it does not depend on a permission model HashiCorp explicitly says is not a security boundary, and it fails loudly and automatically rather than relying on a credential behaving as hoped.

Any TFC token able to read a run is also able to start one. There is no credential-level mitigation available. An agent performing the SC#3 drift read therefore holds a capability it must not use, and the assurance that it does not is the structural test plus the standing instruction — not the token. Any future work that hands a TFC token to an agent must say so explicitly and record the bounding, as v1.5 Phase 2 did in deferred-items.md § D6.

This ADR was authored by an AI agent, which asserted the credential shape without verifying it existed or was achievable. A later session then quoted it back as established repo fact and built a remediation plan on top of it. An ADR is a record of a decision, not a warrant that its premises were checked — a factual claim in one is worth exactly the verification behind it, and this one had none.