공개 GitHub 저장소에 commit/push 할 때 고객사·내부 프로젝트명 같은 민감 식별자가 나가면 막는다 (목록은 로컬 파일에만)

에이전트의 경험 → 사실 → 판단 → 행동 규칙을 쌓는 메모리 시스템 + Claude Code mods. 설계: docs/DESIGN.md
윈도우(회사 PC)는 docs/SETUP-WINDOWS.md를 따르세요.
git clone <이 저장소> ~/workspace/agent-memory
claude plugin marketplace add ~/workspace/agent-memory
claude plugin install memory-events@agent-memory
claude plugin install memory-lens@agent-memory
claude plugin install env-guard@agent-memory
로컬 폴더 marketplace라 git pull 후 세션에서 /reload-plugins만 하면 반영됨.
git clone https://github.com/alstjrwjd99/memory-personal.git ~/workspace/memory-personal
작업 전후로 uv run amem sync (pull --rebase + push). events/는 PC 로컬에만 남음.
cd engine
uv sync
uv run amem init --machine mac --personal ~/workspace/memory-personal # 맥
uv run amem init --machine win-work --work C:\work\memory-work --personal ... # 윈도우
uv run amem health
uv run amem promote # events → episode (신호 있는 세션만 LLM 호출)
uv run amem reflect # 판단 변화 제안 생성
uv run amem review # 제안 확인
uv run amem apply <id> --only 1,3 / uv run amem reject <id>
uv run amem export # 확신도 높은 판단을 Claude Code 메모리로
uv run amem recall "질문" --cwd . # 관련 기억 꺼내기 (memory-lens가 프롬프트마다 자동 호출)
uv run amem history <id> # 한 기억의 변천 (versions + 확신도 + git diff)
uv run amem sync # store git pull --rebase + push
uv run amem --store work ... # 업무 store 대상
설정: ~/.config/agent-memory/config.yaml (윈도우: %USERPROFILE%\.config\agent-memory\config.yaml)
/config 또는 settings.json pluginConfigs)| 필드 | Mac | Windows(회사) |
|---|---|---|
| machine | mac | win-work |
| defaultScope | personal | work |
| personalStore | ~/workspace/memory-personal 절대경로 | (개인 store 경로) |
| workStore | (비움) | C:\...\memory-work |
| workPaths | (비움) | 필요 시 |
hooks/register.ts 74 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, PluginOptions, Register } from 'claude-code'
3
4import type { Visibility } from '../types'
5import { commitMessages, expandHome, findTerms, isPublishing, locate, parseTerms, parseVisibility, repoDirOf } from './scan.ts'
6
7const visibility = atom({ plugin: 'public-guard', key: 'visibility' } as const, {} as Record<string, Visibility>)
8
9async function git($: EngineInterface, dir: string, args: string[]): Promise<string> {
10 const r = await $.process.run(['git', '-C', dir, ...args], { timeoutMs: 15_000 })
11 return r.exitCode === 0 ? r.stdout : ''
12}
13
14// 원격이 공개 GitHub 저장소인가 (gh로 확인, 세션 동안 캐시)
15async function isPublic($: EngineInterface, dir: string): Promise<boolean> {
16 const remote = (await git($, dir, ['remote', 'get-url', 'origin'])).trim()
17 if (!remote.includes('github.com')) return false
18 const cache = await read($, visibility)
19 let v = cache[remote]
20 if (!v) {
21 const r = await $.process.run(['gh', 'repo', 'view', remote, '--json', 'isPrivate,visibility'], { timeoutMs: 15_000 })
22 v = r.exitCode === 0 ? parseVisibility(r.stdout) : 'unknown'
23 await update($, visibility, c => ({ ...c, [remote]: v as Visibility }))
24 }
25 // 확인 못 하면 공개로 간주 (막는 쪽이 안전)
26 return v !== 'private'
27}
28
29async function terms($: EngineInterface, options: PluginOptions, home: string): Promise<string[]> {
30 const file = expandHome(String(options.termsFile || '~/.config/agent-memory/sensitive-terms.txt'), home)
31 return (await $.fs.exists(file)) ? parseTerms(String(await $.fs.read(file))) : []
32}
33
34// 이번에 나갈 내용: 커밋 메시지 + 업스트림 대비 변경 + 아직 안 올린 커밋 메시지 + 새 파일
35async function outgoing($: EngineInterface, dir: string, command: string): Promise<string> {
36 const upstream = (await git($, dir, ['rev-parse', '--abbrev-ref', '@{u}'])).trim()
37 const diff = upstream
38 ? await git($, dir, ['diff', upstream])
39 : (await git($, dir, ['diff', '--cached'])) + (await git($, dir, ['diff']))
40 const logs = upstream ? await git($, dir, ['log', `${upstream}..HEAD`, '--format=%B']) : ''
41 const untracked = (await git($, dir, ['ls-files', '--others', '--exclude-standard'])).split('\n').filter(Boolean).slice(0, 200)
42 let added = ''
43 for (const f of untracked) {
44 const path = `${dir}/${f}`
45 try {
46 added += `\n+++ b/${f}\n` + String(await $.fs.read(path)).split('\n').map(l => `+${l}`).join('\n')
47 } catch {}
48 }
49 return [...commitMessages(command), logs, diff, added].join('\n')
50}
51
52export const register: Register = (on, options) => {
53 on('tool.call', { tool: 'Bash' }, async ($, e, next) => {
54 if (!isPublishing(e.command)) return next(e)
55 const home = (await $.env.get('HOME')) ?? (await $.env.get('USERPROFILE')) ?? ''
56 const list = await terms($, options, home)
57 if (list.length === 0) return next(e)
58 const raw = repoDirOf(e.command)
59 const dir = raw ? expandHome(raw, home) : await $.session.cwd()
60 if (!(await isPublic($, dir))) return next(e)
61 const text = await outgoing($, dir, e.command)
62 const hits = findTerms(text, list)
63 if (hits.length === 0) return next(e)
64 const where = hits.map(t => `${t}${locate(text, t).length ? ` (${locate(text, t).slice(0, 3).join(', ')})` : ''}`).join('; ')
65 $.ui.toast(`public-guard: 공개 저장소로 민감어가 나가려 해서 막았어요: ${hits.join(', ')}`)
66 return {
67 deny: `${$.plugin.name}: 공개 저장소(${dir})에 민감 식별자가 포함됨: ${where}. 가상 이름(acme, billing 등)으로 바꾼 뒤 다시 시도할 것. 커밋 메시지도 확인.`,
68 }
69 }).catch(($, e, next) =>
70 // 검사 자체가 실패하면 막지 않고 통과 (작업 흐름을 멈추지 않게). 실패 사실은 알림
71 next.called ? next(e) : ($.ui.toast('public-guard: 검사 실패, 통과시킴'), next(e)),
72 )
73}
74hooks/scan.ts 66 lines1// 순수 함수: 명령 해석, 민감어 매칭 (테스트 대상)
2
3// git commit / push 가 들어 있는 명령인가 (&&, ; 로 이어진 것 포함)
4// 따옴표 안 문자열(커밋 메시지, echo 인자)은 빼고 본다
5export const isPublishing = (command: string): boolean =>
6 /\bgit\b[^;&|]*\b(commit|push)\b/.test(command.replace(/"[^"]*"|'[^']*'/g, '""'))
7
8// 명령이 어느 저장소에서 실행되는지: git -C <dir> 또는 앞쪽 cd <dir>, 없으면 null (= 세션 cwd)
9export function repoDirOf(command: string): string | null {
10 const c = /\bgit\s+-C\s+("([^"]+)"|'([^']+)'|(\S+))/.exec(command)
11 if (c) return c[2] ?? c[3] ?? c[4] ?? null
12 const cd = /(?:^|&&|;)\s*cd\s+("([^"]+)"|'([^']+)'|(\S+))/.exec(command)
13 if (cd) return cd[2] ?? cd[3] ?? cd[4] ?? null
14 return null
15}
16
17// 명령에서 커밋 메시지만 꺼냄 (-m "..." / -m '...' / --message=...). 명령 전체를 검사하면
18// 같은 줄의 grep 패턴 같은 무관한 인자까지 걸리므로 메시지만 본다
19export function commitMessages(command: string): string[] {
20 const out: string[] = []
21 const re = /(?:-m|--message)(?:\s+|=)("((?:[^"\\]|\\.)*)"|'([^']*)'|(\S+))/g
22 for (let m = re.exec(command); m; m = re.exec(command)) out.push(m[2] ?? m[3] ?? m[4] ?? '')
23 return out
24}
25
26// 홈(~) 펼치기
27export const expandHome = (p: string, home: string): string => (p === '~' || p.startsWith('~/') ? home + p.slice(1) : p)
28
29// 민감어 목록 파일: 한 줄에 하나, # 주석, 빈 줄 무시
30export const parseTerms = (text: string): string[] =>
31 text.split(/\r?\n/).map(l => l.replace(/#.*/, '').trim()).filter(Boolean)
32
33// 영문/숫자 용어는 단어 경계로(긴 단어 속 오탐 방지), 한글 등은 포함 여부로
34export function findTerms(text: string, terms: readonly string[]): string[] {
35 const hits = new Set<string>()
36 for (const term of terms) {
37 const escaped = term.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
38 const re = /^[\w-]+$/.test(term) ? new RegExp(`(?<![A-Za-z0-9])${escaped}(?![A-Za-z0-9])`, 'i') : new RegExp(escaped, 'i')
39 if (re.test(text)) hits.add(term)
40 }
41 return [...hits]
42}
43
44// gh repo view --json isPrivate,visibility 결과 해석
45export function parseVisibility(stdout: string): 'public' | 'private' | 'unknown' {
46 try {
47 const d = JSON.parse(stdout) as { isPrivate?: boolean; visibility?: string }
48 if (d.isPrivate === true || d.visibility === 'PRIVATE' || d.visibility === 'INTERNAL') return 'private'
49 if (d.isPrivate === false || d.visibility === 'PUBLIC') return 'public'
50 } catch {}
51 return 'unknown'
52}
53
54// 매칭된 줄 위치 요약 (어느 파일에서 걸렸는지 보여주기용, diff 헤더 기준)
55export function locate(diff: string, term: string): string[] {
56 const files: string[] = []
57 let file = ''
58 const t = term.toLowerCase()
59 for (const line of diff.split('\n')) {
60 const m = /^\+\+\+ b\/(.+)$/.exec(line)
61 if (m?.[1]) file = m[1]
62 else if (line.startsWith('+') && line.toLowerCase().includes(t) && file && !files.includes(file)) files.push(file)
63 }
64 return files
65}
66types/index.d.ts 9 lines1export type Visibility = 'public' | 'private' | 'unknown'
2
3declare module 'claude-code' {
4 interface PluginState {
5 // 원격 URL → 공개 여부 캐시 (세션 동안)
6 'public-guard': { visibility: Record<string, Visibility> }
7 }
8}
9