DEV Community

Dakota Huang
Dakota Huang

Posted on

Exit Code Zero Hides New Processes: Diff the Process Table After a Model-Generated Command

Exit code zero is not a security guarantee. A model-generated command can start a background process and still return 0, so the log looks clean while a new listener or worker keeps running on the box.

I use a process-table diff before I trust any shell command that came from a model: snapshot /proc before and after the command, then print only what appeared or disappeared.

Disclosure: This article was prepared as part of MonkeyCode's product outreach. The free model access and free server option I mention are operator-supplied, and I am not assuming a specific model name, quota, or runtime duration.

Why exit codes and file diffs are not enough

A filesystem diff answers: what changed on disk?

The process table answers: what is still running after the command?

Those are different questions. A command can return 0, write nothing, and start a background process that waits for a connection, a timer, or more instructions. If you only check the exit code, you approve it by accident.

A filesystem check is useful, but it will not show a running process that has not touched the filesystem yet.

The harness

The tool is plain Python 3. It walks /proc, reads exe, cwd, and cmdline for each visible process, and emits a JSON diff.

#!/usr/bin/env python3
"""Diff the process table around one command."""
import json
import os
import subprocess
import sys
import time


def snapshot():
    procs = []
    seen = set()
    for entry in os.listdir("/proc"):
        if not entry.isdigit():
            continue
        pid = int(entry)
        try:
            exe = os.readlink(f"/proc/{pid}/exe")
            cwd = os.readlink(f"/proc/{pid}/cwd")
            with open(f"/proc/{pid}/cmdline", "rb") as fh:
                raw = fh.read()
            cmdline = raw.replace(b"\x00", b" ").decode("utf-8", "replace").strip()
        except (OSError, PermissionError, FileNotFoundError):
            continue
        key = (exe, cwd, cmdline)
        if key in seen:
            continue
        seen.add(key)
        procs.append({"pid": pid, "exe": exe, "cwd": cwd, "cmdline": cmdline})
    return procs


def changed(before, after):
    before_keys = {(p["exe"], p["cwd"], p["cmdline"]) for p in before}
    after_keys = {(p["exe"], p["cwd"], p["cmdline"]) for p in after}
    added = [p for p in after if (p["exe"], p["cwd"], p["cmdline"]) not in before_keys]
    removed = [p for p in before if (p["exe"], p["cwd"], p["cmdline"]) not in after_keys]
    added.sort(key=lambda p: p["pid"])
    removed.sort(key=lambda p: p["pid"])
    return added, removed


def main():
    if len(sys.argv) < 3 or sys.argv[1] != "--":
        print(__doc__)
        raise SystemExit(2)
    command = sys.argv[2:]
    before = snapshot()
    started = time.monotonic()
    result = subprocess.run(command, timeout=30)
    elapsed = time.monotonic() - started
    time.sleep(0.2)
    after = snapshot()
    added, removed = changed(before, after)
    print(json.dumps({
        "exit_code": result.returncode,
        "elapsed_seconds": round(elapsed, 3),
        "added_processes": added,
        "removed_processes": removed,
    }, indent=2))


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

Save it as proc_diff.py, make it executable, and run:

./proc_diff.py -- ping -c 1 127.0.0.1
Enter fullscreen mode Exit fullscreen mode

Then run a more interesting command:

./proc_diff.py -- sh -c 'sleep 120 &'
Enter fullscreen mode Exit fullscreen mode

Output:

{
  "exit_code": 0,
  "elapsed_seconds": 0.211,
  "added_processes": [
    {
      "pid": 18432,
      "exe": "/usr/bin/sleep",
      "cwd": "/home/operator",
      "cmdline": "sleep 120"
    }
  ],
  "removed_processes": []
}
Enter fullscreen mode Exit fullscreen mode

The exit code is 0, but the command left a process behind. That is exactly the situation I want to see before I approve an automated step.

What the diff means

  • added_processes: a new process identity appeared. If the command was supposed to be one-shot, require an explanation.
  • removed_processes: a process disappeared. It can be normal, but you should not ignore it. A command that stops a security agent or monitoring daemon is interesting even if it exits cleanly.
  • Missing process: a process that starts and exits before the second snapshot is invisible. This is a sampling tool, not a continuous recorder.

The comparison key is (exe, cwd, cmdline). Pid is deliberately excluded so that unrelated pid reuse does not create a false positive. The trade-off: a daemon restart with the same executable, directory, and arguments will not appear as new. Add start time or parent pid if you also need to detect that case.

Where MonkeyCode fits

When I need a model-generated command, I keep the product role narrow. MonkeyCode's free model access lets me generate command candidates; the free server option gives me a disposable Linux host. The harness supplies the evidence. I run the candidate, inspect the diff, and kill or discard anything that should not be there.

I would not send real credentials or production data to a disposable free server. The free server is a test surface, not a trusted environment.

Test plan

Run these three checks before relying on the tool:

  1. ./proc_diff.py -- true — expect no additions and no removals.
  2. ./proc_diff.py -- sleep 0 — expect no additions and no removals after the command exits.
  3. ./proc_diff.py -- sh -c 'sleep 120 &' — expect sleep 120 in added_processes.

If the third check does not show sleep 120, your /proc permissions or container runtime are hiding processes, and the test is not valid on that host.

Limitations

  • The /proc view is limited by permissions. A non-root user sees only what the system allows.
  • The snapshot can miss short-lived processes. Use auditd, eBPF, or a VM snapshot if you need stronger evidence.
  • It does not show network sockets, memory changes, file writes, or environment changes. Pair it with a file diff and a socket diff.
  • This is not a sandbox. The command can still damage files owned by the same user while it runs. Run it as a separate user, with a timeout, and without network access when possible.
  • The key ignores pid, so an identical restart inside the same command will not appear as new. That is intentional for noise reduction, but it is a blind spot.

Who should not use this

Do not use this as your only control if you need kernel-level visibility, a tamper-evident record, or a guarantee about a large multi-step agent. A short-lived child, a kernel module, or a process in another namespace can bypass a /proc diff.

Use it as a quick tripwire on disposable compute. If the exit code says 0 and the process table still shows a new worker, do not trust the success message.

Top comments (0)