A successful tool call is not a reviewed change.
Cheap inference only makes those side effects cheaper.
Do you gate the tool, or trust the chat?
I keep watching agents celebrate a raw HTTP 200.
The demo parses JSON and calls the job done.
Nobody asks which tools were even legal.
That missing allowlist is the whole incident.
The model proposed a call. The runner obeyed it.
Git never saw a policy, only a mutated tree.
The problem is not the model
Tool calling is loud in developer feeds this month.
People wire HTTP, shell, and files into one loop.
Then a green status gets treated like a contract.
A green status code is not a contract.
It is an unscoped side effect with a nickname.
Who signed the allowlist for that curl?
I do not care that inference was complimentary.
I do not care that the box had no invoice.
I care that the tool could mutate production.
How I read a tool-calling failure
I start from symptoms, not from the prompt text.
The chat window is a story about intent.
The transcript is the only evidence I trust.
If there is no transcript, I already lost.
I cannot replay the call, only the narrative.
Would you debug production from a Slack summary?
Here is the catalog I actually use on reviews.
Each entry has a symptom, a cause, a replacement.
Steal the replacement. Leave the branding on the floor.
Anti-pattern 1: The open tool belt
Symptom. The agent can hit HTTP, shell, SQL, files.
One schema dump lists twenty tools with vague copy.
A "search" tool can POST, and nobody noticed.
Root cause. More tools feel more autonomous in demos.
Teams drop a kitchen-sink pack into every task.
Default allow becomes the real architecture, quietly.
Replacement. Default deny. One task, very few tools.
Name every tool in a committed allowlist file.
If the task changes, the allowlist changes in git.
Would you hand that belt to a weekend contractor?
If the answer is no, stop handing it to a model.
Anti-pattern 2: Secrets ride in the prompt
Symptom. Tool args contain tokens, cookies, or PEM blobs.
Those args went out with the remote model context.
Your so-called local agent was never actually local.
Root cause. Pasting the key into the prompt is easy.
A complimentary endpoint feels disposable, so people slacken.
Disposable compute is still somebody else's machine.
Replacement. The runner injects secrets at call time.
The model sees a handle like secret:billing_read.
The gate strips anything that looks like a credential.
If the model can print the key, design already failed.
Rotate that key. Then fix the proposal path.
Anti-pattern 3: Stdout is treated as truth
Symptom. The agent reads stdout and patches source.
A truncated JSON payload still "looks right enough."
Hallucinated fields survive because nobody schema-checks.
Root cause. Models trust the last tokens they saw.
Tool output is just more tokens in the window.
There is no type boundary after the 200.
Replacement. Every tool returns a typed envelope.
Validate with JSON Schema before the model sees it.
Reject extra fields. Reject missing required keys.
A 200 with a bad body is still a failed call.
Do not let the agent launder that into a diff.
Anti-pattern 4: No first-class transcript
Symptom. Slack has a summary. Disk has a mutation.
You cannot replay which tool ran, with which args.
The chat closed. The side effect stayed on disk.
Root cause. People log model prose, not tool IO.
Demos optimize for narrative, not for later forensics.
Forensics needs bytes, hashes, and honest timestamps.
Replacement. Write JSONL for every gated tool call.
Store tool name, arg hash, result hash, and decision.
CI fails if a call happened outside the gate.
Can you rebuild the incident without the chat UI?
If you cannot, you shipped a ghost.
Anti-pattern 5: The free box is "localhost"
Symptom. The agent workspace is a complimentary server.
Your checkout, your .env, and your SSH agent followed.
Egress is wide open "so the tools can work."
Root cause. Free compute feels like an extra laptop.
People confuse the invoice with the trust boundary.
A free host is still a different machine.
Replacement. Use an ephemeral workspace. No prod credentials.
Pin egress to the allowlisted hosts, nothing else.
Destroy the box after the transcript is copied off.
Would you scp your wallet onto a shared runner?
Then stop treating the free box as home.
The replacement pattern, in one pipeline
I want three files before any agent tool may run.
An allowlist. A schema. A transcript path.
No file, no call. That is the whole policy.
tools/
allowlist.yml
schemas/http_get.json
schemas/repo_read.json
transcripts/.gitkeep
scripts/
tool_gate.py
The gate sits between the model and the real tool.
The model proposes. The gate decides. The OS executes.
If that order flips, you are already in production.
Artifact: a local tool gate you can run
This is a proposed runner, not a production IAM system.
I am not claiming latency numbers or pass rates.
Copy it, break it, then tighten the schemas.
tools/allowlist.yml
# Proposed policy. Commit this beside the task, not in chat.
version: 1
task: "read-only docs probe"
max_calls: 8
max_arg_bytes: 4096
default: deny
tools:
- name: repo_read
schema: schemas/repo_read.json
network: false
secrets: []
- name: http_get
schema: schemas/http_get.json
network: true
hosts:
- api.github.com
secrets:
- github_readonly
tools/schemas/http_get.json
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"type": "object",
"additionalProperties": false,
"required": ["url"],
"properties": {
"url": {
"type": "string",
"pattern": "^https://api\\.github\\.com/"
},
"timeout_ms": {
"type": "integer",
"minimum": 100,
"maximum": 5000
}
}
}
scripts/tool_gate.py
#!/usr/bin/env python3
"""Proposed local tool gate. Not IAM. Run it yourself."""
from __future__ import annotations
import argparse
import hashlib
import json
import re
import sys
from datetime import datetime, timezone
from pathlib import Path
from urllib.parse import urlparse
import yaml
from jsonschema import Draft202012Validator
SECRET_RE = re.compile(
r"(?i)(api[_-]?key|secret|token|bearer\s+[A-Za-z0-9\-._]+|-----BEGIN )"
)
def sha256_bytes(data: bytes) -> str:
return hashlib.sha256(data).hexdigest()
def looks_like_secret(payload: str) -> bool:
return bool(SECRET_RE.search(payload))
def decide(allowlist: dict, proposal: dict, schema_root: Path) -> dict:
tool = proposal.get("tool")
args = proposal.get("args") or {}
raw_args = json.dumps(args, sort_keys=True).encode("utf-8")
record = {
"ts": datetime.now(timezone.utc).isoformat(),
"tool": tool,
"arg_sha256": sha256_bytes(raw_args),
"decision": "deny",
"reason": "",
}
if not tool:
record["reason"] = "missing tool name"
return record
if len(raw_args) > int(allowlist.get("max_arg_bytes", 4096)):
record["reason"] = "args exceed max_arg_bytes"
return record
if looks_like_secret(raw_args.decode("utf-8")):
record["reason"] = "secret-shaped value in args"
return record
spec = next(
(t for t in allowlist.get("tools", []) if t.get("name") == tool),
None,
)
if spec is None:
record["reason"] = "tool not in allowlist"
return record
schema_path = schema_root / spec["schema"]
schema = json.loads(schema_path.read_text(encoding="utf-8"))
errors = sorted(
Draft202012Validator(schema).iter_errors(args),
key=lambda e: list(e.path),
)
if errors:
record["reason"] = "schema: " + errors[0].message
return record
if spec.get("network"):
url = args.get("url", "")
host = urlparse(url).hostname or ""
if host not in (spec.get("hosts") or []):
record["reason"] = "host not pinned"
return record
record["decision"] = "allow"
record["reason"] = "ok"
return record
def append_transcript(path: Path, record: dict) -> None:
path.parent.mkdir(parents=True, exist_ok=True)
with path.open("a", encoding="utf-8") as fh:
fh.write(json.dumps(record, sort_keys=True) + "\n")
def main() -> int:
parser = argparse.ArgumentParser(description="Gate one proposed tool call.")
parser.add_argument("--allowlist", required=True)
parser.add_argument("--transcript", required=True)
parser.add_argument("--proposal", required=True)
ns = parser.parse_args()
allowlist_path = Path(ns.allowlist)
transcript = Path(ns.transcript)
allowlist = yaml.safe_load(allowlist_path.read_text(encoding="utf-8"))
existing = transcript.read_text(encoding="utf-8").count("\n") if transcript.exists() else 0
if existing >= int(allowlist.get("max_calls", 8)):
record = {
"ts": datetime.now(timezone.utc).isoformat(),
"tool": None,
"arg_sha256": None,
"decision": "deny",
"reason": "max_calls exceeded",
}
append_transcript(transcript, record)
print(json.dumps(record, indent=2))
return 2
proposal = json.loads(ns.proposal)
record = decide(allowlist, proposal, allowlist_path.parent)
append_transcript(transcript, record)
print(json.dumps(record, indent=2))
return 0 if record["decision"] == "allow" else 2
if __name__ == "__main__":
sys.exit(main())
Install the two libraries the gate actually imports.
python3 -m pip install pyyaml jsonschema
Now throw a bad host at it on purpose.
python3 scripts/tool_gate.py \
--allowlist tools/allowlist.yml \
--transcript tools/transcripts/run.jsonl \
--proposal '{"tool":"http_get","args":{"url":"https://evil.example/steal"}}'
You should see "decision": "deny" and host not pinned.
That deny is the feature. Keep the JSONL line.
Now throw a pinned host at the same gate.
python3 scripts/tool_gate.py \
--allowlist tools/allowlist.yml \
--transcript tools/transcripts/run.jsonl \
--proposal '{"tool":"http_get","args":{"url":"https://api.github.com/repos/octocat/Hello-World"}}'
You should see "decision": "allow" and a new arg hash.
Still do not execute the real HTTP call from this script.
The gate only judges the proposal. Execution stays elsewhere.
Secret-shaped args should die before schema even runs.
python3 scripts/tool_gate.py \
--allowlist tools/allowlist.yml \
--transcript tools/transcripts/run.jsonl \
--proposal '{"tool":"http_get","args":{"url":"https://api.github.com/?token=ghp_example"}}'
Expect deny. Expect secret-shaped value in args.
If that call had reached a remote model, you already leaked.
Decision table I keep next to the gate
| Signal | Meaning | Action |
|---|---|---|
| Tool missing from allowlist | Open belt | Deny, fail the job |
| Args fail JSON Schema | Model improvised | Deny, keep transcript |
| Secret-shaped string in args | Prompt leak | Deny, redact the log |
| Host not in the pin list | Egress drift | Deny |
Call count exceeds max_calls
|
Runaway loop | Deny, kill the runner |
| HTTP 200 with invalid body | Fake success | Treat as an error |
| Transcript file missing | Blind run | Do not merge |
Read the table before you read the model recap.
The recap will sound confident. The table will not lie.
Which column are you actually merging?
A debugging workflow I follow
- Reproduce the proposal as JSON, never as chat paste.
- Run that JSON through
tool_gate.pywith the task allowlist. - Check deny or allow against the table above.
- Execute the real tool only in a throwaway workspace.
- Diff the transcript against git, not against memory.
If step one is hard, the agent UI is hiding the call.
Fix the UI. Do not "just ask it again."
A hidden call is an ungated call.
I also grep the transcript before I open the diff.
python3 - <<'PY'
import json, pathlib, sys
p = pathlib.Path("tools/transcripts/run.jsonl")
if not p.exists():
print("no transcript"); sys.exit(2)
rows = [json.loads(line) for line in p.read_text().splitlines() if line]
denies = [r for r in rows if r.get("decision") == "deny"]
print(f"calls={len(rows)} denies={len(denies)}")
for r in denies:
print(r["reason"])
sys.exit(1 if denies else 0)
PY
Zero denies does not mean the change is good.
It only means the gate stayed on during the run.
You still review the code like a human wrote it.
Where a cheap model loop actually helps
You still need a disposable place to practice dry-runs.
Not to touch production. To generate illegal proposals.
The gate gets stronger when you feed it bad calls.
Disclosure: This article was prepared as part of MonkeyCode's product outreach.
MonkeyCode has free model access and a free server option.
I treat that pair as a sandbox for proposal generation only.
The allowlist, schemas, and gate stay in the application repo.
If you try the sandbox, keep customer data and prod keys off it.
Limitations, stated plainly
This gate is not IAM, and it is not a TEE.
It will not save you from a malicious runner image.
It will not sign an audit log your regulator accepts.
Do not use this approach when any of these apply:
- You handle regulated personal data in tool args
- The tool can move money or mutate cloud IAM
- You cannot destroy the workspace after the run
- You need a vendor-backed, signed audit trail
- The provider cannot see even redacted proposals
Complimentary model access can change without notice.
A free server is not your compliance program.
Price zero is not the same as risk zero.
Who should skip this
Skip it if your agents never call tools at all.
Skip it if a human already types every request.
Skip it if security already brokers every egress path.
This catalog is for teams wiring agents to real APIs.
If that is not you, keep the tools in a drawer.
You do not need a gate for a function you never invoke.
What I will not merge
I will not merge a diff that needed a mystery tool call.
I will not merge "the agent said the API looked fine."
I will not merge a workspace that still holds prod secrets.
Show me the allowlist.
Show me the transcript.
Show me the deny that proved the gate was on.
If those three files are missing, the 200 never happened.
Not in a way I can defend later.
And later is when this stuff actually matters.
Top comments (0)