Skip to content

feat: add ssh tool for remote command execution over SSH - #192

Merged
imaustink merged 9 commits into
mainfrom
worktree-feat+ssh-tool
Aug 2, 2026
Merged

feat: add ssh tool for remote command execution over SSH#192
imaustink merged 9 commits into
mainfrom
worktree-feat+ssh-tool

Conversation

@imaustink

@imaustink imaustink commented Aug 2, 2026

Copy link
Copy Markdown
Owner

Summary

  • Adds tools/ssh, a container Tool that runs a remote command over SSH, mirroring tools/kubectl-readonly/tools/github's shape (allowlist + config + spawn wrapper + messaging + redaction, with tests).
  • Target resolution and command restriction are each independent, individually optional inputs, composable per deployment (tools/ssh/src/target.ts, src/allowlist.ts, src/sshconfig.ts):
    • SSH_ALLOWED_HOSTS -- an explicit user@host[:port] allowlist restricting which resolved targets may be dialed at all.
    • SSH_CONFIG -- ssh_config(5)-shaped Host/HostName/User/Port blocks for alias resolution (e.g. kube0 -> ubuntu@192.168.1.59), the same way an operator's own ~/.ssh/config works. At least one of SSH_ALLOWED_HOSTS/SSH_CONFIG is required at startup.
    • SSH_ALLOWED_COMMANDS -- unset keeps the curated read-only diagnostic default (df, ps, journalctl, systemctl status, docker ps/logs/inspect, ip addr show, ...; systemctl/docker restricted to read-only subcommands, ip/find reject their write/exec forms specifically); a comma-separated list substitutes a custom set; "*" disables the command allowlist entirely for a deployment that accepts that risk.
  • The remote-shell-injection charset check (every token restricted to ^[A-Za-z0-9._\-/:=@,]+$) is unconditional in every mode, including "*" -- OpenSSH hands the remote argv to the target's own login shell unless it forces a fixed command, so this is what actually prevents ;/|/`/$(...) injection regardless of which top-level command is allowed.
  • Wires it into charts/community-components (Tool/ServiceAccount templates, values.yaml defaults, a fail guard requiring at least one target-resolution input, values-ci-all.yaml for full-catalog CI validation) and .github/workflows/release.yml's build matrix.
  • Enables it in values-production.yaml against the operator's home/bastion/console/printcam/airvinyl/airbuddy homelab boxes (aliased via a copy of ~/.ssh/config, with allowedHosts populated from the same list as the actual enforced boundary) -- deliberately excludes the kube0-8/db1-3 cluster nodes, since allowedCommands: "*" gives this tool write access to anything it reaches and those are this repo's own production infrastructure. Uses the operator's own id_rsa as a temporary shared credential pending rotation to a dedicated, scoped key (flagged inline in the values file and tools/ssh/README.md's "Choosing a credential" section).

Review history

  • An automated review caught three real issues, all fixed and verified against a real kind cluster + kubectl apply --dry-run=server (not just helm template): ip/find allowed unrestricted write/exec forms despite the "read-only" claim; CI was red because values-ci-all.yaml never enabled sshTool; and allowedHosts being empty meant the sshConfig alias list wasn't actually an enforced boundary (any literal user@host would resolve).
  • console's IP changed (192.168.1.83 -> .71, confirmed same box via host-key fingerprint match) mid-review; updated everywhere. Its id_rsa authorization is still outstanding on the operator's end -- flagged inline, not blocking this PR.

Test plan

  • npm run typecheck --workspace=ssh
  • npm run test --workspace=ssh (48/48 passing across allowlist.test.ts/sshconfig.test.ts/target.test.ts)
  • npm run build --workspace=ssh
  • helm lint charts/community-components
  • helm template against values.yaml defaults (disabled), a custom host list, SSH_CONFIG-only, both together, and values-production.yaml as-is -- all render cleanly; the fail guard rejects neither-input-set.
  • helm template community-components -f values-ci-all.yaml, verified against validate-crds.yml's literal "every template rendered" assertion.
  • Real kind cluster + kubectl apply --dry-run=server against the full rendered catalog (CEL validation included) -- passes for the ssh Tool CR.
  • Real in-cluster ToolRun against a homelab host once ssh-tool-secrets is created (command in the values-production.yaml comment) and console's key authorization is resolved.

🤖 Generated with Claude Code

https://claude.ai/code/session_01Fg8b9pPWm91nLbDnB6ECJh

imaustink and others added 4 commits August 1, 2026 16:43
New tools/ssh container tool, mirroring tools/kubectl-readonly and
tools/github's shape: a fixed user@host allowlist as the authorization
boundary, a read-only remote-command allowlist, and a strict
plain-argument charset (the remote command reaches the target's login
shell via ssh, unlike the local-spawn-only tools, so this matters more
here). Wires it into charts/community-components (Tool/ServiceAccount
templates, values.yaml defaults) and .github/workflows/release.yml's
build matrix, and enables it in values-production.yaml against the
homelab hosts pulled from ~/.ssh/config, using a temporary personal-key
credential pending a dedicated scoped key.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Fg8b9pPWm91nLbDnB6ECJh
# Conflicts:
#	.github/workflows/release.yml
#	package-lock.json
Splits target resolution into two independently optional inputs
instead of one bundled SSH_ALLOWED_HOSTS shape: SSH_CONFIG
(ssh_config-shaped Host/HostName/User/Port blocks, parsed in the new
sshconfig.ts, for alias resolution like ~/.ssh/config already does)
and SSH_ALLOWED_HOSTS (now optional -- restricts resolved targets when
set, no-ops when unset). At least one must be configured, enforced at
startup, since neither would mean no boundary on what this tool dials.

allowlist.ts shrinks to just the remote-command allowlist; target.ts
(new) owns resolution/restriction and is decoupled from config.ts via
its own narrow TargetResolutionConfig interface so it stays
independently testable. Updates the Tool CR template's fail guard,
values.yaml, README, .env.example, and run.sh to match.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Fg8b9pPWm91nLbDnB6ECJh
Replaces the flat SSH_ALLOWED_HOSTS list with a copy of the operator's
own ~/.ssh/config Host aliases (home, bastion, db1-3, kube0-8,
printcam, airvinyl, airbuddy) via the new SSH_CONFIG feature, and sets
allowedHosts to empty -- the allowlist is disabled for now, per
request, with sshConfig's own closed Host list as the sole boundary
in the meantime. No new public key needs installing on any target:
the credential is still the operator's own id_rsa, already authorized
everywhere below for their interactive access.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Fg8b9pPWm91nLbDnB6ECJh
@k5s-bot

k5s-bot Bot commented Aug 2, 2026

Copy link
Copy Markdown

🤖 Starting work on this now. Watch live or take over the session here: https://claude.ai/code/session_012RcHQ8DZay6svwBFRZMwhY

@k5s-bot k5s-bot Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Solid, well-documented tool that closely mirrors the existing tool shape, and the local-vs-remote-shell reasoning behind the charset guard is exactly right. Three things to address before merge, in priority order:

  1. The read-only guarantee doesn't holdip and find are allowlisted with no subcommand/flag restriction, so ip link set … down, ip route del …, and find … -delete/-fprint all pass validation (inline on allowlist.ts).
  2. CI is red — the validate job fails because the two new templates aren't enabled in values-ci-all.yaml (inline on tool-ssh.yaml).
  3. Production ships with no real target boundary — with allowedHosts empty, the sshConfig alias list isn't enforced; literal user@host targets bypass it (inline on values-production.yaml).

The remaining CI checks (Node, Go) are green as of this review.

"w",
"ss",
"netstat",
"ip",

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ip and find break the "strictly read-only" guarantee. Unlike systemctl/docker, neither is subcommand-restricted, and their state-changing forms fit SAFE_TOKEN, so these all pass validateCommand (verified against this logic):

  • ip link set eth0 down, ip route del default, ip addr add 10.0.0.9/24 dev eth0
  • find /tmp/x -delete, find / -name foo -fprint /etc/cron.d/x

With the production admin key (values-production.yaml) this is remote write / network-DoS / arbitrary-file-write, not diagnostics.

🤖 Prompt to fix this with an AI agent
In imaustink/agent-controller on PR #192, tools/ssh/src/allowlist.ts allowlists `ip` and `find` with no subcommand/flag restriction, so state-changing invocations (`ip link set … down`, `ip route add/del`, `ip addr add/del`, `find … -delete`, `find … -fprint/-fls/-fprintf`, `find … -exec`) pass validateCommand and violate the tool's read-only guarantee. Restrict `ip` to read-only forms only (allow object + `show`/`list`; reject `add`/`del`/`set`/`change`/`replace`/`flush`), and either drop `find` from the allowlist or reject its writing/execution action flags anywhere in argv (-delete, -exec, -execdir, -ok, -okdir, -fprint, -fprintf, -fls). Add tests in allowlist.test.ts asserting each of those write forms is rejected with BlockedCommandError. Do not change unrelated behavior.

@@ -0,0 +1,55 @@
{{- if .Values.sshTool.enabled }}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This PR turns CI red. .github/workflows/validate-crds.yml renders the chart with values-ci-all.yaml and asserts every file under templates/ appears in the output. tool-ssh.yaml and serviceaccount-ssh.yaml render nothing there because sshTool is disabled by default and values-ci-all.yaml was not updated to enable it, so the "Assert every template was actually rendered" step exits 1 (this is the failing validate check on the PR).

🤖 Prompt to fix this with an AI agent
In imaustink/agent-controller on PR #192, the validate-crds CI job fails because charts/community-components/templates/tool-ssh.yaml and serviceaccount-ssh.yaml render nothing under charts/community-components/values-ci-all.yaml. Add a `sshTool` block to values-ci-all.yaml with `enabled: true` and a non-empty `sshConfig` (a throwaway Host block, e.g. HostName ci-host.example.invalid / User ci) so the tool-ssh.yaml `fail` guard passes and both templates render. Verify `helm template charts/community-components -f charts/community-components/values-ci-all.yaml` includes both `# Source:` lines. Do not change unrelated behavior.

enabled: true
image: registry.kurpuis.com:5000/ssh:latest
serviceAccountName: ssh-tool
allowedHosts: ""

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

With allowedHosts empty, the sshConfig Host list is not an enforced boundary. resolveTarget falls back to the caller's literal user@host when no Host block matches (tools/ssh/src/target.ts:91; intended per the test at target.test.ts:62). So callers aren't limited to the aliases below — root@192.168.1.59 … (note: a different user than the ubuntu this file maps kube0 to) or any literal user@host resolves and is dialed, with known_hosts pinning as the only remaining restriction. The comment above claims "sshConfig's own Host list is the only boundary," but that list restricts neither the host set nor the user. Combined with the temporary personal admin key, populating allowedHosts (which pins user+host+port) is the only thing that actually bounds this.

🤖 Prompt to fix this with an AI agent
In imaustink/agent-controller on PR #192, charts/community-components/values-production.yaml enables sshTool with allowedHosts: "" and relies on the sshConfig alias list as the boundary, but resolveTarget (tools/ssh/src/target.ts) dials any literal user@host that isn't a configured alias, so the alias list is not an authorization boundary. Set allowedHosts to a comma-separated user@host list derived from the sshConfig Host blocks in this file (e.g. austinkurpuis@166.113.38.187, ubuntu@192.168.1.59, …, pi@airvinyl.local, pi@192.168.1.218) so the resolved user+host+port must match an explicit entry. Do not change the sshConfig block or unrelated behavior.

@k5s-bot

k5s-bot Bot commented Aug 2, 2026

Copy link
Copy Markdown

All three inline comments anchored to the correct file and line. Review is posted.

Summary

I reviewed PR #192 (feat: add ssh tool for read-only remote diagnostics) by checking out the branch, reading the full diff, verifying the allowlist logic against a local reproduction, and inspecting the failing CI job. Posted one review (COMMENT) with three inline comments, each carrying a self-contained fix.

Findings (most severe first):

  1. Read-only guarantee is broken (tools/ssh/src/allowlist.ts:49) — ip and find are allowlisted with no subcommand/flag restriction (only systemctl/docker are restricted). I verified that ip link set eth0 down, ip route del default, ip addr add …, find … -delete, and find … -fprint /etc/cron.d/x all pass validateCommand. With the production admin key this is remote write/DoS/arbitrary-file-write, not diagnostics.

  2. This PR turns CI red (charts/community-components/templates/tool-ssh.yaml:1) — the validate check is failing because validate-crds.yml asserts every template renders under values-ci-all.yaml, but the two new ssh templates render nothing there (sshTool not enabled), and that file wasn't updated. Concrete, PR-caused failure with a one-line fix.

  3. No enforced target boundary in production (charts/community-components/values-production.yaml:228) — with allowedHosts empty, resolveTarget falls back to any literal user@host for non-alias targets (target.ts:91), so the sshConfig a…


✅ Opened pull request: imaustink/agent-controller#192

@k5s-bot k5s-bot Bot removed the ai-review label Aug 2, 2026
imaustink and others added 2 commits August 2, 2026 05:41
- allowlist.ts: restrict ip to a read-only object+action allowlist
  (addr/route/link/... + show/list/get, ip's own default action) and
  reject find's write/exec primaries (-delete/-exec[dir]/-ok[dir]/
  -fprint/-fprintf/-fls) wherever they appear in argv -- both
  previously passed validateCommand unrestricted, breaking the
  strictly-read-only guarantee. Adds tests for the exact attack forms
  from review (ip link set down, ip route del, find -delete/-exec/
  -fprint).
- values-ci-all.yaml: enables sshTool with a throwaway sshConfig so
  tool-ssh.yaml/serviceaccount-ssh.yaml actually render under CI's
  full-catalog validation; verified against the exact validate-crds.yml
  assertion plus a real kind-cluster server-side dry-run apply.
- values-production.yaml: populates allowedHosts, derived from the
  sshConfig Host list -- resolveTarget falls back to any literal
  user@host when no alias matches, so with allowedHosts empty the
  sshConfig list was restricting nothing at all (any host reachable
  with this key, as any user, would resolve and dial).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Fg8b9pPWm91nLbDnB6ECJh
Adds SSH_ALLOWED_COMMANDS as a third independent, optional input
(alongside SSH_ALLOWED_HOSTS/SSH_CONFIG for target resolution):
unset keeps the curated read-only default; a comma-separated list
substitutes a custom allowed-command set; "*" disables the
command/subcommand allowlist entirely for a deployment that has
decided read-only diagnostics aren't enough and accepts the risk.

The remote-shell-injection charset check in allowlist.ts is
unconditional in every mode, including "*" -- wide open means "any
command, no shell injection", not "no restriction at all". The Tool
CR's description/allowedRoles now reflect which mode is active
(reader for the default, writer for "*").

Sets allowedCommands: "*" in values-production.yaml per explicit
request, against the operator's own homelab hosts they're comfortable
an agent could break -- documented inline as a decision not to copy
elsewhere.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Fg8b9pPWm91nLbDnB6ECJh
@imaustink

Copy link
Copy Markdown
Owner Author

Addressed all three findings:

  1. ip/find read-only bypassip is now restricted to a read-only object+action allowlist (addr/route/link/... + show/list/get, ip's own default action), and find's write/exec primaries (-delete, -exec[dir], -ok[dir], -fprint/-fprintf/-fls) are rejected wherever they appear in argv. Added tests for the exact attack forms from the review (ip link set eth0 down, ip route del default, find ... -delete/-exec/-fprint).
  2. CI redvalues-ci-all.yaml now enables sshTool with a throwaway sshConfig, so both new templates render. Verified against the literal validate-crds.yml assertion, plus a real kind cluster + kubectl apply --dry-run=server (CEL validation included) locally before pushing.
  3. No enforced boundary in prodallowedHosts is now populated in values-production.yaml, derived from the sshConfig Host list, so the resolved user@host:port must match an explicit entry rather than falling back to any literal target.

Separately (not from this review — a follow-up request): added a third independent, optional input, SSH_ALLOWED_COMMANDS, so a deployment can opt into read/write (or fully unrestricted, "*") command execution instead of the curated read-only default. The remote-shell-injection charset check stays unconditional in every mode. values-production.yaml sets it to "*" for this homelab, with an inline note not to copy that setting to infrastructure that matters more.

All 48 tests passing, typecheck/build/helm-lint/helm-template clean, and a real kind-cluster dry-run apply verified locally.

imaustink and others added 3 commits August 2, 2026 06:31
allowedCommands: "*" gives the ssh tool write access to anything it
reaches -- kube0-8 and db1-3 are the actual cluster/database nodes
this repo runs on, too much blast radius to leave wide open with a
personal admin key backing it. Removed both from sshConfig and
allowedHosts; kept home/bastion/printcam/airvinyl/airbuddy.

Added console (192.168.1.83, user admin) instead. Its host key isn't
in SSH_KNOWN_HOSTS yet -- it was unreachable (ssh-keyscan timeout)
when regenerating the Secret prerequisite; noted inline with the
ssh-keyscan command to add it once it's back on the network.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Fg8b9pPWm91nLbDnB6ECJh
console's DHCP lease moved it to 192.168.1.71. Verified via ssh-keyscan
that the ed25519/rsa/ecdsa host keys at the new address match what's
already recorded for it under its console.local mDNS name, so this is
the same box, not a different host to re-vet from scratch.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NynLp3kMJ5vMdvHDv3opGy
`ssh -i ~/.ssh/id_rsa admin@192.168.1.71` returns "Permission denied
(publickey)" -- unlike home/bastion/printcam/airvinyl/airbuddy,
id_rsa is not in admin's authorized_keys on this box. The prior "no
new public key needs installing" claim didn't hold for console; noted
inline so the ssh tool's console calls failing at the auth step isn't
mistaken for an allowlist bug.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Fg8b9pPWm91nLbDnB6ECJh
@imaustink imaustink changed the title feat: add ssh tool for read-only remote diagnostics feat: add ssh tool for remote command execution over SSH Aug 2, 2026
@imaustink
imaustink merged commit b5eb333 into main Aug 2, 2026
5 checks passed
@imaustink
imaustink deleted the worktree-feat+ssh-tool branch August 2, 2026 21:08
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant