---
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:
- permission-audits is a retrospective audit of existing grants across a team. This skill is prospective and covers one run or deployment: it makes the scope decision before the run starts, it does not audit grants that already exist.
- agent-auth-patterns is credential and token architecture: how an agent proves who it is and how tokens are issued, scoped, and rotated. This skill assumes credentials exist and decides what the run may do with them; it does not design the credential system.
- human-in-the-loop-design is approval checkpoint design: where checkpoints sit in a workflow and how an approval is requested, shown, and recorded. This skill only records, per action class, that a gate exists in the manifest; the checkpoint mechanics belong to that skill.
- sandboxing-agents is code execution isolation: containers, filesystem and network policy, and resource limits for running untrusted code. This skill decides which tools a run may call at all; how a code tool is isolated once allowed belongs to that skill.
- agent-security-hardening is end to end security hardening for an agent: threat modeling, sandboxing tier, secrets and egress, prompt injection defense, and an audit trail, with permission tiers assigned as one part of that wider pass. This skill is narrower and per run: the allowlist, caps, expiry, and manifest for one run or deployment, checked before and after that run.
- agent-architecture-audit is a scored rubric review of an agent system that already exists, including its autonomy boundaries and tool scoping. This skill makes the scoping decision prospectively for a new run; it does not score or audit an existing system.
- This skill owns the pre-run scope decision and the manifest. It does not own the audit, the auth, or the sandbox.

## Workflow

1. Write the purpose in one or two sentences before listing any tools.
   - State what the run is for and what it produces. Example: collect public pricing pages for three named competitors and write one summary file into the run workspace.
   - Add a notes field for the explicit exclusions that matter: places the run must not write, actions it must not take, data it must not touch. Exclusions written in the manifest settle arguments later; exclusions kept in conversation do not.
   - If the purpose cannot be stated in two sentences, the run is probably two runs. Split it and scope each part separately, because one manifest stretched over two purposes ends up allowing the union of both tool sets for the whole window.

2. Build the tool allowlist from the task, starting from empty.
   - Walk the task step by step and add only the tools each step needs. A tool that no step names does not go on the list, even if it is usually harmless.
   - Prefer narrow tools over broad ones where both exist. A tool that reads one workspace is a smaller grant than a general shell; a tool that writes one directory is a smaller grant than a tool that writes anywhere. Where only the broad tool exists, keep it and compensate with a tighter cap and a gate (steps 4 and 6).
   - Record the exact tool names the runtime will check. Aliases, display names, and near matches create gaps: the manifest says one name, the run calls another, and the check in step 9 compares nothing.
   - Leave general escape hatches off by default. An unrestricted shell, an arbitrary HTTP caller, or a code runner can reach past every other line of the manifest. If a run genuinely needs one, list it, tier it as write or spend as its reach warrants, flag it, and gate it by name.

3. 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.
   - read: the tool only observes. It returns information and changes nothing outside the run: no records created, edited, or deleted, no messages sent, no money moved. Search, fetch, list, and get style tools are usually read.
   - write: the tool changes state somewhere that persists after the run: files written, records created or edited, messages sent, settings changed. Deletes count as write, and a tool that can delete gets the destructive flag (see below).
   - spend: the tool moves money or commits money: payments, purchases, paid API calls billed per use, trades. A tool that both writes and spends takes the spend tier, because spend is the stricter one.
   - A tool with mixed behavior takes the highest tier it can reach, not the tier of its most common use. A database tool that mostly reads but accepts an update command is a write tool.
   - Set "destructive": true on any tool that can delete, overwrite in place without a recovery path, or send something that cannot be unsent. The flag matters in step 6: destructive tools need a gate that names them, not a broad tier gate.

4. 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.
   - max_actions: the total number of tool calls the run may make. Estimate the expected count from the task, then set the cap above the estimate with room for retries and dead ends. A cap at exactly the estimate trips on normal variation; a cap at a hundred times the estimate stops nothing. As a starting point, one and a half to three times the expected count is a workable range for a well understood task, and a first run of a new task deserves the wider end plus a review of where the calls actually went.
   - max_spend_usd: the total money the run may move or commit, in US dollars. Set it to 0 when the run has no spend tools; the validator flags a spend tool paired with a zero cap, which is a drafting error worth catching. When spend tools exist, set the cap from the priced steps of the task, not from the budget available. The question is what this run should cost, not what could be afforded.
   - max_duration_minutes: the longest the run may stay active. Match it to the expiry window in step 5; the validator fails a manifest whose issued-to-expiry span is longer than this cap.
   - Per-tool caps are optional and worth adding for the one or two tools that dominate cost or risk: a maximum call count for a paid lookup, a maximum file count for a writer. Record them in the notes field or as extra keys under caps; the validator ignores extra cap keys, and step 9 checks them by hand.

5. Set the expiry.
   - Every manifest carries issued_at and expires_at as ISO 8601 UTC timestamps. A scope with no expiry tends to outlive its purpose, because nothing ever forces the decision to be made again.
   - Size the window to the run, not to the calendar. A one hour task gets hours, not a week. Long running deployments get a window sized to a review cycle, with renewal treated as a fresh scoping pass over steps 1 to 6 rather than a date edit.
   - Renewal means a new manifest with a new scope_id, even if nothing else changes. Editing the expiry on an existing manifest erases the record that the scope was re-decided, and the run check in step 9 can no longer tell which version the early part of the run used.

6. Place approval gates per action class.
   - A gate means: this action does not run until an approval is recorded. The manifest records where gates are; how the approval is requested and shown is approval checkpoint design and belongs to human-in-the-loop-design.
   - Gate entries have two fields: applies_to, which is a tool name, a tier class written as tier:read, tier:write, or tier:spend, or * for everything; and requires, which this skill sets to approval.
   - Guidance on placement, with the reasoning attached:
     - Read tools usually run ungated. Gating observation slows the run without reducing what it can change, because reads change nothing by themselves. Exception: a read tool that reaches sensitive data may deserve a gate on the data, not on the reading.
     - Write tools should carry a gate by default, at the tier level (tier:write) or by name. The default can be relaxed for narrow, reversible writes into a contained place, such as draft files in the run workspace, where the cost of a wrong write is a deleted draft.
     - Spend tools should be gated by name, every time, regardless of the cap. A cap limits how much a bad run can spend; a named gate means a specific action was looked at before money moved. Small caps do not substitute for the gate, because many small spends add up and a cap only trips at the end.
     - Destructive tools need a gate that names the tool. A broad tier:write gate approves deletes as a side effect of approving writes in general, which is exactly the class of action a gate exists to slow down.
   - Gates are per action class, not per run. One approval at the start of the run that covers everything the run might do is a scope decision repeated, not a gate.

7. 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.
   - Required top level fields: scope_id, purpose, issued_at, expires_at, tools, caps, gates.
   - scope_id: a unique string for this scope decision. Change it on every re-scope, including renewals.
   - tools: a list of objects with name, tier (read, write, or spend), and optionally destructive set to true.
   - caps: an object with max_actions, max_spend_usd, and max_duration_minutes, all numbers, with max_actions and max_duration_minutes above zero and max_spend_usd at or above zero.
   - gates: a list of objects with applies_to and requires. An empty list is valid for a read-only scope.
   - notes: optional, and the right place for exclusions, per-tool caps, and recorded waivers from step 8.
   - Worked example, in full. This is the valid fixture the validator in the Operating Rules section was run against:

```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."
}
```

8. Validate the manifest before the run starts.
   - Run scope_check.py from the Operating Rules section against the manifest. Fix every violation and re-run until the result is PASS.
   - A violation that is genuinely intended, for example a spend tool gated at tier level by a platform that cannot gate by name, gets recorded in the notes field with the reason, and the run proceeds with the FAIL report attached to the run record. A waiver that is not written in the manifest does not exist.
   - Check the two things the validator cannot: that the tool names match the names the runtime actually exposes, and that the purpose in the manifest still matches the task as finally agreed. A manifest can be internally consistent and scoped for the wrong run.

9. Check the run against the manifest, during and after.
   - During the run, compare as you go: every tool called appears on the allowlist, gated actions show an approval before the call, the running action count and spend stay under the caps, and the clock stays inside the window. Tooling for this varies by platform; where no automatic check exists, the run log plus the manifest is the check, done at natural pauses rather than only at the end.
   - A mid-run scope change means stop, re-scope, and start the new scope under a new scope_id. Widening a live manifest in place, raising a cap because the run hit it, extending an expiry because time ran out, teaches the run that caps are negotiable, and makes the final check meaningless.
   - After the run, write a short check summary: scope_id, tools actually used (and any allowlisted tools never used, which is input for the next scoping pass), total actions against max_actions, total spend against max_spend_usd, any gate approvals given, and any point where the run hit a cap or a gate and stopped. Unused tools and untouched caps are signs the next scope for the same task can be tighter.

## 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:
   - scope_id
   - tools used, with call counts, and allowlisted tools never used
   - total actions against max_actions
   - total spend against max_spend_usd
   - approvals given, by gate
   - cap or gate stops, if any, and what happened at each

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