We ship a small free GitHub Action, Agent PreShip Guard, that flags risky AI-agent code before it merges. Before writing about it, we pointed it at two tiny demo agents. It got both wrong.

The two agents

Agent A is a read-only uptime check. The command is a fixed string:

# Agent A: read-only uptime check. Command is a fixed literal. import subprocess def tool_uptime(): return subprocess.run("uptime", shell=True, capture_output=True, text=True).stdout 

Agent B lets the model pick both the tool and its argument, with no approval step:

# Agent B: model chooses the tool AND the argument; no approval gate. import shutil, json TOOLS = {"remove_dir": shutil.rmtree} def act(plan_json): step = json.loads(plan_json) # plan comes straight from the LLM  return TOOLS[step["tool"]](step["arg"]) # e.g. {"tool":"remove_dir","arg":"/srv/prod"} 

Anyone reading these knows which one should keep you up at night.

What v1.0 said

== Agent A ::error AG001 Model/LLM output passed to shell ::warning AG004 shell=True in tool code Agent PreShip Guard: 1 high, 1 medium findings. == Agent B Agent PreShip Guard: 0 high, 0 medium findings. 

The safe agent got a high-severity finding. The agent where the model chooses what to rmtree got nothing.

Why

Two bugs, one root cause: we were matching syntax, not authority.

  • False positive on A. The AG001 rule looked for "output" near a subprocess call. capture_output=True matched, so a fixed "uptime" command was reported as "model output passed to shell."
  • False negative on B. No rule covered the real risk: model output choosing a destructive call. There's no subprocess, no eval, no shell=True. Just a dict lookup driven by json.loads of a model plan.

What v1.1 says

== Agent A ::warning AG004 shell=True in tool code Agent PreShip Guard: 0 high, 1 medium findings. == Agent B ::error AG008 Model output selects a destructive call with no approval gate Agent PreShip Guard: 1 high, 0 medium findings. 

v1.1 fixes the AG001 false positive and adds AG008: model/LLM output (for example json.loads(plan) or tool_calls) selects a destructive call (rmtree, os.remove, subprocess, DROP TABLE, DELETE FROM, .delete(, transfer() and no approve/confirm/human-in-the-loop check appears in the file. The remaining medium on A is fair: shell=True isn't needed for a fixed command.

AG008 still has limits, and the README says so. It works per file, so an approval check in another module won't be seen (possible false positive). A gate with an unusual name won't be recognized either. It's a heuristic tripwire, not a review.

Use it

# .github/workflows/agent-guard.yml name: agent-guard on: [pull_request, push] jobs: scan: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - uses: elaysuu/agent-preship-guard@main # pin to a commit SHA in production with: path: . fail-on: high # high | medium | none 

Findings show up as inline PR annotations. It's MIT licensed, and the tests run with python3 -m unittest discover -s tests.

The lesson

Ask "who decides?", not "which function is called?" A hardcoded shell command is boring. A model choosing which destructive function runs, with nobody approving it, is the incident.

A scanner only catches the obvious cases. For the full manual pass, I sell a 40-check AI Agent Pre-Ship Security Checklist, and the rest of the kits are in the store (or the 5-kit bundle). If the Action flags something it shouldn't, or misses something, please open an issue. That's how v1.1 happened.