SLOPSHOPPER

command-guard

My Dotfiles!

newguardtoastprocess
A shopper browsing a rack in a slop shop
README

dots

セットアップ (初回のみ、2段階)

1. chezmoi を導入し dots を取得する (反映はしない)

sh -c "$(curl -fsLS get.chezmoi.io)" -- -b $HOME/.local/bin init --source=~/ghq/github.com/0ta2/dots git@github.com:0ta2/dots.git

2. Homebrew と mise を用意して初回セットアップする

~/ghq/github.com/0ta2/dots/bootstrap.sh

Homebrew・mise を導入した後 mise run install (ドットファイル反映・不足ツール・skill・plugin の 導入・Codex 設定同期) を実行する。

日常のコマンド

mise run sync    # ドットファイル反映 + Codex設定マージ (副作用なし、何度実行しても収束する)
mise run update  # brew/mise/skill/uv tool の更新、最後に sync

直接 chezmoi コマンドを実行する場合は下記の通り:

~/.local/bin/chezmoi --source=~/ghq/github.com/0ta2/dots apply

mac セットアップ

  • 一般
  • 情報 
  • コンピュータ名 を変更
  • 自動入力とパスワード
  • パスワードやパスキーを自動入力 をOFF
  • アクセシビリティ
  • ポインタコントロール
  • トラックパッドオプション
  • ドラッグにトラックパッドを使用 をON
  • デスクトップとDock
  • Dock のサイズ小さく好みのサイズへ変更
  • Dock の拡大を好みの大きさへ変更
  • Dock を自動的に表示/非表示をOFF
  • 起動中のアプリケーションをアニメーションで表示をOFF
  • 起動済みのアプリケーションにインジケータを表示をOFF
  • アプリの提案と最近使用したアプリをDockに表示をOFF
  • アプリの提案と最近使用したアプリをDockに表示をOFF
  • 外観
  • 外観モードをダークに変更
  • キーボード
  • キーボードショートカット
  • Spotlight検索を表示 のショートカットのチェックを外す
  • 入力ソース
  • 前の入力ソースを選択 を Cmd + スペース に変更
  • 入力メニューの次のソースを選択 を Cmd + shift + スペース に変更
  • 装飾キー
  • CapsLock を Ctrl に変更する
  • 入力ソース
  • USキー と ひらがな(Google)
  • トラックパッド
  • ポイントとクリック
  • 軌跡の速さを一番早く
  • タップでクリック にチェック

Neovim ローカル開発プラグインのリンク作成

iwano.nvim / amanoukihashi.nvim をローカルのリポジトリから読み込むため、下記のシンボリックリンクを作成する:

mkdir -p ~/.local/share/nvim/site/pack/mine/opt
ln -s ~/ghq/github.com/0ta2/iwano.nvim ~/.local/share/nvim/site/pack/mine/opt/iwano.nvim-local
ln -s ~/ghq/github.com/0ta2/amanoukihashi.nvim ~/.local/share/nvim/site/pack/mine/opt/amanoukihashi.nvim-local
Source 2 files
hooks/register.ts 29 lines
1import type { Register } from 'claude-code'
2import { findRule, type Config } from './guard.ts'
3
4export const register: Register = on => {
5  on('tool.check', async ($, e, next) => {
6    let config: Config
7    try {
8      config = JSON.parse(await $.fs.read(`${$.plugin.root}/rules.json`))
9    } catch (err) {
10      $.ui.toast(`command-guard: rules.json を読めません (${err instanceof Error ? err.message : err})`)
11      return next(e)
12    }
13    const cwd = await $.session.cwd()
14    const home = (await $.env.get('HOME')) ?? ''
15    const git = async (dir: string, args: string[]) => {
16      const r = await $.process.run(['git', '-C', dir, ...args]).catch(() => undefined)
17      return r?.exitCode === 0 ? r.stdout.trim() : ''
18    }
19    const rule = await findRule(config, e.tool, e.input, { cwd, home, git })
20    if (!rule) return next(e)
21    const reason = `command-guard: ${rule.name}`
22    if (rule.action === 'ask') {
23      const verdict = await next(e)
24      return verdict.decision === 'deny' ? verdict : { decision: 'ask', reason }
25    }
26    return { decision: 'deny', reason: `${reason} (blocked)` }
27  })
28}
29
hooks/guard.ts 158 lines
1export type Rule = {
2  name: string
3  tool?: string
4  field?: string
5  pattern: string | string[]
6  flags?: string
7  scrub?: boolean
8  when?: { branch?: string[] }
9  exceptRepos?: string[]
10  exceptMerged?: boolean
11  action?: 'deny' | 'ask'
12}
13
14export type Config = { vars?: Record<string, string>; rules: Rule[] }
15
16export type Context = {
17  cwd: string
18  home: string
19  git: (dir: string, args: string[]) => Promise<string>
20}
21
22const HEREDOC = /<<-?\s*(['"]?)([A-Za-z_]\w*)\1/g
23const MESSAGE_FLAG = /(-m|--message|--body|--title|--notes|--content)(\s*=?\s*)("(?:[^"\\]|\\.)*"|'(?:[^'\\]|\\.)*')/g
24const SUBSTITUTION = /\$\((?:[^()]|\([^()]*\))*\)|`[^`]*`/g
25const SHELL = /^(\S*\/)?((ba|z|da|k)?sh|eval|ssh)$/
26const WRAPPER = /^(\S*\/)?(env|command|exec|nohup|time|nice|sudo|xargs)$/
27
28function runsShell(segment: string): boolean {
29  const words = segment.trim().split(/\s+/).filter(w => !/^\w+=/.test(w))
30  const [first = '', ...rest] = words
31  return SHELL.test(first) || (WRAPPER.test(first) && rest.some(w => SHELL.test(w)))
32}
33
34function feedsShell(cmd: string, at: number, after: number): boolean {
35  const before = cmd.slice(cmd.lastIndexOf('\n', at) + 1, at).split(/[;&|(]/).pop() ?? ''
36  const lineEnd = cmd.indexOf('\n', after)
37  const piped = cmd.slice(after, lineEnd === -1 ? undefined : lineEnd).split('|').slice(1)
38  return [before, ...piped].some(runsShell)
39}
40
41const substitutions = (s: string) =>
42  (s.match(SUBSTITUTION) ?? []).map(sub => ` ; ${sub.replace(/^\$\(|^`|\)$|`$/g, '')} ; `).join('')
43
44export function scrub(cmd: string): string {
45  let out = ''
46  let pos = 0
47  for (const m of cmd.matchAll(HEREDOC)) {
48    if (m.index < pos) continue
49    const head = m.index + m[0].length
50    out += cmd.slice(pos, head)
51    pos = head
52    if (feedsShell(cmd, m.index, head)) continue
53    const end = new RegExp(`^[ \\t]*${m[2]}[ \\t]*$`, 'm').exec(cmd.slice(head))
54    if (!end) continue
55    if (!m[1]) out += ` ${substitutions(cmd.slice(head, head + end.index))}`
56    pos = head + end.index + end[0].length
57  }
58  out += cmd.slice(pos)
59  return out.replace(MESSAGE_FLAG, (_, flag, sep, value: string) =>
60    `${flag}${sep}"${value.startsWith('"') ? substitutions(value) : ''}"`,
61  )
62}
63
64const unquote = (s: string) => s.replace(/^(["'])(.*)\1$/, '$2')
65
66const expand = (p: string, home: string) => (p === '~' ? home : p.startsWith('~/') ? home + p.slice(1) : p)
67
68const PATH_ARG = `("[^"]+"|'[^']+'|\\S+)`
69const LIT = '[A-Za-z0-9._~/-]+'
70const NAME = '[A-Za-z0-9._/][A-Za-z0-9._/-]*'
71const CD = new RegExp(`^cd\\s+(${LIT})$`)
72const BRANCH_DELETE = new RegExp(`^git(?:\\s+-C\\s+(${LIT}))?\\s+branch((?:\\s+(?:-[dDf]+|--delete|--force))+)(\\s+${NAME}(?:\\s+${NAME})*)$`)
73
74const resolve = (base: string, p: string, home: string) => {
75  const abs = expand(unquote(p), home)
76  return abs.startsWith('/') ? abs : `${base}/${abs}`
77}
78
79export function targetDir(cmd: string, re: RegExp, ctx: Context): string {
80  let dir = ctx.cwd
81  for (const seg of cmd.split(/&&|\|\||[;|\n]/)) {
82    const cd = new RegExp(`^\\s*\\(?\\s*cd\\s+${PATH_ARG}`).exec(seg)?.[1]
83    if (cd) {
84      dir = resolve(dir, cd, ctx.home)
85      continue
86    }
87    if (re.test(seg)) {
88      const c = new RegExp(`git\\s+-C\\s+${PATH_ARG}`).exec(seg)?.[1]
89      return c ? resolve(dir, c, ctx.home) : dir
90    }
91  }
92  return dir
93}
94
95function branchNames(cmd: string, ctx: Context): { dir: string; name: string }[] | undefined {
96  if (cmd.includes('|')) return undefined
97  let dir = ctx.cwd
98  const branches = [] as { dir: string; name: string }[]
99  for (const seg of cmd.split(/&&|\|\||[;|\n]/).map(s => s.trim()).filter(Boolean)) {
100    const cd = CD.exec(seg)?.[1]
101    if (cd) {
102      if (!/^(\/|~$|~\/)/.test(cd)) return undefined
103      dir = resolve(dir, cd, ctx.home)
104      continue
105    }
106    const deletion = BRANCH_DELETE.exec(seg)
107    if (!deletion) return undefined
108    const [, deletionDir, flags = '', names = ''] = deletion
109    if (!/(?:^|\s)(?:-[dDf]*[dD][dDf]*|--delete)(?=\s|$)/.test(flags)) return undefined
110    if (!/[Df]|--force/.test(flags)) continue
111    if (deletionDir && !/^(\/|~$|~\/)/.test(deletionDir)) return undefined
112    const target = deletionDir ? resolve(dir, deletionDir, ctx.home) : dir
113    branches.push(...names.trim().split(/\s+/).map(name => ({ dir: target, name })))
114  }
115  return branches
116}
117
118const toolMatches = (glob: string, tool: string) =>
119  new RegExp(`^${glob.split('*').map(s => s.replace(/[.+?^${}()|[\]\\]/g, '\\$&')).join('.*')}$`).test(tool)
120
121export function compile(config: Config): (Rule & { regexes: RegExp[] })[] {
122  const vars = config.vars ?? {}
123  const sub = (s: string) => s.replace(/\$\{(\w+)\}/g, (_, k) => vars[k] ?? '')
124  return config.rules.map(r => ({
125    ...r,
126    regexes: [r.pattern].flat().map(p => new RegExp(sub(p), r.flags)),
127  }))
128}
129
130export async function findRule(config: Config, tool: string, input: unknown, ctx: Context) {
131  for (const rule of compile(config)) {
132    if (!toolMatches(rule.tool ?? 'Bash', tool)) continue
133    const raw = (input as Record<string, unknown> | null)?.[rule.field ?? 'command']
134    if (typeof raw !== 'string') continue
135    const text = rule.scrub === false ? raw : scrub(raw)
136    if (!rule.regexes.every(re => re.test(text))) continue
137    if (rule.when?.branch || rule.exceptRepos) {
138      let dir = targetDir(raw, rule.regexes[0]!, ctx)
139      let top = await ctx.git(dir, ['rev-parse', '--show-toplevel'])
140      if (!top && dir !== ctx.cwd) {
141        dir = ctx.cwd
142        top = await ctx.git(dir, ['rev-parse', '--show-toplevel'])
143      }
144      if (rule.when?.branch && !rule.when.branch.includes(await ctx.git(dir, ['branch', '--show-current']))) continue
145      if (rule.exceptRepos?.some(p => expand(p, ctx.home) === top)) continue
146    }
147    if (rule.exceptMerged) {
148      const branches = branchNames(raw, ctx)
149      if (branches) {
150        const merged = await Promise.all(branches.map(({ dir, name }) => ctx.git(dir, ['branch', '--list', '--merged', 'refs/remotes/origin/HEAD', name])))
151        if (branches.length > 0 && merged.every(Boolean)) continue
152      }
153    }
154    return rule
155  }
156  return undefined
157}
158