A privilege grant is a production write. Free inference is a best-effort drafting surface. Piping a model completion into IAM, Cedar, Rego, or Kubernetes RBAC apply turns a prompt artifact into lasting access, with no named owner and no freeze hash an auditor can cite.
That path is not a merge check in different clothes. A bad CI gate fails closed, and a revert restores the tree. A noisy canary can shift traffic back. A generated PutRolePolicy or ClusterRoleBinding stays attached after the chat ends. Tokens evaporate. Grants do not.
Vibe-coded YAML has made the swap easy to miss. A model will emit a document that unblocks a deploy and reads like engineering. The file is still a completion. Naming it policy.json does not create a control.
The leak is usually mundane. A developer pastes an access-denied error into a free endpoint, asks for a fix, and receives a role policy that adds s3:* on *. The next line in the same shell is aws iam put-role-policy. Nothing in that sequence recorded a reviewer, a content hash, or an expiry. The model was treated as a notary. It is not one.
A useful analogy is a night clerk cutting master keys from a plausible memo. The memo can be well written. The metal still opens the building. Authorization documents deserve the same suspicion as key cutting. The rest of this article treats generated privilege documents as untrusted input, keeps free-model drafting in a sandbox, and requires a human-signed freeze file before any apply CLI is allowed to run. The gate is deliberately boring. Boring is the point.
What the apply path must prove
An apply path for identity policy has to answer three facts in code, not in chat: who reviewed this exact byte string, when that review expires, and which actions or resources the document is still forbidden to contain even after review. Those checks belong next to the policy file, in version control, runnable on a laptop. They do not belong inside the model.
A completion can narrate least privilege while emitting a wildcard. Syntax and narration are different jobs. The artifact below is a small Python gate plus tests. It is a proposal, not a measured production deployment. Teams should replace the reviewer identity and the forbidden-action set with their own, and should not treat the timestamps in the samples as a vendor SLA.
A freeze file the apply wrapper can trust
The freeze file is the only document the wrapper believes. It binds a reviewer identity to the SHA-256 of the policy bytes and to a short expiry. If the policy is edited by one character, the hash dies and apply dies with it.
{
"policy_sha256": "replace-with-real-hash",
"reviewer": "security-oncall@example.com",
"reviewed_at": "2026-09-18T12:00:00Z",
"expires_at": "2026-09-19T12:00:00Z",
"ticket": "SEC-1842",
"source": "human-authored"
}
The source field is not decoration. Values other than human-authored fail closed. Drafts produced on a free inference endpoint can sit beside the policy as draft.md. They must not be the source of the grant. A model name in reviewer fails closed for the same reason: a reviewer has to survive a staffing change.
# policy_apply_gate.py
# Proposal: refuse IAM-shaped JSON unless a human freeze file matches.
from __future__ import annotations
import datetime as dt
import hashlib
import json
import sys
from pathlib import Path
from typing import Any
FORBIDDEN_ACTIONS = {
"*",
"iam:*",
"iam:PassRole",
"iam:CreateUser",
"iam:AttachUserPolicy",
"iam:PutUserPolicy",
"sts:AssumeRole",
"s3:*",
"kms:*",
"ec2:*",
}
class PolicyRefused(Exception):
pass
def sha256_bytes(data: bytes) -> str:
return hashlib.sha256(data).hexdigest()
def iter_statements(policy: dict[str, Any]) -> list[dict[str, Any]]:
document = policy.get("PolicyDocument", policy)
statements = document.get("Statement", [])
if isinstance(statements, dict):
return [statements]
if not isinstance(statements, list):
raise PolicyRefused("Statement must be an object or a list")
return statements
def refuse_wild_grants(policy: dict[str, Any]) -> None:
for stmt in iter_statements(policy):
effect = stmt.get("Effect", "Allow")
actions = stmt.get("Action", [])
resources = stmt.get("Resource", [])
if isinstance(actions, str):
actions = [actions]
if isinstance(resources, str):
resources = [resources]
if effect == "Allow" and not stmt.get("Sid"):
raise PolicyRefused("Allow statements need a stable Sid")
if "NotAction" in stmt or "NotResource" in stmt:
raise PolicyRefused("NotAction/NotResource is not allowed on this path")
if effect != "Allow":
continue
for action in actions:
if action in FORBIDDEN_ACTIONS or str(action).endswith(":*"):
raise PolicyRefused(f"forbidden action {action}")
for resource in resources:
if resource == "*":
raise PolicyRefused("wildcard resource")
if str(resource).startswith("arn:aws:iam::") and str(resource).endswith("*"):
raise PolicyRefused(f"forbidden identity resource {resource}")
def refuse_stale_or_foreign_freeze(policy_bytes: bytes, freeze: dict[str, Any]) -> None:
expected = freeze.get("policy_sha256")
actual = sha256_bytes(policy_bytes)
if expected != actual:
raise PolicyRefused("freeze hash does not match policy bytes")
if freeze.get("source") != "human-authored":
raise PolicyRefused("freeze source must be human-authored")
reviewer = freeze.get("reviewer") or ""
if "@" not in reviewer:
raise PolicyRefused("reviewer must be an identity, not a model name")
expires = dt.datetime.fromisoformat(str(freeze["expires_at"]).replace("Z", "+00:00"))
now = dt.datetime.now(dt.timezone.utc)
if expires <= now:
raise PolicyRefused("freeze expired")
if expires - now > dt.timedelta(hours=36):
raise PolicyRefused("freeze window is too wide for a privilege grant")
def evaluate(policy_path: Path, freeze_path: Path) -> None:
policy_bytes = policy_path.read_bytes()
policy = json.loads(policy_bytes.decode("utf-8"))
freeze = json.loads(freeze_path.read_text(encoding="utf-8"))
refuse_wild_grants(policy)
refuse_stale_or_foreign_freeze(policy_bytes, freeze)
def main(argv: list[str]) -> int:
if len(argv) != 3:
print(
"usage: policy_apply_gate.py <policy.json> <POLICY_REVIEW.json>",
file=sys.stderr,
)
return 2
try:
evaluate(Path(argv[1]), Path(argv[2]))
except (PolicyRefused, OSError, json.JSONDecodeError, KeyError, ValueError) as exc:
print(f"REFUSE APPLY: {exc}", file=sys.stderr)
return 1
print("freeze ok; still requires a human to run the cloud CLI")
return 0
if __name__ == "__main__":
raise SystemExit(main(sys.argv))
The last print line is load-bearing. A green gate is not apply. It is permission for a human to type the cloud command. Wrapping aws iam put-role-policy inside the same script would recreate the original bug with extra ceremony. The wrapper stops at the door. A person still has to turn the key.
Tests that fail closed
Chat transcripts hide wildcards behind fluent explanations. Tests do not. The cases below are meant to run on a laptop with no cloud credentials, which is the only environment this proposal trusts for drafting.
# test_policy_apply_gate.py
import json
from pathlib import Path
import pytest
from policy_apply_gate import PolicyRefused, evaluate, sha256_bytes
SAFE = {
"Version": "2012-10-17",
"Statement": [{
"Sid": "ReadOneBucketPrefix",
"Effect": "Allow",
"Action": ["s3:GetObject"],
"Resource": "arn:aws:s3:::logs-nonprod/app/*",
}],
}
def write_pair(tmp: Path, policy: dict, freeze_extra: dict) -> tuple[Path, Path]:
policy_path = tmp / "policy.json"
raw = json.dumps(policy, indent=2).encode("utf-8")
policy_path.write_bytes(raw)
freeze = {
"policy_sha256": sha256_bytes(raw),
"reviewer": "sec@example.com",
"reviewed_at": "2026-09-18T12:00:00Z",
"expires_at": "2026-09-19T00:00:00Z",
"ticket": "SEC-1",
"source": "human-authored",
}
freeze.update(freeze_extra)
freeze_path = tmp / "POLICY_REVIEW.json"
freeze_path.write_text(json.dumps(freeze), encoding="utf-8")
return policy_path, freeze_path
def test_wildcard_action_refused(tmp_path):
policy = json.loads(json.dumps(SAFE))
policy["Statement"][0]["Action"] = "s3:*"
policy_file, freeze_file = write_pair(tmp_path, policy, {})
with pytest.raises(PolicyRefused, match="forbidden action"):
evaluate(policy_file, freeze_file)
def test_model_source_refused(tmp_path):
policy_file, freeze_file = write_pair(
tmp_path, SAFE, {"source": "free-inference-draft"}
)
with pytest.raises(PolicyRefused, match="human-authored"):
evaluate(policy_file, freeze_file)
def test_hash_mismatch_refused(tmp_path):
policy_file, freeze_file = write_pair(
tmp_path, SAFE, {"policy_sha256": "00" * 32}
)
with pytest.raises(PolicyRefused, match="hash"):
evaluate(policy_file, freeze_file)
def test_star_resource_refused(tmp_path):
policy = json.loads(json.dumps(SAFE))
policy["Statement"][0]["Resource"] = "*"
policy_file, freeze_file = write_pair(tmp_path, policy, {})
with pytest.raises(PolicyRefused, match="wildcard resource"):
evaluate(policy_file, freeze_file)
def test_narrow_human_policy_passes(tmp_path):
policy_file, freeze_file = write_pair(tmp_path, SAFE, {})
evaluate(policy_file, freeze_file)
Commands stay short so the ritual is harder to skip than to run.
python -m pytest -q test_policy_apply_gate.py
python policy_apply_gate.py policy.json POLICY_REVIEW.json
# only then, and only by a human:
# aws iam put-role-policy --role-name app-nonprod \
# --policy-name read-logs --policy-document file://policy.json
A generated draft can still exist. It lives in draft.md and is treated like a rubber duck that types. The duck does not receive an AWS credential. If the draft is useful, a human retypes the intent into the allowlist the tests already understand, then signs the freeze.
Where free inference still belongs
Drafting an explanation of a deny, comparing two already-written statements, or generating extra unit cases for a known-safe action list are read-mostly jobs. They fail open in the worst case: a bad explanation wastes time. They do not mint access. That is the only loop this article is willing to put on a free endpoint.
Disclosure: This article was prepared as part of MonkeyCode's product outreach. MonkeyCode's free model access and free server option can host that read-mostly loop on a machine that has no cloud admin keys. The gate above does not require the product. If every mention of it were removed, the freeze file and the tests would still be the method.
Paste of live account IDs, production ARNs, or session tokens into any remote draft endpoint remains a bad idea, free or not. A sandbox that cannot reach iam.amazonaws.com is the right shape. A sandbox that can apply is just a slower production console.
Red flags that mean stop, not prompt again
The first red flag is a single shell history that contains both a model CLI and put-role-policy, kubectl apply, terraform apply, or gcloud projects add-iam-policy-binding. Adjacent commands are a coupled system. Split them across users if the team cannot keep them apart on one laptop.
The second is a policy without Sid values, or with Sid values that change every generation. Unstable Sids make later revocation a scavenger hunt. A grant that cannot be named cannot be ended on purpose.
The third is any Allow that uses NotAction, *, or a service-star such as iam:*. Models reach for these because they clear the error the human pasted. Clearing an error is not the same as stating intent. The fourth is a freeze that lists the model as reviewer, or a ticket field filled with chat residue. The fifth is asking an endpoint to make the policy work in prod while the working tree already contains account IDs. At that point the prompt is no longer a design aid. It is an unsanctioned change window.
When those flags appear, the exit is mechanical. Delete the generated file. Rotate any credential that sat in the same environment. Rewrite the policy from an allowlist the team already uses in nonprod. Run the tests. Only then open a review that a human can sign. Prompting again is not an exit. It is a second attempt to cut the same key.
Better alternatives than a better prompt
Human-authored templates with substitution parameters beat generated documents. A template that can only fill a bucket name cannot invent iam:CreateUser. Permission boundaries and service control policies then cap what a mistaken inner policy can do even if someone later bypasses the laptop gate.
Policy unit tests, whether this gate, Cedar authorization tests, or OPA/Rego unit tests, beat natural-language assurances. A paragraph that says least privilege is not an oracle. A failing test is. Cloud-native analyzers that read the actual policy after apply are a detection layer, not an author. They belong after a human grant, as a tripwire, not as a substitute for the freeze.
None of those alternatives depend on a free inference quota. They depend on a reviewer who can be named in an incident channel after the context window has closed.
Limitations and who should not use this gate
The gate is syntactic. It will allow a narrow-looking s3:GetObject on the wrong bucket if the reviewer is careless. It will refuse some legitimate break-glass documents that security may still need under a different, slower process. It does not prove semantic least privilege, and it does not watch for identity-based policies attached outside this file.
It also does not make a free endpoint a system of record. Prompt logs are incomplete audit trails. They may be retained by a third party. They may disappear. Neither outcome is acceptable as the history of a privilege grant.
Teams that must generate policy on the apply path in response to incidents should not use this approach. They need a pre-authorized break-glass role with a recorded session, not a model. Teams that cannot name a human reviewer should not use it either. The freeze file would become fiction. Platform groups that already pipe completions into Terraform should not treat this script as a bolt-on blessing. The coupling has to be cut first. Otherwise the gate becomes a sticker on an automated grant factory.
Free inference remains useful as a reader and a critic sitting beside a key cutter, never as the cutter. Privilege is a write. Writes need owners who still work at the company after the session ends. If a keyless sandbox helps a team rehearse deny explanations before review, that loop can live on a free server. The grant itself stays on a hash a human is willing to put a name under.
Top comments (0)