Why I built guard-me-well
A small PATH-based command guard, inspired by nono, that rejects risky CLI invocations without replacing my shell.
The problem is authority, not intelligence
A coding agent working in my terminal inherits my authority. If gh, curl, or node works for me, it works for the agent.
Most of what it runs is harmless. The distance between harmless and not is often a single argument:
gh repo list
gh repo create example --private
gh gist create notes.txt
gh release upload v1.0.0 artifact.zip
One binary, four risk profiles. The first only reads. The second creates a private repository. The last two publish data to places I may not want it. Removing gh from the environment would take the safe operations along with the dangerous ones, and asking an agent nicely is useful context, not enforcement.
What I wanted was a rule at the command boundary: allow the tool, inspect the invocation, reject the shapes I do not accept.
guard-me-well is what I came up with. It places small shims at the front of PATH, checks each intercepted command against a YAML policy, and either blocks it or hands control to the real executable.
It is a guardrail, not a sandbox. That distinction defines both its usefulness and its limits.
What I borrowed from nono
I set out to build my own version of nono. That description turned out to be half right.
What I took was not an implementation detail. It was nono's central claim that policy belongs outside the agent. nono combines kernel-enforced Landlock restrictions on Linux and Seatbelt restrictions on macOS with a trusted supervisor, brokered tool execution, and credential and network proxies. An agent cannot widen those policies from inside its own session.
guard-me-well takes a much smaller route.
| nono | guard-me-well | |
|---|---|---|
| Boundary | operating-system sandbox and policy broker | command resolution through PATH |
| Scope | files, network, credentials, tools, processes | executable name and arguments |
| Setup model | run the agent inside a profile | prepend a shim directory to PATH |
| Bypass resistance | designed as a security boundary | deliberately not a security boundary |
| Goal | least-privilege execution | a local, understandable tripwire |
That last row is the whole comparison. What I built is the smallest slice of the problem that is still useful in my own workflow.
One binary becomes every guarded command
guard-me-well is a single Go binary that answers to many names. The shim installer reads the policy, collects the exact command names it mentions, and creates one symlink per command:
~/.guard/bin/
├── curl ───────────────┐
├── gh ─────────────────┼──> guard-me-well
├── node ───────────────┤
└── wget ───────────────┘
/opt/homebrew/bin/
├── gh
└── node
The shim directory comes first in PATH, so gh repo list starts the guard binary through the symlink named gh.
That name is the only thing the guard needs to know which command it caught. It loads the YAML policy, evaluates the arguments and the caller context, then takes one of two paths:
flowchart TD
A["shell resolves gh"] --> B["~/.guard/bin/gh"]
B --> C["load and validate YAML rules"]
C --> D["environment + parent processes"]
D --> E{"matching rule blocks?"}
E -->|yes| F["print rule and reason<br/>exit 126"]
E -->|no| G["search PATH with shim directory excluded"]
G --> H["find the real gh"]
H --> I["replace guard process with gh"]
On the allowed path, the guard searches a filtered copy of PATH so it does not call itself forever, finds the next executable with the requested name, and uses syscall.Exec to replace itself with that absolute path.
The real tool receives the original arguments and environment, including the unchanged PATH. From the caller's side, an allowed command behaves as if the shim was never there.
Policies match commands, arguments, and context
A policy is YAML. Rules match either an exact command or a command-name regular expression. Argument conditions can use an element-by-element prefix, a regular expression, an inverse regular expression, or a list of alternatives.
commands: [curl, wget]
rules:
- id: block-gh-gist
command: gh
args:
prefix: [gist]
mode: ai
reason: GitHub gists can publish data outside the repository.
- id: require-private-gh-repo-create
command: gh
args:
prefix: [repo, create]
not_regex: '(^| )--private(=true)?($| )'
mode: all
reason: GitHub repositories must be created privately.
- id: block-downloaded-shell
command_regex: '^(curl|wget)$'
args:
regex: '(?i)(sh|bash|zsh|fish)'
mode: ai
reason: Downloaded shell scripts are risky in agent contexts.
mode: all applies to every caller. mode: ai applies only when the detector believes a supported coding agent is somewhere above the command. A rule without a mode is currently treated as all.
The private-repository rule is the shape I like most. It does not ban repository creation, it makes the safe form mandatory:
gh repo create example
# blocked: require-private-gh-repo-create
gh repo create example --private
# allowed
A plain deny rule can only ban repository creation. The inverse condition can require its safer form.
The curl rule has a hard boundary. It sees only the arguments passed to curl or wget. It cannot inspect the rest of a shell pipeline, so it does not catch curl URL | sh.
AI detection is deliberately a heuristic
AI-only rules let me keep an operation available in my interactive shell while blocking it in an agent session.
The detector checks three things:
GUARD_AI=1, mainly so tests stay deterministic.- Known environment markers for Claude Code, Codex, OpenCode, Pi, Cursor, Aider, Copilot, and Windsurf.
- On Unix-like systems, up to twelve parent processes, looking for known agent names.
This buys ergonomics, not security. Anything that can rewrite PATH, clear its environment, or call an absolute executable path walks straight past the guard, and parent-process names produce both false positives and false negatives. Rules that should hold regardless of who is calling get mode: all.
I can inspect a decision without running it
--test parses a small shell-word string and reports the decision without executing anything:
GUARD_AI=1 guard-me-well --test "gh gist create notes.txt"
command: gh gist create notes.txt
matched: yes
rule: block-gh-gist
conditional: yes
only_for_ai: yes
is_ai: yes
interceptor_caught: no
intercepted: no
decision: block
exit_code: 126
This is what I actually use while writing a policy, because it puts the matched rule, the detected context, the decision, and the exit code in one place. The word parser is smaller than a real shell parser on purpose, and --test never evaluates shell syntax.
The current feature set
The project already supports:
- Shell-independent interception through
PATHshims. - Direct invocation with
guard-me-well -- COMMAND [ARG...]. - Exact command and command-regex rules.
- Prefix, regex, inverse-regex, and alternative argument matching.
- Global and AI-only rule scopes, with automatic AI-context detection.
- Config validation that fails usefully.
- A dry decision mode that does not run the command.
doctorchecks for policy syntax, shim ordering, missing shims, and real executables.- Stable exit codes:
0allowed,2invalid configuration,126blocked,127command not found. - Go unit tests plus Bats integration tests for Bash-compatible invocation, Fish, Zsh, shims, direct mode, policies, diagnostics, and exit codes.
The GitHub Actions workflow builds and vets the Go code, runs the unit tests, and exercises the Bats suite on Linux. The latest revision passes that pipeline.
What it does not protect
The most important feature is an honest limitations section.
gh gist create notes.txt
# intercepted when ~/.guard/bin is first in PATH
/opt/homebrew/bin/gh gist create notes.txt
# straight past the shim
The guard also cannot stop a program from calling the GitHub API directly, and a caller that controls its own environment can drop the shim directory. There is no filesystem isolation, no network policy, no credential isolation, no authentication, no authorization.
That is why I call it a command guard. If the caller must be unable to bypass the policy, the answer is a sandbox such as nono, a container, or another protected supervisor. It is not more confidence in a PATH trick.
What I want to improve next
The prototype proves the interaction model. The remaining work is about making the boundary predictable, not about adding more matcher types.
Make shim installation safe and reversible
The installer currently calls os.RemoveAll on a target before creating the symlink, which is far too broad for an installer.
It should record the links it owns, refuse to remove anything it does not recognize, create replacements atomically, and clean up shims for commands that have left the policy. An explicit uninstall-shims command would close the lifecycle.
Make policy behavior easier to reason about
Rules are evaluated in order and the first match wins. That needs to be visible in both the documentation and the diagnostics.
The loader should compile regular expressions once, report shadowed or unreachable rules, preserve argument boundaries when matching, and tell a missing default policy apart from a requested file that does not exist. A guard-me-well policy check command could validate all of it without installing anything.
Replace guessed AI context with a trusted signal
Environment and process detection are convenient defaults, but they cannot prove who launched a command.
The stronger design is an explicit launcher that supplies a session marker the guarded process cannot rewrite, or integration with a supervisor that owns the policy. Until that exists, mode: ai stays a convenience and the rules that matter stay mode: all.
Add useful audit output without leaking secrets
I want an optional structured log containing the time, the executable, the matched rule, and the decision. Raw arguments are out, because commands routinely carry tokens, URLs, and customer data.
Per-rule redaction therefore has to land before the log file does.
Test the platforms I claim
CI runs on Ubuntu. The parent-process detector and the execution path both behave differently elsewhere.
macOS CI, fuzzing for the shell-word and policy parsers, and enforced coverage all come before I either declare the program Unix-only or replace the Unix-specific syscall.Exec path.
Finish the distribution story
The README sketches a Homebrew installation, but the formula is not published. A real release needs versioned binaries, checksums, a license, a security policy, and verifiable artifacts.
The repository also still carries prototype leftovers: a generated zip, an unrelated upload server, and an older line-based policy example. Removing them would make the source match the product described here.
Where I draw the line
guard-me-well is useful to me because I can hold the entire path from command to decision in my head. It gives me somewhere to write down "repository creation must be private" without taking gh away from myself or from an agent.
The next version should become a better guard. It should not drift into pretending to be a sandbox. I would rather compose this with a real isolation boundary than rebuild nono one shell interception at a time.
Small enough to inspect, strict about the commands it sees, honest about the commands it cannot see.