# Path-operand gate (adversarial-review audit fix).
#
# The engine gates its 15 resolved commands' file reads/writes by locus (HP-20). The ~1600
# LEGACY commands are a parallel surface: a content-reader like `od`/`base64`/`diff` or a
# writer like `tee`/`shred` takes a file operand but its handler never checks the operand's
# locus — so `base64 ~/.ssh/id_rsa` exfiltrates a key and `shred /etc/hosts` destroys a system
# file, both without a prompt.
#
# This lists the legacy commands that READ or WRITE their path operands' content. A
# cross-cutting gate (`pathgate::should_deny`, called from `handlers::dispatch`) then denies
# any `looks_like_path` operand whose read/write locus is sensitive — the same locus gate the
# engine applies, retrofitted onto legacy commands by name.
#
# NOT here: metadata-only commands (`ls`, `stat`, `file`, `readlink`, `du`, `find`) — they
# reveal names/sizes, not content, and legitimately walk system paths. And `awk`, whose regex
# programs contain `/` and `$` (they'd false-positive) — it is gated in its own handler, which
# knows the program from the files. Commands that read STDIN only (`tr`) are not readers.
#
# A path operand appearing here is gated even if the command's own handler already allowed it.
# Adding a command is safe (over-gating a non-path arg is a no-op; a system path denies).
# ── Commands that READ their file operands' content → gated by read locus ──────────────────
read = [
# hex / binary / text dumps
"od", "xxd", "hexdump", "hd", "strings", "tac", "nl", "rev", "fold", "fmt",
# (csplit is gated via [roles.csplit] below — first positional only, so its `/regex/`
# split-patterns are not misread as paths.)
"expand", "unexpand", "colrm", "pr", "col", "column",
# pagers / viewers
"less", "more", "most", "bat", "cat",
# field / column / line tools
"cut", "paste", "join", "comm", "sort", "uniq", "tsort", "ptx", "shuf", "numsum", "datamash",
# encoding / format converters (read a file, emit transformed content)
"iconv", "recode", "pv", "pandoc", "xmllint", "xmlstarlet",
# CSV / structured-data tools (their positional after flags is a file)
"xsv", "qsv", "mlr", "csvtool", "jaq", "gojq", "pup", "yj",
# diff family
"diff", "diff3", "sdiff", "colordiff", "delta", "difft", "cmp",
# encoders / structured-data query (their FILTER positional classifies worktree, harmless)
"base64", "base32", "basenc", "jq", "yq", "xq", "tomlq", "dasel", "gron", "htmlq", "fx", "jless",
# crypto tools that read a key/cert/data file via a path arg (`openssl rsa -in ~/.ssh/id_rsa`)
"openssl",
# checksums / hashes (disclose a hash of the secret)
"md5", "md5sum", "sha1sum", "sha224sum", "sha256sum", "sha384sum", "sha512sum",
"shasum", "cksum", "sum", "b2sum", "b3sum", "xxhsum", "rhash",
# compressed-content viewers
"zcat", "gzcat", "bzcat", "xzcat", "lzcat", "zstdcat", "zless", "bzless", "xzless", "zmore",
]
# ── grep-like tools: `CMD [flags] PATTERN [flags] files…` — gate the FILE operands, skipping
# the first positional (the PATTERN, which may itself be a `/regex/`). Same shape as `awk`,
# which is gated in its own handler. (Plain `grep`/`sed` are the engine's job.)
#
# `read_tree`, not `read`: every one of these searches a DIRECTORY recursively by default, so
# the operand is the root of a sweep. `rg foo ~` names `~` — not a credential store — and then
# reads `~/.ssh/id_rsa` out of it. Since a directory cannot be told from a file without
# touching the filesystem, this also refuses `rg foo ~/notes.txt`, which is a false deny; it
# matches what these tools did before local reads opened up, and `grep` (engine-modelled, so
# its recursion is actually known) reads that file fine. See TODO.md.
read_tree_after_first = ["rg", "ag", "ack", "ugrep", "sift", "pt"]
# ── Commands that WRITE / modify / destroy their file operands → gated by write locus ──────
# (Every positional is a write-target. Tools whose args play mixed roles — an output flag, a
# URL, an identity, a transcode input — are in [roles.*] below, not here.)
write = [
"tee", "shred", "sponge", "truncate",
# file/node creators + single-file removers: the positional is the path they create/destroy.
# (`gtruncate`/`gunlink`/`gmknod` are standalone GNU-form commands, not aliases, so they're
# listed by their own names; `mkfifo`/`gmkfifo` share a spec via the alias map.)
"mkfifo", "gmkfifo", "mknod", "gmknod", "unlink", "gunlink", "gtruncate",
# in-place (re)compressors / optimizers that rewrite the file in its directory
"bzip2", "xz", "lzma", "zstd", "lz4", "brotli", "compress", "zip",
"pigz", "unpigz", "pbzip2", "plzip", "lzip", "optipng", "jpegoptim",
# sox: its output is a positional but EFFECTS follow it (`sox in out reverb`), so last_write
# can't find the output — gate every positional as a write. Cost: a home INPUT prompts (grant
# its dir to allow); effect args (`reverb`, `0.5`) aren't paths, so they're harmless.
"sox",
# archivers / sync that write extracted files by path (`7z a /etc/x.7z`, `cpio -F /etc/x`,
# `unzip -p ~/.ssh/id_rsa`). A remote spec (`host:/path`) classifies worktree.
"7z", "7za", "7zr", "cpio", "unzip",
# other HTTP downloaders (curl/wget have richer grammars in [roles.*]). The blanket write
# gate catches a `-o`/`-d`/`-P` path into a sensitive locus; a URL classifies worktree.
"aria2c", "http", "https", "xh", "httpie",
]
# ── Argument-role grammar: tools whose path arguments take DIFFERENT roles by flag/position,
# so a flat read/write list mis-gates them. `positional` is the role of bare operands
# (read | write | ignore); `flags` maps a path-bearing flag to its value's role; `shape`
# (plain | skip_first | last_write | remote) adjusts the positional policy. The walker in
# pathgate.rs interprets these. Role assignment is authored per the tool's real semantics. ──
# HTTP downloaders: the URL operand is gated by read locus, but the locus layer is scheme-aware
# — a network URL admits (so `curl https://x/a/../b` does NOT false-deny on the `..`) and a
# `file:` URL classifies the local path it names (so `curl file:///etc/shadow` denies). The
# output flag writes.
[roles.curl]
positional = "read"
flags = { "-o" = "write", "--output" = "write", "--output-dir" = "write" }
[roles.wget]
positional = "ignore"
flags = { "-o" = "write", "-O" = "write", "--output-document" = "write", "-P" = "write", "--directory-prefix" = "write", "--save-cookies" = "write", "--warc-file" = "write", "--warc-tempdir" = "write", "--post-file" = "read", "-i" = "read", "--input-file" = "read" }
# Remote copy/sync: a LOCAL source is a disclosing read (exfil), the destination is a write, a
# `host:path` endpoint is ignored (remote writes are the tool's own concern), and an `-i`
# identity / `-F` config / `-o` option / `-J` jump is auth plumbing, never a target.
[roles.scp]
positional = "read"
shape = "remote"
flags = { "-i" = "ignore", "-F" = "ignore", "-o" = "ignore", "-J" = "ignore", "-S" = "ignore" }
[roles.sftp]
positional = "read"
shape = "remote"
flags = { "-i" = "ignore", "-F" = "ignore", "-o" = "ignore", "-J" = "ignore", "-S" = "ignore", "-b" = "read" }
[roles.rsync]
# `read_tree`: rsync exists to copy TREES, so its source operand is the root of a sweep and not
# the file it opens. `rsync -a ~/ ./x` names `~`, clears the shield, and lands every key in the
# worktree.
positional = "read_tree"
shape = "remote"
flags = { "-e" = "ignore", "--rsh" = "ignore" }
# Converters whose OUTPUT is the last positional: the input is a non-disclosing transcode
# (ignore, so a home input like ~/Downloads/x still allows), the last operand is the write.
[roles.magick]
positional = "ignore"
shape = "last_write"
[roles.convert]
positional = "ignore"
shape = "last_write"
[roles.ffmpeg]
positional = "ignore"
shape = "last_write"
[roles.pdftotext]
positional = "ignore"
shape = "last_write"
[roles.pdftocairo]
positional = "ignore"
shape = "last_write"
[roles.pdftoppm]
positional = "ignore"
shape = "last_write"
[roles.pdfimages]
positional = "ignore"
shape = "last_write"
[roles.pdfseparate]
positional = "ignore"
shape = "last_write"
[roles.qpdf]
positional = "ignore"
shape = "last_write"
[roles.pdftk]
positional = "ignore"
shape = "last_write"
# Converters whose OUTPUT is a flag (input is the positional → ignore).
[roles.cwebp]
positional = "ignore"
flags = { "-o" = "write", "--output" = "write" }
[roles.mutool]
positional = "ignore"
flags = { "-o" = "write", "-O" = "write" }
# Key / crypto tools with an identity flag: the `-f` key or `-o` output is a write; an `-i`
# identity is auth plumbing (ignore), and the input/recipients are non-disclosing (ignore).
[roles.ssh-keygen]
positional = "ignore"
flags = { "-f" = "write" }
[roles.age]
positional = "ignore"
flags = { "-o" = "write", "--output" = "write", "-i" = "ignore", "--identity" = "ignore" }
# pl (macOS plist checker): --input reads a file, --output writes the normalized form. Inert, but
# both flag values are real paths, so a system-path read/write must gate.
[roles.pl]
positional = "ignore"
flags = { "--input" = "read", "--output" = "write" }
# csplit FILE /regex/… — the FIRST positional is the input FILE (a disclosing read); the
# trailing `/regex/` split-patterns are positionals that look like absolute paths, so only the
# first is gated (`first_only`). Chunk files are written under the `-f` prefix (a write-target).
[roles.csplit]
positional = "read"
shape = "first_only"
flags = { "-f" = "write", "--prefix" = "write" }
# NOT here yet (documented follow-ups — policy calls, not detection):
# disclosure inspectors pdfinfo/pdffonts/ffprobe/mediainfo/exiftool (read-gating over-denies
# home-file inspection — a separate "should we gate metadata reads" decision); converter
# stdout disclosure (`pdftotext ~/x.pdf -`) and file:-protocol inputs, both consequences of
# the ignore-input policy that lets home transcodes through.
[roles."aclocal"]
flags = { "--output" = "write" }
[roles."afconvert"]
# `afconvert [opts] INPUT [OUTPUT]` — the output is the LAST POSITIONAL, not only the `-o` flag, so
# the flag gate alone left `afconvert -f AIFF ./in.wav ~/.ssh/authorized_keys` auto-approving.
# Merged into the existing block; a second `[roles."afconvert"]` would make the file unparseable.
shape = "last_write"
flags = { "-o" = "write" }
[roles."ameba"]
flags = { "--out" = "write" }
write_when = ["--fix"]
[roles."applesingle"]
flags = { "--directory" = "write", "--rename" = "write", "-C" = "write", "-o" = "write" }
[roles."autoconf"]
flags = { "--output" = "write", "-o" = "write" }
[roles."autom4te"]
flags = { "--output" = "write", "-o" = "write" }
[roles."automake"]
flags = { "-o" = "write" }
[roles."avbcapture"]
flags = { "--output" = "write", "-o" = "write" }
[roles."avconvert"]
flags = { "--output" = "write", "-o" = "write" }
[roles."b64decode"]
flags = { "-o" = "write" }
[roles."b64encode"]
flags = { "-o" = "write" }
[roles."bandit"]
flags = { "--output" = "write", "-o" = "write" }
[roles."binhex"]
flags = { "--directory" = "write", "--rename" = "write", "-C" = "write", "-o" = "write" }
[roles."binhex.pl"]
flags = { "-o" = "write" }
[roles."bintrans"]
flags = { "-o" = "write" }
[roles."bison"]
flags = { "--file-prefix" = "write", "--output" = "write", "--output-file" = "write", "-b" = "write", "-o" = "write" }
[roles."bitcode_strip"]
flags = { "-o" = "write" }
[roles."brakeman"]
flags = { "--output" = "write", "-o" = "write" }
[roles."bsqldb"]
flags = { "-e" = "write", "-i" = "read", "-o" = "write" }
[roles."bsqlodbc"]
flags = { "-o" = "write" }
[roles."byacc"]
flags = { "-H" = "write", "-b" = "write", "-o" = "write" }
[roles."cloc"]
flags = { "--out" = "write", "--report-file" = "write", "--sql" = "write", "--write-lang-def" = "write" }
[roles."codesign_allocate"]
flags = { "-i" = "read", "-o" = "write" }
[roles."compression_tool"]
flags = { "-i" = "read", "-o" = "write" }
[roles."coqc"]
flags = { "-o" = "write" }
[roles."ctf_insert"]
flags = { "-o" = "write" }
[roles."cucumber"]
flags = { "--out" = "write", "-o" = "write" }
[roles."debinhex.pl"]
flags = { "-o" = "write" }
[roles."defncopy"]
flags = { "-i" = "read", "-o" = "write" }
[roles."deptry"]
flags = { "--json-output" = "write", "-o" = "write" }
[roles."dotnet-symbol"]
flags = { "--output" = "write", "-o" = "write" }
[roles."dsymutil"]
flags = { "--out" = "write", "-o" = "write" }
[roles."dwarfdump"]
flags = { "-o" = "write" }
[roles."dwebp"]
flags = { "-o" = "write" }
[roles."ecpg"]
flags = { "-o" = "write" }
[roles."eleventy"]
flags = { "--output" = "write" }
[roles."eslint"]
flags = { "--output-file" = "write", "-o" = "write" }
[roles."eyapp"]
flags = { "-o" = "write" }
[roles."fantomas"]
flags = { "--out" = "write" }
[roles."fax2ps"]
flags = { "-o" = "write" }
[roles."fax2tiff"]
flags = { "-o" = "write" }
[roles."flex"]
flags = { "--header-file" = "write", "--outfile" = "write", "--skel" = "read", "--tables-file" = "write", "-S" = "read", "-o" = "write" }
[roles."frames"]
flags = { "--assets" = "read", "--output" = "write", "--subfolder" = "write", "-o" = "write" }
[roles."gcore"]
flags = { "-c" = "write", "-o" = "write" }
[roles."gen_bridge_metadata"]
flags = { "--exception" = "read", "--framework" = "read", "--output" = "write", "-e" = "read", "-f" = "read", "-o" = "write" }
[roles."genstrings"]
flags = { "-o" = "write" }
[roles."gif2webp"]
flags = { "-o" = "write" }
[roles."gifsicle"]
flags = { "--output" = "write", "--use-colormap" = "read", "-o" = "write" }
[roles."gosec"]
flags = { "--out" = "write", "-out" = "write" }
[roles."grype"]
flags = { "--file" = "write", "--output" = "write", "-o" = "write" }
[roles."headerdoc2html"]
flags = { "-o" = "write" }
[roles."heif-dec"]
flags = { "--output" = "write", "-o" = "write" }
[roles."heif-enc"]
flags = { "--output" = "write", "-o" = "write" }
[roles."hurl"]
flags = { "--output" = "write", "-o" = "write" }
[roles."ibtool"]
flags = { "--write" = "write" }
[roles."img2webp"]
flags = { "-o" = "write" }
[roles."iperf3"]
flags = { "-o" = "write" }
[roles."jasper"]
flags = { "--output" = "write", "-F" = "write" }
[roles."jbig2dec"]
flags = { "--output" = "write", "-o" = "write" }
[roles."jpgicc"]
shape = "last_write"
flags = { "-o" = "read" }
[roles."jupyter-nbconvert"]
flags = { "--output" = "write" }
[roles."jupytext"]
# `--sync`/`--set-formats`/`--update-metadata` REWRITE the notebooks given as positionals, so the
# operation decides their role. Merged into the existing block rather than declared as a second
# `[roles."jupytext"]` — a duplicate table key makes the whole file unparseable.
handler = "jupytext_mode"
flags = { "--output" = "write", "-o" = "write" }
[roles."layerutil"]
flags = { "--output" = "write", "-o" = "write" }
[roles."ld"]
flags = { "-o" = "write" }
[roles."lesskey"]
flags = { "--output" = "write", "-o" = "write" }
[roles."libtool"]
flags = { "-filelist" = "read", "-o" = "write" }
[roles."license-checker"]
flags = { "--files" = "write", "--out" = "write" }
[roles."linkicc"]
flags = { "-o" = "write" }
[roles."lockstat"]
flags = { "-o" = "write" }
[roles."macbinary"]
flags = { "--directory" = "write", "--rename" = "write", "-C" = "write", "-o" = "write" }
[roles."markdownlint"]
# `--fix` rewrites the linted files in place, so the operands become writes. Merged here rather than
# declared as a second `[roles."markdownlint"]` — a duplicate table key makes the file unparseable,
# which panics the loader and denies EVERYTHING. That failure looks exactly like a working gate
# until you check the in-workspace control.
positional = "ignore"
write_when = ["--fix", "-f"]
flags = { "--output" = "write", "-o" = "write" }
[roles."marp"]
flags = { "--output" = "write", "-o" = "write" }
[roles."mongodump"]
flags = { "--archive" = "write", "--out" = "write", "-o" = "write" }
[roles."mongoexport"]
flags = { "--out" = "write", "-o" = "write" }
[roles."msgattrib"]
flags = { "--output-file" = "write", "-o" = "write" }
[roles."msgcat"]
flags = { "-o" = "write" }
[roles."msgcomm"]
flags = { "--output-file" = "write", "-o" = "write" }
[roles."msgconv"]
flags = { "--output-file" = "write", "-o" = "write" }
[roles."msgen"]
flags = { "--output-file" = "write", "-o" = "write" }
[roles."msgfmt"]
flags = { "--output-file" = "write", "-o" = "write" }
[roles."msggrep"]
flags = { "--output-file" = "write", "-o" = "write" }
[roles."msginit"]
flags = { "--output-file" = "write", "-o" = "write" }
[roles."msgmerge"]
flags = { "--output-file" = "write", "-o" = "write" }
[roles."msgunfmt"]
flags = { "--output-file" = "write", "-o" = "write" }
[roles."msguniq"]
flags = { "--output-file" = "write", "-o" = "write" }
[roles."nano"]
flags = { "--backupdir" = "write", "--operatingdir" = "write", "-C" = "write", "-o" = "write" }
[roles."nmedit"]
flags = { "-o" = "write" }
[roles."ocamlformat"]
# `--inplace`/`-i` rewrites the POSITIONAL, so the file operand is a write-target as well as the
# `-o` output. Merged here rather than added as a second `[roles."ocamlformat"]` block — a duplicate
# table key makes the whole file unparseable, which panics the loader and denies everything.
positional = "write"
flags = { "--output" = "write", "-o" = "write" }
[roles."opj_compress"]
flags = { "-o" = "write" }
[roles."opj_decompress"]
flags = { "-o" = "write" }
[roles."opj_dump"]
flags = { "-o" = "write" }
[roles."pdfdetach"]
flags = { "-o" = "write" }
[roles."pdfjam"]
flags = { "--builddir" = "write", "--outfile" = "write", "-o" = "write" }
[roles."pg_combinebackup"]
flags = { "--output" = "write", "--tablespace-mapping" = "write", "-T" = "write", "-o" = "write" }
[roles."pip-audit"]
flags = { "--output" = "write", "-o" = "write" }
[roles."pngquant"]
flags = { "--output" = "write", "-o" = "write" }
[roles."pp"]
flags = { "--output" = "write", "-o" = "write" }
[roles."psicc"]
flags = { "-o" = "read" }
[roles."qlmanage"]
flags = { "-o" = "write" }
[roles."racc"]
flags = { "--log-file" = "write", "--output-file" = "write", "-O" = "write", "-o" = "write" }
[roles."raw2tiff"]
flags = { "-o" = "write" }
[roles."rdoc"]
flags = { "--op" = "write", "-o" = "write" }
[roles."re2c"]
flags = { "--depfile" = "write", "--header" = "write", "--include" = "read", "--output" = "write", "--type-header" = "write", "-I" = "read", "-o" = "write", "-t" = "write" }
[roles."rex"]
flags = { "--output-file" = "write", "-o" = "write" }
[roles."rollup"]
flags = { "--dir" = "write", "--file" = "write", "--sourcemapFile" = "write", "-d" = "write", "-o" = "write" }
[roles."rspec"]
flags = { "--deprecation-out" = "write", "--out" = "write", "-o" = "write" }
# `--out`/`-o` names the report file. The autocorrect family (-a/-A/-x and the deprecated
# --auto-correct* / --safe-auto-correct spellings) REWRITES every inspected file in place, so under
# any of them the positionals are write-targets; a bare run only reports.
[roles."rubocop"]
flags = { "--out" = "write", "-o" = "write" }
write_when = [
"--auto-correct", "--auto-correct-all", "--autocorrect", "--autocorrect-all",
"--fix-layout", "--safe-auto-correct", "--safe-autocorrect",
"-A", "-a", "-x",
]
[roles."say"]
flags = { "-o" = "write" }
[roles."sdp"]
flags = { "-i" = "read", "-o" = "write" }
[roles."solc"]
flags = { "-o" = "write" }
[roles."sops"]
flags = { "--output" = "write" }
[roles."spindump"]
flags = { "-i" = "read", "-microstackshots_datastore" = "write", "-microstackshots_dsc_path" = "read", "-o" = "write" }
[roles."stackprof"]
flags = { "--out" = "write" }
# Inherits RuboCop's flag surface, so the same autocorrect family rewrites the inspected files in
# place; `--fix` is Standard's own spelling of it. `--no-fix` is the read-only counterpart.
[roles."standardrb"]
flags = { "--out" = "write", "-o" = "write" }
write_when = [
"--auto-correct", "--auto-correct-all", "--autocorrect", "--autocorrect-all",
"--fix", "--fix-layout", "--safe-auto-correct", "--safe-autocorrect",
"-A", "-a",
]
[roles."strip"]
flags = { "-o" = "write" }
[roles."stylelint"]
flags = { "-o" = "write" }
[roles."terraform-docs"]
flags = { "--output" = "write", "--output-file" = "write" }
[roles."tfsec"]
flags = { "--out" = "write", "-O" = "write" }
[roles."tidy"]
flags = { "-f" = "write", "-file" = "write", "-o" = "write", "-output" = "write" }
[roles."tiff2pdf"]
flags = { "-o" = "write" }
[roles."tiff2ps"]
flags = { "-O" = "write" }
[roles."tificc"]
shape = "last_write"
flags = { "-o" = "read" }
[roles."transicc"]
flags = { "-o" = "read" }
[roles."typedoc"]
flags = { "--out" = "write" }
[roles."typeprof"]
flags = { "-o" = "write" }
[roles."uconv"]
flags = { "--output" = "write", "-o" = "write" }
[roles."unbrotli"]
flags = { "--output" = "write", "-o" = "write" }
[roles."unifdef"]
flags = { "-o" = "write" }
[roles."usdcat"]
flags = { "--out" = "write", "-o" = "write" }
[roles."usdchecker"]
flags = { "--out" = "write", "-o" = "write" }
[roles."usdcrush"]
flags = { "--out" = "write", "-o" = "write" }
[roles."usdextract"]
flags = { "--out" = "write", "-o" = "write" }
[roles."uudecode"]
flags = { "-o" = "write" }
[roles."uuencode"]
flags = { "-o" = "write" }
[roles."wasm2wat"]
flags = { "--output" = "write", "-o" = "write" }
[roles."wat2wasm"]
flags = { "--output" = "write", "-o" = "write" }
[roles."webpmux"]
flags = { "-o" = "write" }
[roles."xgettext"]
flags = { "--output" = "write", "-o" = "write" }
[roles."xgettext.pl"]
flags = { "--output" = "write", "-o" = "write" }
[roles."xsltproc"]
flags = { "--output" = "write", "-o" = "write" }
[roles."yacc"]
flags = { "-H" = "write", "-b" = "write", "-o" = "write" }
[roles."yapp"]
flags = { "-o" = "write" }
[roles."yt-dlp"]
flags = { "--output" = "write", "-o" = "write" }
[roles."ytt"]
flags = { "--dangerous-emptied-output-directory" = "write", "--output-directory" = "write", "--output-files" = "write", "--output-multi-files" = "write" }
[roles."djpeg"]
flags = { "-o" = "write" }
[roles."swc"]
flags = { "-o" = "write", "--out-dir" = "write" }
[roles."syft"]
flags = { "-o" = "write", "--output" = "write" }
[roles."vyper"]
flags = { "-o" = "write" }
# SUB-SCOPED gates. The key is `"<command> <sub>"`, and it is consulted with the sub name in the
# command slot, so it reads exactly like a command-scoped role. Needed when a flag's ROLE or ARITY
# differs per sub: `smbutil -f` names a mounted share on these four subs and is a boolean on `view`,
# so the command-wide gate that used to live in smbutil.toml made `smbutil view -f //server` deny.
[roles."smbutil statshares"]
flags = { "-f" = "read" }
[roles."smbutil multichannel"]
flags = { "-f" = "read" }
[roles."smbutil snapshot"]
flags = { "-f" = "read" }
[roles."smbutil smbstat"]
flags = { "-f" = "read" }
# `rbs annotate` UPDATES each RBS file it is given (upstream calls `annotate_file(path)` per
# positional); its siblings — `validate`, `prototype`, `list` — only read. There is no flag to key
# on here: the SUBCOMMAND is the mode, so before sub-scoped keys the only expressible choices were
# to gate every sub (breaking `rbs prototype ./lib`) or none (leaving
# `rbs annotate ~/.ssh/authorized_keys` auto-approved, which it was).
[roles."rbs annotate"]
positional = "write"
# `swiftlint fix` rewrites its files IN PLACE (the entry's own description has said so all along);
# `lint`/`analyze`/`rules` only read. BOTH spellings need a key — a sub-scoped gate matches the
# literal token, so gating only the canonical name leaves the alias wide open. That is not a
# hypothetical: `swiftlint autocorrect ~/.ssh/authorized_keys` auto-approved while
# `swiftlint fix …` denied. `a_sub_scoped_gate_covers_every_spelling_of_its_sub` now enforces it.
[roles."swiftlint fix"]
positional = "write"
[roles."swiftlint autocorrect"]
positional = "write"
# `git diff` prints the CONTENT of the files it is given, and with two paths outside the worktree it
# stops being a VCS command at all: `git diff --no-index /etc/shadow /dev/null` renders a credential
# file into the caller's context as a unified diff. git engages that mode implicitly too — its own
# docs say `--no-index` may be omitted "when at least one of the paths points outside the working
# tree" — so keying the gate on the flag would miss the shorter spelling. Gating the POSITIONALS
# covers both, and costs nothing in normal use: an in-repo pathspec (`git diff src/x.rs`) and a
# revision (`main`, `HEAD~3`, `main..feature`) are relative and read as worktree, while only a path
# that leaves the tree — which as a pathspec git would reject anyway — denies.
#
# `diff-tree`/`diff-files`/`diff-index` take tree-ish arguments rather than filesystem paths and
# have no --no-index mode, so they are not gated here.
#
# The valued flags need roles too, and in both directions. `-O <orderfile>` names a file git READS,
# so it is gated (`git diff -O /etc/shadow`). But `-S`/`-G` take a SEARCH STRING and a regex, and
# gating a bare `positional = "read"` without saying so denied `git diff -S /etc/passwd` — looking
# for a path-shaped literal in the diff, which reads nothing. Every remaining valued flag takes a
# count, a mode name, an enum or a display prefix, so each is `ignore`: an unlisted one would be
# gated as a path and repeat that false deny.
[roles."git diff"]
positional = "read"
flags = { "-O" = "read", "--relative" = "read", "-S" = "ignore", "-G" = "ignore", "-U" = "ignore", "--unified" = "ignore", "--abbrev" = "ignore", "--color" = "ignore", "--color-moved" = "ignore", "--diff-algorithm" = "ignore", "--diff-filter" = "ignore", "--diff-merges" = "ignore", "--dirstat" = "ignore", "--src-prefix" = "ignore", "--dst-prefix" = "ignore", "--inter-hunk-context" = "ignore", "--line-prefix" = "ignore", "--output-indicator-new" = "ignore", "--output-indicator-old" = "ignore", "--stat-count" = "ignore", "--stat-graph-width" = "ignore", "--stat-name-width" = "ignore", "--stat-width" = "ignore", "--submodule" = "ignore", "--word-diff" = "ignore", "--word-diff-regex" = "ignore" }
# `-w`/`--write` do NOT take a value — they rewrite every file OPERAND in place. Gating them as
# path-valued flags looked right on a one-operand probe (`shfmt -w ~/.ssh/config` denied, because
# the gate ate the path as `-w`'s value) and left the real shape wide open:
# `shfmt -w ./a.sh ~/.ssh/config` auto-approved, overwriting a credential file with formatted shell.
#
# `write_when`, not `positional = "write"`: without `-w` shfmt prints to stdout and `-d`/`-l` only
# report, so the operands are reads and gating them unconditionally would deny ordinary inspection.
[roles."shfmt"]
positional = "ignore"
write_when = ["-w", "--write"]
[roles."weasyprint"]
shape = "last_write"
[roles."tiffcp"]
shape = "last_write"
# ── Adversarial-review residual follow-ups: handler/sub output flags, output-dirs, and positional
# last-arg writers the flag-focused output-flag sweep did not reach. ────────────────────────────
[roles."gs"]
flags = { "-o" = "write" }
[roles."mkdocs"]
flags = { "-d" = "write", "--site-dir" = "write" }
[roles."pdfunite"]
shape = "last_write"
[roles."ps2pdf"]
shape = "last_write"
[roles."pdf2ps"]
shape = "last_write"
[roles."pdftops"]
shape = "last_write"
[roles."sphinx-build"]
shape = "last_write"
[roles."pdfcrop"]
shape = "last_write"
[roles."lame"]
shape = "last_write"
[roles."cargo"]
flags = { "--target-dir" = "write", "--out-dir" = "write" }
[roles."webpack"]
flags = { "-o" = "write", "--output-path" = "write" }
[roles."vite"]
flags = { "--outDir" = "write" }
[roles."esbuild"]
flags = { "--outdir" = "write", "--outfile" = "write" }
[roles."tsup"]
flags = { "--out-dir" = "write", "-d" = "write" }
[roles."cjxl"]
shape = "last_write"
# Positional last-arg CONVERTERS surfaced by `positional_last_arg_writers_are_gated_or_acknowledged`:
# each reads an input and writes its output to the LAST positional (`<in> <out>`), so a sensitive
# last positional (`~/.ssh/authorized_keys`, `.git/hooks/…`) must not be an auto-approved write.
# Ghostscript wrapper scripts (`in.[e]ps out.pdf`):
[roles."dvipdf"]
shape = "last_write"
[roles."eps2eps"]
shape = "last_write"
[roles."pdf2dsc"]
shape = "last_write"
[roles."pfbtopfa"]
shape = "last_write"
[roles."ps2epsi"]
shape = "last_write"
[roles."ps2pdf12"]
shape = "last_write"
[roles."ps2pdf13"]
shape = "last_write"
[roles."ps2pdf14"]
shape = "last_write"
[roles."ps2pdfwr"]
shape = "last_write"
[roles."ps2ps"]
shape = "last_write"
# libtiff converters (`in.tif out.tif` — tiffcrop's own docs call the last arg the destination):
[roles."pal2rgb"]
shape = "last_write"
[roles."ppm2tiff"]
shape = "last_write"
[roles."rgb2ycbcr"]
shape = "last_write"
[roles."thumbnail"]
shape = "last_write"
[roles."tiff2bw"]
shape = "last_write"
[roles."tiff2icns"]
shape = "last_write"
[roles."tiff2rgba"]
shape = "last_write"
[roles."tiffcrop"]
shape = "last_write"
[roles."tiffdither"]
shape = "last_write"
[roles."tiffmedian"]
shape = "last_write"
# (Little CMS jpgicc/tificc gain shape=last_write merged into their existing -o=read blocks below;
# psicc's output is its already-gated -o flag, so it is a non-writer acknowledged on the worklist.)
# Misc converters whose output is the last positional:
[roles."heif-thumbnailer"]
shape = "last_write"
[roles."wkhtmltopdf"]
shape = "last_write"
[roles."usdrecord"]
shape = "last_write"
[roles."gdbm_dump"]
shape = "last_write"
[roles."gdbm_load"]
shape = "last_write"
# Package builders whose output archive is the last positional (`pkgbuild [opts] out.pkg`):
[roles."pkgbuild"]
shape = "last_write"
[roles."productbuild"]
shape = "last_write"
# In-place binary/text mutators and in→out rewriters: the file they rewrite is the last positional
# (or their sole positional), so an out-of-workspace target must gate. `last_write` covers both the
# `in out` and single-file in-place forms (the sole positional is the last).
[roles."llvm-objcopy"]
shape = "last_write"
[roles."llvm-strip"]
shape = "last_write"
[roles."wasm-strip"]
shape = "last_write"
[roles."install_name_tool"]
shape = "last_write"
[roles."indent"]
shape = "last_write"
[roles."resolveLinks"]
shape = "last_write"
[roles."PlistBuddy"]
shape = "last_write"
[roles."initdb"]
shape = "last_write"
[roles."gatherheaderdoc"]
shape = "last_write"
# Multi-file in-place mutators: EVERY positional is rewritten in place, so `last_write` (which gates
# only the final arg) would miss the others — gate every positional as a write.
[roles."nbstripout"]
positional = "write"
[roles."afscexpand"]
positional = "write"
# In-place FORMATTERS, same shape: the `-i`/`-w`/`--in-place` flag carries no value, so the file
# being rewritten is a POSITIONAL — which is why the flag-name-based output guard never saw them.
# All four auto-approved a rewrite of a system or credential file (`clang-format -i /etc/hosts`,
# `gofmt -w /etc/hosts`, `yapf --in-place ~/.ssh/config`) before this.
[roles."clang-format"]
positional = "write"
[roles."gofmt"]
positional = "write"
[roles."gofumpt"]
positional = "write"
[roles."yapf"]
positional = "write"
# Same family, each confirmed against its OWN description rather than pattern-matched from the flag
# name: all seven say they rewrite the file in place.
[roles."autoflake"]
positional = "write"
[roles."autopep8"]
positional = "write"
[roles."cmake-format"]
positional = "write"
[roles."fourmolu"]
positional = "write"
[roles."ormolu"]
positional = "write"
[roles."goimports"]
positional = "write"
# ── Operation-aware gates (the archive/mode selector decides read vs write; see pathgate::handlers).
# r/q/d/m/s write the .a, t/p/x read it — matters at an in-workspace protected path (.git/config:
# readable, write-denied), where an all-write gate would over-deny the read ops.
[roles."ar"]
handler = "ar_archive"
[roles."emar"]
handler = "ar_archive"
[roles."llvm-ar"]
handler = "ar_archive"
# textutil -convert/-strip write (a sibling of each input, or -output/-outputdir); -info/-cat read.
[roles."textutil"]
handler = "textutil_mode"
# xattr -w/-d/-c mutate a file's extended attributes; a bare listing or -p reads them. Operation-aware
# because the read form is the common one and a blanket write gate would over-deny it.
[roles."xattr"]
handler = "xattr_mode"
# exiftool: a `-TAG=VALUE` assignment rewrites the file's metadata in place. WRITE-only — the
# read-gating deferral recorded above (disclosure inspectors over-deny home inspection) is untouched.
[roles."exiftool"]
handler = "exiftool_mode"
# rdfind: -makesymlinks/-makehardlinks/-deleteduplicates destroy duplicates in the scanned trees;
# a bare scan only reports (and writes results.txt into the cwd). -dryrun disarms them.
[roles."rdfind"]
handler = "rdfind_mode"
# mtree -u/-U modify the hierarchy to match the spec and -r REMOVES everything the spec omits, so
# `mtree -r -p ~/.ssh` was mass deletion of a credential directory. The tree is a `-p` FLAG value,
# never a positional, which is why the positional-shaped sweeps never saw it.
[roles."mtree"]
handler = "mtree_mode"
# ncu only rewrites the manifest with --upgrade/-u; without it the run reports.
[roles."ncu"]
handler = "ncu_mode"
# `dart format` rewrites its positionals IN PLACE by default — the dangerous form carries no
# distinguishing flag at all — and `-o write|show|json|none` selects the mode by VALUE, which
# write_when cannot express. Scoped to the `format` sub inside the handler.
[roles."dart"]
handler = "dart_mode"
# ── Autofix LINTERS: inspect their operands by default, REWRITE them under a fix flag. Declared
# with `write_when` rather than eight more Rust handlers — this is the shape that mechanism
# exists for. Reads stay ungated, so linting a file outside the workspace still works; only the
# fixing form is locus-checked.
[roles."ansible-lint"]
positional = "ignore"
write_when = ["--fix", "--write", "-w"]
[roles."erb_lint"]
positional = "ignore"
write_when = ["--autocorrect", "-a"]
[roles."haml-lint"]
positional = "ignore"
write_when = ["--auto-correct", "--auto-correct-all", "-a", "-A"]
[roles."markdownlint-cli2"]
positional = "ignore"
write_when = ["--fix"]
[roles."tflint"]
positional = "ignore"
write_when = ["--fix"]
[roles."clang-tidy"]
positional = "ignore"
write_when = ["--fix", "--fix-errors", "--fix-notes"]
# `--export-fixes FILE` writes a YAML fixes file — a flag VALUE, so `write_when` (which promotes
# positionals) does not cover it and it needs its own role.
flags = { "--export-fixes" = "write" }
[roles."jsonlint"]
positional = "ignore"
write_when = ["--in-place", "-i"]
# ── Derived-output writers: the output is a SIBLING of the input (same directory), so write-gating
# the input path is locus-equivalent to gating the sibling. No read mode, so a plain write gate.
[roles."cap_mkdb"]
positional = "write"
[roles."znew"]
positional = "write"
[roles."pl2pm"]
positional = "write"
# ── Scaffolders: a scaffolder CREATEs inert code into a named target directory. That write is local
# SafeWrite, but the target must stay in the workspace — a scaffold into ~/.ssh or an arbitrary
# out-of-workspace dir is out of scope. Gate the target dir; the inert-code create stays SafeWrite.
[roles."create-next-app"]
positional = "write"
[roles."create-react-app"]
positional = "write"
[roles."create-vite"]
positional = "write"
[roles."degit"]
shape = "last_write"
[roles."djxl"]
shape = "last_write"
# `log show` reads a .logarchive — as a positional (`log show ./x.logarchive`), or named by
# `--directory`; `log stats` names one with `--archive`.
#
# `read`, NOT `read_tree`, even though the value is a directory. read_tree is for a root the
# command descends and renders whatever it finds (`rg foo ~`), and log does not: pointed at an
# ordinary directory it exits with "did not refer to a valid log archive" and emits nothing.
# Measured — a canary file in the target directory does not appear in the output. Gating it as a
# sweep would have denied `log show --directory ~/Desktop/sys.logarchive`, an ordinary read, while
# buying no disclosure protection. `read` still puts the named path under the credential shield.
#
# Only `show` needs a positional gate, which is why its non-path valued flags are spelled out as
# `ignore` — under a positional gate the walk treats an undeclared valued flag's value as a path,
# and `--predicate` carries a filter expression, not a filename.
[roles."log show"]
positional = "read"
flags = { "--directory" = "read", "--color" = "ignore", "--end" = "ignore", "--last" = "ignore", "--predicate" = "ignore", "--process" = "ignore", "--start" = "ignore", "--style" = "ignore", "--timezone" = "ignore", "--type" = "ignore" }
[roles."log stats"]
flags = { "--archive" = "read" }
# `tsc @FILE` splices FILE's contents in as arguments and reports each unresolvable token back, so
# the file is disclosed a word at a time. The `@` prefix means the declarative gate judges `@/path`
# instead of `/path`, which slips every shield anchored to a LOCATION rather than a name. The
# handler gates the stripped form; tsc's flag and positional roles stay declarative in its own TOML.
[roles.tsc]
handler = "tsc_response_file"
# `ghostty +validate-config --config-file=X` parses X and reports its errors. It reads exactly the
# file it is named, so `read` — and reading it is the whole operation, which is why it is gated at
# all rather than ignored.
[roles."ghostty +validate-config"]
flags = { "--config-file" = "read" }