guardrail module
Status: enabled by default. Source:
src/modules/guardrail.zig
Confirmation prompt for dangerous commands.
How it works
When the user presses Enter:
- Match the current line against a configurable rule list. Each rule
carries a
Match(substring / prefix / glob), anAuthorMask(user / llm gate), and aBehavior(.confirm,.confirm_once,.block,.warn). - First matching rule wins, in declaration order.
.confirmswallows the Enter, prints a banner, enters “armed” state. Next Enter passes through; non-Enter keystroke disarms..confirm_onceworks like.confirmbut the confirmation persists for the rest of the session (per rule)..blockreplaces the Enter with Ctrl+U (clears the typed line) and prints a banner; the command can never run..warnprints a banner and forwards the Enter — useful when you want an audit trail without friction.
The banner tags the author: atty guardrail: <reason> [user|llm].
Default rules
Author-aware: model-suggested destructive commands are stricter than
user-typed ones. The exact root-path rm -rf / and the classic fork
bomb are .block for both authors. Most others differentiate:
| Pattern | User behavior | LLM behavior |
|---|---|---|
rm -rf / (exact glob) |
.block |
.block |
:(){ :|:& };: (substring) |
.block |
.block |
rm -rf ~ (substring) |
.confirm |
.block |
rm -rf (substring) |
.confirm |
.block |
sudo mkfs (prefix) |
.confirm † |
.block |
sudo dd (prefix) |
.confirm † |
.block |
sudo (prefix) |
.confirm |
.confirm |
mkfs (prefix) |
.confirm |
.block |
dd (prefix) |
.confirm |
.block |
| sh (substring) |
.confirm |
.confirm |
| bash (substring) |
.confirm |
.confirm |
chmod 777 / (substring) |
.confirm |
.confirm |
† User-typed sudo mkfs … / sudo dd … match the explicit user rules
no earlier than the generic sudo rule (which also .confirms), so
the visible behavior is the same; the explicit sudo-mkfs-llm /
sudo-dd-llm rules exist to shadow the generic sudo rule for the
LLM path under first-match-wins ordering.
Custom rules
Two knobs:
extra_rules— your rules are prepended to whateverrulesresolves to (defaults to the shippeddefault_rules). Under first-match-wins your entries check first, so this is the right place to declare stricter overrides (.blocka pattern the defaults only.confirm) or whitelists (a.warnrule that matches before the default.blockwould). Empty default = userulesonly. Use this for the common “I just want to add a couple more rules” case.rules— full replacement list. Defaults to the shippeddefault_rules. Set this when you want a minimal custom policy tailored to your environment and explicitly not the defaults. Setting bothrulesandextra_rulesis supported (extras prepend to your custom list).
// Common case: extend the defaults.
pub const Guardrail = atty.modules.guardrail.configure(.{
.extra_rules = &.{
.{
// user-only — without the explicit mask this would also
// match llm-authored commits, and because first-match
// wins, the llm-only block below would be unreachable.
.name = "git-force-push-user",
.match = .{ .substring = "git push --force" },
.reason = "force-pushing to a shared branch",
.authors = .{ .user = true, .llm = false },
.behavior = .confirm_once,
},
.{
.name = "git-force-push-llm",
.match = .{ .substring = "git push --force" },
.reason = "force-pushing (llm)",
.authors = .{ .user = false, .llm = true },
.behavior = .block,
},
},
.warning_style = atty.style.presets.danger, // bold red
});
// Rare case: replace defaults entirely.
pub const MinimalGuardrail = atty.modules.guardrail.configure(.{
.rules = &.{
.{ .name = "rm-rf-root",
.match = .{ .glob = "rm -rf /" },
.reason = "rm -rf on root",
.behavior = .block },
},
});
warning_style takes an atty.Style — same type the ghost overlay
uses, so a single palette can drive the whole proxy. Default style
is .{ .dim = true, .italic = true }.
Limitations
- The match runs against our line model, which approximates what
the user typed. Vi-mode
hjklnavigation desyncs the model silently — atty doesn’t see the vi-mode/insert-mode distinction on stdin (hjklare plain printable bytes), so the line model appends them while the shell’s readline treats them as cursor moves and emits\x1b[D/\x1b[B/\x1b[A/\x1b[Cback through the master fd. OSC 133 sync doesn’t recover this either because thesyncFromCapturegate requires either the line model to beuncertainor the captured input to be at least as long as the model — neither holds in this case. Practical consequence: in vi-normal navigation, the guardrail’s matcher reads a partially-wrong buffer; pathological typed content (substring matching a tripwire pattern that the user never actually executed) could trigger a false positive on Enter. The conservative fix would be probingset -oat attach to detect vi-mode + short-circuit guardrail there; currently it’s a documented edge case. - Substring matches are literal — there’s no regex engine. If you need one, write a new module; do not add it here. A pathological regex on the input hot path would be a strict regression.