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

도구 사용 중 실패한 것과 보안상 위험해 보이는 것만 골라 보여주는 mod입니다. 평범하게 성공한 호출은 표시하지 않습니다. 위험한 호출을 막지는 않고 알리기만 합니다.
도구 위험 N · 실패 M. 둘 다 0이면 비웁니다.목록 비우기로 초기화합니다./activity: 패널 다시 열기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 파일, 환경 변수를 외부로 보내는 파이프, 샌드박스 해제 실행.env, ~/.ssh·~/.aws 등 인증 정보 폴더·파일, /etc, 셸 시작 설정(.zshrc 등), .git/hooks, ~/Library/LaunchAgents규칙은 hooks/risk.ts에 있습니다. 명령 문장을 패턴으로 보는 방식이라 모든 위험을 잡지는 못하고, 가끔 안전한 명령을 위험으로 표시할 수 있습니다.
hooks/register.tsx 118 lines1import { 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}
118hooks/risk.ts 62 lines1type 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}
62types/index.d.ts 18 lines1export 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