@tyhld/conductor 0.3.0 → 0.7.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 +301 -19
- package/dist/cli.js +143 -18
- package/dist/ear-routing.js +57 -0
- package/dist/ear.js +57 -0
- package/dist/env-file-perm.js +67 -0
- package/dist/nudge.js +172 -0
- package/dist/realtime-parse.js +116 -0
- package/dist/realtime.js +251 -0
- package/dist/relay-runner.js +65 -0
- package/dist/relay.js +911 -134
- package/dist/websocket-transport.js +66 -0
- package/launchd/ear-install.sh +96 -0
- package/package.json +30 -1
- package/sales-template/README.md +185 -25
- package/sales-template/install.sh +890 -0
- package/sales-template/launchd/install.sh +151 -0
- package/sales-template/settings.json +26 -97
- package/sales-template/setup.sh +754 -117
- package/sales-template/systemd/README.md +28 -4
- package/sales-template/systemd/install.sh +54 -5
- package/sales-template/systemd/paste-cache-prune-install.sh +75 -0
- package/sales-template/systemd/tyhld-paste-cache-prune.service +25 -0
- package/sales-template/systemd/tyhld-paste-cache-prune.timer +19 -0
- package/sales-template/uninstall.sh +245 -0
- package/scripts/hooks/README.md +246 -0
- package/scripts/hooks/cc2_guard.py +145 -0
- package/scripts/hooks/codex-hooks.sample.json +58 -0
- package/scripts/hooks/hook_datalink.py +440 -0
- package/scripts/hooks/install-codex-hooks.sh +127 -0
- package/scripts/hooks/notification_hook.py +167 -0
- package/scripts/hooks/permission_request_hook.py +207 -0
- package/scripts/hooks/policy.py +759 -0
- package/scripts/hooks/settings.sample.json +142 -0
- package/scripts/hooks/stop_hook.py +275 -0
- package/scripts/hooks/summary_ja.py +155 -0
- package/scripts/hooks/test_hook_datalink.py +282 -0
- package/scripts/hooks/test_policy.py +1241 -0
- package/skills/conductor-craftsman/SKILL.md +40 -0
- package/systemd/conductor-ear.service +63 -0
- package/systemd/conductor@.service +62 -0
- package/systemd/ear-install.sh +131 -0
- package/systemd/guard-sync-install.sh +94 -0
- package/systemd/tyhld-guard-sync.service +28 -0
- package/systemd/tyhld-guard-sync.timer +25 -0
- package/sales-template/cc2_guard.py +0 -395
- package/sales-template/systemd/conductor@.service +0 -47
|
@@ -1,395 +0,0 @@
|
|
|
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())
|
|
@@ -1,47 +0,0 @@
|
|
|
1
|
-
# conductor@.service — 采配くん常駐サービス(systemd ユーザーサービス テンプレート)。
|
|
2
|
-
#
|
|
3
|
-
# PC起動時に自動で采配くんの relay(指示の自動流し込み)+ heartbeat を常駐させる。
|
|
4
|
-
# 落ちても Restart=always で自動復活する。
|
|
5
|
-
#
|
|
6
|
-
# テンプレート方式:
|
|
7
|
-
# conductor@<プロジェクト名>.service で1サービス。%i に <プロジェクト名> が入る。
|
|
8
|
-
# 例) systemctl --user enable --now conductor@my-app
|
|
9
|
-
#
|
|
10
|
-
# 導入: sales-template/systemd/install.sh が配置・enable・linger まで面倒を見る。
|
|
11
|
-
#
|
|
12
|
-
# 鍵: %h/.conductor.env(chmod 600)を EnvironmentFile で読み込む。
|
|
13
|
-
# 中身:
|
|
14
|
-
# CONDUCTOR_URL=https://<采配くん管制サーバURL>
|
|
15
|
-
# CONDUCTOR_SHARED_SECRET=<シークレット>
|
|
16
|
-
#
|
|
17
|
-
# WorkingDirectory: 既定は %h/projects/%i。プロジェクト名とディレクトリ名が異なる場合は
|
|
18
|
-
# install.sh が drop-in で WorkingDirectory を上書きする。
|
|
19
|
-
#
|
|
20
|
-
# ExecStart のパス解決: systemd ユーザーサービスはシェルの PATH を継承しない。
|
|
21
|
-
# install.sh が npm グローバルインストール先から conductor コマンドの実体パスを検出し、
|
|
22
|
-
# __CONDUCTOR_BIN__ を実際のフルパスに置換してからユニットを配置する。
|
|
23
|
-
# node 自体も /usr/local/bin/node にシンボリックリンクを作成して解決する。
|
|
24
|
-
#
|
|
25
|
-
# relay: heartbeat + 指示の自動流し込みを常駐で有効化。
|
|
26
|
-
# 送信前ガードが「プロジェクト名と一致する tmux セッションが存在し、
|
|
27
|
-
# Claude Code がアイドルで待機している時だけ送る/それ以外はスキップ」を担保する。
|
|
28
|
-
|
|
29
|
-
[Unit]
|
|
30
|
-
Description=Conductor heartbeat (%i)
|
|
31
|
-
After=network-online.target
|
|
32
|
-
Wants=network-online.target
|
|
33
|
-
|
|
34
|
-
[Service]
|
|
35
|
-
Type=simple
|
|
36
|
-
EnvironmentFile=%h/.conductor.env
|
|
37
|
-
# relay(指示の自動流し込み)を常駐で有効化。送信前ガードで誤爆を防止。
|
|
38
|
-
Environment=CONDUCTOR_RELAY_ENABLED=true
|
|
39
|
-
Environment=CONDUCTOR_RELAY_INTERVAL_SEC=15
|
|
40
|
-
WorkingDirectory=%h/projects/%i
|
|
41
|
-
# __CONDUCTOR_BIN__ は install.sh が配置時に conductor コマンドのフルパスに置換する。
|
|
42
|
-
ExecStart=__CONDUCTOR_BIN__ start -s %i -t working -a "%i常駐"
|
|
43
|
-
Restart=always
|
|
44
|
-
RestartSec=5
|
|
45
|
-
|
|
46
|
-
[Install]
|
|
47
|
-
WantedBy=default.target
|