SLOPSHOPPER

session-activity

실패하거나 보안상 위험한 도구 사용만 알림·상태줄·패널로 표시

newpaneguardcommandtoaststatus
v0.2.0no licenseupdated 2026-10-16wonr-spec/claude-mods/plugins/session-activity
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · session-activity
│ ┃ 도구 주의 알림 ✕ › fix the failing auth te╭─────────────────────────────────────────╮ │ ┃ 위험 1회 · 실패 1회 │ session-activity │ │ ┃ 위험 Bash: 강제 재귀 삭제(rm -rf) ⏺ Read(src/auth.ts) │ 위험할 수 있는 도구 사용: Bash — 강제 재귀 삭제(rm -rf) │ │ ┃ rm -rf build && git push --force origin ⎿ Read 6 lines ╰─────────────────────────────────────────╯ │ ┃ main ⏺ Update(src/auth.ts) │ ┃ 실패 Bash: src/auth.test.ts: ⎿ Added 2 lines, removed 1 line │ ┃ bun test ⏺ 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 │ │ › /activity │ ⎿ session-activity: 도구 주의 알림 패널을 열었습니다. │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts ⚠ session-activity: 도구 위험 1 · 실패 1

Draws

Pane · 도구 주의 알림
위험 1회 · 실패 1회 위험 Bash: 강제 재귀 삭제(rm -rf) rm -rf build && git push --force origin main 실패 Bash: src/auth.test.ts: bun test [ 목록 비우기 ]
README

session-activity

도구 사용 중 실패한 것과 보안상 위험해 보이는 것만 골라 보여주는 mod입니다. 평범하게 성공한 호출은 표시하지 않습니다. 위험한 호출을 막지는 않고 알리기만 합니다.

  • 알림(toast): 위험해 보이는 호출이 시작될 때 도구와 이유를 띄웁니다.
  • 상태줄: 도구 위험 N · 실패 M. 둘 다 0이면 비웁니다.
  • "도구 주의 알림" 패널: 위험한 호출이 생기면 열리고, 위험·실패 항목을 최근 순으로 보여줍니다. 목록 비우기로 초기화합니다.
  • /activity: 패널 다시 열기

위험으로 보는 경우

  • Bash: rm -rf, sudo, curl … | sh, chmod 777, git push --force, git reset --hard, git clean -f, gh repo delete, dd·mkfs·diskutil erase, /usr/bin/security(키체인), ~/.ssh·~/.aws 등 인증 정보 경로, 토큰·credentials 파일, 환경 변수를 외부로 보내는 파이프, 샌드박스 해제 실행
  • Write·Edit·Read: .env, ~/.ssh·~/.aws 등 인증 정보 폴더·파일, /etc, 셸 시작 설정(.zshrc 등), .git/hooks, ~/Library/LaunchAgents

규칙은 hooks/risk.ts에 있습니다. 명령 문장을 패턴으로 보는 방식이라 모든 위험을 잡지는 못하고, 가끔 안전한 명령을 위험으로 표시할 수 있습니다.

Source 3 files
hooks/register.tsx 118 lines
1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { ToolEvent } from '../types'
5import { failureOf, riskOf, summaryOf } from './risk'
6
7const PANE = 'session-activity'
8const TITLE = '도구 주의 알림'
9const events = atom({ plugin: 'session-activity', key: 'events' } as const, [] as ToolEvent[])
10const risky = atom({ plugin: 'session-activity', key: 'risky' } as const, 0)
11const failed = atom({ plugin: 'session-activity', key: 'failed' } as const, 0)
12
13async function showStatus($: EngineInterface) {
14  const r = await read($, risky)
15  const f = await read($, failed)
16  const parts = [r > 0 ? `위험 ${r}` : '', f > 0 ? `실패 ${f}` : ''].filter(p => p !== '')
17  $.ui.status(parts.length > 0 ? `도구 ${parts.join(' · ')}` : undefined)
18}
19
20export const register: Register = on => {
21  on('session.start', async ($, e, next) => {
22    await $.command.register({ name: 'activity', description: '실패하거나 위험한 도구 사용 목록 열기' })
23    await showStatus($)
24    return next(e)
25  })
26
27  on('command.run', { command: 'activity' }, async $ => {
28    await $.ui.open({ id: PANE, title: TITLE })
29    return { text: '도구 주의 알림 패널을 열었습니다.' }
30  })
31
32  // 슬래시 명령이 command.run으로 오지 않고 프롬프트로 들어올 때의 대체 처리: "/activity"
33  on('prompt.submit', async ($, e, next) => {
34    if (!/^\s*\/activity\s*$/.test(e.text)) return next(e)
35    await $.ui.open({ id: PANE, title: TITLE })
36    return { drop: '도구 주의 알림 패널을 열었습니다.' }
37  }).catch(($, e, next) => next(e))
38
39  on('tool.call', async ($, e, next) => {
40    const input = e as unknown as Record<string, unknown>
41    const risk = riskOf(e.tool, input)
42    const summary = summaryOf(e.tool, input)
43
44    if (risk !== undefined) {
45      const event: ToolEvent = { id: e.tool_use_id, tool: e.tool, summary, risk, isDone: false }
46      await update($, events, list => [...list, event].slice(-100))
47      await update($, risky, n => n + 1)
48      $.ui.toast(`위험할 수 있는 도구 사용: ${e.tool} — ${risk}`)
49      await showStatus($)
50      void $.ui.open({ id: PANE, title: TITLE })
51    }
52
53    const ran = await next(e)
54    const failure = failureOf(ran)
55
56    if (risk !== undefined || failure !== undefined) {
57      await update($, events, list => {
58        const known = list.some(one => one.id === e.tool_use_id)
59        const done = { id: e.tool_use_id, tool: e.tool, summary, risk, failure, isDone: true }
60        return known ? list.map(one => (one.id === e.tool_use_id ? done : one)) : [...list, done].slice(-100)
61      })
62      if (failure !== undefined) {
63        await update($, failed, n => n + 1)
64        await showStatus($)
65      }
66    }
67
68    return ran
69  }).catch(($, e, next) => next(e))
70
71  on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
72    const { Box, Text, Button } = $.ui.resolve(e)
73    const list = await read($, events)
74    const r = await read($, risky)
75    const f = await read($, failed)
76    const rows = e.viewport?.rows ?? 24
77    const room = Math.max(1, Math.floor((rows - 4) / 2))
78
79    return (
80      <Box flexDirection="column">
81        <Text bold>
82          위험 {r}회 · 실패 {f}회
83        </Text>
84        {list.length === 0 && <Text dimColor>실패하거나 위험한 도구 사용이 아직 없습니다.</Text>}
85        {list
86          .slice(-room)
87          .reverse()
88          .map(one => (
89            <Box flexDirection="column">
90              <Text color={one.risk !== undefined ? 'yellow' : 'red'}>
91                {[one.risk !== undefined ? '위험' : '', one.failure !== undefined ? '실패' : ''].filter(s => s !== '').join('·')}{' '}
92                {one.tool}
93                {!one.isDone ? ' (실행 중)' : ''}: {one.risk ?? one.failure}
94              </Text>
95              <Text dimColor>
96                {'  '}
97                {one.summary}
98                {one.risk !== undefined && one.failure !== undefined ? ` — ${one.failure}` : ''}
99              </Text>
100            </Box>
101          ))}
102        {list.length > 0 && (
103          <Button
104            key="clear"
105            label="목록 비우기"
106            onPress={async () => {
107              await update($, events, () => [])
108              await update($, risky, () => 0)
109              await update($, failed, () => 0)
110              await showStatus($)
111            }}
112          />
113        )}
114      </Box>
115    )
116  })
117}
118
hooks/risk.ts 62 lines
1type Rule = { pattern: RegExp; reason: string }
2
3const BASH_RULES: Rule[] = [
4  { pattern: /\brm\s+(-[a-zA-Z]*[rR][a-zA-Z]*f|-[a-zA-Z]*f[a-zA-Z]*[rR]|--recursive\s+--force|--force\s+--recursive)\b/, reason: '강제 재귀 삭제(rm -rf)' },
5  { pattern: /(^|[\s;&|(])sudo\s/, reason: '관리자 권한 실행(sudo)' },
6  { pattern: /\b(curl|wget)\b[^|;&]*\|\s*(sudo\s+)?(ba|z)?sh\b/, reason: '내려받은 스크립트 바로 실행' },
7  { pattern: /\bchmod\s+(-R\s+)?0?777\b/, reason: '파일 권한 전체 개방(chmod 777)' },
8  { pattern: /\bgit\s+push\b[^;&|]*(\s--force(-with-lease)?\b|\s-f\b)/, reason: 'git 강제 push' },
9  { pattern: /\bgit\s+reset\s+--hard\b/, reason: '변경 사항 버리기(git reset --hard)' },
10  { pattern: /\bgit\s+clean\s+-[a-zA-Z]*f/, reason: '추적 안 되는 파일 삭제(git clean)' },
11  { pattern: /\bgh\s+repo\s+delete\b/, reason: 'GitHub 저장소 삭제' },
12  { pattern: /\b(dd\s+if=|mkfs(\.\w+)?\s|diskutil\s+(erase|partition))/, reason: '디스크 직접 쓰기' },
13  { pattern: /(\/usr\/bin\/security\b|(^|[\s;&|(])security\s+(find|dump|export|delete)-)/, reason: '키체인 접근(security)' },
14  { pattern: /(~|\$HOME|\/Users\/[^/\s]+|\/root)\/\.(ssh|aws|gnupg|kube)\b|\bid_(rsa|ed25519|ecdsa)\b|\.netrc\b/, reason: '인증 정보 경로 접근' },
15  { pattern: /(_token|\.token|credentials?)(\.json)?\b/i, reason: '토큰·자격 증명 파일 접근' },
16  { pattern: /\b(printenv|env)\b[^;&]*\|\s*(curl|wget|nc)\b/, reason: '환경 변수 외부 전송' },
17]
18
19const SENSITIVE_PATH: Rule[] = [
20  { pattern: /(^|\/)\.env(\.[\w-]+)?$/, reason: '환경 변수 파일(.env)' },
21  { pattern: /(^|\/)\.(ssh|aws|gnupg|kube)\//, reason: '인증 정보 폴더' },
22  { pattern: /(^|\/)(id_(rsa|ed25519|ecdsa)|\.netrc|credentials?(\.json)?|[^/]*_token)$/i, reason: '인증 정보 파일' },
23  { pattern: /^\/etc\//, reason: '시스템 설정(/etc)' },
24  { pattern: /(^|\/)\.(zshrc|bashrc|bash_profile|profile|zprofile)$/, reason: '셸 시작 설정' },
25  { pattern: /(^|\/)\.git\/hooks\//, reason: 'git hook' },
26  { pattern: /(^|\/)Library\/LaunchAgents\//, reason: '자동 실행 등록(LaunchAgents)' },
27]
28
29const firstMatch = (rules: Rule[], text: string): string | undefined => rules.find(r => r.pattern.test(text))?.reason
30
31/** 도구 호출이 보안상 위험해 보이면 그 이유를, 아니면 undefined. */
32export function riskOf(tool: string, input: Record<string, unknown>): string | undefined {
33  if (tool === 'Bash') {
34    const command = typeof input.command === 'string' ? input.command : ''
35    if (input.dangerouslyDisableSandbox === true) return '샌드박스 해제 실행'
36    return firstMatch(BASH_RULES, command)
37  }
38  const path = [input.file_path, input.notebook_path, input.path].find((v): v is string => typeof v === 'string')
39  if (path === undefined) return undefined
40  const reason = firstMatch(SENSITIVE_PATH, path)
41  if (reason === undefined) return undefined
42  if (tool === 'Write' || tool === 'Edit' || tool === 'NotebookEdit') return `${reason} 수정`
43  if (tool === 'Read') return `${reason} 읽기`
44  return undefined
45}
46
47/** 목록에 보여줄 한 줄 요약. */
48export function summaryOf(tool: string, input: Record<string, unknown>): string {
49  const pick = [input.command, input.file_path, input.notebook_path, input.url, input.pattern, input.path]
50  const text = pick.find((v): v is string => typeof v === 'string') ?? ''
51  const line = text.split('\n')[0] ?? ''
52  return line.length > 80 ? `${line.slice(0, 79)}…` : line
53}
54
55/** 실패 결과에서 사유 한 줄. */
56export function failureOf(result: { deny?: string; isError?: boolean; text?: string }): string | undefined {
57  if (result.deny !== undefined) return `거부됨: ${result.deny.split('\n')[0]}`
58  if (result.isError !== true) return undefined
59  const line = (result.text ?? '').split('\n').find(l => l.trim() !== '') ?? '오류'
60  return line.length > 120 ? `${line.slice(0, 119)}…` : line
61}
62
types/index.d.ts 18 lines
1export type ToolEvent = {
2  id: string
3  tool: string
4  /** 무엇을 했는지: Bash 명령 앞부분, 파일 경로 등 */
5  summary: string
6  /** 보안상 위험하다고 본 이유. 위험하지 않으면 없음 */
7  risk?: string
8  /** 실패 사유. 성공했거나 아직 실행 중이면 없음 */
9  failure?: string
10  isDone: boolean
11}
12
13declare module 'claude-code' {
14  interface PluginState {
15    'session-activity': { events: ToolEvent[]; risky: number; failed: number }
16  }
17}
18