@tyhld/conductor 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +339 -0
- package/dist/cli.js +262 -0
- package/dist/relay.js +1315 -0
- package/package.json +35 -0
- package/sales-template/README.md +199 -0
- package/sales-template/cc2_guard.py +395 -0
- package/sales-template/settings.json +161 -0
- package/sales-template/setup.sh +350 -0
- package/sales-template/systemd/README.md +169 -0
- package/sales-template/systemd/conductor@.service +47 -0
- package/sales-template/systemd/install.sh +179 -0
package/package.json
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@tyhld/conductor",
|
|
3
|
+
"version": "0.3.0",
|
|
4
|
+
"description": "采配くん管制の見守りアプリ(conductor-agent)。各PCで常駐し、中央(devlog-tracker)へ定期的に生存報告(heartbeat)を送る常駐CLI。",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"bin": {
|
|
7
|
+
"conductor": "dist/cli.js"
|
|
8
|
+
},
|
|
9
|
+
"files": [
|
|
10
|
+
"dist",
|
|
11
|
+
"sales-template",
|
|
12
|
+
"README.md"
|
|
13
|
+
],
|
|
14
|
+
"engines": {
|
|
15
|
+
"node": ">=18"
|
|
16
|
+
},
|
|
17
|
+
"scripts": {
|
|
18
|
+
"build": "tsc",
|
|
19
|
+
"start": "node dist/cli.js start",
|
|
20
|
+
"test": "tsc && node --test --experimental-strip-types test/*.test.ts",
|
|
21
|
+
"prepublishOnly": "npm run build"
|
|
22
|
+
},
|
|
23
|
+
"publishConfig": {
|
|
24
|
+
"access": "public"
|
|
25
|
+
},
|
|
26
|
+
"repository": {
|
|
27
|
+
"type": "git",
|
|
28
|
+
"url": "git+https://github.com/tyhld/conductor.git"
|
|
29
|
+
},
|
|
30
|
+
"license": "MIT",
|
|
31
|
+
"devDependencies": {
|
|
32
|
+
"@types/node": "^25.9.1",
|
|
33
|
+
"typescript": "^6.0.3"
|
|
34
|
+
}
|
|
35
|
+
}
|
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
# 采配くん販売テンプレート
|
|
2
|
+
|
|
3
|
+
采配くんを顧客PCにインストールするためのテンプレート一式です。
|
|
4
|
+
|
|
5
|
+
## クイックスタート(1コマンドインストール)
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
# @tyhld/conductor パッケージに同梱された setup.sh を実行
|
|
9
|
+
bash setup.sh my-app /path/to/my-app
|
|
10
|
+
```
|
|
11
|
+
|
|
12
|
+
### 必要なもの
|
|
13
|
+
|
|
14
|
+
| 項目 | 説明 |
|
|
15
|
+
|------|------|
|
|
16
|
+
| Node.js 18+ | fnm / nvm / volta / apt いずれか |
|
|
17
|
+
| tmux | `sudo apt install tmux` |
|
|
18
|
+
| git | `sudo apt install git` |
|
|
19
|
+
| Python 3.8+ | OS 標準で入っていることが多い |
|
|
20
|
+
| systemd | WSL の場合: `/etc/wsl.conf` に `[boot] systemd=true` |
|
|
21
|
+
| sudo | node symlink 作成(③)と linger 設定(⑥)で必要 |
|
|
22
|
+
|
|
23
|
+
### setup.sh がやること
|
|
24
|
+
|
|
25
|
+
```
|
|
26
|
+
bash setup.sh <プロジェクト名> [プロジェクトディレクトリ]
|
|
27
|
+
|
|
28
|
+
① 前提チェック (node/tmux/git/python3/systemd)
|
|
29
|
+
② conductor 本体インストール (npm i -g @tyhld/conductor)
|
|
30
|
+
③ node symlink (/usr/local/bin/node → 実体) ← sudo
|
|
31
|
+
④ .conductor.env 作成 (対話入力) ← URL/SECRET入力
|
|
32
|
+
⑤ .claude/ 配置 (cc2_guard.py + settings.json)
|
|
33
|
+
⑥ systemd enable (conductor@<名前>) ← sudo (linger)
|
|
34
|
+
⑦ 職人起動の案内表示
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
顧客の手数: **1コマンド + 対話2問 + sudo + Claude Code 起動 = 実質3アクション**
|
|
38
|
+
|
|
39
|
+
### インストール(認証不要)
|
|
40
|
+
|
|
41
|
+
@tyhld/conductor は公開 npm レジストリ(registry.npmjs.org)で配布されます。
|
|
42
|
+
GitHub PAT や `.npmrc` の設定は不要で、そのまま無認証でインストールできます:
|
|
43
|
+
|
|
44
|
+
```bash
|
|
45
|
+
npm i -g @tyhld/conductor
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
`setup.sh` はこのインストールを自動で行うため、通常は個別に実行する必要はありません。
|
|
49
|
+
|
|
50
|
+
---
|
|
51
|
+
|
|
52
|
+
## PERM ルール(権限設定)の詳細
|
|
53
|
+
|
|
54
|
+
采配くんが Claude Code(職人/CC2)の操作を安全に制御するための権限設定テンプレートです。
|
|
55
|
+
|
|
56
|
+
## この2ファイルは何をするか
|
|
57
|
+
|
|
58
|
+
| ファイル | 役割 | 設置先 |
|
|
59
|
+
|----------|------|--------|
|
|
60
|
+
| `cc2_guard.py` | Bash コマンド実行前の自動検査(Hook) | プロジェクトの `.claude/hooks/` に配置し、hooks 設定で PreToolUse に登録 |
|
|
61
|
+
| `settings.json` | Claude Code の権限ルール(deny/allow/ask) | プロジェクトの `.claude/settings.json` として配置 |
|
|
62
|
+
|
|
63
|
+
### 動作の仕組み(3層防御)
|
|
64
|
+
|
|
65
|
+
```
|
|
66
|
+
コマンド実行要求
|
|
67
|
+
│
|
|
68
|
+
▼
|
|
69
|
+
[第1層] cc2_guard.py (Hook)
|
|
70
|
+
│ 危険コマンド → 即ブロック(deny)
|
|
71
|
+
│ 安全コマンド → 即許可(allow)
|
|
72
|
+
│ 不明 → 次の層へ委譲
|
|
73
|
+
▼
|
|
74
|
+
[第2層] settings.json (権限ルール)
|
|
75
|
+
│ deny パターン → ブロック(Hook の allow より優先)
|
|
76
|
+
│ allow パターン → 自動許可
|
|
77
|
+
│ ask パターン → 人間に確認
|
|
78
|
+
▼
|
|
79
|
+
[第3層] Claude Code のデフォルト判断
|
|
80
|
+
│ permission-mode に応じて人間に確認
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
**設計思想: 「危険だけ確実に止める・それ以外は自動許可」**
|
|
84
|
+
|
|
85
|
+
- 赤(deny): 取り返しのつかない操作(force push、本番DB破壊、rm -rf 等)を問答無用でブロック
|
|
86
|
+
- 黄(ask): 本番デプロイや PR マージなど、実行自体は正当だが人間の確認が欲しい操作
|
|
87
|
+
- 白(allow): ビルド・テスト・git 操作・ファイル閲覧など、日常開発で安全な操作を自動許可
|
|
88
|
+
|
|
89
|
+
## セットアップ手順
|
|
90
|
+
|
|
91
|
+
### 1. settings.json を配置
|
|
92
|
+
|
|
93
|
+
```bash
|
|
94
|
+
# プロジェクトルートに .claude/ がなければ作成
|
|
95
|
+
mkdir -p .claude
|
|
96
|
+
|
|
97
|
+
# テンプレートをコピー
|
|
98
|
+
cp settings.json .claude/settings.json
|
|
99
|
+
|
|
100
|
+
# プレースホルダを差し替え(下記「プレースホルダ一覧」参照)
|
|
101
|
+
# エディタで .claude/settings.json を開いて編集
|
|
102
|
+
```
|
|
103
|
+
|
|
104
|
+
### 2. cc2_guard.py を Hook として登録
|
|
105
|
+
|
|
106
|
+
```bash
|
|
107
|
+
# hooks ディレクトリを作成
|
|
108
|
+
mkdir -p .claude/hooks
|
|
109
|
+
|
|
110
|
+
# cc2_guard.py をコピー
|
|
111
|
+
cp cc2_guard.py .claude/hooks/cc2_guard.py
|
|
112
|
+
chmod +x .claude/hooks/cc2_guard.py
|
|
113
|
+
```
|
|
114
|
+
|
|
115
|
+
Claude Code の設定(`.claude/settings.json` または `~/.claude/settings.json`)に以下を追加:
|
|
116
|
+
|
|
117
|
+
```json
|
|
118
|
+
{
|
|
119
|
+
"hooks": {
|
|
120
|
+
"PreToolUse": [
|
|
121
|
+
{
|
|
122
|
+
"matcher": "Bash",
|
|
123
|
+
"hooks": [
|
|
124
|
+
{
|
|
125
|
+
"type": "command",
|
|
126
|
+
"command": "python3 .claude/hooks/cc2_guard.py"
|
|
127
|
+
}
|
|
128
|
+
]
|
|
129
|
+
}
|
|
130
|
+
]
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
```
|
|
134
|
+
|
|
135
|
+
### 3. 動作確認
|
|
136
|
+
|
|
137
|
+
```bash
|
|
138
|
+
# Claude Code を起動して以下を試す:
|
|
139
|
+
# - `ls` → 自動許可されるはず(allow)
|
|
140
|
+
# - `git push --force` → ブロックされるはず(deny)
|
|
141
|
+
# - 未知のコマンド → 人間に確認が来るはず(ask/prompt)
|
|
142
|
+
```
|
|
143
|
+
|
|
144
|
+
## プレースホルダ一覧
|
|
145
|
+
|
|
146
|
+
テンプレート内の `<PLACEHOLDER>` を顧客環境に合わせて差し替えてください。
|
|
147
|
+
|
|
148
|
+
| プレースホルダ | 説明 | 記載ファイル | 例 |
|
|
149
|
+
|---------------|------|-------------|-----|
|
|
150
|
+
| `<PROJECT_DIR>` | プロジェクトの絶対パス | settings.json (allow) | `/home/user/projects/my-app` |
|
|
151
|
+
| `<SUPABASE_PROJECT_ID>` | Supabase プロジェクトの Reference ID | settings.json (sandbox) | `abcdefghijklmnop` |
|
|
152
|
+
|
|
153
|
+
### 差し替えが不要なケース
|
|
154
|
+
|
|
155
|
+
- **Supabase を使わない場合**: `<SUPABASE_PROJECT_ID>` を含む行(sandbox の allowedDomains / deniedDomains)を削除してください。Supabase MCP の deny ルールもそのまま残しても害はありません。
|
|
156
|
+
- **Vercel を使わない場合**: sandbox の `vercel.com` / `api.vercel.com` 行を削除してください。
|
|
157
|
+
- **sandbox 自体を使わない場合**: `"sandbox"` セクション全体を削除してください(sandbox は Claude Code の実験的機能です)。
|
|
158
|
+
|
|
159
|
+
## カスタマイズガイド
|
|
160
|
+
|
|
161
|
+
### プロジェクト固有の保護対象を追加する
|
|
162
|
+
|
|
163
|
+
特定のディレクトリやファイルを職人に編集させたくない場合:
|
|
164
|
+
|
|
165
|
+
```json
|
|
166
|
+
"deny": [
|
|
167
|
+
"Edit(config/production/**)",
|
|
168
|
+
"Write(config/production/**)"
|
|
169
|
+
]
|
|
170
|
+
```
|
|
171
|
+
|
|
172
|
+
### 本番デプロイコマンドを追加する
|
|
173
|
+
|
|
174
|
+
自社固有のデプロイコマンドがある場合、ask に追加:
|
|
175
|
+
|
|
176
|
+
```json
|
|
177
|
+
"ask": [
|
|
178
|
+
"Bash(./deploy.sh:*)",
|
|
179
|
+
"Bash(make deploy:*)"
|
|
180
|
+
]
|
|
181
|
+
```
|
|
182
|
+
|
|
183
|
+
### cc2_guard.py の安全動詞を追加する
|
|
184
|
+
|
|
185
|
+
プロジェクト固有のCLIツール(例: `turbo`, `nx`)を自動許可したい場合、
|
|
186
|
+
`cc2_guard.py` の `SAFE_VERBS` セットに追加:
|
|
187
|
+
|
|
188
|
+
```python
|
|
189
|
+
SAFE_VERBS = {
|
|
190
|
+
# ... 既存の動詞 ...
|
|
191
|
+
'turbo', 'nx', # 追加
|
|
192
|
+
}
|
|
193
|
+
```
|
|
194
|
+
|
|
195
|
+
## 注意事項
|
|
196
|
+
|
|
197
|
+
- `settings.json` の deny は cc2_guard.py の allow より**優先**されます(多重防御)。Hook が「安全」と判断しても settings の deny に引っかかればブロックされます。
|
|
198
|
+
- `disableBypassPermissionsMode` を `"disable"` にしてあるため、`--dangerously-skip-permissions` フラグは無効化されています。
|
|
199
|
+
- cc2_guard.py は Python 3.8 以上で動作します(外部ライブラリ不要)。
|
|
@@ -0,0 +1,395 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""cc2_guard.py — 采配くん職人(CC2)用の PreToolUse フック(硬い安全境界)。
|
|
3
|
+
|
|
4
|
+
【目的】
|
|
5
|
+
Claude Code の Bash ツール実行直前にコマンド全文を検査し、
|
|
6
|
+
- 赤(取り返しのつかない操作) → deny(問答無用ブロック)
|
|
7
|
+
- 白黄(読み取り/コード変更・PR・本番デプロイ等の安全動詞のみ) → allow(自動許可)
|
|
8
|
+
- それ以外(判断不能・未知) → 何も返さず(=従来の許可ルール/プロンプトに委ねる)
|
|
9
|
+
を実現する。複合コマンド(&&/;/|)や変数展開(${...}/$?)込みでも、
|
|
10
|
+
allow パターン照合のように「展開で止まる」ことなく、フック側のロジックで判定する。
|
|
11
|
+
|
|
12
|
+
【設計の核(前回調査 cmqau0e9 / 公式 hooks.md に基づく)】
|
|
13
|
+
- パターン deny は公式が「fragile・確実な遮断はフックを使え」と明言 → 赤はここで全文検査して確実に止める。
|
|
14
|
+
- フックの "allow" は設定の deny を上書きできない(hooks cannot approve what rules deny)。
|
|
15
|
+
つまり本フックが allow を返しても、~/.claude/settings.json の deny13 は二重の壁として残る(多重防御)。
|
|
16
|
+
- 「迷ったら止める」: 読めない/未知のコマンドは絶対に allow しない。最悪でも defer(プロンプト)に倒す。
|
|
17
|
+
→ 変数に隠れたコマンド(例 `X="..."; $X`)は `$X` が安全動詞でないため自動 allow されず、人間へ。
|
|
18
|
+
|
|
19
|
+
【入出力(公式 hooks.md, v2.1.x)】
|
|
20
|
+
入力(stdin JSON): {"tool_name":"Bash","tool_input":{"command":"..."}, ...}
|
|
21
|
+
出力(stdout JSON, exit 0):
|
|
22
|
+
{"hookSpecificOutput":{"hookEventName":"PreToolUse",
|
|
23
|
+
"permissionDecision":"deny"|"allow","permissionDecisionReason":"..."}}
|
|
24
|
+
defer(委譲)は「何も出力せず exit 0」で表現する(非決定 → 通常の許可フローへ)。
|
|
25
|
+
例外時も何も出力せず exit 0(フェイルセーフ:誤って allow しない。赤は settings deny13 が拾う)。
|
|
26
|
+
|
|
27
|
+
本ファイルはテスト容易性のため、判定ロジックを純粋関数 decide(command) に分離してある。
|
|
28
|
+
"""
|
|
29
|
+
import sys
|
|
30
|
+
import json
|
|
31
|
+
import re
|
|
32
|
+
|
|
33
|
+
# ─────────────────────────────────────────────────────────────────────────────
|
|
34
|
+
# 安全な動詞(白=読み取り / 黄=コード変更・PR・本番)。これ「のみ」で構成される複合コマンドは allow。
|
|
35
|
+
# 任意コード実行ラッパ(bash/sh/eval/exec/source/.)は含めない=中身が読めないため defer に倒す。
|
|
36
|
+
# ─────────────────────────────────────────────────────────────────────────────
|
|
37
|
+
SAFE_VERBS = {
|
|
38
|
+
# 読み取り(白)
|
|
39
|
+
'cat', 'grep', 'egrep', 'fgrep', 'rg', 'ls', 'sed', 'head', 'tail', 'find',
|
|
40
|
+
'wc', 'echo', 'printf', 'which', 'type', 'awk', 'sort', 'uniq', 'cut', 'tr',
|
|
41
|
+
'diff', 'cmp', 'file', 'stat', 'basename', 'dirname', 'realpath', 'readlink',
|
|
42
|
+
'date', 'pwd', 'true', 'false', 'test', '[', '[[', 'jq', 'column', 'xxd', 'base64',
|
|
43
|
+
'tee', 'tar',
|
|
44
|
+
# シェル組込みの無害系(制御構文の条件/本文で出現する。read=標準入力→変数、:=no-op)。
|
|
45
|
+
'read', ':',
|
|
46
|
+
# ディレクトリ移動(読み取り系。複合コマンド先頭の `cd ... &&` で defer に倒れていた根治)。
|
|
47
|
+
'cd', 'pushd', 'popd', 'dirs',
|
|
48
|
+
# コード変更・ビルド・PR・本番(黄)— 当ワークフローで信頼する実行系
|
|
49
|
+
'npm', 'npx', 'node', 'python', 'python3', 'pip', 'pip3', 'pnpm', 'yarn',
|
|
50
|
+
'curl', 'wget', 'vercel', 'tsc', 'eslint', 'prettier', 'vitest', 'jest',
|
|
51
|
+
'tsx', 'ts-node', 'mkdir', 'touch', 'cp', 'mv',
|
|
52
|
+
# git / gh / rm はサブコマンド・パスを個別に検査する(下記)
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
# git の安全サブコマンド(push は force 系を別途レッド判定済みなのでここでは安全扱い)。
|
|
56
|
+
GIT_SAFE_SUB = {
|
|
57
|
+
'status', 'log', 'diff', 'add', 'commit', 'checkout', 'switch', 'pull',
|
|
58
|
+
'push', 'stash', 'merge', 'branch', 'show', 'fetch', 'rev-parse',
|
|
59
|
+
'rev-list', 'ls-files', 'ls-tree', 'remote', 'config', 'restore', 'tag',
|
|
60
|
+
'describe', 'blame', 'cherry-pick', 'rebase', 'reset', 'init', 'clone',
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
# gh pr の安全サブコマンド。
|
|
64
|
+
GH_PR_SAFE = {
|
|
65
|
+
'create', 'merge', 'view', 'checks', 'list', 'status', 'diff', 'comment',
|
|
66
|
+
'ready', 'edit', 'close', 'reopen',
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
# 動詞単体で赤(取り返しのつかない)と見なすもの。部分一致だと echo 等で誤爆するため「動詞位置」でのみ判定。
|
|
70
|
+
RED_VERBS = {'psql', 'dropdb', 'truncate'}
|
|
71
|
+
|
|
72
|
+
# 危険でない作業フォルダの絶対パス先頭成分(/tmp/xxx は限定削除として通す)。
|
|
73
|
+
_SAFE_ABS_TOP = {'tmp'}
|
|
74
|
+
|
|
75
|
+
# シェル制御演算子(複合コマンド分割用)。rm 抽出など「素の全文」用(括弧は含めない)。
|
|
76
|
+
_SPLIT_RE = re.compile(r'&&|\|\||[;|&\n]')
|
|
77
|
+
|
|
78
|
+
# 白黄の安全判定用の分割。上記に加えサブシェル/グループ/case パターンの `( )` でも割る。
|
|
79
|
+
# (クォートは事前に潰すので、文字列中の括弧では誤爆しない。`{ }` はブレース展開 file{1,2}
|
|
80
|
+
# を壊すため split には含めず、_real_tokens 側で構文トークンとして透過スキップする。)
|
|
81
|
+
_SPLIT_SIMPLE_RE = re.compile(r'&&|\|\||[;|&()\n]')
|
|
82
|
+
|
|
83
|
+
# `&` を含むリダイレクト(2>&1 / >&2 / 1>&2 / &>file / &>>file / <&0 等)。
|
|
84
|
+
# これらの `&` を _SPLIT_RE が「バックグラウンド/区切り」と誤認すると、コマンドが
|
|
85
|
+
# 途中で割れて残骸トークン(例: 2>&1 の "1")が未知コマンド扱いになり defer に倒れる。
|
|
86
|
+
# 分割前に空白へ潰して、安全な複合コマンド(cd ... && build > log 2>&1 等)の自動allowを守る。
|
|
87
|
+
_REDIR_AMP_RE = re.compile(r'\d*>&\d*|&>>?|\d*<&\d*')
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def _strip_redirs(s: str) -> str:
|
|
91
|
+
"""`&` を含むリダイレクト記法を空白化する(コマンド分割の誤爆防止)。"""
|
|
92
|
+
return _REDIR_AMP_RE.sub(' ', s)
|
|
93
|
+
|
|
94
|
+
# 先頭に付く環境/ラッパ系(実体の動詞へ読み進めるためにスキップ)。
|
|
95
|
+
_WRAPPERS = {'env', 'nohup', 'time', 'stdbuf', 'setsid', 'timeout', 'nice', 'ionice', 'command', 'builtin'}
|
|
96
|
+
|
|
97
|
+
# ─── シェル制御構文(for/while/if/case …)の一般化 ────────────────────────────
|
|
98
|
+
# 制御語は「実行される実コマンド」ではないので、動詞探索時に透過的に扱う。
|
|
99
|
+
# _NOOP_KW … このセグメントに走るコマンドは無い(ヘッダ/終端)。→ no-op 扱い。
|
|
100
|
+
# for/select/case はワードリストを導くだけ(本文は別セグメント)。
|
|
101
|
+
# done/fi/esac は終端語のみ。
|
|
102
|
+
# _COND_KW … 直後が「実行される条件コマンド」。語だけスキップし後続を実コマンドとして評価。
|
|
103
|
+
# (例 `if [ -f a ]` → `[ -f a ]`、`while read l` → `read l`)
|
|
104
|
+
# _TRANSPARENT_KW … 構文語。スキップして後続(本文コマンド)を評価。(例 `then cat a`→`cat a`)
|
|
105
|
+
_NOOP_KW = {'for', 'select', 'case', 'done', 'fi', 'esac'}
|
|
106
|
+
_COND_KW = {'if', 'elif', 'while', 'until'}
|
|
107
|
+
_TRANSPARENT_KW = {'then', 'do', 'else', 'in', '!'}
|
|
108
|
+
|
|
109
|
+
# クォート内('...' / "...")は分割・動詞判定の対象外。プレースホルダ 'Q' に潰して
|
|
110
|
+
# 文字列中の `( ) && ; |` 等が複合コマンド分割を誤爆させないようにする。
|
|
111
|
+
# 注意: これは「安全動詞の構造判定」専用。赤(_hard_reds/_rm_findings)は常に素の全文を走査するため、
|
|
112
|
+
# クォートに隠した危険(例 "$(rm -rf /)")はそちら+$()中身抽出で従来どおり捕捉される。
|
|
113
|
+
def _blank_quotes(s: str) -> str:
|
|
114
|
+
out = []
|
|
115
|
+
i, n, q = 0, len(s), None
|
|
116
|
+
while i < n:
|
|
117
|
+
c = s[i]
|
|
118
|
+
if q:
|
|
119
|
+
if c == '\\' and q == '"' and i + 1 < n:
|
|
120
|
+
i += 2
|
|
121
|
+
continue
|
|
122
|
+
if c == q:
|
|
123
|
+
out.append('Q') # 閉じクォートで丸ごと1トークン化
|
|
124
|
+
q = None
|
|
125
|
+
i += 1
|
|
126
|
+
continue
|
|
127
|
+
if c in ('"', "'"):
|
|
128
|
+
q = c
|
|
129
|
+
i += 1
|
|
130
|
+
continue
|
|
131
|
+
out.append(c)
|
|
132
|
+
i += 1
|
|
133
|
+
return ''.join(out)
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
# ─────────────────────────────────────────────────────────────────────────────
|
|
137
|
+
# 赤(無条件): 全文(小文字化)に対する正規表現。複合の同一サブコマンド内に限定し、
|
|
138
|
+
# かつ変数に直書きされた赤(例 X="git push --force"; $X)も文字列として拾う。
|
|
139
|
+
# ─────────────────────────────────────────────────────────────────────────────
|
|
140
|
+
def _hard_reds(low: str):
|
|
141
|
+
# force push(--force / --force-with-lease / -f / refspec の +)
|
|
142
|
+
if re.search(r'git\s+push\b[^;&|\n]*?(--force(-with-lease)?\b|(?:^|\s)-f(?:\s|$)|\s\+\S)', low):
|
|
143
|
+
return 'force push'
|
|
144
|
+
# git reset --hard
|
|
145
|
+
if re.search(r'git\s+reset\b[^;&|\n]*--hard\b', low):
|
|
146
|
+
return 'git reset --hard'
|
|
147
|
+
# git clean で f と d の両方(-fd / -fdx / -df / -xfd 等)
|
|
148
|
+
for m in re.finditer(r'git\s+clean\b([^;&|\n]*)', low):
|
|
149
|
+
fl = ''.join(re.findall(r'-([a-z]+)', m.group(1)))
|
|
150
|
+
if 'f' in fl and 'd' in fl:
|
|
151
|
+
return 'git clean -fd'
|
|
152
|
+
# 本番DB破壊
|
|
153
|
+
if re.search(r'\bdrop\s+table\b', low):
|
|
154
|
+
return 'drop table'
|
|
155
|
+
if re.search(r'\bdrop\s+database\b', low):
|
|
156
|
+
return 'drop database'
|
|
157
|
+
if re.search(r'\bsupabase\s+db\s+reset\b', low):
|
|
158
|
+
return 'supabase db reset'
|
|
159
|
+
# 権限・秘密・sudo rm
|
|
160
|
+
if re.search(r'\bgh\s+secret\b', low):
|
|
161
|
+
return 'gh secret'
|
|
162
|
+
if re.search(r'\bsudo\s+rm\b', low):
|
|
163
|
+
return 'sudo rm'
|
|
164
|
+
return None
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
# ─────────────────────────────────────────────────────────────────────────────
|
|
168
|
+
# rm のパス危険度判定。'dangerous'(赤=deny)/ 'safe'(作業フォルダ=allow可)/ 'unknown'(変数/glob=prompt)。
|
|
169
|
+
# ─────────────────────────────────────────────────────────────────────────────
|
|
170
|
+
def _classify_rm_path(path: str) -> str:
|
|
171
|
+
p = path.strip().strip('"').strip("'").strip()
|
|
172
|
+
if p == '':
|
|
173
|
+
return 'unknown'
|
|
174
|
+
# 明示的ホーム変数
|
|
175
|
+
if p in ('$HOME', '${HOME}', '$HOME/', '${HOME}/'):
|
|
176
|
+
return 'dangerous'
|
|
177
|
+
if p.startswith('$HOME/') or p.startswith('${HOME}/'):
|
|
178
|
+
rest = p.split('/', 1)[1] if '/' in p else ''
|
|
179
|
+
return 'safe' if rest.strip() else 'dangerous'
|
|
180
|
+
# 変数・コマンド置換・glob を含む → 不明(自動 allow しない / 自動 deny もしない)
|
|
181
|
+
if any(c in p for c in ('$', '`', '*', '?', '{', '[')):
|
|
182
|
+
return 'unknown'
|
|
183
|
+
# ルート / ホーム
|
|
184
|
+
if p in ('/', '~', '~/'):
|
|
185
|
+
return 'dangerous'
|
|
186
|
+
if p.startswith('~'):
|
|
187
|
+
rest = p[1:].lstrip('/')
|
|
188
|
+
return 'dangerous' if rest == '' else 'safe' # ~ 単体は危険、~/sub は作業扱い
|
|
189
|
+
if p.startswith('/'):
|
|
190
|
+
comps = [c for c in p.split('/') if c != '']
|
|
191
|
+
if len(comps) == 0:
|
|
192
|
+
return 'dangerous' # '/'
|
|
193
|
+
if comps[0] in _SAFE_ABS_TOP and len(comps) >= 2:
|
|
194
|
+
return 'safe' # /tmp/xxx
|
|
195
|
+
return 'dangerous' # /home, /etc, /x ... 非tmpの絶対パス
|
|
196
|
+
# 相対パス(先頭が / ~ $ でない)=作業フォルダ → 安全
|
|
197
|
+
return 'safe'
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
def _rm_findings(command: str):
|
|
201
|
+
"""コマンド全文から再帰的 rm を全て拾い、各々の危険度を返す(変数に直書きされた rm も拾う)。"""
|
|
202
|
+
findings = []
|
|
203
|
+
for m in re.finditer(r'\brm\b', command):
|
|
204
|
+
seg = _SPLIT_RE.split(command[m.end():], maxsplit=1)[0]
|
|
205
|
+
toks = seg.split()
|
|
206
|
+
flagchars = ''
|
|
207
|
+
longs = []
|
|
208
|
+
paths = []
|
|
209
|
+
for t in toks:
|
|
210
|
+
if t.startswith('--'):
|
|
211
|
+
longs.append(t)
|
|
212
|
+
elif t.startswith('-') and len(t) > 1:
|
|
213
|
+
flagchars += t[1:]
|
|
214
|
+
else:
|
|
215
|
+
paths.append(t)
|
|
216
|
+
recursive = ('r' in flagchars) or ('R' in flagchars) or ('--recursive' in longs)
|
|
217
|
+
if not recursive:
|
|
218
|
+
continue # 単一ファイル削除は赤の対象外
|
|
219
|
+
if not paths:
|
|
220
|
+
findings.append('unknown') # rm -rf でパスが直書きされていない(変数等)→ 不明
|
|
221
|
+
continue
|
|
222
|
+
cls = [_classify_rm_path(p) for p in paths]
|
|
223
|
+
if 'dangerous' in cls:
|
|
224
|
+
findings.append('dangerous')
|
|
225
|
+
elif 'unknown' in cls:
|
|
226
|
+
findings.append('unknown')
|
|
227
|
+
else:
|
|
228
|
+
findings.append('safe')
|
|
229
|
+
return findings
|
|
230
|
+
|
|
231
|
+
|
|
232
|
+
# ─────────────────────────────────────────────────────────────────────────────
|
|
233
|
+
# 白黄判定: 全サブコマンド(subshell 中身を含む)が安全動詞のみか。
|
|
234
|
+
# ─────────────────────────────────────────────────────────────────────────────
|
|
235
|
+
def _simple_commands(command: str):
|
|
236
|
+
command = _strip_redirs(command) # 2>&1 等の `&` で割れないよう先に潰す
|
|
237
|
+
# $(...)/`...` の中身は「素の全文」から抽出(クォート内でも必ず走査対象にする)。
|
|
238
|
+
inner = re.findall(r'\$\(([^()]*)\)', command) # $(...) の中身
|
|
239
|
+
inner += re.findall(r'`([^`]*)`', command) # `...` の中身
|
|
240
|
+
base = re.sub(r'\$\([^()]*\)', ' ', command) # 置換部を除去(残った素の () はサブシェル/case)
|
|
241
|
+
base = re.sub(r'`[^`]*`', ' ', base)
|
|
242
|
+
base = _blank_quotes(base) # クォートを潰してから括弧/演算子で分割
|
|
243
|
+
cands = _SPLIT_SIMPLE_RE.split(base)
|
|
244
|
+
for s in inner:
|
|
245
|
+
cands += _SPLIT_SIMPLE_RE.split(_blank_quotes(s))
|
|
246
|
+
return [c.strip() for c in cands if c.strip()]
|
|
247
|
+
|
|
248
|
+
|
|
249
|
+
def _real_tokens(seg: str):
|
|
250
|
+
"""セグメントから実体コマンドのトークン列を返す。
|
|
251
|
+
|
|
252
|
+
先頭の VAR=val 代入・ラッパ(env/timeout 等)・グループ括弧 `( ) { }`・制御構文語
|
|
253
|
+
(for/while/if/then/do/… )・リダイレクト(例 `> f`)を透過的に読み飛ばし、実際に走る
|
|
254
|
+
コマンドの先頭に到達したらそれ以降を返す。制御構文のヘッダ/終端など「走るコマンドが
|
|
255
|
+
無い」セグメントは [] を返す(no-op)。`xargs` は後続コマンドの実行器なのでオプションを
|
|
256
|
+
飛ばして後続を実コマンドとして扱う。"""
|
|
257
|
+
toks = seg.split()
|
|
258
|
+
i = 0
|
|
259
|
+
while i < len(toks):
|
|
260
|
+
t = toks[i].strip('(){}') # 付随するサブシェル/グループ括弧を除去
|
|
261
|
+
if t == '': # 素の `(` `)` `{` `}` 単体 → 透過
|
|
262
|
+
i += 1
|
|
263
|
+
continue
|
|
264
|
+
if re.match(r'^[A-Za-z_][A-Za-z0-9_]*=', t): # VAR=val 代入
|
|
265
|
+
i += 1
|
|
266
|
+
continue
|
|
267
|
+
if re.match(r'^[0-9]*[<>]+$', t): # リダイレクト演算子単体(> / 2> / < 等)
|
|
268
|
+
i += 1
|
|
269
|
+
if i < len(toks) and not re.match(r'^[0-9]*[<>]', toks[i]):
|
|
270
|
+
i += 1 # リダイレクト先ファイルを飛ばす
|
|
271
|
+
continue
|
|
272
|
+
base = t.split('/')[-1]
|
|
273
|
+
if base in _NOOP_KW: # for/select/case/done/fi/esac → 走るコマンド無し
|
|
274
|
+
return []
|
|
275
|
+
if base in _COND_KW or base in _TRANSPARENT_KW: # if/while/then/do/… → 語を飛ばし後続を評価
|
|
276
|
+
i += 1
|
|
277
|
+
continue
|
|
278
|
+
if base in _WRAPPERS:
|
|
279
|
+
i += 1
|
|
280
|
+
while i < len(toks) and toks[i].startswith('-'):
|
|
281
|
+
i += 1 # ラッパのフラグをスキップ
|
|
282
|
+
if base in ('timeout', 'nice', 'ionice') and i < len(toks) and not toks[i].startswith('-'):
|
|
283
|
+
i += 1 # 期間/優先度の引数をスキップ
|
|
284
|
+
continue
|
|
285
|
+
if base == 'xargs': # xargs は後続コマンドの実行器
|
|
286
|
+
i += 1
|
|
287
|
+
while i < len(toks) and (toks[i].startswith('-') or toks[i] == '{}' or toks[i].isdigit()):
|
|
288
|
+
i += 1 # xargs のオプション/置換文字列/数値引数を飛ばす
|
|
289
|
+
continue
|
|
290
|
+
return [t] + toks[i + 1:] # 実コマンド先頭(括弧除去済み)以降
|
|
291
|
+
return [] # 代入/構文語のみ=実コマンドなし(no-op)
|
|
292
|
+
|
|
293
|
+
|
|
294
|
+
def _is_safe_command(toks) -> bool:
|
|
295
|
+
if not toks:
|
|
296
|
+
return True # 代入のみ等の no-op
|
|
297
|
+
verb = toks[0].split('/')[-1].strip('"\'')
|
|
298
|
+
if verb in RED_VERBS:
|
|
299
|
+
return False # ここには来ない想定(先に deny 済)だが保険
|
|
300
|
+
if verb == 'rm':
|
|
301
|
+
return True # 危険/不明 rm は decide() で先に deny/defer 済 → ここに来る rm は安全パス
|
|
302
|
+
if verb == 'git':
|
|
303
|
+
sub = toks[1].split('/')[-1] if len(toks) > 1 else ''
|
|
304
|
+
return sub in GIT_SAFE_SUB
|
|
305
|
+
if verb == 'gh':
|
|
306
|
+
if len(toks) > 2 and toks[1] == 'pr' and toks[2] in GH_PR_SAFE:
|
|
307
|
+
return True
|
|
308
|
+
if len(toks) > 2 and toks[1] == 'run' and toks[2] in ('view', 'list', 'watch'):
|
|
309
|
+
return True
|
|
310
|
+
return False
|
|
311
|
+
if verb == 'find':
|
|
312
|
+
if any(a in ('-delete', '-exec', '-execdir', '-ok', '-okdir') for a in toks):
|
|
313
|
+
return False # find -delete / -exec は実質任意実行 → 自動 allow しない
|
|
314
|
+
return True
|
|
315
|
+
return verb in SAFE_VERBS
|
|
316
|
+
|
|
317
|
+
|
|
318
|
+
def _red_verb_hit(simples):
|
|
319
|
+
for seg in simples:
|
|
320
|
+
toks = _real_tokens(seg)
|
|
321
|
+
if toks:
|
|
322
|
+
verb = toks[0].split('/')[-1].strip('"\'')
|
|
323
|
+
if verb in RED_VERBS:
|
|
324
|
+
return verb
|
|
325
|
+
return None
|
|
326
|
+
|
|
327
|
+
|
|
328
|
+
# ─────────────────────────────────────────────────────────────────────────────
|
|
329
|
+
# 中心の純粋関数。
|
|
330
|
+
# 返り値: {'decision': 'deny'|'allow'|'defer', 'reason': str}
|
|
331
|
+
# deny … 赤。ブロック。
|
|
332
|
+
# allow … 白黄。自動許可。
|
|
333
|
+
# defer … 委譲(何も出力しない=従来の許可ルール/プロンプトへ)。
|
|
334
|
+
# ─────────────────────────────────────────────────────────────────────────────
|
|
335
|
+
def decide(command: str):
|
|
336
|
+
if not command or not command.strip():
|
|
337
|
+
return {'decision': 'defer', 'reason': 'empty command'}
|
|
338
|
+
low = command.lower()
|
|
339
|
+
|
|
340
|
+
# 1) 無条件レッド(多語キーワード/force push 等。変数直書きも文字列として拾う)
|
|
341
|
+
hit = _hard_reds(low)
|
|
342
|
+
if hit:
|
|
343
|
+
return {'decision': 'deny', 'reason': f'red: {hit}'}
|
|
344
|
+
|
|
345
|
+
simples = _simple_commands(command)
|
|
346
|
+
|
|
347
|
+
# 2) 動詞位置のレッド(psql/dropdb/truncate)
|
|
348
|
+
rv = _red_verb_hit(simples)
|
|
349
|
+
if rv:
|
|
350
|
+
return {'decision': 'deny', 'reason': f'red verb: {rv}'}
|
|
351
|
+
|
|
352
|
+
# 3) rm の危険パス
|
|
353
|
+
rmf = _rm_findings(command)
|
|
354
|
+
if 'dangerous' in rmf:
|
|
355
|
+
return {'decision': 'deny', 'reason': 'red: rm -rf on dangerous path (root/home/non-tmp absolute)'}
|
|
356
|
+
if 'unknown' in rmf:
|
|
357
|
+
return {'decision': 'defer', 'reason': 'rm -rf with variable/glob path → prompt'}
|
|
358
|
+
|
|
359
|
+
# 4) 白黄: 全サブコマンドが安全動詞のみなら allow
|
|
360
|
+
if simples and all(_is_safe_command(_real_tokens(s)) for s in simples):
|
|
361
|
+
return {'decision': 'allow', 'reason': 'white/yellow: safe verbs only'}
|
|
362
|
+
|
|
363
|
+
# 5) それ以外は委譲(未知 → 通常の許可フロー=プロンプト)
|
|
364
|
+
return {'decision': 'defer', 'reason': 'unknown/uncertain → normal permission flow'}
|
|
365
|
+
|
|
366
|
+
|
|
367
|
+
def main() -> int:
|
|
368
|
+
try:
|
|
369
|
+
raw = sys.stdin.read()
|
|
370
|
+
data = json.loads(raw) if raw.strip() else {}
|
|
371
|
+
except Exception:
|
|
372
|
+
return 0 # 入力不正 → 委譲(フェイルセーフ)
|
|
373
|
+
try:
|
|
374
|
+
if data.get('tool_name') != 'Bash':
|
|
375
|
+
return 0 # Bash 以外は委譲
|
|
376
|
+
command = (data.get('tool_input') or {}).get('command', '')
|
|
377
|
+
result = decide(command)
|
|
378
|
+
if result['decision'] in ('allow', 'deny'):
|
|
379
|
+
out = {
|
|
380
|
+
'hookSpecificOutput': {
|
|
381
|
+
'hookEventName': 'PreToolUse',
|
|
382
|
+
'permissionDecision': result['decision'],
|
|
383
|
+
'permissionDecisionReason': result['reason'],
|
|
384
|
+
}
|
|
385
|
+
}
|
|
386
|
+
sys.stdout.write(json.dumps(out))
|
|
387
|
+
# defer は何も出力しない(exit 0)
|
|
388
|
+
return 0
|
|
389
|
+
except Exception:
|
|
390
|
+
# 想定外の例外でも絶対に allow を出さない=委譲(赤は settings deny13 が拾う)
|
|
391
|
+
return 0
|
|
392
|
+
|
|
393
|
+
|
|
394
|
+
if __name__ == '__main__':
|
|
395
|
+
sys.exit(main())
|