Fix cmd.exec allowlist bypass; harden exec and logs; remove exporter mode - #5
Merged
Merged
Conversation
The script gate reduced a cmd.exec request to filepath.Base, confirmed a script of that name existed in scripts_directory, and then ran the unreduced request through `bash -c` (or `powershell -Command`). So `$(anything)/deploy.sh` passed the check and ran `anything`: arbitrary execution for any caller able to publish to cmd.exec, whatever the allowlist said. Both platforms carried the same copy of the gate, and the same hole. Script requests must now be a bare filename. The agent builds the path itself and starts that file directly -- by shebang on unix, with `powershell -File` on Windows -- so no shell parses the request. Allowlisted commands run the operator's allowlist entry rather than the request, which only had to match after whitespace normalization; a newline could split one allowed line into two commands. The gate now lives once, in exec.go. The platform files say only how to start a process. Behaviour changes: a full-path script request is refused where it used to work, and a Windows script's own `exit N` is now the reported exit code instead of collapsing to 0 or 1. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
A command that ran and exited non-zero replied with only "command exited with code 1": the executor returned the output, and the handler dropped it on the error path. The stderr that said why never left the box. One pure function, execResponse, now builds the reply for every outcome. A non-zero exit keeps status "error", so callers checking status are unaffected, and adds output and exit_code. exit_code is a pointer, present exactly when the command ran; omitempty used to drop it on success. A timeout now returns what the command printed before it was killed. The timeout also did not bound anything. Killing a shell left its child holding the output pipe, and Run waited for the pipe: a 1s timeout over `sleep 5` took 5s. A script that exited while leaving a daemon behind held the reply for as long as the daemon lived. cmd.WaitDelay caps output collection at one second after the process exits or is killed. ErrWaitDelay after a clean exit is reported as success, since the command itself succeeded. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
isPathAllowed put a substring denylist in front of the allowlist
("sam", "system32", ".exe", ".dll", ".sys", "..") and a prefix check
behind it. Neither refused anything the allowlist admitted: the cleaned
absolute request must exactly equal a filepath.Glob match of an allowed
pattern, which already rules out traversal. What they did refuse was
/var/log/samba/*, any path under a home directory containing "sam", and
-- because the prefix check split the pattern only on "*" -- every
pattern using "?" or "[...]". Both are gone.
The Windows copy of the path tests named files that did not exist, and
glob reads the disk, so its positive cases could not have passed and
its "blocked" cases passed for the wrong reason. The unix test already
used real files in a temp dir with filepath.Join, so it now runs on
every platform, and the Windows file keeps only the Windows path forms.
Docs: windows.md promised a recursive "**" that Go's glob does not have,
and suggested allowlisting a binary .evtx file, which the denylist
refused anyway.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
`tasks.system_metrics.source: exporter` read CPU, memory and disk by scraping node_exporter or windows_exporter instead of using gopsutil. Nothing used it, and it was a second implementation of the same figures: a Prometheus text parser, per-platform metric-name tables and its own rate cache, about 500 lines plus tests. Anyone who wants node_exporter's series can run it and have Prometheus scrape it directly. With one collector left, the MetricsCollector interface and the factory that chose between the two go as well, along with the HTTP client only the exporter used, the ignored ScrapeMetrics(exporterURL) parameter, the unused collector argument to validateMetrics, and the Name and ResetCache methods that existed only for the interface. NewExecutor can no longer fail, so it no longer returns an error. The `source` and `exporter_url` keys are deleted rather than rejected. Viper ignores keys it does not know, so an old config loads and gets the builtin collector, in the same payload shape. A test pins that down. `go mod tidy` demotes prometheus/client_model to indirect, and promotes nats-io/jwt and nkeys to direct, which the code already imports. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
`// ADDED:` and `// MODIFIED: Now accepts context for cancellation` record what a past diff did, which is git's job, and read as noise once the change is the only code there is. Markers that only narrated are deleted; the three that also explained the code keep the explanation. Comments only; gofmt realigned the neighbours. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
On Windows, ControlService connected to the Service Control Manager before looking at the action; "invalid action" was the switch's default branch, reached only after the connection. The SCM refuses a non-admin connection, so from an ordinary shell a bogus action came back as "Access is denied" -- the answer depended on who ran the agent rather than on what was asked. TestControlService caught it on a Windows box. Linux and FreeBSD already validated first. The allowlist and action checks now live once, in checkServiceRequest, called first on every platform. The three identical copies of isServiceAllowed go with them. The stub had none, which is why the package's tests did not compile on darwin; the stub now runs the same check before answering "not supported". Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
The `!windows && !linux && !freebsd` build had no GetOSInfo, so the agent did not build on a Mac at all. The stub reports "not supported" and the health command's existing fallback answers with runtime.GOOS. The stubs compile on no platform CI vetted, which is how they drifted. CI now runs `GOOS=darwin go vet ./...` -- vet only; darwin is still not a release target. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
runShell used /bin/bash on both unix platforms. FreeBSD's base system has no bash -- the port installs it under /usr/local -- so on a stock install every allowlisted command failed with "no such file". None of the documented FreeBSD examples need bash, and nothing told anyone to install it. FreeBSD now uses its own /bin/sh; Linux is unchanged. TestUnixShellExists checks that the shell exists on whatever platform runs the tests. It is the check that would have caught this. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
The timeout killed only the process runProcess started -- the shell or powershell.exe -- so a child it had started kept running after the reply had gone out. WaitDelay stopped the reply waiting for it, but did not stop it. On Linux and FreeBSD the command now gets its own process group and cmd.Cancel kills the group. On Windows cmd.Cancel runs `taskkill /T /F`, which walks the tree by parent process. Killing the group also closes the output pipe at once, so a timeout no longer waits out WaitDelay: the unix timeout test now returns in 0.5s instead of 1.5s. Only cancellation (timeout or agent shutdown) does this. A command that exits normally and leaves a daemon behind is left alone, and the unix test now checks that as well as the kill. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Ten commits, each passing on its own. They're easiest to review one at a time.
1.
fix(exec): the caller's string never reaches a shell. Security.The script check reduced the request to its last path element, confirmed a script with that name existed, and then passed the whole request to
bash -c(orpowershell -Command). So$(anything)/deploy.shpassed the check and rananything. Both platforms had the same bug, because each carried its own copy of the check.powershell -Fileon Windows, so no shell ever parses the request.internal/tasks/exec.go. The platform files only say how to start a process.2.
fix(exec): a failed command returns its output, and the timeout is a real limit"command exited with code 1", so the stderr explaining the failure never left the box. The reply keepsstatus: "error"and now includesoutputandexit_code.exit_codeis present exactly when the command ran, 0 included.sleep, say) kept the reply waiting until the child finished: a 1s timeout oversleep 5took 5s. A script that exited normally while leaving a background process behind held the reply for as long as that process lived.cmd.WaitDelaynow caps the wait at one second after the command exits or is killed.3.
fix(logs): the allowlist alone decides whatcmd.logscan readsam,system32,.exe,.., …). It refused nothing the allowlist admitted, but it did block/var/log/samba/*.*, so every pattern using?or[...]matched nothing.4.
refactor(metrics): remove exporter modesource: exporter(scraping node_exporter or windows_exporter instead of using gopsutil) was unused. Removed with it:ScrapeMetrics(exporterURL)parameterOld configs still load, because the keys are ignored, and they get the same payload. A test covers this.
5.
chore: strip the// ADDED:/// MODIFIED:commentsComments only. No behaviour change.
6.
fix(service): check the action before touching the service managerFound by the Windows test run. Windows connected to the Service Control Manager before checking the action, and the SCM refuses non-admin connections, so from an ordinary shell a bogus action returned "Access is denied" instead of "invalid action". Linux and FreeBSD already checked first. The allowlist and action checks now live once, in
checkServiceRequest, and run first on every platform. The three copies ofisServiceAllowedare merged into that one place, which also fixes the darwin test build ofinternal/tasks.7.
fix(build): the stub platforms compile again, and CI keeps them honestGetOSInfohad no stub, so the agent didn't build on a Mac at all. The stub now reports "not supported", and the health command's existing fallback answers withruntime.GOOS. CI now runsGOOS=darwin go vet ./..., because the stub files compile on no platform it was already vetting, which is how they drifted. darwin is still not a release target.8.
fix(exec): allowlisted commands run through/bin/shon FreeBSDFreeBSD's base system has no bash, so
/bin/bash -cfailed with "no such file" and every allowlisted command failed on a stock install. None of the documented FreeBSD examples need bash. FreeBSD now uses its own/bin/sh, and Linux is unchanged.TestUnixShellExistschecks that the shell exists on whatever platform runs the tests.9.
fix(exec): a timeout kills everything the command startedThe timeout killed only the shell or
powershell.exe, so its children kept running after the reply went out.taskkill /T /F.10.
docs: CLAUDE.md spellsmakefilethe way the file is namedUpgrade notes
deploy.sh, not/opt/agent/scripts/deploy.sh.cmd.exec'sexit_codenow appears on success and on failure. Failures also carryoutput.tasks.system_metrics.source/exporter_urlare ignored.allowed_log_pathspattern, narrow the pattern.Testing
?,[...]and samba paths are refusedgo test -race ./...passes.go vetis clean on linux, windows and freebsd.go test ./internal/tasks/passes from a non-admin shell as of commit 6: the exec gate,-Fileexit codes, the injection refusal, the log path forms and service validation. The first run caught the ordering bug that commit 6 fixes.TestTimeoutKillsTheProcessTree: PowerShell starts apingchild, the command times out, and the child is gone.GOOS=darwin go vet ./...is clean.Found along the way
Everything this work turned up is fixed on this branch: the darwin build (commit 7), FreeBSD's missing bash (commit 8) and children outliving a timeout (commit 9).
🤖 Generated with Claude Code