DEV Community

Taylor Lin
Taylor Lin

Posted on

Grant Shell Access by Side-Effect Class, Not by Model Confidence

A merge bot printed All tests passed. The next tool call in the trace was kubectl delete ns staging-pr-418. The model had treated a flaky integration test as leftover infrastructure. The command never ran. What stopped it was not a style nit. It was a side-effect class.

Agents that emit patches are only half the problem. The other half is the shell they request after the patch. A free model can draft a fix. An isolated server can run tests. Neither fact is permission to mutate the world.

This article is a glossary, a four-branch decision tree, and one worked command at each leaf. The classifier below is deterministic. Treat any model-produced label as a hint. Authorization stays in code you can test.

Glossary

Read-only probe. A command that inspects state and writes nothing durable. Examples: git status, pytest --collect-only, ls, head. Stderr logs do not count as workspace mutation.

Workspace mutation. A command that changes files inside a git worktree you can revert. Examples: pytest with cache files, npm install after a lockfile review, git apply, formatters. Undo is git checkout or a fresh clone.

Network egress. A command that opens a socket. Examples: curl, npm publish, a remote terraform apply, unpinned package downloads. Egress is a trust boundary. It is not “just installing.”

Irreversible action. A command whose undo is a restore, an incident, or a ticket with lawyers. Examples: kubectl delete, DROP TABLE, git push --force, rm -rf, production secret use, payments.

Privilege expansion. Any binary or flag that widens identity. Examples: sudo, --privileged, mounting ~/.ssh, sharing a kubeconfig, chmod 777.

Replay hazard. Running the same session twice would double-apply a side effect. Schema migrations without an idempotency key are the usual case.

Isolated venue. A machine or container whose credentials cannot reach production, billing, or your laptop’s SSH agent. A free server is this venue only if you do not copy production secrets onto it.

Command contract. The allowlist, argv shape, cwd, env, and timeout you accept before spawn. Models do not author contracts. Reviewers do.

Decision tree

Start with the raw argv. Do not start with the model’s story about the argv. If two classes apply, take the higher-risk leaf. npm test that downloads packages is egress, not a probe.

  1. Does the binary expand privilege (sudo, Docker --privileged, host networking)? If yes, go to Leaf D.
  2. Is the effect irreversible or outside the worktree (cluster, database, remote git, cloud control plane)? If yes, go to Leaf D.
  3. Does the command need the network? If yes, go to Leaf C.
  4. Does it mutate the worktree but stay inside it? If yes, go to Leaf B.
  5. Otherwise it is a read-only probe. Go to Leaf A.

Leaf A — Read-only probe

Command. git diff --stat and python -m compileall -q src.

Worked path. Allow on a laptop or on an isolated server. Log argv, cwd, duration, and exit code. Do not skip the log because the command “looks harmless.”

python3 classify_command.py --argv 'git diff --stat'
# {"command_class": "read_only", "action": "allow_logged"}
Enter fullscreen mode Exit fullscreen mode

A probe that reads ~/.aws/credentials or /etc/shadow is not a probe. Reclassify it as irreversible and refuse.

Leaf B — Workspace mutation

Command. pytest -q tests/test_billing.py after the agent patched one function.

Worked path. Require a dedicated branch or a disposable clone. Snapshot git status --porcelain before spawn. Keep the resulting diff only if tests you already trusted still pass.

git switch -c agent/cmd-leaf-b
python3 classify_command.py --argv 'pytest -q tests/test_billing.py'
# {"command_class": "workspace_mutation", "action": "allow_on_branch"}
Enter fullscreen mode Exit fullscreen mode

Run this leaf on an isolated venue when fixtures execute user-controlled code. A server that you did not load with production env files is a better default than a developer laptop with ten kube contexts.

Leaf C — Network egress

Command. curl -fsS https://pypi.org/pypi/requests/json versus curl -fsS -X POST https://api.prod.internal/refunds.

Worked path. Default deny. Allow only hostnames you list, over TLS, with no request body that contains secrets. Package installs belong here, not in Leaf A.

python3 classify_command.py --argv 'curl -fsS https://pypi.org/pypi/requests/json' --allow-host pypi.org
# {"command_class": "network_egress", "action": "allow_allowlisted_host"}
Enter fullscreen mode Exit fullscreen mode

If the agent cannot name the host before spawn, the answer is no. “The test needs the internet” is not a hostname.

Leaf D — Irreversible or privilege expansion

Command. kubectl delete ns staging-pr-418. Also git push --force origin main. Also sudo systemctl restart nginx.

Worked path. Refuse inside the agent loop. Move the intent to a human-owned runbook. If the namespace delete is actually required, a person runs it from a ticket, not from a tool call.

python3 classify_command.py --argv 'kubectl delete ns staging-pr-418'
# {"command_class": "irreversible", "action": "refuse"}
Enter fullscreen mode Exit fullscreen mode

There is no “just this once” branch. That branch is how staging disappears on a Friday.

Artifact: a classifier you can test

The script is a proposal. It is not a production authorization service. It parses argv with shlex, matches a small binary table, and prints a class. Extend the tables. Do not replace them with a prompt.

#!/usr/bin/env python3
"""classify_command.py — deterministic side-effect class for agent argv."""
from __future__ import annotations

import argparse
import json
import shlex
from dataclasses import dataclass

IRREVERSIBLE_BINARIES = {
    "kubectl", "aws", "gcloud", "az", "terraform", "ansible-playbook",
    "dropdb", "psql", "mysql", "mongo",
}
PRIVILEGE = {"sudo", "su", "doas"}
EGRESS = {
    "curl", "wget", "http", "ssh", "scp", "rsync",
    "npm", "pnpm", "yarn", "pip", "pip3", "cargo", "go",
}
MUTATION = {"pytest", "python", "python3", "node", "git", "make", "ruff", "black", "eslint"}
READ_ONLY_GIT = {"status", "diff", "log", "show", "rev-parse"}
IRREVERSIBLE_GIT = {"push", "filter-branch"}
IRREVERSIBLE_FLAGS = {"--force", "-f", "--privileged", "--host-network"}


@dataclass(frozen=True)
class Verdict:
    command_class: str
    action: str
    reason: str


def classify(argv: list[str], allow_hosts: set[str]) -> Verdict:
    if not argv:
        return Verdict("empty", "refuse", "no argv")
    bin_ = argv[0].rsplit("/", 1)[-1]
    flags = {a for a in argv[1:] if a.startswith("-")}
    if bin_ in PRIVILEGE or flags & IRREVERSIBLE_FLAGS:
        return Verdict("irreversible", "refuse", "privilege expansion")
    if bin_ in IRREVERSIBLE_BINARIES:
        return Verdict("irreversible", "refuse", f"binary {bin_} is out of worktree")
    if bin_ == "git" and len(argv) > 1 and argv[1] in IRREVERSIBLE_GIT:
        return Verdict("irreversible", "refuse", "git remote mutation")
    if bin_ in EGRESS:
        hosts = {a for a in argv[1:] if "://" in a}
        if hosts and allow_hosts and all(
            any(h.endswith(ok) for ok in allow_hosts) for h in hosts
        ):
            return Verdict("network_egress", "allow_allowlisted_host", "host allowlist matched")
        return Verdict("network_egress", "refuse", "egress without allowlisted host")
    if bin_ == "git" and len(argv) > 1 and argv[1] in READ_ONLY_GIT:
        return Verdict("read_only", "allow_logged", "git read")
    if bin_ in MUTATION:
        return Verdict("workspace_mutation", "allow_on_branch", "in-tree tool")
    return Verdict("unknown", "refuse", f"unlisted binary {bin_}")


def main() -> None:
    parser = argparse.ArgumentParser()
    parser.add_argument("--argv", required=True)
    parser.add_argument("--allow-host", action="append", default=[])
    args = parser.parse_args()
    verdict = classify(shlex.split(args.argv), set(args.allow_host))
    print(json.dumps(verdict.__dict__, indent=2))


if __name__ == "__main__":
    main()
Enter fullscreen mode Exit fullscreen mode

Tests that do not need a model:

# test_classify_command.py
from classify_command import classify


def test_leaf_a():
    v = classify(["git", "diff", "--stat"], set())
    assert v.command_class == "read_only"
    assert v.action == "allow_logged"


def test_leaf_b():
    v = classify(["pytest", "-q", "tests/test_billing.py"], set())
    assert v.action == "allow_on_branch"


def test_leaf_c_denied():
    v = classify(["curl", "-fsS", "https://example.com"], set())
    assert v.action == "refuse"


def test_leaf_c_allowlisted():
    v = classify(
        ["curl", "-fsS", "https://pypi.org/pypi/requests/json"],
        {"pypi.org"},
    )
    assert v.action == "allow_allowlisted_host"


def test_leaf_d():
    v = classify(["kubectl", "delete", "ns", "staging-pr-418"], set())
    assert v.action == "refuse"
Enter fullscreen mode Exit fullscreen mode
python3 -m pytest -q test_classify_command.py
Enter fullscreen mode Exit fullscreen mode

Wire the tree into a session

Number the steps. Skip none of them when the model sounds confident.

  1. Snapshot the worktree with git status --porcelain and git rev-parse HEAD.
  2. Parse the proposed tool call as argv, not as prose. If you cannot parse it, refuse.
  3. Run classify_command.py and keep the JSON next to the patch.
  4. If Leaf A or Leaf B, spawn on the isolated venue with a timeout and a non-production env.
  5. If Leaf C, require a committed host allowlist file. No ad-hoc exceptions in chat.
  6. If Leaf D or unknown, refuse and open a runbook ticket. Do not “try it with --dry-run” unless that flag is real and tested.
  7. Record exit code and a second git status. Replay hazard is a failed test, not a footnote.

Where a free model and a free server fit

A model is useful at one step: drafting the patch and a one-line note after the classifier returns a class. It is not useful as the classifier. If the note disagrees with the argv, keep the argv.

Disclosure: This article was prepared as part of MonkeyCode's product outreach.

MonkeyCode is an open-source project with free model access and a free server option. In this workflow those two things map to narrow jobs. The free model can propose a patch and explain why pytest was requested. The free server can be the isolated venue for Leaf A and Leaf B, so agent tests do not share a kubeconfig with production. Copying cluster credentials onto that server collapses Leaf B into Leaf D. Do not do that.

Swap the product for any disposable VM you control and the workflow still holds. The contract is the point. The vendor is not.

Limitations

The binary tables miss python -c "import os; os.system('kubectl ...')" and any wrapper script. That is a reason to refuse unknown binaries. It is not a reason to ask the model for permission.

Do not use this approach if the agent must deploy, if you need a formal authorization product, or if production secrets already live on the machine that runs tool calls. Do not use it if nobody will maintain the allowlists. An unmaintained allowlist is default-allow with extra steps.

Free model access and a free server are availability options. They are not an SLA, a pentest, or a substitute for git history. Model confidence is not a side-effect class.

If you already review agent diffs, run the next proposed command through these four classes before it gets a shell. Start with the tests above on a disposable workspace.

Top comments (0)