Releases: LoopHubs/agent-guard
Release list
v0.4.0
Newly blocked
The guard now decides from the paths a command touches and what it does with each one (read, write, list, use, enter, name), inferred under the command semantics that the program table in src/programs.ts models. Before, it decided from lists of command shapes. The changes users can see are below.
- Programs the table does not model read every path they are handed.
aws s3 cp .env s3://bucket/x,python3 script.py .env,cmp -l x .env,open .env, andfind . -name .env -exec python3 -c x {} \;are denied. A file that a client consumes itself is allowed through the client's own option:--env-file,--kubeconfig,ssh -i/-F,scp -i/-F,sftp -i/-F,ssh-add,ssh-keygen -f,dotenvx -f,npm --userconfig,curl --cacert/--cert/--key. The guard does not control what such a client does with the contents. - Inline interpreter code. Each token of
python3 -c,node -e,ruby -e,perl -e,deno eval, or a here-document on an interpreter's standard input that resolves to a credential file or a directory holding one is a read:python3 -c "print(open('.env').read())"andpython3 -c 'x = ".env"'are denied with a new reason that tells the agent to write the file with the Write or Edit tool or hand it to the runtime's own option. The old scan for a reader program named in the code is gone, sonode -e "... cat ~/.ssh/config"is allowed. - Directories as targets.
find -name xandfind -type fwith no path scan the working directory, so they are denied at~;cat < ~/.aws,curl -T ~/.ssh,git log -p ~/.ssh, anddd if=x of=~/.sshare denied because the directory itself is the target;fd x ~/.aws -x catis denied without-H. Inside an App Data container, a program that names no path (pwd,make,python3 -c) or that the table does not model is denied, whilels /tmpstays allowed. dd if=andof=. Both values are resolved, sodd if=~/Library/Containers/xand anof=naming a private key under~/.sshare denied.- Option values that name a file the client reads, sends, or writes.
- curl:
-w @FILE,--proxy-header @FILE,--etag-compare,-b/--cookiewith a file,--pubkey,--pinnedpubkey,--proxy-pinnedpubkey,--unix-socket, and the output files of-c/--cookie-jar,--etag-save,--libcurl,--stderr,--hsts,--alt-svc,--trace,--trace-ascii, and--ssl-sessions(-is standard output). - wget:
--ca-certificate,--ca-directory,--certificate,--private-key,--crl-file,--random-file. - docker:
--file,--label-file,--cidfile,--iidfile,--tlscacert,--tlscert,--tlskey,--config,--env-file, and-fofbuildandcompose, in the spellings-f=PATHand-qf PATH;cpoperands,load -i,--secret src=,-v, and--mount. - ssh, scp, and sftp:
-iand-F(and-Efor ssh), sftp's-bbatch file, and theIdentityFile,CertificateFile,GlobalKnownHostsFile,UserKnownHostsFile,RevokedHostKeys, andPKCS11Providersettings of-o, in the spellings-oKEY=VALUE,-o "KEY VALUE", quoted values, several files,%d, and${HOME}. The cluster letters come from each client's synopsis in OpenSSH 10.3p1; the ssh command after the destination is not scanned. - A value glued to a short option of a program the table models is a path:
ssh -idata,ssh-keygen -fdata,curl -Edata,dotenvx -fdata. @pathand httpiefield=@pathread the file;-t DIRmakes every operand a source; git's--pathspec-from-file,-Foftagandmerge, a glued--work-tree=, and operands after the last-C;git bundle createwrites its file; tar's implicit extraction target and-O;curl -O,--output-dir, wget's download into the working directory, and wgetrc commands through-e.- A client that consumes the file itself (
ssh -i,docker run --env-file,node --env-file) is allowed unless the file is App Data.
- curl:
- Paths spelled through a firmlink or a link.
/System/Volumes/Datain any case, including..from a directory that is also reached from the root, is judged as the plain path, which removes that entry from the known limits. A link whose text leads into App Data or~/.sshis judged by where it leads, and a home directory that is itself spelled through a link (/tmp/...) is compared by the spelling the link walk reports. - The guard's own probes stay out of App Data. A glob operand behind a link into App Data, such as
cache/*.txt, used to reachstat, so the kernel searched the tree the guard exists to protect. Link traversal now usesreadlinkalone and follows only the part of a glob before its first wildcard, and the~/.sshinode comparison runs only for a target already in~/.sshscope. Areadlinkorstatfailure other than "not a link" or "does not exist" is a denial.
Newly allowed
- Writing a credential file through a command's destination.
cp dotfiles/config.json ~/.docker/config.json,cp .env.example .env,tee .env < x,tar -cf .env.tar src,curl -o .env URL, andwget -O .env URLare allowed. A private key under~/.sshis still denied (cp x ~/.ssh/id_rsa,curl -o ~/.ssh/id_rsa URL,install -m 600 x ~/.ssh/id_rsa). - Names are not paths.
--exclude,--exclude-dir,--include, curl and wget operands, ssh operands, git refs, remotes, and names (git branch .env), the first operand ofyq, and ajqfilter are not judged as files.wget URL/.envis allowed. - Words after a container or a remote host.
docker run img cat --file X,docker exec web cat -v X, andssh host grep -E error app.logbelong to the command run inside, not to docker or ssh.docker compose logs -f SERVICEfollows the log, andcurl --stderr -,curl -w @-, andcurl -b name=valuename no file. - Reasons that change. A copy sends what it reads only when an operand names another machine (
host:,user@host:,rsync://);rsync -a ~/.aws/ backup/reportsfileinstead ofupload, whilescpandrsyncto a host keepupload.wget --post-fileand--body-filereportupload.jq . .envandtar -czf x.tgz -C ~/.aws .reportfile.curl -d @~/xkeeps the~literal, while@$HOME/xis denied.
Not covered
The limits are listed in the "Safety model and limits" section of docs/setup.md, grouped as observation coverage, execution semantics, and state and resource identity. New in this release: docker's build context and its --build-context, --cache-from, --cache-to, --output, --ssh, --metadata-file, and --security-opt values, the words docker compose run and compose exec pass to the container command, the other file settings of ssh -o, git's clone --reference, --template, --separate-git-dir, and worktree add, and a - that a client reads as standard input in a sensitive working directory. The guard is a bounded preflight check, not a sandbox, and exit code 0 means only that it found no objection to the targets it inferred.
Release pipeline
The publish job skips a version the registry already holds and reuses an existing GitHub Release, so re-running the job reaches the tap dispatch instead of failing before it; the tap is dispatched when a version is published, and its token is minted from the app's client id.
v0.3.0
Newly blocked
Every behavior fixture that 0.2.0 already contained still passes except one: printf '%s\n' ~/.npmrc | xargs cat was allowed and is now denied. Everything else below is a new fixture row. The rest of the range since 0.2.0 (exact dependency pinning, Renovate's commit prefix, the release skill) has no runtime effect.
- Shell functions and pipelines move the directory. A function call runs its body in the caller's shell with the caller's current directory, so
f() { cd ~/Library; }; f; cat Containers/…andf() { find .; }; cd ~; fare denied. The last element of a pipeline may run in the current shell (zsh does this), sotrue | cd ~/Library; cat Containers/…is denied. A chain of function calls that multiplies past 256 body runs is denied with the syntax reason instead of being inspected. - The current directory spelled out.
$PWD,${PWD},$(pwd),`pwd`and~+expand to the directory the command was read in, including the old directory when acdbefore it may have failed.cd -P,cd -L,cd -q, andcd -swith no directory go home, socd -P && find .is a scan of the home directory. - Wildcards in redirect targets.
cat < ~/Library/Cont*/…andwc -c < .en*are denied because the shell expands the target before the program reads it. - Brace sequences.
cat ~/Library/{C..C}ontainers/…andcat ~/.e{n..n}vare denied. The guard reads{a..z}and{1..3}as a wildcard, because Bun's brace expansion handles only{a,b}. - Option values that name a file.
node --env-file=$HOME/Library/Containers/…andgit --work-tree=$HOME/Library/Containers statusare denied because a value glued to its option with=is a path.rg --ignore-file PATHandgrep --exclude-from PATHare denied whenPATHis under App Data, because the program opens that file. - Git reads of credential files.
git show HEAD:.env,git show :.env,git cat-file -p HEAD:.env,git diff -- .env,git diff --no-index /dev/null .env,git log -p -- .env,git grep TOKEN .env,git grep -f .env, andgit grep … -- '*.pem'are denied. The guard checks the operands ofshow,diff,log,cat-file,blame,annotate,grep,archive,format-patch,whatchanged,difftool,diff-index, anddiff-tree, and the path after arev:prefix; forgit grepthe pattern is not an operand.git credential fillis denied.git add .env,git grep .env, andgit show HEAD:.env.examplestay allowed. egrepandfgrep. They follow thegreprules, including recursive searches.find -execandfd -xwith a reader.find … -exec cat {} +and the-execdir,-ok, and-okdirforms are denied whenever the program is a file reader, a search tool, a shell, or a wrapper, whatever-nameor-typefilters come first and however many-execclauses there are, becausefindreaches hidden files and a filter such as-name '*.json'still reaches~/.docker/config.json.find … | xargs cat,fd -H … | xargs cat,ls -a | xargs cat,ls .env | xargs cat, andecho .* | xargs catare denied for the same reason: the names come from a walk that reaches dotfiles, anlsthat lists them, or a dotfile glob. Names another command prints intoxargs, as inls *.pem | xargs catorfd -e pem | xargs cat, are not checked.find src -name '*.ts' -exec grep -l TODO {} \;is therefore denied too; userg -l TODO src -g '*.ts'. An interpreter such aspython3is not on that list.fd -xand-Xare denied when-H,--hidden, or-uis present; without themfdskips hidden files and stays allowed.- More wrappers and readers.
sudo,doas, andarchare wrappers, sosudo cat .envandsudo bash -c "cat .env"are inspected.scriptis a wrapper.tac,column,pr,vim,vi,nvim,view,ed,ex,hg,svn,perl,ruby,dd if=,zip, and the shellssh,bash,zsh,dash, andkshare readers, becausesh -von a credential file prints it.tcsh -candcsh -care parsed likebash -c, and a literalechoorprintfpiped into a shell is parsed as the command line it prints, soecho 'cmd' | sh,printf 'cat %s\n' FILE | sh, andprintf '%s %s' cat FILE | share inspected; escapes such as\n,\xHH,\uHHHH, and octal\NNNare decoded and\cends the text, and aprintfconversion other than%s,%b, and%%is not expanded. Csh syntax is parsed as bash, so a validtcsh -c 'if ( -f x ) echo y'is denied.scpandrsyncare denied with the upload reason when they send a credential file.xargs -a FILEis checked as a read ofFILE, and a literalechoorprintfvalue piped intoxargs, or a here-string or here-document given to it, is checked as the argument it becomes, with or without-I.wget,php,zgrep,zless, andzmoreare readers too, and the operands and--flag=PATHvalues ofghare checked, sogh gist create .envandwget --post-file=.env URLare denied.|&is treated like|forxargsand shell input.tar --exclude=.envis allowed: an exclude pattern is not a file the command reads. - Commands that print a secret.
security dump-keychain,security export, and combined flags such assecurity find-generic-password -ws NAMEare denied with the keychain reason.gcloud auth print-access-tokenandprint-identity-token,az account get-access-token,aws configure getof a name that holdsSECRET,TOKEN,KEY,PASSWORD, orCREDENTIAL,npm config getof an auth, token, or password key,kubectl config view --raw, andgpg --export-secret-keysare denied with a new reason that tells the agent to use the credential without printing it. The list is not exhaustive: a command that prints a secret through another subcommand is allowed. - More credential files.
~/.netrc,~/.git-credentials,~/.docker/config.json,~/.kube/config,~/.pypirc,~/.pgpass,~/.cargo/credentials*, and~/.config/gh/hosts.ymlare listed. A search rooted at a directory that holds such a file, or at~/.configabove~/.config/gh, is denied, and so is a search with no path operand run from such a directory (cd ~/.docker && rg auths).tar,zip,scp,rsync, andgit grephanded such a directory are denied as well (tar -cf - ~/.aws,scp -r ~/.aws host:/tmp), but the last operand ofcp,rsync, andscpand the directory aftertar -Care written to, not read, sorsync -a ~/dotfiles/.config/ ~/.config/andtar -xzf a.tgz -C ~/.configstay allowed. Inside a named directory, a glob that could match a listed name counts, socat ~/.cargo/*.tomlandcat ~/.cargo/credential[s].tomlare denied. Otherwise a glob matches such a name only when its directory segment matches too, socat conf*andcat *.jsonin a project stay allowed, while a bare wildcard in the directory that holds a listed file, as incat ~/.docker/*orcat ~/.config/gh/*, is denied. This also narrows the older.aws/credentials*entry:cat cred*outside~/.awsis no longer denied. - curl file operands through a link.
curl -d @link,--data-binary=@link,-F f=@link,-Tlink,--upload-file=link, and--config=linkresolve the link before the upload check.
Not covered: process substitution as input, such as xargs cat < <(echo …), a wrapper the guard does not list, such as xcrun sh, and a read whose target the program picks while it runs, such as an interpreter opening a file itself, git diff without a path operand, fd -x over credential names outside hidden files, and a path built by command substitution, held in a variable, or reached through a file moved earlier. Only the operating system's read restrictions on the credential stores cover those. A command that prints a secret through a subcommand not listed above is allowed, because it has no path for the guard to check. A program that is not a file reader is allowed even when it is handed a credential path, as in aws s3 cp .env s3://bucket/x and rclone copy .env remote:.
v0.2.0
Newly blocked
Compared with 0.1.0, every change in the behavior fixtures (49 of 710 rows) is a new denial; no fixture that 0.1.0 denied is allowed now.
- Credential files by any spelling. Credential names match regardless of case, so
.ENV,~/.AWS/credentials, and a Grep glob such as*.ENVare denied on a case-insensitive APFS volume..env.testand.env.prodare denied;.env.exampleand.env.agestay allowed. - Directories that hold credentials. A search rooted at
~/.awsor~/.gnupgis denied, as iscat ~/.aws/*. A search with no path operand (rg,ag,ack,grep -r) run from inside~/.sshis denied, and so arels ~/.sshandfind ~/.ssh, which list private key names. Reading a private file under~/.sshthrough input redirection, including a case alias such as~/.SSH, is denied; public keys,config,allowed_signers, andknown_hostsstay readable. - Wildcards in directory names.
~/Lib*/Cont*/…,~/Library/*/…,~/Library/[C]ontainers/…, and~/.s*/id_ed25519are denied because the shell can expand them into App Data or private SSH material.~/.s*/configand~/Library/Preferences/*.pliststay allowed. file://URLs.curlreadsfile://from disk, so the guard checks it as the path it names:curl file:///…/Library/Containers/…andcurl file:///…/.envare denied.- Hidden-file searches. Recursive
grep(-r,-d recurse,--directories=recurse),rg --hidden,rg -uu,rg -., andag --hiddenorag -uare denied because they read dotfiles such as.env. A later-uno longer overridesrg --no-hidden. - Home-directory scans without a
~/.ignore. 0.1.0 deniedrgandfdrun from the home directory only when a flag such as--no-ignorewas present, and otherwise relied on the user having a~/.ignorethat excludes~/Library. Nowrg,fd,ag,ack, andtreerun from the home directory are denied on their own. - Working-directory tracking and case.
cd --; rg …andrg …run from<symlink to an App Data tree>/..are denied because both resolve to a broad scan. Lower-caselibrary/containersin an unresolved expansion or in interpreter code is denied.
Fail-closed changes
- A known tool whose input field is missing or not a string (
command,file_path, or a non-string Greppath) is denied instead of passing. Tools the guard does not know still pass. - An empty or relative
HOMEis denied before the guard starts, and aHOMEwith a trailing slash no longer disables App Data matching. - A supervisor now owns the guard's process group and kills it, with its child processes, at the deadline. The outer watchdog fires 3 s after the supervisor reports the group, so the guard stays under Pi's 4.5 s hook timeout.
- The package ships its
tsconfig.jsonand refuses to start without it, so an ancestortsconfig.jsoncannot redirect the shell parser.
Known limits are listed in AGENTS.md in the repository.