削除・巻き戻しの影響範囲ガード: rm -rf・git reset --hard・force push・find -delete・rsync --delete・sh -c/eval/ssh の入れ子・rollback…

Claude Code の mod(function hooks のプラグイン)。Claude が Bash ツールで「消す・巻き戻す・強制上書きする」コマンドを実行しようとした時に、コマンド文字列から静的に判定して保留し、確認ダイアログで「止める(実行しない)/実行する」を聞きます。
画面の文言について: 確認ダイアログや理由の文言は日本語です(English UI texts are not provided; the dialog and deny reasons are in Japanese)。
claude -p・SDK)では、待たずに理由つきで止めます。動作確認の状況: 判定器と、偽の $ を使ったフック本体は自動試験で確認しています。実画面での確認ダイアログは、作者の環境で常用して動作を確認していますが、環境やバージョンの差で挙動が違う可能性があります。
| 種類 | 例 | |
|---|---|---|
| rm | rm -r/-R/-rf、対象が複数・glob・ブレース展開・変数や置換を含む、危険名(.env・.git・node_modules・*.sqlite・鍵や session/auth/token を含む名前)、~・/・.。長オプションの接頭辞(--rec など)も正式名に直して判定 | |
| 他の削除 | rimraf・del-cli・unlink・shred・truncate・dd of=・mkfs・diskutil erase*・crontab -r・中身を書かないリダイレクト(> file) | |
| docker / kubectl 等 | docker volume rm/prune・system prune・compose down -v・kubectl delete・terraform destroy・gh repo delete・DB CLI の DROP TABLE/DATABASE | |
| git | reset --hard・clean・強制 push(-f・--force-with-lease・+ref・--delete・:branch)・checkout -- <path>/checkout .・実在パスへの checkout <name>・restore <path>・switch --discard-changes・branch -D・stash drop/clear・reflog expire・gc --prune など | |
| まとめて消す | find -delete・find -exec …・xargs rm・rsync --delete* | |
| 入れ子 | sh -c '…'・eval・$( )・ヒアドキュメント・ssh host '…'・npx/uv run 等の起動子の中身も同じ規則で読む | |
| 測れないもの | 変数だけのコマンド名($CMD)・中身が変数の bash -c "$X"・`curl … \ | sh` |
| 名前で止める | 名前に rollback・wipe・purge・nuke・teardown・destroy を含むスクリプト(例: rollback.sh)やタスク名、マイグレーションの巻き戻し(rollback・reset・drop など) |
通常の ls・git status/diff/commit・通常の git push・ブランチ名への git checkout・単一の相対ファイルへの rm/rm -f out.txt(条件あり・下記)・echo 'rm -rf x' のような文字列・ビルド掃除(npm run clean)・履歴の書き換え(git rebase・--amend)など。
単一の相対ファイルへの rm -f は通します(損害が小さく、git 管理下なら戻せるため)。次を全部満たす時だけです: 対象が 1 個の文字どおりの相対パス/フラグは -f・-v・-i の組合せのみ/glob・変数・置換・~ なし/危険名でない/.. や / 始まりでない/sudo 等の前置きなし/同じ行に cd がない。
/tmp の定型掃除は通します: 対象が全部確定した /tmp/…(または /private/tmp/…)の絶対パスで、親フォルダの実体が /private/tmp 配下と確かめられた時だけです。/tmp/claude-<uid> 配下は、今のセッション自身の scratchpad だけを通し、他のセッションの作業物・セッションのフォルダそのものは止めます。
mod は利用者の権限で動きます。claude plugin validate ./guard-a の calls: 行で、この mod が呼ぶ API を確認できます: $.clock.after・$.fs.stat・$.session.id・$.session.surfaces・$.state.get/set・$.ui.ask・$.ui.toast($.process・$.fs.write・$.http は呼びません)。受けるイベントは tool.call{tool=Bash} だけです。
$.fs.stat・$.session.id・$.session.surfaces・$.ui.ask・$.ui.toast・$.clock.after・$.state だけ。.catch で deny)。pkexec・strace・screen -dm・tmux new・direnv exec・conda run など)。python3 cleanup.py・Makefile・npm scripts の中の rm は止められません(名前による停止のみ)。printf -v・エイリアス・関数定義・base64 -d の実行など)。DELETE FROM(WHERE の有無を読まない)・aws s3 rm(--recursive なし)は止めません。cd した後の作業フォルダの違い、CDPATH、パスの途中のシンボリックリンクは確認できません。if … ; then cd X && …; fi の中の cd、bash -c "$X" などです。$.ui.ask は AskUserQuestion を経由するため、別のプラグインが回答しうる点にも注意してください。nocorrect・noglob・repeat N・先頭の -・=cmd)だけ対応です。hash や alias による名前の差し替えは見ません。「危険名」のリストは hooks/guard-a.mjs の dangerOf 関数にある正規表現です(隠しフォルダ名の候補: .git・.env・.ssh・.aws など)。自分の環境の設定フォルダ名(例: ツールの設定ディレクトリ)を止めたい時は、その正規表現の選択肢に名前を足してください。設定ファイルでの追加は未実装です。
リポジトリのルートで、Node.js が入っていれば動きます。
node guard-a/test/battery.mjs # 判定器・フック本体の試験束
node guard-a/test/mutate.mjs # 判定器をわざと壊して、試験束が検出するか確かめる(test/mut/ に一時ファイルを書く)
claude plugin validate ./guard-a
battery.mjs は実コマンドを走らせません(stat は偽物)。claude --plugin-dir ./guard-a.claude-plugin/plugin.json — マニフェストhooks/guard-a.mjs — 本体(判定器 classify/decide・dangerOf など)hooks/hooks.json — フックモジュールの指定types/index.d.ts — $.state の型test/battery.mjs — 試験束。test/reviewer_cases.mjs・round3〜5_cases.mjs は 1 行ずつ理由つきの入力集test/gen_reviewer_cases.mjs — reviewer_cases.mjs を作った一度きりの生成スクリプト(入力元は公開物に無く、単体では実行できません)test/mutate.mjs — 変異試験/plugin install guard-a@guard-and-band-for-claude-codehooks/guard-a.mjs 2899 lines1// guard-a: 削除・巻き戻しの影響範囲ガード(自前 mod・2026-10-13)
2//
3// tool.call (Bash) で、消す・巻き戻す・強制上書きするコマンドを「静的に」判定し、
4// 該当すれば engine の確認ダイアログ($.ui.ask)で「止める/実行する」を聞く。
5//
6// 設計原則(README に詳細):
7// 1. 決定的コードのみ。LLM・ネットワーク・ファイル書込・環境変数参照なし。
8// 2. 承認前に外部コマンドを一切実行しない($.process.run を使わない)。
9// 読むのは $.fs.stat だけ: /tmp 例外の検証(親フォルダの realPath)と、
10// git checkout <名前> の名前が実在パスかの確認(無ければブランチ名として通す)。
11// 3. fail-closed: 判定中の例外は .catch で deny(理由つき)。
12// 4. 画面が無い($.session.surfaces() が空)なら待たずに理由つきで deny。
13// 待ちは最大 10 分、無回答・ダイアログを閉じた・中断は deny。
14// 5. 同じ対象の削除の試みが同一セッションで 2 回目なら警告する($.state)。
15// 6. コマンドは書き換えない。止めるか通すかだけ。
16//
17// 公式 blast-radius(Apache-2.0)の構造(tool.call で保留→deny に理由)を手本にしたが、
18// 判定器・保留の仕組みは書き直している。公式のコードは複製していない。
19
20const HOLD_LIMIT_MS = 10 * 60 * 1000;
21const MAX_DEPTH = 6;
22const SEEN_MAX_KEYS = 500;
23const CANCEL = "止める(実行しない)";
24const PROCEED = "実行する";
25const SEEN = { plugin: "guard-a", key: "seen" };
26
27// 一度に 1 件だけ保留する。2 件目は待たずに理由つきで止める(待ちに外部 sleep を使わないため)。
28let holding = false;
29
30export function register(on) {
31 on("tool.call", { tool: "Bash" }, async ($, e, next) => {
32 const command = String(e.command ?? "");
33 // $.session.id() は /tmp/claude-<uid>/<プロジェクト>/<セッション> の線引き(自分のセッションだけ通す)にだけ使う。
34 const verdict = await decide(command, (path) => $.fs.stat(path, { resolve: true }), { sessionId: () => $.session.id() });
35 const repeats = await noteAttempts($, verdict.deletes);
36
37 if (!verdict.hold) {
38 if (repeats.length > 0) {
39 $.ui.toast(`guard-a: 同じ対象の削除はこのセッションで2回目です(${repeats.slice(0, 3).join(", ")})。消していること自体を疑ってください。`, { timeoutMs: 8000 });
40 }
41 return next(e);
42 }
43
44 const summary = summarize(verdict, command);
45 // 排他: 検査と設定の間に await を挟まない(同時に届いた 2 件が両方ダイアログを出さないため)
46 if (holding) {
47 return { deny: `guard-a がこのコマンドを止めました: 別の確認待ちが既にあります。${summary} ユーザーが求めない限り再試行しないでください。` };
48 }
49 holding = true;
50 let answer;
51 try {
52 const surfaces = await $.session.surfaces();
53 if (surfaces.length === 0) {
54 return { deny: `guard-a がこのコマンドを止めました: 確認を出せる画面がありません(headless / -p 実行)。${summary} 必要ならユーザーに対話セッションで実行してもらってください。` };
55 }
56 answer = await askUser($, next.signal, question(verdict, command, repeats));
57 } finally {
58 holding = false;
59 }
60
61 if (answer.kind === "proceed") {
62 $.ui.toast("guard-a: ユーザーの承認で実行します");
63 return next(e);
64 }
65 const why = {
66 cancel: "ユーザーが「止める」を選びました",
67 other: `ユーザーは実行を選ばず、こう答えました: 「${String(answer.text ?? "").slice(0, 300)}」`,
68 dismissed: `確認ダイアログが閉じられたか、出せませんでした(${String(answer.text ?? "").slice(0, 120)})`,
69 timeout: "10分以内に回答がありませんでした",
70 interrupted: "ターンが中断されました",
71 }[answer.kind] ?? "回答を記録できませんでした";
72 return { deny: `guard-a がこのコマンドを止めました: ${why}。${summary} ユーザーが求めない限り再試行しないでください。` };
73 }).catch(($, e, next) => {
74 // 判定・保留のどこかで例外 → 止める(fail-closed)。
75 // re-entry(このフックの $ 呼び出しの下で Bash が起きた)は $ を使えないので静的判定だけで決める。
76 if (next.error !== undefined && next.error.kind === "re-entry") {
77 let c;
78 try {
79 c = classify(String(e.command ?? ""));
80 } catch {
81 c = null;
82 }
83 if (c !== null && c.risks.length === 0 && c.tmpChecks.length === 0 && c.existChecks.length === 0) {
84 return next(e);
85 }
86 return { deny: "guard-a がこのコマンドを止めました: 他の確認の最中に届いた削除・巻き戻し系のコマンドで、確認を出せません。ユーザーが求めない限り再試行しないでください。" };
87 }
88 if (next.called) {
89 return next(e);
90 }
91 const kind = next.error !== undefined ? next.error.kind : "unknown";
92 return { deny: `guard-a がこのコマンドを止めました: 判定中にエラーが起きました(${kind})。安全側に倒して実行していません。ユーザーが求めない限り再試行しないでください。` };
93 });
94}
95
96// ---- 保留(ダイアログ) ----------------------------------------------------
97
98async function askUser($, signal, text) {
99 let timer = null;
100 let onAbort = null;
101 const asked = $.ui.ask(text, { header: "guard-a", options: [CANCEL, PROCEED] }).then(
102 (a) => ({ kind: a === PROCEED ? "proceed" : a === CANCEL ? "cancel" : "other", text: a }),
103 (err) => ({ kind: "dismissed", text: String(err?.message ?? err) }),
104 );
105 const timedOut = new Promise((resolve) => {
106 timer = $.clock.after(HOLD_LIMIT_MS, () => resolve({ kind: "timeout" }));
107 });
108 const interrupted = new Promise((resolve) => {
109 if (signal.aborted) {
110 resolve({ kind: "interrupted" });
111 return;
112 }
113 onAbort = () => resolve({ kind: "interrupted" });
114 signal.addEventListener("abort", onAbort, { once: true });
115 });
116 try {
117 return await Promise.race([asked, timedOut, interrupted]);
118 } finally {
119 if (timer !== null) {
120 timer.cancel();
121 }
122 if (onAbort !== null) {
123 signal.removeEventListener("abort", onAbort);
124 }
125 }
126}
127
128// 同じ対象への削除の試みを数える(情報用。失敗しても判定は変えない)。
129async function noteAttempts($, keys) {
130 if (keys.length === 0) {
131 return [];
132 }
133 try {
134 const { value, version } = await $.state.get(SEEN);
135 const seen = value !== undefined && value !== null && typeof value === "object" ? { ...value } : {};
136 const repeats = [];
137 for (const k of new Set(keys)) {
138 if ((seen[k] ?? 0) >= 1) {
139 repeats.push(k);
140 }
141 seen[k] = (seen[k] ?? 0) + 1;
142 }
143 const names = Object.keys(seen);
144 for (const k of names.slice(0, Math.max(0, names.length - SEEN_MAX_KEYS))) {
145 delete seen[k];
146 }
147 await $.state.set(SEEN, seen, { ifVersion: version });
148 return repeats;
149 } catch {
150 return [];
151 }
152}
153
154function question(verdict, command, repeats) {
155 const lines = [];
156 lines.push("guard-a: このコマンドは削除・巻き戻し・強制上書きを含みます。");
157 lines.push(`コマンド: ${oneLine(command, 300)}`);
158 for (const r of verdict.risks.slice(0, 8)) {
159 lines.push(`・${r.label}${r.where ? `(${r.where})` : ""}`);
160 if (r.targets && r.targets.length > 0) {
161 lines.push(` 対象: ${r.targets.slice(0, 6).join(" ")}${r.targets.length > 6 ? ` ほか${r.targets.length - 6}件` : ""}`);
162 }
163 for (const note of r.notes.slice(0, 4)) {
164 lines.push(` 注意: ${note}`);
165 }
166 }
167 if (verdict.risks.length > 8) {
168 lines.push(`・ほか ${verdict.risks.length - 8} 件`);
169 }
170 if (repeats.length > 0) {
171 lines.push(`★同じ対象の削除はこのセッションで2回目です(${repeats.slice(0, 3).join(", ")})。消していること自体を疑ってください。`);
172 }
173 lines.push("(文字列からの静的判定です。何が消えるかは実測していません。安全保証ではありません)");
174 lines.push("実行しますか?");
175 return lines.join("\n");
176}
177
178function summarize(verdict) {
179 const parts = verdict.risks.slice(0, 5).map((r) => {
180 const t = r.targets && r.targets.length > 0 ? ` 対象=${r.targets.slice(0, 4).join(" ")}` : "";
181 const n = r.notes.length > 0 ? `(${r.notes.slice(0, 2).join("/")})` : "";
182 return `${r.label}${t}${n}`;
183 });
184 return `検出: ${parts.join("; ")}${verdict.risks.length > 5 ? ` ほか${verdict.risks.length - 5}件` : ""}。`;
185}
186
187// 線形: 先に長さを切り、行ごとに trim して ⏎ でつなぐ(正規表現の \s*\n\s* は長い空白列で二次になる)。
188export function oneLine(s, max) {
189 const cut = s.length > max * 4 ? s.slice(0, max * 4) : s;
190 const t = cut.split("\n").map((l) => l.trim()).join(" ⏎ ");
191 return t.length > max || cut.length < s.length ? `${t.slice(0, max)}…` : t;
192}
193
194// ---- 判定(純関数。$ を使わない) -------------------------------------------
195
196/**
197 * コマンドと stat 関数から最終判定を出す。stat は /tmp 例外の検証と git checkout <名前> の実在確認だけに使う。
198 * opts.sessionId(関数または文字列)は /tmp/claude-<uid>/<プロジェクト>/<セッション>/… の線引きにだけ使う。
199 * @returns {{ hold: boolean, risks: object[], deletes: string[] }}
200 */
201export async function decide(command, stat, opts = {}) {
202 const c = classify(command);
203 const risks = c.risks.slice();
204 let sid;
205 const sessionId = async () => {
206 if (sid === undefined) {
207 try {
208 const v = typeof opts.sessionId === "function" ? await opts.sessionId() : opts.sessionId;
209 sid = typeof v === "string" && v !== "" ? v : null;
210 } catch {
211 sid = null;
212 }
213 }
214 return sid;
215 };
216 for (const check of c.tmpChecks) {
217 const problem = await verifyTmp(check.path, stat, sessionId);
218 if (problem !== null) {
219 risks.push({ label: check.label, where: check.where, targets: [check.path], notes: [`/tmp 例外を認めない: ${problem}`] });
220 }
221 }
222 // git checkout <名前> の後段チェック: 名前が実在パスなら「作業ツリーの変更を捨てる」形。
223 // 無い(ENOENT/ENOTDIR)なら ブランチ名などとして通す。それ以外の失敗・基準フォルダ不明は確かめられない=止める。
224 for (const check of c.existChecks) {
225 if (check.path === null) {
226 risks.push({ label: check.label, where: check.where, targets: [check.name], notes: [`${check.name} がパスかどうか確かめられない(${check.unknown})。${check.note}かもしれない`] });
227 continue;
228 }
229 const found = await pathExists(check.path, stat);
230 if (found !== false) {
231 risks.push({ label: check.label, where: check.where, targets: [check.path], notes: [found === true ? `${check.path} は実在するパス=${check.note}` : `${check.path} がパスかどうか確かめられない(${found})。${check.note}かもしれない`] });
232 }
233 }
234 return { hold: risks.length > 0, risks, deletes: c.deletes };
235}
236
237/** true=実在 / false=無い(ENOENT・ENOTDIR) / 文字列=確かめられない理由。 */
238async function pathExists(path, stat) {
239 try {
240 await stat(path);
241 return true;
242 } catch (err) {
243 const code = String(err?.code ?? "");
244 const msg = String(err?.message ?? err);
245 if (/^(ENOENT|ENOTDIR)$/.test(code) || /\b(ENOENT|ENOTDIR)\b/.test(msg)) {
246 return false;
247 }
248 return msg.slice(0, 80) || "不明なエラー";
249 }
250}
251
252/**
253 * /tmp 例外の検証: rm は最後の要素を消すので、親フォルダの realPath が /tmp 配下かを見る。
254 * /tmp/claude-<uid>(全体)・<プロジェクト>・<セッション> は根扱いで止める。
255 * その下は「自分のセッション($.session.id())」のものだけ通す。他のセッション・確かめられない時は止める。
256 */
257async function verifyTmp(path, stat, sessionId) {
258 const cut = path.lastIndexOf("/");
259 const parent = cut <= 0 ? "/" : path.slice(0, cut);
260 const name = path.slice(cut + 1);
261 let st;
262 try {
263 st = await stat(parent);
264 } catch {
265 return `親フォルダ ${parent} の実体を確認できない`;
266 }
267 const real = st && typeof st.realPath === "string" ? st.realPath : null;
268 if (real === null) {
269 return `親フォルダ ${parent} の実体を確認できない`;
270 }
271 const root = TMP_ROOTS.find((r) => real === r || real.startsWith(`${r}/`));
272 if (root === undefined) {
273 return `親フォルダの実体が ${real}(/tmp の外)`;
274 }
275 const full = `${real}/${name}`;
276 const rest = full.slice(root.length + 1).split("/");
277 if (/^claude-/.test(rest[0])) {
278 if (rest.length === 1) {
279 return `${full} は全セッション共有の scratchpad 根`;
280 }
281 if (rest.length <= 3) {
282 return `${full} は scratchpad の親(プロジェクト・セッション単位のフォルダ)`;
283 }
284 const own = await sessionId();
285 if (own === null) {
286 return `${full} がどのセッションの作業物か確かめられない(自分のセッション ID を得られない)`;
287 }
288 if (rest[2] !== own) {
289 return `${full} は他のセッション(${rest[2]})の作業物`;
290 }
291 }
292 return null;
293}
294
295const TMP_ROOTS = ["/private/tmp", "/tmp"];
296
297/**
298 * 静的判定。全セグメント・全入れ子を評価し、見つけた危険を全部返す。
299 * @returns {{ risks: object[], tmpChecks: object[], existChecks: object[], deletes: string[] }}
300 */
301export function classify(command, depth = 0) {
302 // cwd: "" = $.fs.stat の基準(セッションの作業フォルダ)/文字列=そこからの cd 先/null=cd 先を確かめられない
303 // vars: 同じコマンド行で確実に代入された変数(名前→文字どおりの値)。null=追えない(ループ・関数・IFS など)
304 const ctx = { risks: [], tmpChecks: [], existChecks: [], deletes: [], cwd: "", cwdChain: null, cdpath: false, vars: new Map(), unstable: new Set(), chain: 0, seq: 0, dropVars: false, softRm: [], cwdMoved: false };
305 walk(String(command), depth, ctx, "");
306 // rm -f 単一相対の緩和は、同じ行で作業フォルダが動かない時だけ(動けば従来どおり HOLD)
307 if (ctx.cwdMoved) {
308 ctx.risks.push(...ctx.softRm);
309 }
310 delete ctx.softRm;
311 delete ctx.cwdMoved;
312 delete ctx.cwd;
313 delete ctx.vars;
314 delete ctx.unstable;
315 delete ctx.chain;
316 delete ctx.seq;
317 delete ctx.cwdChain;
318 delete ctx.cdpath;
319 delete ctx.dropVars;
320 return ctx;
321}
322
323const CTL_OPEN = new Set(["if", "case", "{", "while", "until", "for", "select"]);
324const CTL_CLOSE = new Set(["fi", "esac", "}", "done"]);
325// 添字の中のコマンド置換(a[$(cmd)]・a[`cmd`]。算術 $(( は除く)
326const SUBSCRIPT_SUB = /\$\((?!\()|`/;
327// 引数・オプション値を「変数名(添字つき)」として評価する組込み: 添字 a[…] の中は算術として展開される=$( ) が走る
328const SUBSCRIPT_CMDS = new Set(["unset", "read", "declare", "typeset", "local", "readonly", "export", "let", "printf", "test", "[", "[[", "mapfile", "readarray"]);
329const VAR_RESET = new Set(["read", "export", "declare", "typeset", "local", "readonly", "unset", "getopts", "mapfile", "readarray", "let", "eval", "source", ".", "printf"]);
330
331// ---- 変数追跡(第3ラウンドで頼まれずに足した機構・通す方向に効くので最も保守的に扱う) ----
332// 前提は「同じコマンド行で、確実に 1 回だけ代入された文字どおりの値」。bash の組込み・特殊変数・分岐・eval/trap で
333// この前提は簡単に崩れる。方針: 疑わしければ追跡をやめる(ctx.vars = null)=従来どおり「測れない」で止める。
334// 同じコマンド行に(引用・\・builtin/command 前置きを問わず=語の値で)出たら、その行では変数を追わない語
335const VAR_ABORT = new Set(["declare", "typeset", "local", "readonly", "trap", "eval", "source", "read", "mapfile", "readarray", "unset", "set", "shopt", "let", "coproc", "getopts", "select"]);
336// 記録しない変数(bash が自分で書き換える・意味が特別・展開や cd の結果を変える)。BASH*・EPOCH* も
337const NO_TRACK = new Set(["PWD", "OLDPWD", "_", "REPLY", "BASH_REMATCH", "MAPFILE", "OPTARG", "OPTIND", "OPTERR", "COPROC", "RANDOM", "SECONDS", "LINENO", "BASHPID", "SHLVL", "IFS", "CDPATH", "HOME", "PATH", "GLOBIGNORE", "ENV", "BASH_ENV", "PS1", "PS2", "PS3", "PS4", "PROMPT_COMMAND", "FUNCNAME", "SRANDOM"]);
338function noTrack(name) {
339 return NO_TRACK.has(name) || /^(BASH|EPOCH)/.test(name);
340}
341// 左辺 NAME= の形(引用・エスケープの有無を問わず語の値で見る)/左辺が展開($N=…・${N}=…)
342const ASSIGN_LIKE = /^(?:[A-Za-z_][A-Za-z0-9_]*(?:\[[^\]]*\])?\+?=|\$\{?[A-Za-z_][A-Za-z0-9_]*\}?(?:\[[^\]]*\])?\+?=)/;
343// コマンド位置で外す前置き(この後もまだコマンド位置)
344const CMDPOS_PREFIX = new Set(["builtin", "command", "time", "exec", "nohup", "!"]);
345
346/**
347 * 変数を追えるかの下見。null=この行では追わない(VAR_ABORT の語・関数定義・IFS・算術・nameref・printf -v・export のオプション・
348 * 引用/エスケープされた代入の左辺・コマンド位置の「.」・ループの中の export/let 等)。
349 * それ以外は「2 回以上代入される名前・for などで値が変わる名前」の集合(追わない名前)。
350 * 代入が 1 回だけの名前なら、文字どおりの順で読んでもループの回り方で値が変わらない。
351 */
352function varPrescan(toks) {
353 const count = new Map();
354 const bump = (name) => count.set(name, (count.get(name) ?? 0) + 2);
355 let loop = false;
356 let risky = false;
357 let cmdPos = true; // 次の語がコマンド位置か
358 let prevPrefix = false; // 直前の語が command/builtin/time(その後のオプションもまだコマンド位置)
359 let exportSeg = false; // 今のセグメントが export
360 for (let k = 0; k < toks.length; k += 1) {
361 const t = toks[k];
362 if (t.t === "op" && t.v === "(" && toks[k + 1] !== undefined && toks[k + 1].t === "op" && (toks[k + 1].v === ")" || toks[k + 1].v === "(")) return null; // f() { … } / (( S=1 ))
363 if (t.t === "op") {
364 cmdPos = true;
365 prevPrefix = false;
366 exportSeg = false;
367 continue;
368 }
369 if (t.t !== "w") continue;
370 if (VAR_ABORT.has(t.v)) return null;
371 if (t.v.includes("$((")) return null; // $(( S=1 )) は代入しうる
372 if (t.v === "IFS" || /^IFS(\[|\+?=)/.test(t.v)) return null;
373 if (t.v === "function") return null;
374 if (t.assigns) return null;
375 if (t.v === "printf") {
376 // printf -v NAME・-vNAME は代入する。最初の引数が展開なら -v かもしれない
377 for (let j = k + 1; j < toks.length && toks[j].t === "w"; j += 1) {
378 if (toks[j].v.startsWith("-v") || (j === k + 1 && toks[j].v.startsWith("$"))) return null;
379 }
380 }
381 if (t.v === "export") {
382 exportSeg = true;
383 // export -n・-f・-p など、オプション(展開でオプションになりうる語も)が付いたら追わない
384 for (let j = k + 1; j < toks.length && toks[j].t === "w"; j += 1) {
385 if (/^[-+]/.test(toks[j].v) || toks[j].v.startsWith("$")) return null;
386 }
387 }
388 // 左辺が引用・エスケープ・展開の「代入らしい語」("S=$HOME"・S\=x・$N=x)がコマンド位置か export の引数にある=意味を確定できない
389 if ((cmdPos || exportSeg) && ASSIGN_LIKE.test(t.v) && !ASSIGN.test(t.lead)) return null;
390 if (cmdPos) {
391 if (t.v === ".") return null; // . file(source)
392 if (KEYWORDS.has(t.v) || CTL_OPEN.has(t.v) || CMDPOS_PREFIX.has(t.v) || ASSIGN.test(t.lead) || (prevPrefix && t.v.startsWith("-"))) {
393 prevPrefix = CMDPOS_PREFIX.has(t.v) || (prevPrefix && t.v.startsWith("-"));
394 } else {
395 cmdPos = false;
396 prevPrefix = false;
397 }
398 }
399 if (!t.q && /^(for|while|until)$/.test(t.v)) {
400 loop = true;
401 if (t.v === "for" && toks[k + 1] !== undefined && toks[k + 1].t === "w") bump(toks[k + 1].v);
402 }
403 if (/^(\.|export|let)$/.test(t.v)) risky = true; // ループの中の「.」は位置を問わず追わない(第3Rの線を緩めない)
404 const m = /^([A-Za-z_][A-Za-z0-9_]*)(?:\[[^\]]*\])?\+?=/.exec(t.lead);
405 if (m !== null) count.set(m[1], (count.get(m[1]) ?? 0) + 1);
406 }
407 if (loop && risky) return null;
408 return new Set([...count].filter(([, n]) => n > 1).map(([name]) => name));
409}
410
411/** 鎖(&&)の中だけ有効な代入を捨てた写し($( )・( ) の中へ引き継ぐ時に使う)。 */
412function globalVars(vars) {
413 return vars === null ? null : new Map([...vars].filter(([, e]) => e.chain === null));
414}
415
416/**
417 * inherit=true は $( ) の中(親の変数が見え、中の代入は外へ出ない)。
418 * sh -c・eval・ssh などの文字列は別のシェル・別の時点で読まれるので、変数は引き継がない(追えない扱い)。
419 */
420function walk(src, depth, ctx, where, inherit = false) {
421 if (depth > MAX_DEPTH) {
422 ctx.risks.push({ label: "入れ子が深すぎる", where, targets: [], notes: ["sh -c / eval / $( ) の入れ子が深く、中身を読み切れない"] });
423 return;
424 }
425 const toks = lex(src);
426 const segs = split(toks);
427 const saved = [ctx.cwd, ctx.vars, ctx.unstable, ctx.chain, ctx.cwdChain];
428 const unstable = varPrescan(toks);
429 if (unstable === null || (inherit && saved[1] === null)) {
430 ctx.vars = null;
431 } else {
432 // $( ) の中へは「鎖の中だけ有効」な代入を引き継がない(鎖番号の衝突を構造的に無くす)
433 ctx.vars = inherit ? globalVars(saved[1]) : new Map();
434 ctx.unstable = inherit && saved[2] ? new Set([...saved[2], ...unstable]) : unstable;
435 }
436 // CDPATH を代入する行では相対の cd 先を確かめない
437 if (toks.some((t) => t.t === "w" && /^CDPATH(\+?=|$)/.test(t.v))) ctx.cdpath = true;
438 // ${!r}(間接展開)と、添字の中のコマンド置換らしい語が同じ行にある=添字の評価でコマンドが走りうる
439 if (toks.some((t) => t.t === "w" && t.v.includes("${!")) && toks.some((t) => t.t === "w" && t.v.includes("[") && SUBSCRIPT_SUB.test(t.v))) {
440 ctx.risks.push({ label: "間接展開と添字内のコマンド置換", where, targets: [], notes: ["${!名前} の先が配列の添字 a[$(…)] なら、展開の時に添字の中のコマンドが走る(測れない)"] });
441 }
442 const stack = [];
443 let pd = 0;
444 let ctl = 0;
445 let chain = 0;
446 for (const seg of segs) {
447 // ( … ) の中の cd・代入は外へ出ない(中へは鎖の中だけ有効な代入を持ち込まない)
448 while (pd < seg.pd) {
449 stack.push([ctx.cwd, ctx.vars === null ? null : new Map(ctx.vars), ctx.cwdChain]);
450 ctx.vars = globalVars(ctx.vars);
451 pd += 1;
452 }
453 while (pd > seg.pd) {
454 [ctx.cwd, ctx.vars, ctx.cwdChain] = stack.pop();
455 pd -= 1;
456 }
457 // if/case/{/ループの中の代入は「確実」でない。case のパターン(fi)・})・done))は語であって制御構造ではない(第5R B2)
458 for (const x of seg.pattern ? [] : seg.words) {
459 if (x.q) break;
460 if (CTL_OPEN.has(x.v)) ctl += 1;
461 else if (CTL_CLOSE.has(x.v)) ctl -= 1;
462 else if (!KEYWORDS.has(x.v)) break;
463 }
464 seg.ctl = ctl;
465 // && でつながる限り同じ鎖(a && X=v && rm $X の rm は X=v を通った時だけ走る)。番号は ctx 全体で単調増加
466 if (seg.prev !== "&&") {
467 ctx.seq += 1;
468 chain = ctx.seq;
469 // cd X && … の cd 先は鎖の中だけ確か(鎖の外では cd が失敗・不実行だったかもしれない)
470 if (ctx.cwdChain !== null && ctx.cwdChain !== chain) {
471 ctx.cwd = null;
472 ctx.cwdChain = null;
473 }
474 }
475 seg.chain = chain;
476 ctx.chain = chain;
477 // $( ) ・ ` ` ・ <( ) の中身も同じ規則で見る
478 for (const w of seg.words.concat(seg.redirs.map((r) => r.word).filter(Boolean))) {
479 for (const sub of w.subs) {
480 const keep = [ctx.cwd, ctx.chain, ctx.cwdChain];
481 walk(sub, depth + 1, ctx, where ? `${where} › $( )` : "$( ) の中", true);
482 [ctx.cwd, ctx.chain, ctx.cwdChain] = keep;
483 }
484 }
485 if (seg.pattern) {
486 continue; // case のパターンはコマンドではない
487 }
488 const outerDrop = ctx.dropVars;
489 ctx.dropVars = false;
490 analyze(seg, depth, ctx, where);
491 const drop = ctx.dropVars;
492 ctx.dropVars = outerDrop;
493 // コマンド名が測れない($C …)なら、それが declare・read などでありうる=この後は変数を追わない
494 if (drop) ctx.vars = null;
495 }
496 [ctx.cwd, ctx.vars, ctx.unstable, ctx.chain, ctx.cwdChain] = saved;
497}
498
499/**
500 * 代入の確かさ: "global"(行頭・;・改行・( の後)/"chain"(&& の後=同じ鎖の中だけ)/null(不確か)。
501 * 「改行の後」は &&・||・| の直後の改行を含まない(それは同じ演算子の続き。prev は && / || / | のまま=chain / null / パイプ。第5R B1)。
502 */
503function assignMode(seg, ctx) {
504 if (ctx.vars === null || seg.ctl !== 0 || seg.piped || ["|", "|&", "&"].includes(seg.next ?? "")) return null;
505 const prev = seg.prev ?? "";
506 if (prev === "" || prev === ";" || prev === "\n" || prev === "(") return "global";
507 return prev === "&&" ? "chain" : null;
508}
509
510/** 代入 NAME=値 を記録する。不確か・値を追えない・配列・追記・~・2 回以上代入される名前は「分からない」(消す)。 */
511function recordAssign(ctx, name, x, mode) {
512 const eq = x.lead.indexOf("=");
513 if (noTrack(name)) {
514 ctx.vars.delete(name); // PWD・_・HOME・IFS など=bash が書き換える・意味が特別なので記録しない
515 return;
516 }
517 const val = resolveWord(x, ctx, true);
518 // B=$(git branch --show-current) のような ref だけを出す置換は「ref として安全」と覚える(値は追わない)
519 if (val === null && mode !== null && !ctx.unstable.has(name) && x.lead === x.lit && x.lead[eq - 1] !== "+" && refGenerator({ ...x, lit: "" })) {
520 ctx.vars.set(name, { value: null, refSafe: true, chain: mode === "chain" ? ctx.chain : null });
521 return;
522 }
523 if (mode === null || val === null || ctx.unstable.has(name) || x.lead[eq - 1] === "+" || x.lead.slice(0, eq).includes("[")) {
524 ctx.vars.delete(name);
525 return;
526 }
527 const value = val.slice(val.indexOf("=") + 1);
528 if (value.startsWith("~") || value.includes(":~")) {
529 ctx.vars.delete(name);
530 return;
531 }
532 ctx.vars.set(name, { value, chain: mode === "chain" ? ctx.chain : null });
533}
534
535/** 変数を変えうる組み込み: read・eval・source などは全部忘れる。export/declare/local は代入を記録(オプション付きは忘れる)。 */
536function resetVars(ctx, cmd, args, mode) {
537 if (cmd === "unset") {
538 args.forEach((a) => ctx.vars.delete(a.v));
539 return;
540 }
541 if (cmd === "printf" && !args.some((a) => a.v === "-v" || a.v.startsWith("-v"))) {
542 return;
543 }
544 if (/^(export|declare|typeset|local|readonly)$/.test(cmd)) {
545 const hasOpt = args.some((a) => a.v.startsWith("-") || a.v.startsWith("+"));
546 for (const a of args) {
547 if (!ASSIGN.test(a.lead)) continue;
548 const name = a.lead.slice(0, a.lead.indexOf("=")).replace(/\+$/, "").replace(/\[.*$/, "");
549 recordAssign(ctx, name, a, hasOpt ? null : mode);
550 }
551 return;
552 }
553 ctx.vars.clear();
554}
555
556/** 語が「ref だけを出す置換を確実に代入した変数」だけでできているか("$B"・$B)。フラグ・+ref・:ref にならない。 */
557function refSafeWord(w, ctx) {
558 if (ctx.vars === null || !w.exp || w.complex || w.refs.length === 0 || /^[-+:]/.test(w.v)) return false;
559 // 引用なしの $B は空展開で引数ごと消える(git push -f origin $B → 既定の push 先)=ref 扱いしない
560 if (w.refs.some((r) => !r.q)) return false;
561 return w.refs.every((r) => {
562 const e = ctx.vars.get(r.name);
563 return e !== undefined && e.refSafe === true && (e.chain === null || e.chain === ctx.chain);
564 }) && w.lit.trim() === "";
565}
566
567/**
568 * 語の値を、同じコマンド内で確実に代入された変数で埋める。追えなければ null。
569 * 置換($( )・` `)・$'…' を含む語は null。引用なしの $S(単語分割・glob を受ける)は null(assign=true=代入の右辺だけは分割されないので読む)。
570 */
571function resolveWord(w, ctx, assign = false) {
572 if (w.subs.length > 0 || w.ansi) return null;
573 if (!w.exp) return w.v;
574 if (ctx.vars === null || w.complex || w.refs.length === 0) return null;
575 if (!assign && w.refs.some((r) => !r.q)) return null;
576 let out = "";
577 let prev = 0;
578 for (const r of w.refs) {
579 const e = ctx.vars.get(r.name);
580 if (e === undefined || e.value === null || (e.chain !== null && e.chain !== ctx.chain)) return null;
581 // 引用なしの $X は単語分割と glob を受ける=空白・glob 文字・空の値は件数が変わるので追わない
582 if (!r.q && (e.value === "" || /[\s*?[\]{}~]/.test(e.value))) return null;
583 out += w.v.slice(prev, r.s) + e.value;
584 prev = r.e;
585 }
586 return out + w.v.slice(prev);
587}
588
589// ---- 字句解析 ------------------------------------------------------------
590
591// v=語の値(引用を外し、展開はそのままの綴り)/unq=引用の外の文字だけ/lit=展開以外の文字(引用の中も含む)
592// lead=先頭から最初の引用・展開までの引用なしの文字(VAR= の判定用)/ansi=$'…' を復号した
593function newWord() {
594 return { v: "", unq: "", lit: "", lead: "", leadOpen: true, q: false, glob: false, exp: false, tilde: false, ansi: false, subs: [], refs: [], complex: false, assigns: false };
595}
596
597/** $'…'(ANSI-C 引用)の中身を復号する: \xHH・\NNN・\uHHHH・\n 等・\\・\'・\cX。 */
598export function ansiDecode(s) {
599 const simple = { n: "\n", t: "\t", r: "\r", a: "\x07", b: "\b", e: "\x1b", E: "\x1b", f: "\f", v: "\v", "\\": "\\", "'": "'", '"': '"', "?": "?" };
600 let out = "";
601 for (let i = 0; i < s.length; i += 1) {
602 const c = s[i];
603 if (c !== "\\" || i + 1 >= s.length) {
604 out += c;
605 continue;
606 }
607 i += 1;
608 const d = s[i];
609 if (Object.hasOwn(simple, d)) {
610 out += simple[d];
611 continue;
612 }
613 if (d === "x") {
614 const m = /^[0-9a-fA-F]{1,2}/.exec(s.slice(i + 1, i + 3));
615 if (m !== null) {
616 out += String.fromCharCode(parseInt(m[0], 16));
617 i += m[0].length;
618 } else {
619 out += "\\x";
620 }
621 continue;
622 }
623 if (d === "u" || d === "U") {
624 const len = d === "u" ? 4 : 8;
625 const m = /^[0-9a-fA-F]+/.exec(s.slice(i + 1, i + 1 + len));
626 if (m !== null) {
627 out += String.fromCodePoint(Math.min(parseInt(m[0], 16), 0x10ffff));
628 i += m[0].length;
629 } else {
630 out += `\\${d}`;
631 }
632 continue;
633 }
634 if (d >= "0" && d <= "7") {
635 const m = /^[0-7]{1,3}/.exec(s.slice(i, i + 3));
636 out += String.fromCharCode(parseInt(m[0], 8) & 0xff);
637 i += m[0].length - 1;
638 continue;
639 }
640 if (d === "c" && i + 1 < s.length) {
641 i += 1;
642 out += String.fromCharCode(s.charCodeAt(i) & 0x1f);
643 continue;
644 }
645 out += `\\${d}`;
646 }
647 return out;
648}
649
650/** j から「\+改行」(行継続)を読み飛ばした位置。bash は単一引用の外でこれを取り除く。 */
651function skipLC(text, j) {
652 while (text[j] === "\\" && text[j + 1] === "\n") {
653 j += 2;
654 }
655 return j;
656}
657
658/** シェルの字句を読む。引用・エスケープ・$( )・` `・リダイレクト・ヒアドキュメントを扱う。 */
659export function lex(src) {
660 const toks = [];
661 const n = src.length;
662 let i = 0;
663 let cur = null;
664 let pendingRedir = null; // 次の語をリダイレクト先として受ける
665 const heredocs = []; // 改行で本文を読む待ち
666 const start = () => {
667 if (cur === null) {
668 cur = newWord();
669 }
670 };
671 const plain = (s) => {
672 cur.v += s;
673 cur.unq += s;
674 cur.lit += s;
675 if (cur.leadOpen) {
676 cur.lead += s;
677 }
678 };
679 const quoted = (s) => {
680 cur.v += s;
681 cur.lit += s;
682 cur.q = true;
683 cur.leadOpen = false;
684 };
685 // ref: 単純な変数参照なら { name, q }(同じコマンド内の代入から値を読める)。null なら complex(置換・特殊変数・演算つき ${ })
686 const expand = (s, ref = null) => {
687 if (ref !== null) {
688 cur.refs.push({ name: ref.name, q: ref.q, s: cur.v.length, e: cur.v.length + s.length });
689 } else {
690 cur.complex = true;
691 }
692 cur.v += s;
693 cur.exp = true;
694 cur.leadOpen = false;
695 };
696 const flush = () => {
697 if (cur === null) {
698 return;
699 }
700 if (/\{[^{}]*(,|\.\.)[^{}]*\}/.test(cur.unq)) {
701 cur.glob = true; // ブレース展開=複数対象
702 }
703 if (pendingRedir !== null) {
704 const r = { t: "redir", kind: pendingRedir, word: cur, body: null };
705 toks.push(r);
706 if (pendingRedir === "<<" || pendingRedir === "<<-") {
707 heredocs.push({ tok: r, delim: cur.v, strip: pendingRedir === "<<-", quoted: cur.q });
708 }
709 pendingRedir = null;
710 } else {
711 toks.push({ t: "w", ...cur });
712 }
713 cur = null;
714 };
715 const balanced = (open) => balancedIn(src, open);
716 const dollar = (j, inDq) => {
717 const j1 = skipLC(src, j + 1);
718 const nx = src[j1];
719 if (nx === "(") {
720 if (src[skipLC(src, j1 + 1)] === "(") {
721 // $(( … )) 算術展開: 中の $( )・` ` は実行される
722 const [inner, end] = balanced(j1);
723 cur.subs.push(...extractSubs(inner));
724 expand(`$(${inner})`);
725 return end;
726 }
727 const [inner, end] = balanced(j1);
728 cur.subs.push(inner);
729 expand(`$(${inner})`);
730 return end;
731 }
732 if (nx === "{") {
733 // ${ … } パラメータ展開: ${X:-$(cmd)} の $(cmd) は実行される。入れ子の { } と $( ) を数えて閉じを探す
734 const end = braceEnd(src, j1);
735 const inner = src.slice(j1 + 1, Math.max(j1 + 1, end - 1));
736 cur.subs.push(...extractSubs(inner));
737 if (/^[A-Za-z_][A-Za-z0-9_]*:?[=]/.test(inner)) {
738 cur.assigns = true; // ${X:=v}・${X=v} は代入する
739 }
740 expand(`\${${inner}}`, /^[A-Za-z_][A-Za-z0-9_]*$/.test(inner) ? { name: inner, q: inDq } : null);
741 return end;
742 }
743 if (nx === "'" && !inDq) {
744 // $'...'(ANSI-C): 中身は確定するので復号して判定に使う
745 let k = j1 + 1;
746 let body = "";
747 while (k < n && src[k] !== "'") {
748 if (src[k] === "\\" && k + 1 < n) {
749 body += src.slice(k, k + 2);
750 k += 2;
751 continue;
752 }
753 body += src[k];
754 k += 1;
755 }
756 cur.ansi = true;
757 quoted(ansiDecode(body));
758 return k + 1;
759 }
760 VAR_NAME.lastIndex = j1;
761 const m = VAR_NAME.exec(src);
762 if (m !== null) {
763 expand(`$${m[0]}`, /^[A-Za-z_]/.test(m[0]) ? { name: m[0], q: inDq } : null);
764 return j1 + m[0].length;
765 }
766 if (inDq) {
767 quoted("$");
768 } else {
769 plain("$");
770 }
771 return j + 1;
772 };
773 const backtick = (j) => {
774 let k = j + 1;
775 while (k < n && src[k] !== "`") {
776 k += src[k] === "\\" ? 2 : 1;
777 }
778 const inner = src.slice(j + 1, Math.min(k, n));
779 cur.subs.push(inner);
780 expand(`\`${inner}\``);
781 return k + 1;
782 };
783 const readHeredocs = (j) => {
784 while (heredocs.length > 0) {
785 const h = heredocs.shift();
786 const body = [];
787 while (j < n) {
788 const eol = src.indexOf("\n", j);
789 const line = src.slice(j, eol < 0 ? n : eol);
790 j = eol < 0 ? n : eol + 1;
791 const cmp = h.strip ? line.replace(/^\t+/, "") : line;
792 if (cmp === h.delim) {
793 break;
794 }
795 body.push(line);
796 }
797 h.tok.body = body.join("\n");
798 if (!h.quoted) {
799 // 非引用デリミタ(<<EOF)の本文は「\+改行」を除いてから $( )・` `・${ } が展開・実行される。<<'EOF' は展開されない
800 h.tok.word.subs.push(...extractSubs(h.tok.body.split("\\\n").join("")));
801 }
802 }
803 return j;
804 };
805 const redirect = (j) => {
806 // j は < か > の位置
807 const ops = ["<<<", "<<-", "<<", "<>", ">>", ">|", ">&", "<&", ">", "<"];
808 const op = ops.find((o) => src.startsWith(o, j));
809 pendingRedir = op;
810 return j + op.length;
811 };
812
813 while (i < n) {
814 const c = src[i];
815 if (c === "\\") {
816 if (src[i + 1] === "\n") {
817 i += 2;
818 continue;
819 }
820 start();
821 quoted(src[i + 1] ?? "");
822 i += 2;
823 continue;
824 }
825 if (c === "'") {
826 start();
827 const k = src.indexOf("'", i + 1);
828 const end = k < 0 ? n : k;
829 quoted(src.slice(i + 1, end));
830 i = end + 1;
831 continue;
832 }
833 if (c === '"') {
834 start();
835 cur.q = true;
836 cur.leadOpen = false;
837 i += 1;
838 while (i < n && src[i] !== '"') {
839 if (src[i] === "\\" && i + 1 < n) {
840 // 二重引用の中の \ は $ ` " \ 改行 の前だけ働く。「\+改行」は両方消える(行継続)
841 const nx = src[i + 1];
842 if (nx === "\n") {
843 i += 2;
844 continue;
845 }
846 if (nx === "$" || nx === "`" || nx === '"' || nx === "\\") {
847 quoted(nx);
848 i += 2;
849 continue;
850 }
851 quoted("\\");
852 i += 1;
853 continue;
854 }
855 if (src[i] === "$") {
856 i = dollar(i, true);
857 continue;
858 }
859 if (src[i] === "`") {
860 i = backtick(i);
861 continue;
862 }
863 quoted(src[i]);
864 i += 1;
865 }
866 i += 1;
867 continue;
868 }
869 if (c === "$") {
870 start();
871 i = dollar(i, false);
872 continue;
873 }
874 if (c === "`") {
875 start();
876 i = backtick(i);
877 continue;
878 }
879 // \r は bash では区切りでない(普通の文字)。CRLF の行でもヒアドキュメントの終端は "EOF\r"
880 if (c === " " || c === "\t") {
881 flush();
882 i += 1;
883 continue;
884 }
885 if (c === "\n") {
886 flush();
887 toks.push({ t: "op", v: "\n" });
888 i = readHeredocs(i + 1);
889 continue;
890 }
891 if (c === "#" && cur === null) {
892 while (i < n && src[i] !== "\n") {
893 i += 1;
894 }
895 continue;
896 }
897 if (c === "&" && src[i + 1] === ">") {
898 flush();
899 i = redirect(i + 1); // &> と &>>
900 continue;
901 }
902 if (c === "&" || c === "|" || c === ";" || c === "(" || c === ")") {
903 flush();
904 const two = src.slice(i, i + 2);
905 if (two === "&&" || two === "||" || two === "|&" || two === ";;") {
906 toks.push({ t: "op", v: two });
907 i += 2;
908 continue;
909 }
910 toks.push({ t: "op", v: c });
911 i += 1;
912 continue;
913 }
914 if (c === "<" || c === ">") {
915 if (src[i + 1] === "(") {
916 start();
917 const [inner, end] = balanced(i + 1);
918 cur.subs.push(inner);
919 expand(`${c}(${inner})`);
920 i = end;
921 continue;
922 }
923 if (cur !== null && /^\d+$/.test(cur.v) && !cur.q && cur.unq === cur.v) {
924 cur = null; // 2> の 2
925 } else {
926 flush();
927 }
928 i = redirect(i);
929 continue;
930 }
931 start();
932 if (c === "*" || c === "?" || c === "[") {
933 cur.glob = true;
934 }
935 if (c === "~" && cur.v === "") {
936 cur.tilde = true;
937 }
938 plain(c);
939 i += 1;
940 }
941 flush();
942 return toks;
943}
944
945const VAR_NAME = /(?:[A-Za-z_][A-Za-z0-9_]*|[0-9?$!#@*-])/y;
946
947/** text[open] === "(" から対応する ")" まで。引用の中の括弧は数えない。[中身, 閉じの次の位置]。 */
948function balancedIn(text, open) {
949 const n = text.length;
950 let depth = 0;
951 let j = open;
952 while (j < n) {
953 const ch = text[j];
954 if (ch === "\\") {
955 j += 2;
956 continue;
957 }
958 if (ch === "'") {
959 const k = text.indexOf("'", j + 1);
960 j = k < 0 ? n : k + 1;
961 continue;
962 }
963 if (ch === '"') {
964 j += 1;
965 while (j < n && text[j] !== '"') {
966 j += text[j] === "\\" ? 2 : 1;
967 }
968 j += 1;
969 continue;
970 }
971 if (ch === "(") {
972 depth += 1;
973 } else if (ch === ")") {
974 depth -= 1;
975 if (depth === 0) {
976 return [text.slice(open + 1, j), j + 1];
977 }
978 }
979 j += 1;
980 }
981 return [text.slice(open + 1), n];
982}
983
984/** text[open] === "{"(${ の {)から対応する "}" の次の位置。入れ子の { } と $( ) を数える。 */
985function braceEnd(text, open) {
986 const n = text.length;
987 let depth = 0;
988 let j = open;
989 while (j < n) {
990 const ch = text[j];
991 if (ch === "\\") {
992 j += 2;
993 continue;
994 }
995 if (ch === "$" && text[j + 1] === "(") {
996 j = balancedIn(text, j + 1)[1];
997 continue;
998 }
999 if (ch === "{") {
1000 depth += 1;
1001 } else if (ch === "}") {
1002 depth -= 1;
1003 if (depth === 0) {
1004 return j + 1;
1005 }
1006 }
1007 j += 1;
1008 }
1009 return n;
1010}
1011
1012/**
1013 * 展開される文脈の文字列(非引用ヒアドキュメント本文・${ } の中・$(( )) の中)から、
1014 * 実行される $( ) と ` ` の中身を拾う。$(( は算術なので中を続けて走査する。「$\+改行(」も $( と読む。
1015 */
1016function extractSubs(text) {
1017 const out = [];
1018 const n = text.length;
1019 let i = 0;
1020 while (i < n) {
1021 const c = text[i];
1022 if (c === "\\" && text[i + 1] !== "\n") {
1023 i += 2;
1024 continue;
1025 }
1026 if (c === "$") {
1027 const j = skipLC(text, i + 1);
1028 if (text[j] === "(") {
1029 if (text[skipLC(text, j + 1)] === "(") {
1030 i = j + 2;
1031 continue;
1032 }
1033 const [inner, end] = balancedIn(text, j);
1034 out.push(inner);
1035 i = end;
1036 continue;
1037 }
1038 }
1039 if (c === "`") {
1040 let k = i + 1;
1041 while (k < n && text[k] !== "`") {
1042 k += text[k] === "\\" ? 2 : 1;
1043 }
1044 out.push(text.slice(i + 1, Math.min(k, n)));
1045 i = k + 1;
1046 continue;
1047 }
1048 i += 1;
1049 }
1050 return out;
1051}
1052
1053/**
1054 * 演算子でセグメントに分ける。パイプの右側は piped を立てる。
1055 * pd=そのセグメントの ( ) の深さ(cd・変数の効き目の範囲)/afterClose=直前が ")"(( … ) > f の > f は複合コマンドに付く)
1056 * prev/next=前後の演算子("" は行頭・行末)/pattern=case の分岐パターン(a|b) の a・b。コマンドではない)
1057 */
1058const CONT_OPS = new Set(["&&", "||", "|", "|&"]);
1059
1060function split(toks) {
1061 const segs = [];
1062 let pd = 0;
1063 let inPattern = false; // case WORD in の後、")" までがパターン
1064 const mk = (piped, prev, afterClose = false) => ({ words: [], redirs: [], piped, pd, afterClose, prev, next: "", pattern: inPattern });
1065 let seg = mk(false, "");
1066 const push = (op, nextPiped, afterClose = false) => {
1067 seg.next = op;
1068 if (seg.words.length > 0 || seg.redirs.length > 0) {
1069 segs.push(seg);
1070 }
1071 seg = mk(nextPiped, op, afterClose);
1072 };
1073 for (const t of toks) {
1074 if (t.t === "op") {
1075 // &&・||・| の直後の改行(コメント行を挟んでも)は同じ演算子の続き(第5R B1)。新しいセグメントを「行頭」にしない。
1076 // 続きの行は prev が演算子のまま(代入は && なら鎖の中だけ・|| なら不確か)。さらに contNL を立て、そこでの cd は追わない(cdTracked・保守側)
1077 if (t.v === "\n" && seg.words.length === 0 && seg.redirs.length === 0 && CONT_OPS.has(seg.prev)) {
1078 seg.contNL = true;
1079 continue;
1080 }
1081 if (t.v === "(") {
1082 const piped = seg.piped && seg.words.length === 0;
1083 if (inPattern && seg.words.length === 0) {
1084 continue; // case のパターンの前の任意の "("
1085 }
1086 pd += 1;
1087 push("(", piped);
1088 continue;
1089 }
1090 if (t.v === ")") {
1091 if (seg.pattern) {
1092 inPattern = false;
1093 push(")", false);
1094 continue;
1095 }
1096 pd = Math.max(0, pd - 1);
1097 push(")", false, true);
1098 continue;
1099 }
1100 if (t.v === ";;") {
1101 inPattern = true; // 次の分岐のパターン
1102 }
1103 push(t.v, t.v === "|" || t.v === "|&");
1104 continue;
1105 }
1106 if (t.t === "redir") {
1107 seg.redirs.push(t);
1108 continue;
1109 }
1110 if (seg.pattern && !t.q && t.v === "esac" && seg.words.length === 0) {
1111 inPattern = false;
1112 seg.pattern = false;
1113 }
1114 seg.words.push(t);
1115 // case WORD in: 次からパターン
1116 if (!t.q && t.v === "in" && seg.words.length === 3 && !seg.words[0].q && seg.words[0].v === "case") {
1117 inPattern = true;
1118 push("in", false);
1119 }
1120 }
1121 push("", false);
1122 return segs;
1123}
1124
1125// ---- セグメントの判定 ------------------------------------------------------
1126
1127const KEYWORDS = new Set(["{", "}", "then", "do", "else", "elif", "if", "while", "until", "!", "fi", "done", "esac"]);
1128const CLOSERS = new Set(["}", "done", "fi", "esac"]);
1129const ASSIGN = /^[A-Za-z_][A-Za-z0-9_]*(?:\[[^\]]*\])?\+?=/;
1130const ARRAY_ELEM = /^\[[^\]]*\]\+?=/; // declare -A X=( [k]=v … ) の要素
1131const SIMPLE_PREFIX = new Set(["command", "builtin", "nohup", "noglob", "stdbuf", "unbuffer", "setsid"]);
1132const SUDO_VALUE_OPTS = new Set(["-u", "-g", "-C", "-D", "-h", "-p", "-r", "-t", "-T", "-U", "--user", "--group", "--chdir", "--prompt", "--role", "--type", "--close-from", "--host", "--other-user", "--command-timeout", "--login-class"]);
1133const SHELLS = new Set(["sh", "bash", "zsh", "dash", "ksh", "fish", "ash", "mksh", "yash", "busybox"]);
1134const INTERPRETER = /^(python[0-9.]*|pypy[0-9.]*|node|nodejs|deno|bun|perl[0-9.]*|ruby[0-9.]*|php[0-9.]*|osascript|pwsh|powershell|Rscript|lua[0-9.]*|tclsh|swift)$/;
1135const SCRIPT_DANGER = /(rollback|roll-back|wipe|purge|nuke|teardown|destroy)/i;
1136// 値に命令を持つ環境変数(VAR=… cmd の前置きで、値をシェルが・git が実行する)
1137const CMD_ENV = new Set(["GIT_EDITOR", "GIT_SEQUENCE_EDITOR", "GIT_SSH_COMMAND", "GIT_SSH", "GIT_PAGER", "GIT_EXTERNAL_DIFF", "GIT_ASKPASS", "SSH_ASKPASS", "GIT_PROXY_COMMAND", "EDITOR", "VISUAL", "PAGER", "MANPAGER", "BASH_ENV", "ENV", "PROMPT_COMMAND", "LESSOPEN", "LESSCLOSE"]);
1138// npm パッケージの rm 相当(rm -rf と同じ扱い)。trash は ゴミ箱経由なので入れない。
1139const RM_LIKE = new Set(["rimraf", "del-cli", "del", "rm-cli", "rmrf"]);
1140// 変数のコマンド名($RM・$GIT_BIN など)で「削除・巻き戻し系」と読める語
1141const DESTR_WORDS = new Set(["rm", "rmdir", "del", "delete", "unlink", "shred", "rsync", "git", "trunc", "truncate", "wipe", "purge", "nuke", "destroy", "rollback", "rimraf", "erase", "clean", "reset", "srm"]);
1142const FLAG_DESTR = /^-[a-zA-Z]*[rRf][a-zA-Z]*$|^--(force|recursive|delete|hard)/;
1143// マイグレーションの巻き戻し・作り直し(名前で止める)
1144const MIGRATE_TOOLS = new Set(["knex", "prisma", "alembic", "sequelize", "sequelize-cli", "typeorm", "rails", "rake", "dbmate", "goose", "migrate", "diesel", "sqlx", "drizzle-kit", "node-pg-migrate", "db-migrate", "mikro-orm", "flyway", "liquibase"]);
1145const MIGRATE_DOWN = /^(?:migrate:(?:rollback|reset|fresh|refresh|undo(?::all)?|down)|db:(?:drop|reset|rollback|migrate:undo(?::all)?|schema:load)|downgrade|rollback|reset|drop|drop-all|down|undo|fresh|refresh|revert|clean|migration:revert|schema:drop|--force-reset)$/i;
1146const NESTED_RULES = new Set(["docker", "podman", "docker-compose", "podman-compose", "kubectl", "oc", "find", "xargs", "parallel", "npx", "bunx", "pnpx", "uvx"]);
1147const DB_CLI = /^(sqlite3?|psql|mysql|mariadb|duckdb|sqlcmd|clickhouse-client|cockroach)$/;
1148const DB_DROP = /\bDROP\s+(?:TABLE|DATABASE|SCHEMA)\b/i;
1149
1150/** 名前で止める判定。purgecss(CSS の未使用クラス除去)は消去系ではないので除く。 */
1151function scriptDanger(name) {
1152 return SCRIPT_DANGER.test(name.replace(/purge[-:_]?css/gi, ""));
1153}
1154
1155/** 変数名・パスを語に割って(_ / - . と camelCase)削除系の語を含むか。$FORMAT や $MODEL は含まない。 */
1156function destrName(s) {
1157 return s.split(/[^A-Za-z]+|(?<=[a-z])(?=[A-Z])/).some((t) => DESTR_WORDS.has(t.toLowerCase()));
1158}
1159
1160// インタプリタのコード内: 呼び出しの形だけを見る(文字列リテラル内の語だけでは止めない)。
1161const DANGER_CALL = new RegExp(
1162 [
1163 "(?<![\\w'\"])rmtree\\b(?!\\s*['\".])",
1164 "\\bos\\s*\\.\\s*(?:remove|unlink|rmdir|removedirs|truncate)\\b(?!\\s*\\.)",
1165 "\\.\\s*(?:unlink|rmdir)\\s*\\(",
1166 "(?<![\\w/$'\"])unlink(?:Sync)?\\b(?!\\s*[./])",
1167 "['\"]unlink['\"]\\s*,",
1168 "\\brmdir(?:Sync)?\\s*\\(",
1169 "\\brmSync\\b(?!\\s*\\.)",
1170 "(?<![\\w$-])rm\\s*\\(",
1171 "\\bfs(?:\\.promises)?\\s*\\.\\s*(?:rm|unlink|rmdir|truncate)\\b(?!\\s*\\.)",
1172 "\\btruncate(?:Sync)?\\s*\\(",
1173 "\\bFileUtils\\s*\\.\\s*(?:rm|rm_r|rm_rf|rm_f|remove|remove_dir|remove_entry|remove_entry_secure)\\b",
1174 "\\bFile\\s*\\.\\s*(?:delete|unlink)\\b",
1175 "\\bDir\\s*\\.\\s*(?:delete|rmdir|unlink)\\b",
1176 "\\bremove_tree\\b",
1177 "\\brimraf\\b",
1178 "\\bsend2trash\\b",
1179 "\\bRemove-Item\\b",
1180 "\\bDeno\\s*\\.\\s*(?:remove|truncate)(?:Sync)?\\s*\\(",
1181 "\\bremoveItem\\s*\\(\\s*at",
1182 "\\bDROP\\s+(?:TABLE|DATABASE)\\b",
1183 ].join("|"),
1184 "i",
1185);
1186// シェルの削除コマンドの文字列。コード内に「外部コマンドを起動する呼び出し」がある時だけ数える。
1187const SHELL_STR = /(?:^|[^\w.-])rm(?:\s+-|["'])|\bgit\s+(?:reset\s+--hard|clean\s+-[a-z]*f|push\s+(?:-[a-z]*f|--force))/i;
1188const SPAWN = /\b(?:system|popen|Popen|subprocess|exec[A-Za-z]*|spawn[A-Za-z]*|check_call|check_output|shell_exec|passthru|proc_open|getattr|__import__|child_process|Command)\b|%x|`|do\s+shell\s+script/;
1189const PWSH_DEL = /(?:^|[;|&{(]\s*)(?:ri|rd|del|erase|rmdir|rm)\s+\S/i;
1190const PWSH_ENC = /^-(?:e|ec|en|enc|enco|encod|encode|encoded|encodedc|encodedco|encodedcom|encodedcomm|encodedcomma|encodedcomman|encodedcommand)$/i;
1191const FINDER_DEL = /\bdelete\s+(?:file|folder|every|item|items|disk\s+item|POSIX\s+file|alias|\()|\bempty\s+(?:the\s+)?trash\b/i;
1192
1193function base(v) {
1194 return v.replace(/^.*\//, "");
1195}
1196
1197function wordsText(ws) {
1198 return ws.map((w) => w.v).join(" ");
1199}
1200types/index.d.ts 9 lines1// guard-a の $.state 契約: 同一セッションでの削除の試みを対象ごとに数える。
2export type GuardASeen = { [target: string]: number };
3
4declare module "claude-code" {
5 interface PluginState {
6 "guard-a": { seen: GuardASeen };
7 }
8}
9