SLOPSHOPPER

config-guard

Ask the person before Claude edits Claude Code's own config, instruction files or this guard, and before a known-format secret is written.

newguard
v0.1.0no licenseupdated 2026-10-0838kta-lab/dotfile/mods/config-guard
A shopper browsing a rack in a slop shop
README

dotfile

Personal dotfiles for zsh, WezTerm, and global Codex / Claude Code Skills.

Initialization

./init.sh

init.sh は idempotent。以下のいずれかをやったら再実行する:

  • skills/<name>/ を追加した (新 skill)
  • skills/<name>/ を削除した (broken symlink が link_skills で自動 cleanup)
  • zsh/, wezterm/, nvim/, git/, lazygit/, czg/, cz-git/, starship/ の構成を変えた

Bootstrap (new machine)

./bootstrap.sh

Then run:

gh auth login
gh auth refresh -s project
git config --global ghq.root "$HOME/src"
mkdir -p "$HOME/.config/zsh" "$HOME/.config/wezterm" "$HOME/.config/codex/skills" "$HOME/.claude/skills"
./init.sh

既にあるマシンへ 追加分だけ 取り込むときは --no-upgrade を付ける。 付けないと brew bundle は古い formula をまとめて upgrade する。

git pull && brew bundle --file=./Brewfile --no-upgrade

./bootstrap.sh also installs Miniforge3 into ~/miniforge3 when missing. ./init.sh links zsh/env.zsh, which loads conda shell support without auto-activating base.

Manual bootstrap (no script)

/bin/bash -c "$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh)"
echo "" >> "$HOME/.zprofile"
echo 'eval "$(/opt/homebrew/bin/brew shellenv zsh)"' >> "$HOME/.zprofile"
eval "$(/opt/homebrew/bin/brew shellenv zsh)"
# 公式以外の tap は Homebrew 7 から明示的な信頼が要る(~/.homebrew/trust.json, マシンごと)
brew trust olets/tap
# パッケージの一覧は Brewfile が正本(個別の brew install をここに増やさない)
brew bundle --file=./Brewfile
npm install -g git-cz czg cz-git
npx -y czg --api-key="sk-XXXX"

Clone this repo with ghq, then install:

gh auth login
gh auth refresh -s project
git config --global ghq.root "$HOME/src"
mkdir -p "$HOME/.config/zsh" "$HOME/.config/wezterm" "$HOME/.config/codex/skills" "$HOME/.claude/skills"
ghq get https://github.com/38kta-lab/dotfile
cd "$(ghq root)/github.com/38kta-lab/dotfile"
./install_miniforge.sh
./init.sh

gh auth refresh -s project is required on each machine where Codex or gh updates GitHub Projects, such as the Life project Status field.

Codex / Antigravity (Gemini) CLI

# Codex CLI
npm install -g @openai/codex
mkdir -p ~/.config/codex
mv ~/.codex/* ~/.config/codex/
codex sign-in

Antigravity CLI (agy) — Gemini CLI の後継

Google は 2026-06-18 に個人 Google アカウント向けの Gemini CLI ログインを サーバ側で打ち切った(This client is no longer supported for Gemini Code Assist for individuals エラー)。後継の Antigravity CLI (agy) に移行する。

# 旧 gemini CLI が残っていれば削除
npm uninstall -g @google/gemini-cli 2>/dev/null; rm -rf ~/.gemini

# Antigravity CLI をインストール (Go 製 single binary、~/.local/bin/agy)
curl -fsSL https://antigravity.google/cli/install.sh | bash

# 初回起動でブラウザ認証 (要 Google アカウント。Pro 系モデルはサブスク必要)
agy
  • ~/.local/bin は zsh/env.zsh で既に PATH 追加済み → 追加設定不要。
  • 使い方: 対話 agy / 非対話 agy -p "プロンプト" / モデル指定 agy --model <id> -p ... / 一覧 agy models / 継続 agy -c。
  • 認証は初回ブラウザ OAuth のみ、以降は OS keyring から自動サインイン。

Miniforge / Conda

Miniforge3 is installed under:

~/miniforge3

Install or verify it:

./install_miniforge.sh

Use a pinned Miniforge release when needed:

MINIFORGE_VERSION=25.11.0-0 ./install_miniforge.sh

The installer supports Apple Silicon and Intel macOS by selecting the matching installer from the official conda-forge/miniforge GitHub releases:

https://github.com/conda-forge/miniforge/releases

base should not auto-activate:

conda config --set auto_activate_base false

For per-repo environments, prefer environment.yml in that repo:

conda env create -f environment.yml
conda activate <env-name>

If an environment already exists:

conda env update -f environment.yml --prune

Google Calendar Credentials

For Codex-assisted Calendar reads in the life repo, place the OAuth desktop client JSON at:

~/.config/life/google-calendar-credentials.json

Copy this file between personal Macs using a private secure channel. Do not commit it to git, and do not create a public/shared link.

On a new Mac:

mkdir -p "$HOME/.config/life"
mv "$HOME/Downloads/google-calendar-credentials.json" "$HOME/.config/life/google-calendar-credentials.json"
chmod 600 "$HOME/.config/life/google-calendar-credentials.json"

If the downloaded file has a client_secret_*.json name:

mkdir -p "$HOME/.config/life"
mv "$HOME/Downloads"/client_secret_*.json "$HOME/.config/life/google-calendar-credentials.json"
chmod 600 "$HOME/.config/life/google-calendar-credentials.json"

Do not copy this token between Macs:

~/.config/life/google-calendar-read-token.json

Generate that token separately on each Mac by running the Calendar reader from the life repo after activating its conda environment:

conda activate life
python scripts/google_calendar_read.py --format json

Skills (Codex / Claude Code)

Global user Skills are managed in this repo under:

skills/

./init.sh links each directory under skills/ into both:

~/.config/codex/skills/
~/.claude/skills/

System Skills under ~/.config/codex/skills/.system/ are not managed here. Claude Code's auto memory under ~/.claude/projects/.../memory/ is also not managed here.

PR Workflow (squash)

Use PRs with squash merge to keep main clean and reduce cross-machine conflicts. Rule of thumb: update main, but do not work directly on it.

Branch naming (one branch per machine):

  • work/<hostname> (example: work/kta38-mini-lab)

Get <hostname> with:

hostname -s

Initial setup (first time on a machine):

HOST="$(hostname -s)"
git switch main
git pull --rebase
git switch -c "work/$HOST"
git push -u origin "work/$HOST"

Flow (manual):

# start work (every time)
git switch main
git pull --rebase
git switch work/<hostname>
git rebase main

# work + commit
git add -A
git commit -m "feat: ..."
git push -u origin work/<hostname>

Create a PR from work/<hostname> to main:

gh pr create --base main --head work/<hostname> --fill

Then Squash and merge it on GitHub, or with gh:

gh pr merge <PR_NUMBER> --squash

Merge commit (no squash):

gh pr merge <PR_NUMBER> --merge

Aliases (see zsh/alias.zsh):

winit   # initial setup: create/push work/<hostname> branch
wmain   # update main only (before switching)
wstart  # start work: update main -> switch work/<hostname> -> rebase
wrebase # rebase current work branch onto main
prc     # gh pr create --base main --head work/<hostname> --fill
prs     # gh pr merge --squash
prm     # gh pr merge --merge

Then on other machines:

git switch main
git pull --rebase

Notes:

  • Keep work/<hostname> rebased onto main to avoid long-lived divergence.
  • Squash keeps history clean; use merge commits only when you need full commit history preserved.
  • Avoid pushing directly to main.

Notes

  • WezTerm keybinds are managed at wezterm/keybinds.lua and linked to ~/.config/wezterm/keybinds.lua.
  • Global Skills are managed under skills/ and linked to both ~/.config/codex/skills/ and ~/.claude/skills/.
  • ~/.config/.czrc is not committed; see czrc/.czrc.example for a template.

Mac Setup Checklist

Keyboard

  • Input source: 日本語 - ローマ字入力
  • Input mode: 英字
  • Caps Lock: オフの時「英字」を入力

Trackpad

  • Tracking speed: Max
  • Tap to click: オン

Pointer

  • Size: 1つ大きくする
  • Fill: #D05654
  • Outline: #464758

iCloud

  • Desktop and Documents sync: オン

Desktop

  • スタックを使用
  • 表示オプションを表示
  • テキストサイズ: 10
  • 並べ替え: 種類
  • 表示順序: 名前
  • アイコン: 36x36
  • グリッド間隔: 下から4番目

Dock

  • Position: 左
  • Automatically show/hide: オン
  • Size/zoom: いい感じに

Default browser

  • Chrome

Mac app

  • Zoom: Download is here
  • Microsoft: Word, Excel, Powerpoint
  • Magnet: Download is here
  • Gmail, Google calender
  • Google drive for mac: Download is here
  • ChimeraX: Download is here
Source 1 files
hooks/register.ts 165 lines
1import type { Register } from 'claude-code'
2
3// Claude Code's own configuration and instruction files, and this guard.
4// A change to any of them changes what every later session is allowed to do.
5const PROTECTED_PATHS: readonly RegExp[] = [
6  /\.claude\/settings(\.local)?\.json$/,
7  /(^|\/)\.claude\.json$/,
8  /(^|\/)\.mcp\.json$/,
9  /(^|\/)CLAUDE\.md$/,
10  /(^|\/)AGENTS\.md$/,
11  /\.claude\/hooks\//,
12  /\.claude\/statusline[^/]*$/,
13  /\.claude\/plugins\//,
14  /\/mods\/config-guard\//,
15]
16
17// The same files as they appear inside a shell command (not anchored, and
18// `cd ~/.claude && ... settings.json` still counts).
19const PROTECTED_IN_COMMAND: readonly RegExp[] = [
20  /\.claude\b[\s\S]*settings(\.local)?\.json/,
21  /\.claude\.json/,
22  /\.mcp\.json/,
23  /CLAUDE\.md/,
24  /AGENTS\.md/,
25  /\.claude\/hooks\b/,
26  /\.claude\/statusline/,
27  /\.claude\/plugins\b/,
28  /mods\/config-guard\b/,
29]
30
31// Shell forms that can change a file. Best effort: a command built from
32// variables or an interpreter script gets past it.
33const WRITES =
34  /(^|[^<>&0-9])>>?(?!&)|\btee\b|\bsed\b[^|;&]*\s-[a-zA-Z]*i|\bperl\b[^|;&]*\s-[a-zA-Z]*i|\b(cp|mv|rm|ln|install|rsync|truncate|chmod|chown|touch|dd)\b|\bgit\s+(checkout|restore|apply|mv|rm)\b|\bopen\([^)]*['"][wa]/
35
36// Secrets by their published prefixes only. Never by entropy: research notes
37// carry long hex digests (database md5s) that are not secrets.
38const SECRETS: readonly (readonly [string, RegExp])[] = [
39  ['Anthropic API key', /sk-ant-[A-Za-z0-9_-]{20,}/],
40  ['OpenAI API key', /\bsk-(proj-)?[A-Za-z0-9_-]{32,}/],
41  ['GitHub token', /\bgh[pousr]_[A-Za-z0-9]{36,}|\bgithub_pat_[A-Za-z0-9_]{40,}/],
42  ['AWS access key', /\bAKIA[0-9A-Z]{16}\b/],
43  ['Slack token', /\bxox[abprs]-[A-Za-z0-9-]{10,}/],
44  ['Google API key', /\bAIza[0-9A-Za-z_-]{35}/],
45  ['Google OAuth token', /\bya29\.[0-9A-Za-z_-]{20,}/],
46  ['Tailscale auth key', /\btskey-[A-Za-z0-9-]{20,}/],
47  ['private key', /-----BEGIN [A-Z ]*PRIVATE KEY-----/],
48]
49
50// The shared task list: only the session started with LIFE_ROLE=ops writes it.
51// Every other session reads it. Refused without a dialog: no other session
52// has a reason to write it.
53const OPS_ONLY_PATH = /(^|\/)ideas\/task-review\/tasks\.md$/
54const OPS_ONLY_IN_COMMAND = /task-review\b[\s\S]*\btasks\.md/
55const OPS_ONLY_DENY =
56  'config-guard: ideas/task-review/tasks.md is written by the ops session only. ' +
57  'Do not write it another way; put the change you wanted in your report instead.'
58
59const ALLOW = 'Allow once'
60const DENY = 'Deny'
61
62function extraPaths(value: unknown): string[] {
63  return String(value ?? '')
64    .split(',')
65    .map(s => s.trim())
66    .filter(Boolean)
67}
68
69function pathReasons(path: string, extra: readonly string[]): string[] {
70  const hit = PROTECTED_PATHS.some(r => r.test(path)) || extra.some(s => path.includes(s))
71  return hit ? [`writes to a protected file: ${path}`] : []
72}
73
74// Names the kind of secret, never the matched text, so the question itself
75// does not carry the secret into the transcript.
76function secretReasons(text: string): string[] {
77  const hit = SECRETS.find(([, r]) => r.test(text))
78  return hit ? [`the content looks like it holds a ${hit[0]}`] : []
79}
80
81// The command cut into the pieces that each run on their own, each paired with
82// the `cd` targets before it (`cd ~/.claude && sed -i ... settings.json` still
83// names settings.json under .claude). A file named in one piece and a write in
84// another (`git rm note/x.md && git commit -m "... CLAUDE.md"`) is not a write
85// to that file. Commit messages are dropped: they name files, they write none.
86function segments(command: string): string[] {
87  const c = command
88    .replace(/\d*&?>>?\s*\/dev\/null/g, '')
89    .replace(/(\s-m|\s--message)(=|\s+)("(?:[^"\\]|\\.)*"|'[^']*')/g, '$1 ""')
90  const out: string[] = []
91  let dirs = ''
92  for (const piece of c.split(/&&|\|\||;|\n|\|/)) {
93    out.push(`${dirs} ${piece}`)
94    const cd = piece.match(/^\s*cd\s+(\S+)/)
95    if (cd) dirs += ` ${cd[1]}`
96  }
97  return out
98}
99
100function opsOnlyCommand(command: string): boolean {
101  return segments(command).some(s => OPS_ONLY_IN_COMMAND.test(s) && WRITES.test(s))
102}
103
104function bashReasons(command: string, extra: readonly string[]): string[] {
105  const hit = segments(command).some(s =>
106    (PROTECTED_IN_COMMAND.some(r => r.test(s)) || extra.some(x => s.includes(x))) && WRITES.test(s))
107  return [
108    ...(hit ? [`a shell command that may change a protected file: ${command.slice(0, 120)}`] : []),
109    ...secretReasons(command),
110  ]
111}
112
113// Puts the call to the person in a dialog. `tool.check`'s `ask` is not used:
114// under auto mode it goes to the auto-mode classifier, not to the person.
115// No dialog (a `-p` run) or a dismissed one refuses the call. Deny is listed
116// first and only the exact Allow label allows, so a dialog that resolves on
117// its own while the person is away does not let the call through.
118async function confirm(ask: (q: string) => Promise<string>, tool: string, reasons: readonly string[]): Promise<string | undefined> {
119  const why = reasons.join(' / ')
120  let answer: string
121  try {
122    answer = await ask(`config-guard: ${tool} ${why}. Allow it?`)
123  } catch {
124    return `config-guard refused this ${tool} call (${why}): nobody could be asked to confirm it.`
125  }
126  return answer === ALLOW ? undefined : `config-guard: the person declined this ${tool} call (${why}).`
127}
128
129const FAIL_CLOSED = 'config-guard: its check failed, so the call was refused.'
130
131export const register: Register = (on, options) => {
132  const extra = extraPaths(options.extra_paths)
133
134  on('tool.call', { tool: 'Write' }, async ($, e, next) => {
135    if (OPS_ONLY_PATH.test(e.file_path) && (await $.env.get('LIFE_ROLE')) !== 'ops') return { deny: OPS_ONLY_DENY }
136    const reasons = [...pathReasons(e.file_path, extra), ...secretReasons(e.content)]
137    if (reasons.length === 0) return next(e)
138    const deny = await confirm(q => $.ui.ask(q, { header: 'Guard', options: [DENY, ALLOW] }), 'Write', reasons)
139    return deny ? { deny } : next(e)
140  }).catch(($, e, next) => (next.called ? next(e) : { deny: FAIL_CLOSED }))
141
142  on('tool.call', { tool: 'Edit' }, async ($, e, next) => {
143    if (OPS_ONLY_PATH.test(e.file_path) && (await $.env.get('LIFE_ROLE')) !== 'ops') return { deny: OPS_ONLY_DENY }
144    const reasons = [...pathReasons(e.file_path, extra), ...secretReasons(e.new_string)]
145    if (reasons.length === 0) return next(e)
146    const deny = await confirm(q => $.ui.ask(q, { header: 'Guard', options: [DENY, ALLOW] }), 'Edit', reasons)
147    return deny ? { deny } : next(e)
148  }).catch(($, e, next) => (next.called ? next(e) : { deny: FAIL_CLOSED }))
149
150  on('tool.call', { tool: 'NotebookEdit' }, async ($, e, next) => {
151    const reasons = [...pathReasons(e.notebook_path, extra), ...secretReasons(e.new_source)]
152    if (reasons.length === 0) return next(e)
153    const deny = await confirm(q => $.ui.ask(q, { header: 'Guard', options: [DENY, ALLOW] }), 'NotebookEdit', reasons)
154    return deny ? { deny } : next(e)
155  }).catch(($, e, next) => (next.called ? next(e) : { deny: FAIL_CLOSED }))
156
157  on('tool.call', { tool: 'Bash' }, async ($, e, next) => {
158    if (opsOnlyCommand(e.command) && (await $.env.get('LIFE_ROLE')) !== 'ops') return { deny: OPS_ONLY_DENY }
159    const reasons = bashReasons(e.command, extra)
160    if (reasons.length === 0) return next(e)
161    const deny = await confirm(q => $.ui.ask(q, { header: 'Guard', options: [DENY, ALLOW] }), 'Bash', reasons)
162    return deny ? { deny } : next(e)
163  }).catch(($, e, next) => (next.called ? next(e) : { deny: FAIL_CLOSED }))
164}
165