SLOPSHOPPER

public-guard

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

newguardtoastprocess
v0.1.0no licenseupdated 2026-10-12alstjrwjd99/agent-memory/mods/public-guard
A shopper browsing a rack in a slop shop
README

agent-memory

에이전트의 경험 → 사실 → 판단 → 행동 규칙을 쌓는 메모리 시스템 + Claude Code mods. 설계: docs/DESIGN.md

윈도우(회사 PC)는 docs/SETUP-WINDOWS.md를 따르세요.

설치 (Mac / Windows 공통)

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만 하면 반영됨.

기억 store (private)

git clone https://github.com/alstjrwjd99/memory-personal.git ~/workspace/memory-personal

작업 전후로 uv run amem sync (pull --rebase + push). events/는 PC 로컬에만 남음.

엔진 (amem)

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)

memory-events 설정 (/config 또는 settings.json pluginConfigs)

필드MacWindows(회사)
machinemacwin-work
defaultScopepersonalwork
personalStore~/workspace/memory-personal 절대경로(개인 store 경로)
workStore(비움)C:\...\memory-work
workPaths(비움)필요 시
Source 3 files
hooks/register.ts 74 lines
1import { 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}
74
hooks/scan.ts 66 lines
1// 순수 함수: 명령 해석, 민감어 매칭 (테스트 대상)
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}
66
types/index.d.ts 9 lines
1export 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