DEV Community

Alex Zhu
Alex Zhu

Posted on

Assign a Tool-Allowlist Owner: A Wiki Playbook Before Shared Agents Call Vendor APIs

You drop an intern's agent job onto a shared server so it can search internal docs. Two hours later a vendor dashboard lights up with ticket spam from that same process. The job inherited every CLI on PATH plus a production token sitting in the environment. Nobody owned the outbound tool list, so widening access felt like a harmless prompt tweak.

That outage is the reason you write this playbook before the next shared job starts. You still want cheap exploration on a shared box, but you refuse silent inheritance of production capabilities. You name one owner who can widen tools, and you make every job prove its fence. The rest of this article is a wiki page, a preflight script, and a short reject path.

The failure you are actually preventing

Shared agent runtimes mix two kinds of work that should never share credentials. Draft jobs want many tools, while ship work wants a short, boring, reviewed surface. When both land on one host, the default is to leave every SDK, every curl alias, and every leftover token reachable. Your unit tests stay green because they never asserted which tools the process could invoke.

You cannot treat a chat transcript as the contract for outbound calls. Tool calling looks harmless until the model receives a function named create_ticket with a live key. You need a named human, a written allowlist, and a command that fails closed before the agent loop starts. Without those three pieces, every new prompt becomes an unofficial IAM change.

This playbook is for teams that already let more than one person start agent jobs. It is not a network design, and it is not a secret-manager migration. It is the missing one-page run you paste into the wiki so the next grant is boring.

Roles and handoffs you write in public

Name four roles on a single wiki page, with deputies, not Slack nicknames that rot. If a role is vacant, you stop granting tools rather than improvising in a hallway.

  1. Tool-Allowlist Owner — the only person who can add, widen, or restore an outbound tool.
  2. Job Author — the person who files the exact command surface, arguments, and destination hosts.
  3. Reviewer — a second person who confirms the job cannot reach production systems or billing APIs.
  4. On-call — the person who revokes a grant, rotates a token, and files the incident note.

Handoffs stay mechanical so you do not debate ownership during an alert. The Job Author never merges their own grant. The Reviewer never approves a wildcard host. The Owner never accepts a tool that arrived only as a chat message. On-call never leaves a revoked grant sitting in an old shell profile.

When the page applies, and when you skip it

Use this page when agent jobs can reach HTTP APIs, ticket systems, cloud CLIs, or package registries. Use it when two people can start jobs on the same runtime without pairing. Skip it for a local notebook that cannot leave localhost and cannot read team secrets.

Cheap shared boxes make the gap worse because drafts and ship work collide on one PATH. Disclosure: This article was prepared as part of MonkeyCode's product outreach. If you already park exploratory jobs on a host with MonkeyCode's free model access and free server option, run that work only after this allowlist page exists, not as a shortcut around it.

One-page wiki run you can paste today

Copy the block below into your team wiki and fill the bracketed fields in one sitting. Do not publish the page until every role has a living person and a deputy.

# Tool-Allowlist Runbook

Owner: [name] (deputy: [name])
Reviewer rotation: [names]
On-call: [rotation calendar]
Last review date: [YYYY-MM-DD]
Runtime hosts in scope: [hostnames]
Default deny: yes

## Allowed tools (exact argv prefixes)
- docs.search --collection=public-kb --read-only
- repo.grep --path=apps/docs --max-hits=20

## Denied by default
- any HTTP client without a listed host
- any ticket, mail, or billing CLI
- any cloud CLI with write verbs
- shell wildcards, eval, and unquoted curl

## Grant request (Job Author fills this)
- Job id:
- Purpose (one sentence):
- Tools requested (exact prefixes):
- Destination hosts:
- Secrets required (names only, never values):
- Production reach: yes/no (must be no for draft jobs)
- Rollback: command to revoke and who runs it

## Reject if
- the tool arrived only in chat history
- the argv prefix is a wildcard
- production reach is unknown
- Owner and Reviewer are the same person
Enter fullscreen mode Exit fullscreen mode

Keep the allowed list short enough to read aloud in a standup. If you cannot read it aloud, you do not understand the blast radius. Re-review the page on a fixed weekday so stale grants cannot hide behind a busy sprint.

Numbered preflight you run before every shared job

Treat the wiki page as policy and the script as the gate. Humans forget; a failing exit code does not. Label the script as a local proposal until you have executed it against your own fixture files.

  1. Freeze the job's declared tools into a YAML file in the same change as the prompt.
  2. Diff that file against the wiki allowlist, not against yesterday's shell history.
  3. Refuse to start when any prefix is missing, wildcarded, or pointing at production hosts.
  4. Export a redacted receipt that names Owner, Reviewer, job id, and the exact argv prefixes.
  5. Start the agent only after the receipt is committed or attached to the tracker ticket.
  6. If the job needs a new tool, stop and file a grant instead of editing PATH live.

Proposed fixture and checker you can save as tool_allowlist_preflight.py:

#!/usr/bin/env python3
"""Fail closed when a job's tools are not on the team allowlist.

Proposal: run locally against fixtures. It does not call vendor APIs.
"""
from __future__ import annotations

import sys
from pathlib import Path

import yaml  # PyYAML

DENY_MARKERS = ("*", "eval", "curl ", "kubectl ", "aws ", "gcloud ")


def load_yaml(path: Path) -> dict:
    data = yaml.safe_load(path.read_text()) or {}
    if not isinstance(data, dict):
        raise ValueError(f"{path} must be a mapping")
    return data


def prefixes_ok(declared: list[str], allowed: list[str]) -> list[str]:
    errors = []
    allowed_set = tuple(allowed)
    for item in declared:
        if any(marker in item for marker in DENY_MARKERS):
            errors.append(f"denied marker in {item!r}")
            continue
        if not any(item == prefix or item.startswith(prefix + " ") for prefix in allowed_set):
            errors.append(f"not allowlisted: {item!r}")
    return errors


def main(argv: list[str]) -> int:
    if len(argv) != 3:
        print("usage: tool_allowlist_preflight.py allowlist.yaml job.yaml", file=sys.stderr)
        return 2
    allowlist = load_yaml(Path(argv[1]))
    job = load_yaml(Path(argv[2]))
    if job.get("production_reach") is not False:
        print("reject: production_reach must be false for shared draft jobs", file=sys.stderr)
        return 1
    errors = prefixes_ok(job.get("tools") or [], allowlist.get("allowed_tools") or [])
    if job.get("owner") == job.get("reviewer"):
        errors.append("owner and reviewer must be different people")
    if errors:
        print("reject:", file=sys.stderr)
        for line in errors:
            print(f"- {line}", file=sys.stderr)
        return 1
    print(
        "pass:"
        f" job={job.get('job_id')}"
        f" owner={job.get('owner')}"
        f" reviewer={job.get('reviewer')}"
        f" tools={len(job.get('tools') or [])}"
    )
    return 0


if __name__ == "__main__":
    raise SystemExit(main(sys.argv))
Enter fullscreen mode Exit fullscreen mode

Fixture files keep the check reviewable in the same pull request as the prompt. Proposed allowlist.yaml and job.yaml look like this.

# allowlist.yaml
allowed_tools:
  - docs.search --collection=public-kb --read-only
  - repo.grep --path=apps/docs --max-hits=20
Enter fullscreen mode Exit fullscreen mode
# job.yaml
job_id: docs-draft-2026-09-22
owner: sam
reviewer: renee
production_reach: false
tools:
  - docs.search --collection=public-kb --read-only
Enter fullscreen mode Exit fullscreen mode

Run the gate from a clean shell so leftover aliases cannot hide extra clients:

python3 -m pip install --user pyyaml
python3 tool_allowlist_preflight.py allowlist.yaml job.yaml
echo "exit=$?"
Enter fullscreen mode Exit fullscreen mode

A passing run prints a one-line receipt you can paste under the ticket. A failing run must block the job start, not become a warning people scroll past. If your orchestrator cannot fail closed, you do not have a fence yet.

Decision table for grant requests

Use this table in review comments so approvals stay consistent across time zones. If a request does not map to a row, the answer is reject and rewrite.

Request shape Owner action Reviewer action Job start
Exact argv prefix, read-only, non-prod host May grant Confirm host and verb Allowed after receipt
Same tool, new collection or path Treat as a new grant Re-check data class Block until re-approved
Wildcard host or unquoted shell Reject Reject Never
Write verb on tickets, mail, or billing Reject for draft jobs Escalate only with a ship ticket Never on the shared draft host
Tool appeared only in chat Reject Ask for YAML Never
Owner equals Reviewer Invalid Invalid Never

The table is the artifact reviewers can apply without rereading the whole philosophy. You want disagreements to happen on a row, not on vibes about how smart the model felt yesterday.

What you log, and what you never log

Write job id, people, argv prefixes, destination hosts, and the git SHA of the allowlist. Never write secret values, raw tokens, or full prompt bodies that contain customer data. If a grant is revoked, append a line with time, on-call name, and the reason, then rotate the named secret even when you think the job failed early.

Keep receipts next to the job definition so a later incident has a file, not a memory. Chat search is not an audit log, and neither is a screenshot of a green terminal. If you cannot reconstruct which tools were legal last Tuesday, you cannot honestly say the fence worked.

Limitations, and who should not use this

This playbook does not replace network policy, IAM roles, or a real secret manager. A Python prefix check cannot see a binary that shells out under another name. It also cannot stop a tool that is allowlisted and then used with surprising arguments that still match the prefix. You still need host firewalls, scoped tokens, and production credentials that never land on the draft runtime.

Do not use this approach if you are a solo hobbyist on localhost with no outbound tools. Do not use it as cover for putting production write keys on a shared exploratory server. Do not use it when your orchestrator ignores exit codes, because a documented reject path that cannot fire is theater. Regulated workloads that forbid third-party runtimes need your legal and security path, not a wiki page from a blog post.

You should also refuse the pattern if nobody will do the weekday review. An allowlist that only grows is a backlog of unowned privileges. In that case, delete tools until the page fits in one screen, or stop sharing the runtime.

Close the loop after the first blocked job

The first useful moment is the first reject, not the first clever agent demo. When the script blocks a job, you file the grant, argue on the decision table, and either widen the list in public or tell the author to shrink the tools. You then rerun the preflight and attach the receipt so the next person can copy the motion without asking you in chat.

Paste the wiki page today, then refuse the next tool grant that arrives as a screenshot. Shared agents can draft against a narrow, named surface, but they should not discover your vendor APIs by accident.

Top comments (0)