SLOPSHOPPER

remote-guard

Asks before a destructive command runs on another machine over ssh (restarts, deletes, kills, reboots)

newguard
v0.1.0no licenseupdated 2026-10-04narrowstacks/claude-code-mods/remote-guard
A shopper browsing a rack in a slop shop
README

claude-code-mods

Function-hook mods for Claude Code. Each folder is one plugin.

ModWhat it does
context-watchOne-line gauge above the prompt for context fill, cost and hot rate limits; toasts at 60/80/90%; compaction keeps your instructions and edited files; /ctx opens a context breakdown pane
live-spinnerSpinner shows what is running: the Bash description or a trimmed command, the ssh host, file being read or edited, search, subagent, MCP tool; a tool's own elapsed time after 15s; "Waiting for signing approval" when a signed git commit or tag sits quiet
pr-watchTracks PRs from gh pr / gt submit / git push output, shows checks above the prompt, toasts on green/red/merged, blocks foreground CI polling loops; /prs
house-styleNo em dashes: system prompt rule, reminder to the model after a write that adds one, toast when a reply uses one
handoff/carryover [focus] writes a state note from the conversation and clears; the next prompt carries it. /carryover save, show, drop
agent-jobs/jobs opens a pane of running subagents and background shells with elapsed time and a Stop button; toasts when background work finishes
zsh-safeQuotes glob flags zsh would expand (--include=*.ts, find -name *.ts) and keeps a grep that finds nothing from failing a cd ... && grep chain
pm-guardBlocks npm/npx/yarn where the lockfile says bun or pnpm, and steers to bun where there is no lockfile
remote-guardAsks before a destructive command (restart, rm, kill, reboot, pct/qm stop, zfs destroy) runs over ssh
xcode-statusOnly in a folder with an .xcodeproj/.xcworkspace: one line with the last build or test result (flags runs that executed 0 tests) and the booted simulator; blocks a second xcodebuild while one is running; re-checks on /cd and keeps each project's last result
agent-modelsPins the model each subagent type runs on (Explore, general-purpose, Plan, any other, plus custom type=model pairs), set in /config or with /agent-models <type> <model>; a model the caller names wins unless "Override explicit models" is on
worktrees/worktrees pane: each git worktree with branch, uncommitted changes, ahead/behind and PR state; Remove (after asking) on clean worktrees whose PR is merged or closed, plus "Remove N merged" to clear every merged one at once
pr-rulesBlocks gh pr create / gt submit when the repo keeps an [Unreleased] changelog and the branch adds no entry (skip with --label no-changelog); where the repo's CLAUDE.md says to label every PR (or requireLabels is on), blocks a PR with no --label and lists the repo's labels. Repos with neither are untouched; `/pr-rules onoffauto` overrides per repo (kept across sessions)

Install

claude plugin marketplace add narrowstacks/claude-code-mods
claude plugin install context-watch@claude-code-mods

Or load from a checkout without installing:

claude --plugin-dir ./context-watch --plugin-dir ./live-spinner

Check

claude plugin validate ./context-watch
claude plugin test ./context-watch

tsc -p ./context-watch works once Claude Code has loaded the mod and written .claude-plugin/types/.

Source 2 files
hooks/register.ts 74 lines
1import { atom, read, update } from 'claude-code'
2import type { Register } from 'claude-code'
3
4const allowedHosts = atom({ plugin: 'remote-guard', key: 'allowedHosts' } as const, [] as string[])
5
6// ssh options that take a value: the token after them is not the host.
7const VALUED = new Set(['-b', '-c', '-D', '-E', '-e', '-F', '-I', '-i', '-J', '-L', '-l', '-m', '-O', '-o', '-p', '-Q', '-R', '-S', '-W', '-w', '-B'])
8
9const DESTRUCTIVE: RegExp[] = [
10  /\brm\s+(-\w*\s+)*\S/,
11  /\b(systemctl|service)\b.*\b(restart|stop|disable|mask|kill)\b/,
12  /\b(reboot|shutdown|poweroff|halt)\b/,
13  /\b(kill|pkill|killall)\b/,
14  /\bdocker\b.*\b(rm|rmi|stop|restart|kill|prune|down)\b/,
15  /\b(pct|qm)\s+(stop|destroy|shutdown|reboot|reset|rollback)\b/,
16  /\bzfs\s+(destroy|rollback)\b/,
17  /\bzpool\s+(destroy|export)\b/,
18  /\b(mkfs|wipefs|fdisk|parted)\b/,
19  /\bdd\s+.*\bof=/,
20  /\b(apt|apt-get|dnf|yum)\s+(remove|purge|autoremove)\b/,
21  /\bmv\s+\S+\s+\/(etc|usr|var|boot)\b/,
22  />\s*\/etc\//,
23]
24
25const unquote = (text: string) => text.trim().replace(/^(['"])([\s\S]*)\1$/, '$2')
26
27// Each ssh invocation in the command: the host and what it runs there.
28export const sshCalls = (command: string): { host: string; remote: string }[] => {
29  const calls: { host: string; remote: string }[] = []
30  for (const match of command.matchAll(/(?:^|[;&|(]\s*|\s)ssh\s+([^;&|]*(?:'[^']*'|"[^"]*")?[^;&|]*)/g)) {
31    const tokens = (match[1] ?? '').match(/'[^']*'|"[^"]*"|\S+/g) ?? []
32    let i = 0
33    while (i < tokens.length && (tokens[i] ?? '').startsWith('-')) {
34      i += VALUED.has(tokens[i] ?? '') ? 2 : 1
35    }
36    const host = tokens[i]
37    if (host !== undefined) {
38      calls.push({ host: host.replace(/^.*@/, ''), remote: unquote(tokens.slice(i + 1).join(' ')) })
39    }
40  }
41
42  return calls
43}
44
45export const isDestructive = (remote: string) => DESTRUCTIVE.some(pattern => pattern.test(remote))
46
47const RUN = 'Run it'
48const CANCEL = 'Cancel'
49const allowLabel = (host: string) => `Always allow on ${host} this session`
50
51export const register: Register = on => {
52  on('tool.call', { tool: 'Bash' }, async ($, e, next) => {
53    const allowed = await read($, allowedHosts)
54    const risky = sshCalls(e.command).find(call => isDestructive(call.remote) && !allowed.includes(call.host))
55    if (risky === undefined) {
56      return next(e)
57    }
58
59    const shown = risky.remote.length > 120 ? `${risky.remote.slice(0, 119)}…` : risky.remote
60    const always = allowLabel(risky.host)
61    const answer = await $.ui
62      .ask(`Run this on ${risky.host}?  ${shown}`, { options: [RUN, always, CANCEL], header: 'Remote' })
63      .catch(() => CANCEL)
64
65    if (answer === always) {
66      await update($, allowedHosts, hosts => [...hosts, risky.host])
67    }
68
69    return answer === RUN || answer === always
70      ? next(e)
71      : { deny: `remote-guard: the user did not approve running this on ${risky.host}${answer !== CANCEL ? `. They said: ${answer}` : ''}.` }
72  })
73}
74
types/index.d.ts 8 lines
1export type AllowedHosts = string[]
2
3declare module 'claude-code' {
4  interface PluginState {
5    'remote-guard': { allowedHosts: AllowedHosts }
6  }
7}
8