Skip to content

Releases: LoopHubs/agent-guard

v0.4.0

Choose a tag to compare

@github-actions github-actions released this 29 Sep 20:38
v0.4.0
26c85c5

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, and find . -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())" and python3 -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, so node -e "... cat ~/.ssh/config" is allowed.
  • Directories as targets. find -name x and find -type f with no path scan the working directory, so they are denied at ~; cat < ~/.aws, curl -T ~/.ssh, git log -p ~/.ssh, and dd if=x of=~/.ssh are denied because the directory itself is the target; fd x ~/.aws -x cat is 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, while ls /tmp stays allowed.
  • dd if= and of=. Both values are resolved, so dd if=~/Library/Containers/x and an of= naming a private key under ~/.ssh are denied.
  • Option values that name a file the client reads, sends, or writes.
    • curl: -w @FILE, --proxy-header @FILE, --etag-compare, -b/--cookie with 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 -f of build and compose, in the spellings -f=PATH and -qf PATH; cp operands, load -i, --secret src=, -v, and --mount.
    • ssh, scp, and sftp: -i and -F (and -E for ssh), sftp's -b batch file, and the IdentityFile, CertificateFile, GlobalKnownHostsFile, UserKnownHostsFile, RevokedHostKeys, and PKCS11Provider settings 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.
    • @path and httpie field=@path read the file; -t DIR makes every operand a source; git's --pathspec-from-file, -F of tag and merge, a glued --work-tree=, and operands after the last -C; git bundle create writes 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.
  • Paths spelled through a firmlink or a link. /System/Volumes/Data in 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 ~/.ssh is 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 reach stat, so the kernel searched the tree the guard exists to protect. Link traversal now uses readlink alone and follows only the part of a glob before its first wildcard, and the ~/.ssh inode comparison runs only for a target already in ~/.ssh scope. A readlink or stat failure 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, and wget -O .env URL are allowed. A private key under ~/.ssh is 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 of yq, and a jq filter are not judged as files. wget URL/.env is allowed.
  • Words after a container or a remote host. docker run img cat --file X, docker exec web cat -v X, and ssh host grep -E error app.log belong to the command run inside, not to docker or ssh. docker compose logs -f SERVICE follows the log, and curl --stderr -, curl -w @-, and curl -b name=value name 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/ reports file instead of upload, while scp and rsync to a host keep upload. wget --post-file and --body-file report upload. jq . .env and tar -czf x.tgz -C ~/.aws . report file. curl -d @~/x keeps the ~ literal, while @$HOME/x is 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

Choose a tag to compare

@github-actions github-actions released this 29 Sep 00:09
v0.3.0
62ba787

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/… and f() { find .; }; cd ~; f are denied. The last element of a pipeline may run in the current shell (zsh does this), so true | 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 a cd before it may have failed. cd -P, cd -L, cd -q, and cd -s with no directory go home, so cd -P && find . is a scan of the home directory.
  • Wildcards in redirect targets. cat < ~/Library/Cont*/… and wc -c < .en* are denied because the shell expands the target before the program reads it.
  • Brace sequences. cat ~/Library/{C..C}ontainers/… and cat ~/.e{n..n}v are 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/… and git --work-tree=$HOME/Library/Containers status are denied because a value glued to its option with = is a path. rg --ignore-file PATH and grep --exclude-from PATH are denied when PATH is 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, and git grep … -- '*.pem' are denied. The guard checks the operands of show, diff, log, cat-file, blame, annotate, grep, archive, format-patch, whatchanged, difftool, diff-index, and diff-tree, and the path after a rev: prefix; for git grep the pattern is not an operand. git credential fill is denied. git add .env, git grep .env, and git show HEAD:.env.example stay allowed.
  • egrep and fgrep. They follow the grep rules, including recursive searches.
  • find -exec and fd -x with a reader. find … -exec cat {} + and the -execdir, -ok, and -okdir forms are denied whenever the program is a file reader, a search tool, a shell, or a wrapper, whatever -name or -type filters come first and however many -exec clauses there are, because find reaches 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, and echo .* | xargs cat are denied for the same reason: the names come from a walk that reaches dotfiles, an ls that lists them, or a dotfile glob. Names another command prints into xargs, as in ls *.pem | xargs cat or fd -e pem | xargs cat, are not checked. find src -name '*.ts' -exec grep -l TODO {} \; is therefore denied too; use rg -l TODO src -g '*.ts'. An interpreter such as python3 is not on that list. fd -x and -X are denied when -H, --hidden, or -u is present; without them fd skips hidden files and stays allowed.
  • More wrappers and readers. sudo, doas, and arch are wrappers, so sudo cat .env and sudo bash -c "cat .env" are inspected. script is a wrapper. tac, column, pr, vim, vi, nvim, view, ed, ex, hg, svn, perl, ruby, dd if=, zip, and the shells sh, bash, zsh, dash, and ksh are readers, because sh -v on a credential file prints it. tcsh -c and csh -c are parsed like bash -c, and a literal echo or printf piped into a shell is parsed as the command line it prints, so echo 'cmd' | sh, printf 'cat %s\n' FILE | sh, and printf '%s %s' cat FILE | sh are inspected; escapes such as \n, \xHH, \uHHHH, and octal \NNN are decoded and \c ends the text, and a printf conversion other than %s, %b, and %% is not expanded. Csh syntax is parsed as bash, so a valid tcsh -c 'if ( -f x ) echo y' is denied. scp and rsync are denied with the upload reason when they send a credential file. xargs -a FILE is checked as a read of FILE, and a literal echo or printf value piped into xargs, 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, and zmore are readers too, and the operands and --flag=PATH values of gh are checked, so gh gist create .env and wget --post-file=.env URL are denied. |& is treated like | for xargs and shell input. tar --exclude=.env is 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 as security find-generic-password -ws NAME are denied with the keychain reason. gcloud auth print-access-token and print-identity-token, az account get-access-token, aws configure get of a name that holds SECRET, TOKEN, KEY, PASSWORD, or CREDENTIAL, npm config get of an auth, token, or password key, kubectl config view --raw, and gpg --export-secret-keys are 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.yml are listed. A search rooted at a directory that holds such a file, or at ~/.config above ~/.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, and git grep handed such a directory are denied as well (tar -cf - ~/.aws, scp -r ~/.aws host:/tmp), but the last operand of cp, rsync, and scp and the directory after tar -C are written to, not read, so rsync -a ~/dotfiles/.config/ ~/.config/ and tar -xzf a.tgz -C ~/.config stay allowed. Inside a named directory, a glob that could match a listed name counts, so cat ~/.cargo/*.toml and cat ~/.cargo/credential[s].toml are denied. Otherwise a glob matches such a name only when its directory segment matches too, so cat conf* and cat *.json in a project stay allowed, while a bare wildcard in the directory that holds a listed file, as in cat ~/.docker/* or cat ~/.config/gh/*, is denied. This also narrows the older .aws/credentials* entry: cat cred* outside ~/.aws is no longer denied.
  • curl file operands through a link. curl -d @link, --data-binary=@link, -F f=@link, -Tlink, --upload-file=link, and --config=link resolve 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

Choose a tag to compare

@github-actions github-actions released this 28 Sep 20:13
v0.2.0
2fd7d95

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 *.ENV are denied on a case-insensitive APFS volume. .env.test and .env.prod are denied; .env.example and .env.age stay allowed.
  • Directories that hold credentials. A search rooted at ~/.aws or ~/.gnupg is denied, as is cat ~/.aws/*. A search with no path operand (rg, ag, ack, grep -r) run from inside ~/.ssh is denied, and so are ls ~/.ssh and find ~/.ssh, which list private key names. Reading a private file under ~/.ssh through input redirection, including a case alias such as ~/.SSH, is denied; public keys, config, allowed_signers, and known_hosts stay readable.
  • Wildcards in directory names. ~/Lib*/Cont*/…, ~/Library/*/…, ~/Library/[C]ontainers/…, and ~/.s*/id_ed25519 are denied because the shell can expand them into App Data or private SSH material. ~/.s*/config and ~/Library/Preferences/*.plist stay allowed.
  • file:// URLs. curl reads file:// from disk, so the guard checks it as the path it names: curl file:///…/Library/Containers/… and curl file:///…/.env are denied.
  • Hidden-file searches. Recursive grep (-r, -d recurse, --directories=recurse), rg --hidden, rg -uu, rg -., and ag --hidden or ag -u are denied because they read dotfiles such as .env. A later -u no longer overrides rg --no-hidden.
  • Home-directory scans without a ~/.ignore. 0.1.0 denied rg and fd run from the home directory only when a flag such as --no-ignore was present, and otherwise relied on the user having a ~/.ignore that excludes ~/Library. Now rg, fd, ag, ack, and tree run from the home directory are denied on their own.
  • Working-directory tracking and case. cd --; rg … and rg … run from <symlink to an App Data tree>/.. are denied because both resolve to a broad scan. Lower-case library/containers in 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 Grep path) is denied instead of passing. Tools the guard does not know still pass.
  • An empty or relative HOME is denied before the guard starts, and a HOME with 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.json and refuses to start without it, so an ancestor tsconfig.json cannot redirect the shell parser.

Known limits are listed in AGENTS.md in the repository.