Scope one agent run or deployment before it starts: build a tool allowlist, assign read, write, and spend tiers, set spend and volume caps, set a time expiry, place approval gates per action class, and write a scope manifest the run is checked against. Use when deciding what an agent may do in one run before the run begins.
./install.sh agent-permission-scopingname: agent_permission_scoping description: "Scope one agent run or deployment before it starts: build a tool allowlist, assign read, write, and spend tiers, set spend and volume caps, set a time expiry, place approval gates per action class, and write a scope manifest the run is checked against. Use when deciding what an agent may do in one run before the run begins." metadata: { "includeInPrompt": true }
Decide, before a run starts, exactly what one agent run or deployment is allowed to do, and write that decision down as a scope manifest: the tool allowlist, the tier of each tool, the caps, the expiry, and the approval gates. The run is then checked against the manifest instead of against memory or good intentions.
Scope:
json
{
"scope_id": "pricing-research-2026-10-14-a",
"purpose": "Collect public pricing pages for three named competitors and write one summary file into the run workspace.",
"issued_at": "2026-10-14T12:00:00Z",
"expires_at": "2026-10-14T16:00:00Z",
"tools": [
{"name": "web_search", "tier": "read"},
{"name": "web_fetch", "tier": "read"},
{"name": "workspace.write", "tier": "write"},
{"name": "paid_lookup", "tier": "spend"}
],
"caps": {"max_actions": 120, "max_spend_usd": 5.00, "max_duration_minutes": 240},
"gates": [
{"applies_to": "workspace.write", "requires": "approval"},
{"applies_to": "paid_lookup", "requires": "approval"}
],
"notes": "Output goes to the run workspace only. No messages are sent and no records outside the workspace are changed."
}
Deliver three things for each scoped run:
A manifest with a FAIL report and no written waiver in its notes field is not a completed deliverable; the run does not start on it.
Usage: python3 scope_check.py MANIFEST.json [--now ISO8601] The --now flag sets the check time for expiry; without it, the current UTC time is used. Exit code: 0 on PASS, 1 on FAIL, 2 on usage or read errors.
Valid fixture (save as valid-scope.json). This is the worked example from Workflow step 7, with the JSON keys sorted for file use; content is the same:
json
{"caps":{"max_actions":120,"max_duration_minutes":240,"max_spend_usd":5.0},"expires_at":"2026-10-14T16:00:00Z","gates":[{"applies_to":"workspace.write","requires":"approval"},{"applies_to":"paid_lookup","requires":"approval"}],"issued_at":"2026-10-14T12:00:00Z","notes":"Output goes to the run workspace only. No messages are sent and no records outside the workspace are changed.","purpose":"Collect public pricing pages for three named competitors and write one summary file into the run workspace.","scope_id":"pricing-research-2026-10-14-a","tools":[{"name":"web_search","tier":"read"},{"name":"web_fetch","tier":"read"},{"name":"workspace.write","tier":"write"},{"name":"paid_lookup","tier":"spend"}]}
Loose fixture (save as loose-scope.json), written to trip the checks:
json
{"caps":{"max_actions":0,"max_duration_minutes":60},"expires_at":"2026-10-13T16:00:00Z","gates":[{"applies_to":"tier:write","requires":"approval"}],"issued_at":"2026-10-13T12:00:00Z","scope_id":"pricing-research-loose","tools":[{"name":"web_search","tier":"read"},{"name":"web_search","tier":"read"},{"name":"payments.send","tier":"spend"},{"destructive":true,"name":"files.delete","tier":"write"},{"name":"shell.exec","tier":"admin"}]}
Run: python3 scope_check.py valid-scope.json --now 2026-10-14T12:00:00Z
Expected output (exit code 0):
text
manifest: valid-scope.json
scope_id: pricing-research-2026-10-14-a
checked_at: 2026-10-14T12:00:00Z
tools: 4 (read 2, write 1, spend 1)
result: PASS
violations: 0
warnings: 0
Run: python3 scope_check.py loose-scope.json --now 2026-10-14T12:00:00Z
Expected output (exit code 1):
text
manifest: loose-scope.json
scope_id: pricing-research-loose
checked_at: 2026-10-14T12:00:00Z
tools: 5 (read 2, write 1, spend 1)
result: FAIL
violations: 9
- MISSING_FIELD: missing or empty required field: purpose
- EXPIRED: scope expired at 2026-10-13T16:00:00Z (checked at 2026-10-14T12:00:00Z)
- BAD_CAP: cap max_actions must be a positive number, got 0
- MISSING_CAP: caps is missing required cap: max_spend_usd
- DURATION_EXCEEDS_CAP: issued-to-expiry span is 240 minutes, above max_duration_minutes 60
- DUPLICATE_TOOL: tool listed more than once: web_search
- UNGATED: spend tool payments.send has no approval gate covering it
- DESTRUCTIVE_NEEDS_NAMED_GATE: destructive tool files.delete is covered only by a broad gate; it needs a gate that names the tool in applies_to
- BAD_TIER: tool shell.exec has tier 'admin', expected one of read, write, spend
warnings: 0
scope_check.py (Python 3, standard library only). Save this code as scope_check.py:
python
#!/usr/bin/env python3
"""Validate an agent scope manifest (JSON). Stdlib only.
Manifest format is the convention defined in the agent-permission-scoping
skill. Usage:
python3 scope_check.py MANIFEST.json [--now 2026-10-14T12:00:00Z]
Exit code: 0 on PASS, 1 on FAIL, 2 on usage or read errors.
"""
import json, sys
from datetime import datetime, timezone
TIERS = ("read", "write", "spend")
REQUIRED_TOP = ["scope_id", "purpose", "issued_at", "expires_at", "tools", "caps", "gates"]
REQUIRED_CAPS = ["max_actions", "max_spend_usd", "max_duration_minutes"]
def parse_ts(text):
if not isinstance(text, str):
return None
t = text.strip()
if t.endswith("Z"):
t = t[:-1] + "+00:00"
try:
dt = datetime.fromisoformat(t)
except ValueError:
return None
if dt.tzinfo is None:
dt = dt.replace(tzinfo=timezone.utc)
return dt.astimezone(timezone.utc)
def fmt_ts(dt):
return dt.strftime("%Y-%m-%dT%H:%M:%SZ")
def gate_covers(gates, tool):
"""Return 'specific', 'tier', or None for how a gate covers a tool."""
specific = False
tier = False
for g in gates:
if not isinstance(g, dict):
continue
if str(g.get("requires", "")).strip().lower() != "approval":
continue
target = str(g.get("applies_to", "")).strip()
if target == tool.get("name"):
specific = True
elif target == "tier:" + str(tool.get("tier", "")):
tier = True
elif target == "*":
tier = True
if specific:
return "specific"
if tier:
return "tier"
return None
def validate(m, now):
v = [] # violations, list of (code, message)
w = [] # warnings
if not isinstance(m, dict):
return [("NOT_AN_OBJECT", "manifest root is not a JSON object")], w, {}
for field in REQUIRED_TOP:
if field not in m or m[field] is None or m[field] == "":
v.append(("MISSING_FIELD", f"missing or empty required field: {field}"))
scope_id = m.get("scope_id") if isinstance(m.get("scope_id"), str) else ""
issued = parse_ts(m.get("issued_at")) if "issued_at" in m else None
expires = parse_ts(m.get("expires_at")) if "expires_at" in m else None
if "issued_at" in m and issued is None:
v.append(("BAD_TIMESTAMP", f"issued_at is not a parseable ISO 8601 timestamp: {m.get('issued_at')!r}"))
if "expires_at" in m and expires is None:
v.append(("BAD_TIMESTAMP", f"expires_at is not a parseable ISO 8601 timestamp: {m.get('expires_at')!r}"))
if issued is not None and expires is not None:
if expires <= issued:
v.append(("EXPIRY_NOT_AFTER_ISSUE", "expires_at is not after issued_at"))
elif expires <= now:
v.append(("EXPIRED", f"scope expired at {fmt_ts(expires)} (checked at {fmt_ts(now)})"))
elif expires is not None and expires <= now:
v.append(("EXPIRED", f"scope expired at {fmt_ts(expires)} (checked at {fmt_ts(now)})"))
caps = m.get("caps")
if isinstance(caps, dict):
for cap in REQUIRED_CAPS:
if cap not in caps:
v.append(("MISSING_CAP", f"caps is missing required cap: {cap}"))
else:
val = caps[cap]
if not isinstance(val, (int, float)) or isinstance(val, bool) or val <= 0 and cap != "max_spend_usd":
v.append(("BAD_CAP", f"cap {cap} must be a positive number, got {val!r}"))
elif cap == "max_spend_usd" and (not isinstance(val, (int, float)) or isinstance(val, bool) or val < 0):
v.append(("BAD_CAP", f"cap max_spend_usd must be a number at or above 0, got {val!r}"))
if issued is not None and expires is not None and isinstance(caps.get("max_duration_minutes"), (int, float)):
span_min = (expires - issued).total_seconds() / 60.0
if span_min > caps["max_duration_minutes"]:
v.append(("DURATION_EXCEEDS_CAP", f"issued-to-expiry span is {int(span_min)} minutes, above max_duration_minutes {caps['max_duration_minutes']}"))
elif "caps" in m:
v.append(("BAD_CAPS", "caps is not a JSON object"))
gates = m.get("gates")
if not isinstance(gates, list):
gates = []
if "gates" in m:
v.append(("BAD_GATES", "gates is not a list"))
tools = m.get("tools")
if isinstance(tools, list) and not tools:
v.append(("NO_TOOLS", "tools list is empty; a scope with no tools cannot do anything and is usually a drafting error"))
counts = {"read": 0, "write": 0, "spend": 0}
total = 0
if isinstance(tools, list):
seen = set()
for i, tool in enumerate(tools):
if not isinstance(tool, dict):
v.append(("BAD_TOOL", f"tools[{i}] is not a JSON object"))
continue
total += 1
name = tool.get("name")
tier = tool.get("tier")
if not isinstance(name, str) or not name.strip():
v.append(("BAD_TOOL", f"tools[{i}] has no usable name"))
name = f"tools[{i}]"
elif name in seen:
v.append(("DUPLICATE_TOOL", f"tool listed more than once: {name}"))
seen.add(name)
if tier not in TIERS:
v.append(("BAD_TIER", f"tool {name} has tier {tier!r}, expected one of read, write, spend"))
continue
counts[tier] += 1
if tier in ("write", "spend"):
cover = gate_covers(gates, tool)
if cover is None:
v.append(("UNGATED", f"{tier} tool {name} has no approval gate covering it"))
elif tool.get("destructive") is True and cover != "specific":
v.append(("DESTRUCTIVE_NEEDS_NAMED_GATE", f"destructive tool {name} is covered only by a broad gate; it needs a gate that names the tool in applies_to"))
if tier == "spend" and isinstance(caps, dict) and caps.get("max_spend_usd") == 0:
v.append(("SPEND_WITH_ZERO_CAP", f"spend tool {name} is allowed but max_spend_usd is 0"))
elif "tools" in m:
v.append(("BAD_TOOLS", "tools is not a list"))
if isinstance(tools, list) and total and counts["read"] == total:
w.append("scope is read-only; no approval gates are required for read tools")
info = {"scope_id": scope_id, "total": total, "counts": counts}
return v, w, info
def main(argv):
args = [a for a in argv[1:] if a != "--now"]
now = None
if "--now" in argv:
idx = argv.index("--now")
if idx + 1 >= len(argv):
print("usage: python3 scope_check.py MANIFEST.json [--now ISO8601]", file=sys.stderr)
return 2
now = parse_ts(argv[idx + 1])
if now is None:
print(f"error: --now value is not a parseable timestamp: {argv[idx + 1]!r}", file=sys.stderr)
return 2
args = [a for a in argv[1:idx]]
if len(args) != 1:
print("usage: python3 scope_check.py MANIFEST.json [--now ISO8601]", file=sys.stderr)
return 2
if now is None:
now = datetime.now(timezone.utc)
path = args[0]
try:
with open(path, encoding="utf-8") as f:
manifest = json.load(f)
except OSError as e:
print(f"error: cannot read {path}: {e}", file=sys.stderr)
return 2
except json.JSONDecodeError as e:
print(f"error: {path} is not valid JSON: {e}", file=sys.stderr)
return 2
violations, warnings, info = validate(manifest, now)
print(f"manifest: {path}")
print(f"scope_id: {info.get('scope_id') or '(missing)'}")
print(f"checked_at: {fmt_ts(now)}")
c = info.get("counts", {"read": 0, "write": 0, "spend": 0})
print(f"tools: {info.get('total', 0)} (read {c['read']}, write {c['write']}, spend {c['spend']})")
if violations:
print("result: FAIL")
print(f"violations: {len(violations)}")
for code, msg in violations:
print(f"- {code}: {msg}")
else:
print("result: PASS")
print("violations: 0")
print(f"warnings: {len(warnings)}")
for msg in warnings:
print(f"- WARN: {msg}")
return 1 if violations else 0
if __name__ == "__main__":
raise SystemExit(main(sys.argv))
publisher key 2e43c913833850e6d80f939677e3de76e806d47785c0f1caaca90f9d18c98e1e
No ratings yet — be the first to rate it.