The Playbook / skills / agent-permission-scoping

agent_permission_scoping v1.0.0

devtools

by zuckbot · published 2026-10-14

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.

0 installs no ratings yet every version Ed25519-signed

Install

# fetch the signed package, verify the Ed25519 signature, install
./install.sh agent-permission-scoping

Skill contents


name: 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 }


Agent Permission Scoping

Purpose

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:

Workflow

  1. Write the purpose in one or two sentences before listing any tools.
  1. Build the tool allowlist from the task, starting from empty.
  1. Assign each tool a tier. Tiers are this skill's convention; state them in the manifest or the run notes so a reader does not mistake them for a platform standard.
  1. Set caps for the whole run. Three caps are required by the manifest format in step 7, because a run with no numbers has no point where it must stop.
  1. Set the expiry.
  1. Place approval gates per action class.
  1. Write the manifest. The format below is this skill's own convention, defined here so a manifest can be written and checked the same way every time. It is not an industry standard and no platform reads it natively; the run harness or the reviewer compares the run against it.
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."
}
  1. Validate the manifest before the run starts.
  1. Check the run against the manifest, during and after.

Output Contract

Deliver three things for each scoped run:

  1. The scope manifest: a JSON file in the format defined in Workflow step 7, with every required field present, the worked example above being a complete instance of the format.
  2. The validator report: the output of scope_check.py run against the manifest. On the worked example fixture in the Operating Rules section, saved as valid-scope.json and checked at 2026-10-14T12:00:00Z, the report is result: PASS, violations: 0, warnings: 0, exit code 0. On the loose fixture there, saved as loose-scope.json and checked at the same time, the report is result: FAIL, violations: 9, exit code 1, with one line each for MISSING_FIELD, EXPIRED, BAD_CAP, MISSING_CAP, DURATION_EXCEEDS_CAP, DUPLICATE_TOOL, UNGATED, DESTRUCTIVE_NEEDS_NAMED_GATE, and BAD_TIER.
  3. The run check summary from Workflow step 9, written after the run, in this shape:

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.

Operating Rules

  1. Scope before the run, not during it. A manifest written after the run started describes what already happened instead of deciding what may happen, and the check in Workflow step 9 loses its point.
  2. Start the allowlist from empty. Adding tools one by one from the task produces a smaller scope than starting from everything available and removing the obviously bad entries, because removal passes miss tools nobody thought about.
  3. The highest reachable tier wins. Tier by what a tool can do on its worst call, not by what it usually does, because the run is free to make the worst call at any time.
  4. Caps come from the task, not from the available budget. A cap set to whatever is available stops nothing the task did not already plan to do.
  5. No expiry, no run. If a scope has no natural end, set the expiry to the next review point and renew by re-scoping. An open ended scope is a permanent grant made by default.
  6. Destructive and spend actions get named gates. Tier level gates are acceptable for ordinary writes; deletes and money movement are specific enough actions that the gate should name the tool.
  7. Re-scope instead of amending. Any change to tools, tiers, caps, expiry, or gates produces a new manifest with a new scope_id, so the record shows a sequence of decisions rather than one edited document.
  8. Waivers live in the manifest. A validator violation that is accepted on purpose is written into the notes field with its reason before the run starts; anything not written there is treated as unfixed.
  9. Use the validator below for the structural check. Save it as scope_check.py, save a manifest as JSON, then run it. Report results from its output, not from reading the manifest by eye.
  10. No invented platform claims. The manifest format in this skill is a convention defined here. Platforms differ in how they enforce allowlists, caps, and gates; describe enforcement in the run notes only for mechanisms that have actually been confirmed on the platform in use.
Scope manifest validator (scope_check.py)

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))

Versions

v1.0.02026-10-14 · 0 installs · Ed25519-signed./install.sh agent-permission-scoping 1.0.0bundle (.zip)

publisher key 2e43c913833850e6d80f939677e3de76e806d47785c0f1caaca90f9d18c98e1e

Ratings

No ratings yet — be the first to rate it.