Before giving AI a bash, I first thought through what I was afraid of: the gatekeeping design of ops-agent
ops-agent v0.1.0-rc.1 classifies commands into four levels, R0–R3. Read-only commands are allowed through directly, while write operations must go through a change request, confirmation, approval stamp, and controlled execution. The standalone Agent's bash handler only executes R0. Approval stamps are bound to the actual execution environment; changing directories, modifying the registry, or switching KUBECONFIG will invalidate old stamps; kubectl change commands must explicitly specify --context. This version includes 309 self-checks and 223 independent regression tests, but real SSH/LLM/notification external acceptance and 7 consecutive days of observation have still not been completed.
Body · AI translation
"ops-agent Build Notes" Part 1 Code Baseline: v0.1.0-rc.1, commit d897d2c95e70 (released 2026-10-25, release candidate) Main code: core.py, config.yaml, agent/tools.py; at runtime only the Python 3.9+ standard library is required
--- 0. The shape I didn't dare actually use
The most direct way to have an LLM help me with ops is to give it a bash tool and tell it, "You're an ops expert, go investigate."
When I reasoned through this shape, I found I didn't dare put it directly on a real machine. It's not that the model isn't smart enough; it's that no link in the whole chain could answer a few questions for me: Before it types systemctl restart nginx, who takes a look? It says "I'll change the config"—where's the copy from before the change? It runs a command on a remote machine, the SSH connection drops—did that command actually execute or not? In the session records, audit logs, and the context sent to the model, could my server's private keys and tokens get mixed in?
Not one of the four questions could be answered. What I figured out is: investigation can be done automatically, but changes have to be made within clear boundaries. Read-only commands can go straight through; anything involving writes needs a verifiable command, target, authorization, and execution record.
That's how the project's later shape was settled. Gatekeeping wasn't a feature added later; it was the foundation that came first. Inspection, notifications, scenarios, and scheduling were all built on top afterward, and no layer is allowed to bypass it.
Below are a few decisions I made on that boundary.
--- 1. The grading standard is whether I need to be present
Commands are divided into four levels, R0–R3. I use this to arrange the degree of involvement, but it is first and foremost a set of actual matching rules; code can't be graded based on whether an operation sounds "light" or not. The default configuration for this version is:
| Level | Meaning | Examples | Flow | |---|---|---|---| | R0 | Read-only | df -h, systemctl status, kubectl get pods | Pass through directly | | R1 | Lower-level changes listed in the config | systemctl restart/reload, docker restart | Change ticket → confirmation → approval stamp → controlled execution | | R2 | Higher-risk or unknown commands | kubectl apply, package changes; ordinary config writes that don't match a rule also default to R2 | Likewise confirm the full command, environment, and rollback arrangements | | R3 | Highest-risk changes listed in the config | rm -rf, systemctl stop, dd writing to a device, database-dropping types | Additionally requires force=true + explicit confirmation; approval can only be used once |
The grading rules are written in config.yaml, and additional overrides go in /.ops-agent/config.override.yaml. The classification implementation is linear regex matching—I noted this in the README's known boundaries: it does not parse the shell AST; compound commands are split on ; && || |, newlines, and background &, and the highest level among the segments is taken.
Why not parse the AST? I weighed it. Full analysis would have to handle the syntax differences of bash/zsh/sh, quote expansion, and the boundaries of command substitution. In the end I didn't introduce a full shell parser; instead, in addition to segment classification, I added conservative checks: command substitution, write redirections, find -exec/-delete, etc. cannot be passed through just because the command name is read-only. A false positive is "a legitimate command gets confirmed one extra time"; a missed detection is "a write operation is passed through as read-only." These two costs are asymmetric.
The pre-release review showed me that checking only the raw string is still not enough. The regression suite includes find victim -de''lete, -de, and -{delete,print}: only after the shell strips quotes or expands them does the program receive the actual options. haswritemarker() now performs a combined check using the decoded arguments, dynamic arguments, and glob/brace options; Git's --upload-pack, --ext-diff, and less's initial command and log parameters also cannot escape review just because they're attached to a read command.
This cost is directly visible: cat $HOME/file is no longer passed through as R0; it must be changed to a confirmed path or go through approval; the literal in echo '$HOME' is still fine. Conversely, an ordinary grep -n "def check" core.py won't be escalated to R2 just because the search term contains check. I'd rather write these kinds of boundaries as reproducible regressions than justify the rules with a false positive whose cause can't be explained.
--- 2. Beyond the prompt, the tool itself must also gatekeep internally
Read-only is the default; the full chain for write operations is as follows:
The risk descriptions and rollback arrangements in the change ticket still have a process-discipline component; when the model gets distracted and the context is polluted, the prompt cannot cover for me. So the standalone Agent's bash handler calls core.classify() and core.check() itself and only executes R0; write commands, even if they already have a stamp, must go through opsexec and cannot use bash to bypass the attempt consumption, write lock, and execution record.
The repository also retains a PreToolUse hook for use by the Claude Code host. In .claude/settings.json, an opsx hook is registered for Bash command execution. It only allows two categories: R0 read-only commands; and the CLI in this repository that is guarded (opsx itself, whose entry point internally re-validates approval and snapshots).
Bare write commands—even with an approval stamp—are also rejected. In this host, directly calling Bash("systemctl restart nginx") will be stopped at the hook; the standalone REPL, single-shot, and RPC rely on their own tool implementations for gatekeeping and do not require another layer of Claude Code outside.
There is a boundary here that I must acknowledge, and it is written in the README: approval stamps are created by the Agent side after obtaining user confirmation, and they prevent "accidental execution," not "malicious Agents." If the model is dead set on doing evil, it can trick me into clicking agree during the confirmation step. The premise of this system is that "the model may make mistakes but is not an enemy." I accepted it with another yardstick: I am the only one operating my own machine, so the risk exposure is originally only mine.
--- 3. An approval stamp binds not only the command, but also where it executes
Early approval relying only on the command and target name was not enough; during review I checked it against this scenario:
Suppose I approved systemctl restart nginx with target web1, and then changed web1's host in /.ops-agent/targets.json from 10.0.0.11 to another machine. If approval bound only "command + target name," the old stamp would still be valid, and the same command could be sent to a machine that was never approved.
Now the approval stamp binds the actual execution environment, collected by core.resolveenv:
Remote targets are expanded into the actual host/user/port/identity/options. checkstamp() compares the entire env dictionary during validation; if any item changes—a different directory, a modified registry, a switched KUBECONFIG—the old stamp immediately becomes invalid and re-approval is required. kubectl's context/namespace are also added to the execution environment by resolveenv(), not relying only on that localenv() block.
kubectl also has an additional hard rule: change commands must explicitly write --context in the command, otherwise stamp issuance is refused (core.validatek8schangescope). Not writing context means using the current default, and the default depends on the environment at the moment of execution, which amounts to the approval binding a value known only at runtime. This rule does not even count fake --context inside sh -c inner scripts or comments: the inner layer of sh -c "kubectl apply …" is not parsed and is directly treated as unbound; the comment in kubectl apply … # --context=prod is stripped.
The cost is clear: the migration notice states that all old stamps before W1 are invalid and must be re-authorized. I have set several rules for the project on such matters: put compatibility later and security earlier.
--- 4. Output protection must be shared; restoring originals must be isolated
The secret-leak surface in operations scenarios is much wider than in ordinary applications. When the model troubleshoots problems it needs to look at configurations and logs, and configurations and logs are precisely where private keys, tokens, and webhook URLs are most likely to appear. Once these enter the model context, they are effectively sent to a third-party API; once written into session files or audit logs, they land on disk as permanent copies.
My approach is that all exits share the same redaction function (core.redact): model requests, session persistence, auditing, long-output persistence, and approval list display all go through it.
This excerpt comes from core.py. It replaces using the previously defined PEM private key, Authorization, secret key-value pairs, and common token patterns, and redactobj() then recursively processes dictionaries and lists. They can only recognize known patterns, and I cannot promise on that basis that any secret can be recognized.
Restoring originals takes another path. Snapshots retain the original content without redaction, because restoring a redacted snapshot would break the configuration; offline state backups also save these original bytes. The state/snapshot directory is 0700, and the model's read and command-read entry points are prohibited from sending originals into the context. The originals still exist on disk and in backups, and remote restoration also transmits them back to the target machine via SSH, so what is done here is permission isolation and read restriction, not making secrets exist only for a single moment.
Beyond the output side, there is also a layer of sensitive-file read restrictions on the input side: read directly rejects private keys, .env, .netrc, .kube/config, /.aws/credentials, and /.ops-agent/notify.json (the path of a webhook URL is the token, and 0600 cannot stop a model process with the same uid). Command entry points are covered too: cat /.ssh/idrsa, a symlink pointing to a private key, the inner layer of ssh host "cat idrsa", and kubectl get secret -o yaml are all rejected. The boundary is stated as well: the regex does not guarantee coverage of all secret formats and rejects conservatively; to change a key, a human should do it directly.
This time, wildcards and API entry points were also added. cat .env checks the actual local matches and symlink targets, and rejects if the cumulative count exceeds 1024; remote content globs cannot be verified locally, and brace expansion for content reads is also conservatively rejected. kubectl get --raw, config view --raw, and reads of dynamically generated resource names cannot be used to bypass Secret protection. Sensitive-read rejection and "requires approval" are two different things, and an existing approval cannot override the former.
Redacting only on write is also not enough. Manually edited memory.md and old events may never have gone through the write-side entry points at all, so this version redacts again during memory recall and event reads, without modifying the original files. I will expand on memory in post 8; here I first close the exit of "imported old content is also re-sent to the model."
The verification method is sentinels. Fake keys are planted in selftest, and it asserts that plaintext does not appear at any exit: model requests, sessions, audit, and on-disk output. A batch of the 309 self-checks are exactly this.
--- 5. A few places even I found too strict, which I ultimately did not change
During development I hesitated over a few places, wondering whether they were too strict, and in the end I kept them all.
I require the target of controlled remote execution to be registered in the registry first. The target of opsexec must be a name already present in /.ops-agent/targets.json; the model cannot assemble a target on the fly. To be precise here: the gatekeeper also allows a strictly limited direct ssh host "read-only command" form, so it is not a complete sandbox for network access; the registry constrains the controlled-target channel, and it should not be exaggerated into "the model cannot connect to any new host."
The opsapprove of an independent Agent looks at whether there is a confirmation callback. Non-TTY one-shot runs usually do not have one, so self-created stamps are rejected; RPC can have the client answer via a reverse request, which does not mean it is uniformly unconfirmable. Scheduled inspections are stricter: the tool whitelist does not expose approve/exec at all, and pre-created stamps will not conjure write tools into existence. A manually pre-authorized non-interactive CLI can perform changes, but that is an action a human has explicitly prepared, and it cannot be written as the daemon repairing itself.
Target fields must be validated before loading and use. Connection fields such as host/user/identity in targets.json must not contain whitespace or a leading -, and port is validated separately as a valid port. Otherwise, once these values enter SSH arguments, a configuration that looks like a hostname could be treated as an option. This version has no "registration server"; the manifest is edited by humans, and I do not describe runtime validation as a registration interface that does not exist.
--- 6. Status as of the writing of this post
On top of the foundation, the capabilities split by W1–W8 have been delivered; these are work-package numbers and must not be taken as eight weeks of production operation already experienced. v0.1.0-rc.1 has 309 self-checks and 223 independent regressions, and at release all four CI groups (Ubuntu/macOS × Python 3.9/3.12) passed. Local drills are 10/10, but the real SSH/LLM/notification external acceptance for W8 in this version and the 7 consecutive days of observation after fixes are still incomplete.
My claim about "security" remains in the same place: this system guards against misoperation and bypasses of known flows, not a malicious-model sandbox; the classification is not a complete AST; secret detection has a surface of false negatives. The release candidate has not turned these into things of the past.
The next post will discuss the first capability built on the foundation: inspection. Why I insist that check items be fixed in code and the model only interprets, and why the unknown state matters more than ok. The code corresponding to this post: default grading and whitelist: config.yaml classification, sensitive reads, approval and rollback: core.py bash/approve/exec entry points of the independent Agent: agent/tools.py gatekeeping, wildcards, and path-redirection regressions