SLOPSHOPPER

probe-check

Toasts when a Bash verification probe is misleading: $? read after a pipeline, grep used as proof of absence, git grep ignoring untracked files

newguardcommandtoastpromptprocess
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · probe-check
› fix the failing auth test and add an audit log call ⏺ Read(src/auth.ts) ⎿ Read 6 lines ⏺ Update(src/auth.ts) ⎿ Added 2 lines, removed 1 line ⏺ Bash(bun test) ⎿ 3 pass, 1 fail ● Done. refresh now rejects expired claims and logs an audit event. ✻ Worked for 42s · done 4:20 PM › /probe-check ⎿ probe-check: probe-check is on. No misleading probe found yet. ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts
README

probe-check

Toasts when a Bash verification probe is misleading, before it can fool you.

What it does

  • Watches each Bash command of the main conversation (subagents are ignored). The command always runs untouched: probe-check never denies or rewrites.
  • Shows a short toast per distinct pattern, once per turn, and keeps the last 10 findings.
  • Patterns:
  • pipe-status: $? read right after a pipeline (cmd | tail; echo $?, echo "exit=$?") reports the last stage, not cmd. Not flagged when -o pipefail appears earlier in the same command or PIPESTATUS is used.
  • assign-pipe-status: x=$(a | b); echo $?, same problem.
  • git-grep-untracked: a git grep (also git -C <dir> grep, after rtk or env prefixes) that printed nothing while git status --porcelain --untracked-files=all (limited to the pathspecs after --, run in the -C directory or the session's, 5 s timeout) lists untracked files: they were not searched. Checked after the command ran; silent when git grep matched, nothing is untracked, or --untracked / --no-index is used.
  • grep-absent: grep … file || echo "not found" with no existence check of the file: a missing file also triggers the message.
  • Reading $? right after a simple command, or after cmd > /dev/null 2>&1, is fine.

Commands

CommandEffect
/probe-check or /probe-check listShow the last 10 findings, newest first.
/probe-check offStop checking.
/probe-check onResume.

Install

claude --plugin-dir /path/to/ModsTools/mods/probe-check

Limits

  • git-grep-untracked treats an empty stdout as "no match"; without -- it checks the whole repository. If git status fails or times out, nothing is reported.
  • Static text analysis otherwise: no shell expansion, no heredocs, no backtick substitutions, no $? read in a later pipeline segment or inside a function.
  • Only the segment directly after the pipeline is inspected for $?.
  • grep-absent is a heuristic: it counts non-option words after grep (at least a pattern and a path) and a not found-style echo.
  • The on/off switch and the findings last for the session only.

Develop

claude plugin validate mods/probe-check
claude plugin test mods/probe-check   # 25 tests
Source 2 files
hooks/register.ts 86 lines
1import type { EngineInterface, Register } from 'claude-code'
2
3import { classify, countUntracked, gitGrepScope, untrackedFinding } from './probes'
4import type { Finding } from './probes'
5
6const KEEP = 10
7
8type Seen = Finding & { command: string }
9
10const short = (command: string) => {
11  const line = command.replace(/\s+/g, ' ').trim()
12
13  return line.length > 80 ? `${line.slice(0, 79)}…` : line
14}
15
16// After a `git grep` that matched nothing: untracked files in its path were never searched.
17async function untrackedAfterGitGrep($: EngineInterface, command: string, result: unknown): Promise<Finding | null> {
18  const scope = gitGrepScope(command)
19  const stdout = typeof result === 'object' && result !== null ? (result as { stdout?: unknown }).stdout : undefined
20  if (scope === null || (typeof stdout === 'string' && stdout.trim() !== '')) return null
21  try {
22    const argv = ['git', 'status', '--porcelain', '--untracked-files=all', ...(scope.pathspecs.length > 0 ? ['--', ...scope.pathspecs] : [])]
23    const status = await $.process.run(argv, { ...(scope.dir === null ? {} : { cwd: scope.dir }), timeoutMs: 5000 })
24    const count = status.exitCode === 0 ? countUntracked(status.stdout) : 0
25
26    return count > 0 ? untrackedFinding(count) : null
27  } catch {
28    return null
29  }
30}
31
32export const register: Register = on => {
33  let isEnabled = true
34  let turnIds = new Set<string>()
35  const recent: Seen[] = []
36
37  on('session.start', async ($, e, next) => {
38    await $.command.register({
39      name: 'probe-check',
40      description: 'Misleading verification probes (list | off | on)',
41    })
42
43    return next(e)
44  })
45
46  on('command.run', { command: 'probe-check' }, ($, e) => {
47    const arg = e.args.trim()
48    if (arg === 'off' || arg === 'on') {
49      isEnabled = arg === 'on'
50      return { text: `probe-check is ${arg}.` }
51    }
52    if (recent.length === 0) return { text: `probe-check is ${isEnabled ? 'on' : 'off'}. No misleading probe found yet.` }
53
54    return { text: [`probe-check is ${isEnabled ? 'on' : 'off'}. Last ${recent.length} finding(s), newest first:`, ...recent.map(f => `- [${f.id}] ${f.message} (${f.command})`)].join('\n') }
55  })
56
57  on('prompt.submit', ($, e, next) => {
58    turnIds = new Set()
59
60    return next(e)
61  })
62
63  // Records findings and returns those not yet shown this turn.
64  const fresh = (findings: Finding[], command: string): Finding[] =>
65    findings.filter(finding => {
66      recent.unshift({ ...finding, command: short(command) })
67      recent.length = Math.min(recent.length, KEEP)
68      if (turnIds.has(finding.id)) return false
69      turnIds.add(finding.id)
70
71      return true
72    })
73
74  on('tool.call', { tool: 'Bash' }, async ($, e, next) => {
75    const isActive = isEnabled && e.agentId === undefined
76    if (isActive) for (const finding of fresh(classify(e.command), e.command)) $.ui.toast(`probe-check: ${finding.message}`)
77    const ran = await next(e)
78    if (isActive && ran.deny === undefined) {
79      const found = await untrackedAfterGitGrep($, e.command, ran.result)
80      if (found !== null) for (const finding of fresh([found], e.command)) $.ui.toast(`probe-check: ${finding.message}`)
81    }
82
83    return ran
84  })
85}
86
hooks/probes.ts 141 lines
1// Pure logic: spot Bash verification probes that can mislead.
2
3export type Finding = { id: string; message: string }
4
5type Segment = { raw: string; masked: string; sep: string; hasPipe: boolean; pipeInside: boolean }
6
7// Split a command on top-level && || ; and newlines. `masked` hides quoted text (except $? in double quotes).
8export function splitSegments(command: string): Segment[] {
9  const segments: Segment[] = []
10  let quote: string | null = null
11  let depth = 0
12  let start = 0
13  let sep = ''
14  let masked = ''
15  let hasPipe = false
16  let pipeInside = false
17  const close = (end: number, next: string) => {
18    segments.push({ raw: command.slice(start, end), masked, sep, hasPipe, pipeInside })
19    start = end + next.length
20    sep = next
21    masked = ''
22    hasPipe = false
23    pipeInside = false
24  }
25  for (let i = 0; i < command.length; i++) {
26    const c = command[i] ?? ''
27    const two = command.slice(i, i + 2)
28    if (quote === "'") {
29      masked += c === "'" ? c : 'x'
30      if (c === "'") quote = null
31    } else if (quote === '"') {
32      if (c === '"') {
33        quote = null
34        masked += c
35      } else if (c === '\\') {
36        masked += 'xx'
37        i++
38      } else masked += '|;&()'.includes(c) ? 'x' : c
39    } else if (c === '\\') {
40      masked += 'xx'
41      i++
42    } else if (c === "'" || c === '"') {
43      quote = c
44      masked += c
45    } else if (c === '(') {
46      depth++
47      masked += c
48    } else if (c === ')') {
49      depth = Math.max(0, depth - 1)
50      masked += c
51    } else if (depth === 0 && (two === '&&' || two === '||')) {
52      close(i, two)
53      i++
54    } else if (depth === 0 && (c === ';' || c === '\n')) close(i, c)
55    else {
56      if (c === '|') {
57        if (depth === 0) hasPipe = true
58        else pipeInside = true
59      }
60      masked += c
61    }
62  }
63  close(command.length, '')
64
65  return segments
66}
67
68const PIPEFAIL = /-\w*o\s+pipefail\b/
69const GIT_GREP = /^\s*(?:rtk\s+)?(?:sudo\s+)?(?:\w+=\S*\s+)*git\s+(?:(?:-C\s+\S+|--no-pager)\s+)*grep\b/
70const GREP = /^\s*(?:rtk\s+)?grep\b/
71const NOT_FOUND = /not found|no match|absent|missing|none|not present|nothing/i
72const EXISTS_CHECK = /\[\[?\s+!?\s*-[efdrs]\s|\btest\s+!?\s*-[efdrs]\s|\b(?:ls|stat)\s/
73const ASSIGN_SUBST = /^\s*(?:(?:export|local|declare|readonly)\s+)?\w+=\$\(/
74
75const MESSAGES = {
76  'pipe-status': '$? after a pipeline is the last stage\'s status, not the command\'s. Use set -o pipefail or ${PIPESTATUS[0]}.',
77  'assign-pipe-status': '$? after x=$(a | b) is the last stage\'s status, not a\'s. Use set -o pipefail or capture a on its own.',
78  'grep-absent': 'grep || echo "not found" also fires when the file is missing. Check the path exists first.',
79}
80
81function reportsAbsence(segments: Segment[], i: number): boolean {
82  const next = segments[i + 1]
83  if (next === undefined) return false
84  const candidate = next.sep === '||' ? next : next.sep === '&&' && segments[i + 2]?.sep === '||' ? segments[i + 2] : undefined
85
86  return candidate !== undefined && /^\s*echo\b/.test(candidate.raw) && NOT_FOUND.test(candidate.raw)
87}
88
89function operandCount(masked: string): number {
90  return masked.trim().split(/\s+/).slice(1).filter(token => !token.startsWith('-') && token !== '').length
91}
92
93// The misleading probes found in a Bash command, one finding per distinct pattern.
94export function classify(command: string): Finding[] {
95  const segments = splitSegments(command)
96  const ids = new Set<keyof typeof MESSAGES>()
97  const usesPipestatus = command.includes('PIPESTATUS')
98  const hasExistsCheck = EXISTS_CHECK.test(command)
99  let pipefail = false
100  segments.forEach((segment, i) => {
101    if (PIPEFAIL.test(segment.raw)) pipefail = true
102    const prev = segments[i - 1]
103    if (prev !== undefined && !pipefail && !usesPipestatus && segment.masked.includes('$?')) {
104      if (prev.hasPipe) ids.add('pipe-status')
105      else if (prev.pipeInside && ASSIGN_SUBST.test(prev.raw)) ids.add('assign-pipe-status')
106    }
107    if (GREP.test(segment.raw) && !segment.hasPipe && operandCount(segment.masked) >= 2 && !hasExistsCheck && reportsAbsence(segments, i)) ids.add('grep-absent')
108  })
109
110  return [...ids].map(id => ({ id, message: MESSAGES[id] }))
111}
112
113export type GitGrepScope = { dir: string | null; pathspecs: string[] }
114
115const tokenize = (text: string): string[] =>
116  [...text.matchAll(/"([^"]*)"|'([^']*)'|(\S+)/g)].map(match => match[1] ?? match[2] ?? match[3] ?? '')
117
118// The directory (-C) and pathspecs (after --) of the first `git grep` in a command, or null when there is none
119// or it searches untracked files itself (--untracked, --no-index).
120export function gitGrepScope(command: string): GitGrepScope | null {
121  const segment = splitSegments(command).find(candidate => GIT_GREP.test(candidate.raw))
122  if (segment === undefined) return null
123  const all = tokenize(segment.raw)
124  const pipe = all.indexOf('|')
125  const tokens = all.slice(all.indexOf('git'), pipe === -1 ? undefined : pipe).filter(token => !/^\d*>/.test(token))
126  const grep = tokens.indexOf('grep')
127  if (tokens.slice(grep).some(token => token === '--untracked' || token === '--no-index')) return null
128  const c = tokens.indexOf('-C')
129  const dash = tokens.indexOf('--', grep)
130
131  return { dir: c !== -1 && c < grep ? (tokens[c + 1] ?? null) : null, pathspecs: dash === -1 ? [] : tokens.slice(dash + 1) }
132}
133
134// How many untracked files `git status --porcelain` lists.
135export const countUntracked = (stdout: string): number => stdout.split('\n').filter(line => line.startsWith('?? ')).length
136
137export const untrackedFinding = (count: number): Finding => ({
138  id: 'git-grep-untracked',
139  message: `git grep found nothing, but ${count} untracked file(s) in that path were not searched — use grep -r`,
140})
141