iTranslated by AI
Plug One Hole, Another Appears — Defensive Design in omamori v0.9
TL;DR
- omamori is a macOS guard tool that blocks destructive commands via Claude Code / Cursor / Codex CLI.
- In the v0.9 series, diagnostics, supply chain, auditing, and pipe-to-shell defenses were all enhanced.
- The central theme was dealing with pipe-to-shell bypasses. Even after blocking
curl URL | bash, bypasses kept emerging in different forms such asenv bash,sudo bash,env -S 'bash -e', and those containing redirects. - Ultimately, we shifted toward treating wrappers, shells, and redirects as input shapes rather than individual strings.
- The lesson learned was to "write out the shape × operation matrix" before implementing security measures.
Status Up to the Previous Version
v0.1-v0.4: Protecting commands (shim + config guard)
v0.5-v0.6: Protecting the guard itself (integrity, unwrap stack)
v0.7: Protecting evidence (audit log, HMAC chain)
v0.8: Protecting file editing
v0.9: Closing loopholes while organizing diagnostics and auditing ← This time
Reading up to v0.8, there was an atmosphere of "this is now robust." What I realized in v0.9 was the fact that there were still loopholes I thought I had blocked.
Structure of This Article
The v0.9 series has 9 releases, but this article will not follow them in version order. Instead, it will be summarized across the following four axes:
- Enabling diagnostics (v0.9.0)
- Hardening distributions (v0.9.3 / v0.9.4)
- Blocking pipe-to-shell bypasses (v0.9.2 / v0.9.5 / v0.9.6 / v0.9.8)
- Enabling observation even during fail-open (v0.9.6 / v0.9.7)
Item 3 is the main subject of this article, taking up 4 releases. Please read the rest as peripheral infrastructure supporting item 3.
Note that v0.9.1 was a release that simply removed stray files from the distribution, with no code changes. It does not belong to any of these 4 axes, so no independent section has been created.
v0.9.0: Returning "Why it broke" and "Why it was blocked"
omamori doctor
omamori has a fair number of installation steps: creating shims, adding to PATH, configuring Claude Code hooks, and generating baselines. If any are missing, Layer 1 or Layer 2 fails. Until now, the only way to know if something was missing was to "notice when it breaks."
In v0.9.0, we added omamori doctor. It checks 13 items; if healthy, it returns the status in 2 lines, and if there is a problem, it only displays the problematic item. Adding --fix performs an automatic repair.
$ omamori doctor
omamori: all healthy
13/13 checks passed
Problem lines are only shown when unhealthy. To see all items, add --verbose.
We implemented an AI guard for --fix. --fix cannot be used in an AI environment (where environment variables like CLAUDECODE are set). The reason is simple: if you let the AI rewrite the guard's own baseline, the criteria for stopping the AI itself disappear. This is written in SECURITY.md as design invariant DI-7.
omamori explain
We enabled the tool to return "why a blocked command was blocked."
$ omamori explain -- rm -rf /
omamori explain: rm -rf /
Verdict: BLOCK
Layer 1 (PATH shim):
rule: rm-recursive-to-trash
action: trash
detail: ...
Layer 2 (hooks):
phase: meta-pattern
detail: ...
note: Layer 2 applies rule action directly (no context override)
Guidance: run this command directly in your terminal (not via AI)
It returns the structured verdict of what Layer 1 and Layer 2 did, respectively. Adding --json returns the output in JSON for automation.
We also added the line hint: run \omamori explain -- 0cmd0` for details` to the block message. The strategy is to write in the return value how to investigate next when the AI is blocked.
explain also cannot be used in an AI environment. This is to prevent oracle attacks. If you let the AI query "will this command be blocked?" indefinitely, it will use trial and error to find a form that isn't blocked. This is also written as design invariant DI-8.
Test Count at This Point
473 → 491 (+18).
v0.9.3 / v0.9.4: Hardening the Package Itself
Since the guard body was relatively well-tested up to v0.8, we spent time hardening the pipeline that distributes omamori in v0.9.3 and v0.9.4.
v0.9.3: Supply Chain Hardening
Here is a list of what we did:
- Tracked
Cargo.lockin git. CI runs entirely with--locked. - Fixed all
uses:in.github/workflows/*with 40-character SHAs.@v3notation is prohibited. - Changed the tarball released to crates.io to a deny-by-default allowlist. Reduced from 58 files to 42.
.github/,scripts/,rust-toolchain.toml, andACCEPTANCE_TEST.mdare not included in the distribution. - Added
action-pin-checkto CI, which verifies that the actions used have SHA pins. This is a gate; other jobs wait withneeds: action-pin-check. - Replaced fluid installs like
cargo install cargo-tarpaulinwith version-fixed installs viataiki-e/install-action.
These are written in the "AI-assisted Contribution Invariants" section of SECURITY.md as things that are mechanically enforced. Since you cannot rely on "being careful," these are fixed as CI check jobs.
v0.9.4: CI Matrix and Structural Invariants
- Set
testandclippyCI tomatrix: [macos-latest, ubuntu-latest]. Merging is impossible unless both pass. - Added
tests/hook_integration.rs. We wrote an 8-category corpus (allow baseline / direct-path bypass / unset / env -u / export -n / VAR= / compound separator / FP guard) table-driven and actually run theinstaller → wrapper → hook-checkchain. - Added invariants #6, #7, and #8 to
scripts/check-invariants.sh. This structurally verifies the corpus form of hook integration (containing both Allow and Block,#[ignore]being zero) and ensures thatcat | omamori hook-check,set -eu, andexit $?remain in the body ofrender_hook_script. - Limited Dependabot's
github-actionsecosystem to monthly + patch-only. Security updates arrive via other channels, so we do not ignore them.
omamori is macOS-only, but CI also runs on Linux. The reason is simple: race conditions and CWD dependencies are more likely to appear on Linux, so Linux picks up bugs that might be missed on macOS. In fact, in v0.9.4, Ubuntu CI became flaky in context::tests::multi_target_*, which evolved into applying serial_test::serial in v0.9.5, and a structural fix passing an explicit base to normalize_path in v0.9.6.
v0.9.2 / v0.9.5 / v0.9.6 / v0.9.8: Closing pipe-to-shell loopholes over 4 releases
This is the main theme of the v0.9 series.
What was the problem?
omamori blocks pipe-to-shell patterns like curl URL | bash at Layer 2. This is a typical dangerous operation that feeds a remote script directly into a shell. We have been detecting this since v0.5.
The implementation up to v0.9.2 generally followed this flow:
1. Split input command using shell_words
2. Look at each segment separated by pipes
3. Block if the right-hand side is bash, sh, or zsh
The problem was that the commands the AI produces were not nearly as straightforward as this.
curl URL | env bash # wrapped in a wrapper
curl URL | sudo bash # wrapped in another wrapper
curl URL | env -S 'bash -e' # hidden by quotes
curl URL | bash -c 'source /dev/stdin' # sourcing internally
curl URL | env bash 2>&1 # confused by redirects
echo ok\nrm -rf / # sneaking into the 2nd line with a newline
All of these are equivalent to "the right-hand side of a pipe-to-shell is bash," but they look different at the string level. Once we blocked one, the AI would find the next form.
v0.9.2: Treating newlines and & as separators
The first hole was in the most primitive string-level spot. We were not treating \n (literal newline) in echo ok\nrm -rf / as a separator. The same applied to & (background operator) in echo x & rm -rf /. Since we need to preserve redirects like &>, >&, and 2>&1, we had to distinguish between these.
We fixed normalize_compound_operators to:
- Treat unquoted
\n,\r, and\r\nas equivalent to; - Treat a standalone
&as a segment boundary by padding it with spaces - Leave
&>,>&, and2>&1as redirects
In addition, we changed detection of environment variable tampering (unset, env -u, export -n) from string matching to token-level detection. This was because unset CLAUDECODE (double space) or unset\tCLAUDECODE (tab) were bypassing the string matching. We now determine if it is a "token in a command position" using is_command_position() after passing it through shell_words::split.
This closed 3 bypasses. Tests: 490 → 538 (+48).
v0.9.5: Peeling transparent wrappers for evaluation
We need to treat curl URL | env bash the same as curl URL | bash. Since env and sudo are wrappers that change the environment without changing the target command, we treat them as transparent for pipe-to-shell detection.
In v0.9.5, we started handling 7 commands as transparent wrappers:
env, sudo, nice, timeout, nohup, exec, command
The tricky part here is that looking just at the wrapper name isn't enough. We have to account for all options and variations of each wrapper.
- Absolute paths (
/usr/bin/env,/bin/sudo) - Stdin mode flags (
-s, bare-,/dev/stdin) - Option-value pairs (
-O extglob,-o errexit,--rcfile /tmp/rc) - Grouped short options (
-la argv0,-pv) - bash's
|&(pipe stdout + stderr)
In our implementation, we moved the pipe-to-shell detection before unwrap_transparent. We first determine if the result of peeling off the right-hand side wrapper is bash-like. The list of wrappers to peel is kept in sync with the 7 wrappers in the unwrap side.
We kept the block reason message as the same pipe to shell interpreter as in v0.9.4. Returning the type of wrapper to the AI would create an iteration where it bypasses with a different wrapper. Wrapper types are written only to the audit log. We intentionally separated stderr and the audit log (in v0.9.7, we record in the audit as detection_layer: "layer2:pipe-to-shell:{wrapper}").
This PR underwent 8 rounds of Codex adversarial review. 16 fixes were accumulated before reaching the final commit.
v0.9.6: Split-strings, internal sourcing, and privilege escalation wrappers
Once we blocked the 7 wrappers in v0.9.5, the next 3 systems that weren't blocked in v0.9.5 became visible.
Split-string format: env -S 'bash -e' causes env to internally parse arguments and launch bash -e. shell-words doesn't split the contents of quotes when splitting the outside. Since omamori passes through here before looking at the content of quoted strings, we decided to "block the env -S argument string regardless of its content" rather than interpreting it deeply.
Internal source/. (dot): Forms like bash -c 'source /dev/stdin' result in bash -c calling the source builtin internally to read stdin. bash -c '. /dev/fd/0' is the same. We block if the right-hand side of source or . (POSIX dot) is one of /dev/stdin, /dev/fd/0, or /proc/self/fd/0. Bypasses via eval or exec were not blocked in v0.9.6 and remain as known limitations.
Privilege escalation wrappers: Added doas and pkexec to the transparent wrapper list, bringing the total to 9.
Additionally, we structurally fixed the Ubuntu CI flakiness. We replaced the normalize_path that read the CWD of the entire process with normalize_path_with_base, which passes an explicit base directory. The #[serial_test::serial] was a short-term quarantine and was removed.
v0.9.8: Fixing misclassification of redirect operators
This was the most bitter part. After adding the logic to peel wrappers before evaluation in v0.9.5–v0.9.6, the AI started outputting forms like this:
curl URL | env bash 2>&1
curl URL | bash &>> log
2>&1 and &>> are redirect operators, but the implementation was not classifying them correctly. The code at the time used two booleans for classification.
// Until v0.9.7
let is_pure_redirect = ...;
let is_concatenated_redirect = ...;
This could not distinguish between &>> (PureWithOperand, consumes operator + operand) and simple 2>&1 (Concatenated, completes in 1 token). In fact, up to v0.9.7, &>> was misclassified as "Concatenated," and the file path after the operator (the log part) was flowing into the next positional argument. This corrupted the interpretation of bash's -s flag (stdin script mode), ultimately bypassing the pipe-to-shell detection.
In v0.9.8, we replaced this with a RedirectToken enum.
pub(crate) enum RedirectToken {
PureWithOperand, // Forms consuming operator + operand like `&>>` `2>`
Concatenated, // Forms completing in 1 token like `2>&1` `&>log`
NotRedirect,
}
impl RedirectToken {
pub(crate) fn token_span(self) -> usize {
match self {
Self::NotRedirect => 0,
Self::Concatenated => 1,
Self::PureWithOperand => 2,
}
}
}
The caller advances with idx.saturating_add(kind.token_span()). The three locations unwrap_transparent, strip_leading_noise, and classify_shell_args now handle redirects only via this single enum. token_span is received as a Copy value (Rust idiom).
Additionally, 20 cases of 9 wrappers × redirect operator combinations were added to HOOK_DECISION_CASES. The 22 lines R-1 to R-22 were also added to ACCEPTANCE_TEST.md so that AI agents can reproduce them via actual execution paths (described later).
We also added performance benchmarks at this time (criterion 0.8.2). Hook check is about 1 µs in the block path and about 57 µs in the allow path. Since the subprocess startup cost is much larger, there is effectively no overhead originating from omamori.
Lessons from 4 releases
The 3 axes of "pipe-to-shell + transparent wrapper + redirect operator" are not independent. The number of combinations easily explodes with 9 wrappers × N redirects × M concatenations. Even if you close one axis at a time, a new combination is born when you add the second axis.
As a design lesson, it became: "Enumerate the shape × operation matrix during Phase 2 design." Afterward, it became a checklist we always verify in Phase 2 (detailed design). Specifically, before adding new defenses, we chart out "axes this blocks / axes it doesn't / known combination table." If you enter implementation with a table full of holes, you end up closing them step-by-step over 4 releases.
v0.9.6 / v0.9.7: Making it observable
In parallel with closing loopholes, we added 2 mechanisms to "leave traces even if fail-open occurs."
v0.9.6: Routing unknown tools by shape
Claude Code / Cursor / Codex CLI keep increasing their new tool names. Because omamori was unconditionally allowing tool names it didn't know (HookInput::UnknownTool), one could skip the entire pipeline just by changing the tool name.
{"tool_name":"FuturePlanWriter","tool_input":{"command":"/bin/rm -rf /"}}
This was allowed until v0.9.5. Just because the tool_name was unknown, routing would not run even if command: "/bin/rm -rf /" was inside.
In v0.9.6, we changed routing from tool_name to the shape of tool_input.
-
command/cmdarrives as string → route to Bash pipeline -
file_path/patharrives as string → route to FileOp / protected-path check -
urlarrives as string → allow as read-only - Doesn't match any → fail-open (but observe)
The point of this release is leaving a trace for the final fail-open. We output a 1-line hint to stderr and append an unknown_tool_fail_open event to the audit chain.
$ omamori audit unknown
TIMESTAMP PROVIDER COMMAND ACTION RESULT RULE
2026-04-26T10:14:32Z claude-code NotebookEdit unknown_tool_fail_open allow —
2026-04-26T11:02:18Z claude-code Task unknown_tool_fail_open allow —
The tool_name goes into the COMMAND column, and unknown_tool_fail_open goes into the ACTION column. The number of top-level keys in tool_input is recorded as target_count (borrowing existing columns for different meanings).
We also updated omamori doctor to return the count in a "Last 30 days" line. It doesn't display if 0. If it's 1 or more, it displays it as a signpost to notice anomalies and urges checking details via audit unknown.
Note that this is an "upper bound (maximum of adversarial activity)" and not a "lower bound." Claude Code's NotebookEdit, Task, TodoWrite, WebSearch, etc., are not in the routing table, so fail-open events occur even for healthy tools. What you should look for is change: has it suddenly increased, or did an unknown tool name appear? There is no need to worry just because the count isn't 0 in a normal state.
v0.9.7: Including Layer 2 blocks in the HMAC chain
In v0.7, we made the audit log an HMAC chain resistant to tampering. While Layer 1 (PATH shim) blocks were recorded end-to-end, Layer 2 (hook) block verdicts only appeared in stderr. The evidence in the HMAC chain was only for the PATH shim; it hadn't reached the hooks.
In v0.9.7, we started appending 3 types of block verdicts—BlockMeta, BlockRule, and BlockStructural—to the audit chain via the Claude Code / Codex hook-check path. The Cursor hook path remains stderr-only as of v0.9.8, and appending to the audit chain is unsupported. Protection works, but observation is weaker than the Claude Code / Codex path.
In detection_layer:
"layer2:meta-pattern"
"layer2:rule"
"layer2:pipe-to-shell:env" # wrapper basename included
"layer2:pipe-to-shell:sudo"
"layer2:structural"
The stderr message remains the same fixed string as in v0.9.5. This is to prevent the AI agent from reading the wrapper type from stderr and bypassing with the next wrapper; wrapper types are written only to the audit log. We intentionally separated the channels for "reason for block" and "observation of block."
We do not increment CHAIN_VERSION. By simply adding new values to detection_layer, the parser handles them as opaque strings just as before.
v0.9.7: Automatic merging for install --hooks
Until v0.9.7, omamori install --hooks printed the hook block that should be added to Claude Code's ~/.claude/settings.json as [todo]. It assumed the user would copy-paste it by hand, but in reality, no one does that.
In v0.9.7, we added merge_claude_settings(), which takes a .bak and writes it with 0o600 using atomic write (tmpfile + rename). It explicitly becomes 0o600 regardless of the process umask. In case of a JSON parse error, it aborts without overwriting (fail-close on safety).
We can identify omamori-managed entries (where the command field starts with ~/.omamori/). Old matcher formats ("tool == \"Bash\"") are automatically migrated to "Bash" in the current spec. User-managed entries (not managed by omamori) are left untouched, upholding the principle of "not changing user configs without permission."
Additionally, omamori doctor parses settings.json and verifies the matcher syntax and the SHA-256 of the script_path of omamori-managed entries. If doctor is green, it's in a state where Layer 2 is structurally valid.
Big Picture from v0.1 to v0.9.8
| ver | Action | Target protected |
|---|---|---|
| v0.1-v0.4 | shim + config guard | Commands |
| v0.5-v0.6 | integrity + Recursive Unwrap Stack | Guard itself |
| v0.7 | Audit log (HMAC chain, verify, retention) | Evidence |
| v0.8.0 | Edit/Write block, fail-close, fuzz | File editing |
| v0.8.1 | lib.rs 2,893 → 103 lines (module split) | Code sustainability |
| v0.9.0 | doctor + explain | UX |
| v0.9.2 | Newline/& separator, token-level env tampering | 3 bypasses |
| v0.9.3 | Cargo.lock track, SHA pin, tarball allowlist | Supply chain |
| v0.9.4 | Linux + macOS CI matrix, structural invariants | Regression detection |
| v0.9.5 | Peel 7 transparent wrappers | pipe-to-shell |
| v0.9.6 | env -S / source /dev/stdin / doas / pkexec, unknown tool routing | pipe-to-shell + observation |
| v0.9.7 | Append Layer 2 deny to audit chain, automatic install merge | Audit + UX |
| v0.9.8 | RedirectToken enum, 9 wrappers × redirect combinations | Redirect axis |
Tests: 491 (v0.9.0) → 538 (v0.9.2) → 595 (v0.9.6) → 658 (v0.9.7) → +36 (unit 16 + hook integration 20) (v0.9.8).
Where it protects and where it doesn't
| Attack Method | Status |
|---|---|
| `curl URL | env bash/sudo bash` |
| `curl URL | env -S 'bash -e'` |
| `curl URL | bash -c 'source /dev/stdin'` |
| `curl URL | env bash 2>&1/&>>` series |
Unknown tool with command / cmd
|
block (v0.9.6) |
Unknown tool name + unknown tool_input shape |
fail-open but leaves trace in audit |
| obfuscated command (base64, hex, var expansion) | undetected |
Dynamic generation (python -c, node -e) |
undetected |
| Commands inside heredoc | undetected |
| Executed directly by human in terminal | unpreventable |
| Operation with root privileges | unpreventable |
Obfuscation and dynamic generation are structural limits of static shell-word analysis, and stepping into them causes FP rates to soar. Not looking inside python -c is safer to ensure valid usage like cat data | python -c 'parse' isn't blocked. This is written as-is in SECURITY.md's "Known Limitations C."
As a premise, omamori is not a sandbox. It is a moat that captures "representative shapes of dangerous operations emitted by AI CLIs" through static shell-word analysis, not something that completely seals arbitrary code execution itself. It is a layer to prevent the AI from doing whatever it likes, not the last line of defense.
Lessons from the v0.9 series
If I were to pick one, it's the "shape × operation matrix" story.
When we blocked 7 wrappers in v0.9.5, I thought the wrapper axis was closed. In reality, I had only filled one column of the 9 wrappers × N redirects × M concatenations combination table. I spent 4 releases to realize the 3rd axis—2 wrappers + source-type column in v0.9.6, and the redirect axis in v0.9.8.
Lesson: Write out the input space as a rectangle (axis × axis × axis) before implementing. Explicitly label axes with holes as "unsupported" before implementing. Even if you fill them one column at a time, implementing one column at a time without holding the table means the remaining columns start to take on new meanings as you add more.
This also became a checklist in Phase 2 (detailed design) to explicitly enumerate axes.
Another one: Acceptance tests for AI-oriented security tools are meaningless unless written to follow the paths the AI agent actually hits. We didn't notice the redirect-axis bypass after v0.9.7 shipped until it was reproduced via actual execution through the AI tool.
There are regressions that only reproduce via AI tools. Admitting this, I rewrote ACCEPTANCE_TEST.md not as a "manual for humans" but as "verification procedures actually executed by the AI agent" (v0.9.8 PR1).
Next (to v0.10.0)
Since the major closures around pipe-to-shell were completed in v0.9.8, the next step is verifiability. Since doctor expansion and report addition are large, I plan to cut it as v0.10.0 rather than v0.9.9.
- Reconstitute
omamori doctorfrom a "13-item OK/NG list" to a "Layer 1 / Layer 2 / Integrity / Risk 4-axis trust dashboard" (#220) - Add a backend that aggregates audit logs with
omamori report --last 7d(#221)
v1.0 has a GUI and mechanisms to verify boundary matrices in real environments in view, but it will take a little more time.
Updates
brew upgrade omamori
omamori install --hooks # Claude Code's settings.json will also be auto-merged
omamori doctor # healthy if it's 13/13
Discussion