Each agent action carries one tier. The tier decides whether code runs, and what may change.
Tiers are ordered by blast radius. A higher tier is never implied by a lower one.
Tier | Side effects | Needs a person |
|---|---|---|
Read | None; sources are only viewed | No, unless the source is restricted. |
Propose | None; output is draft text | No, review happens at use. |
Write to scratch | Inside one scratch directory only | No, while paths stay inside. |
Write to repo | Tracked sources change | Yes, every time. |
Run jobs | Compute runs and tool side effects | Yes, for each named job. |
Least privilege means the agent holds the lowest tier that still finishes the task. A triage run starts at read and propose.
Elevation is per action and expires. A run that edited one file does not keep repo write for its next run.
Default a new run to read plus propose, and add scratch only when it must write.
Name exact targets: file paths for writes and job names for runs.
Separate duties: the run that edits sources is not the run that approves them.
This is a policy-decision exercise for a fixed synthetic resource set, not a filesystem sandbox. Approval records come from a trusted controller and bind kind, target and revision; the model cannot mint them. A real executor must additionally enforce canonical filesystem containment, symlink/race protection, network restrictions and single-use approval consumption at execution time. A path string or a Boolean from the model is not authorisation.
from pathlib import PurePosixPath
READABLE = {"rtl/alu.sv", "reports/lint.txt"}
WRITABLE = {"rtl/alu.sv"}
JOBS = {"lint", "sim-smoke"}
def safe_path(target):
if not isinstance(target, str) or not target or chr(92) in target:
return False
path = PurePosixPath(target)
parts = target.split("/")
return (not path.is_absolute() and
not any(p in ("", ".", "..") for p in parts) and
not any(p in ("secrets", "keys", ".ssh", "release") for p in parts))
def decide(kind, target, approval, revision="r1"):
if kind == "propose":
return "allow"
if kind == "read":
return "allow" if safe_path(target) and target in READABLE else "deny"
if kind == "scratch":
return "allow" if safe_path(target) and target.startswith("scratch/") else "deny"
if kind == "repo":
if not safe_path(target) or target not in WRITABLE:
return "deny"
elif kind == "run":
if target not in JOBS:
return "deny"
else:
return "deny"
expected = (kind, target, revision)
return "allow" if type(approval) is tuple and approval == expected else "needs-approval"
def main():
cases = [
("read", "rtl/alu.sv", False, "allow"),
("read", "secrets/api_key.txt", False, "deny"),
("propose", "draft fix plan", False, "allow"),
("scratch", "scratch/trial1/log.txt", False, "allow"),
("scratch", "../outside.txt", False, "deny"),
("repo", "rtl/alu.sv", False, "needs-approval"),
("repo", "rtl/alu.sv", ("repo", "rtl/alu.sv", "r1"), "allow"),
("repo", "release/tag.txt", True, "deny"),
("run", "lint", False, "needs-approval"),
("run", "lint", ("run", "lint", "r1"), "allow"),
("run", "deploy-prod", True, "deny"),
("delete-all", "rtl/", True, "deny"),
("repo", "/outside.sv", True, "deny"),
("repo", "keys/signing.pem", True, "deny"),
("repo", "rtl/top.sv", ("repo", "rtl/alu.sv", "r1"), "deny"),
("repo", "rtl/alu.sv", True, "needs-approval"),
("repo", "rtl/alu.sv", ("repo", "rtl/alu.sv", "old"), "needs-approval"),
]
bad = 0
allow_n = 0
deny_n = 0
gate_n = 0
for (kind, target, approved, want) in cases:
got = decide(kind, target, approved)
if got != want:
bad += 1
if got == "allow":
allow_n += 1
elif got == "deny":
deny_n += 1
else:
gate_n += 1
if bad != 0:
print("FAIL: %d of %d policy cases wrong" % (bad, len(cases)))
raise SystemExit(1)
if allow_n != 5 or deny_n != 8 or gate_n != 4:
print("FAIL: verdict mix drifted from 5 allow, 8 deny, 4 gated")
raise SystemExit(1)
print("PASS: 17 policy decisions match; executor containment is a separate responsibility")
main()release/tag.txt. Which verdict, and why?Deny. Scratch covers only paths under scratch/, and release/ is a protected repo path.
No. It is tied to the exact action, target and revision. The controller must obtain a fresh approval and enforce it at execution time; this example only models the decision.