SLOPSHOPPER

guard-a

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

newguardtoasttimer
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · guard-a
› fix the failing auth test and add an audit log call ⏺ Read(src/auth.ts) ⎿ Read 6 lines ⏺ Update(src/auth.ts) ⎿ Added 2 lines, removed 1 line ⏺ Bash(rm -rf build && git push --force origin main) ⎿ Denied by guard-a: guard-a がこのコマンドを止めました: ユーザーが「止める」を選びました。検出: rm -rf 対象=build(相対パスは作業フォルダ(前段の cd 先)基準。実在・ ● Done. refresh now rejects expired claims and logs an audit event. ✻ Worked for 42s · done 4:20 PM ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts
README

guard-a(削除・巻き戻しの影響範囲ガード)

Claude Code の mod(function hooks のプラグイン)。Claude が Bash ツールで「消す・巻き戻す・強制上書きする」コマンドを実行しようとした時に、コマンド文字列から静的に判定して保留し、確認ダイアログで「止める(実行しない)/実行する」を聞きます。

画面の文言について: 確認ダイアログや理由の文言は日本語です(English UI texts are not provided; the dialog and deny reasons are in Japanese)。

まず知っておくこと(限界)

  • 補助であって防御ではありません。 判定は「危ない形の一覧」(denylist)で、知らない形は通ります。サンドボックスでも権限システムでもありません。確実に止めたい操作は Claude Code の permission rules(deny)で守ってください(トップ README の「推奨設定」。Bash の deny も前方一致なので別形は抜けます。確実な境界は sandbox と組み合わせます)。
  • 拒否の理由は回避を誘いえます。 止めた理由は Claude に返るため、「スクリプトを書いて実行する」など別の道を選ぶことがあります。スクリプトの中身や Edit/Write・MCP ツールは見ません。
  • 「fail-closed」の意味は狭いです。 判定・保留の途中で例外が起きたら止める、という意味です。判定器が「測れない」と認識できた信号(変数・置換・glob など)だけを止めます。
  • Bash ツールの呼び出しだけを見ます。
  • 画面が無い実行(claude -p・SDK)では、待たずに理由つきで止めます。

動作確認の状況: 判定器と、偽の $ を使ったフック本体は自動試験で確認しています。実画面での確認ダイアログは、作者の環境で常用して動作を確認していますが、環境やバージョンの差で挙動が違う可能性があります。

何を止めるか(主なもの)

種類例
rmrm -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
gitreset --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} だけです。

保留の動き

  • 該当すると確認ダイアログ(AskUserQuestion)に、コマンド・検出した項目・対象・注意を出します。選択肢は「止める(実行しない)」「実行する」。
  • 「実行する」だけが実行です。「止める」・自由回答・ダイアログを閉じた・10 分無回答・ターン中断はすべて止めます(deny。理由は Claude に返ります)。
  • 確認待ちの間に別の該当コマンドが来たら、待たずに理由つきで止めます(ダイアログは 1 枚)。
  • 同じ対象の削除の試みがセッション内で 2 回目なら、ダイアログに注意を足します。
  • コマンドは書き換えません。止めるか、そのまま通すかだけです。

設計の原則

  1. 決定的なコードだけ(LLM・ネットワーク・ファイル書込・環境変数参照なし)。
  2. 承認前に外部コマンドを実行しない。使う host API は $.fs.stat・$.session.id・$.session.surfaces・$.ui.ask・$.ui.toast・$.clock.after・$.state だけ。
  3. 例外が起きたら止める(.catch で deny)。
  4. コマンドを黙って書き換えない。

既知の限界

  • 知らない包み・起動子は通ります(pkexec・strace・screen -dm・tmux new・direnv exec・conda run など)。
  • スクリプトの中身は読みません。 python3 cleanup.py・Makefile・npm scripts の中の rm は止められません(名前による停止のみ)。
  • インタプリタの中は呼び出しの形を正規表現で探すだけです。別名・文字列の組み立て・ライブラリ経由の削除は見えません。
  • 難読化は追い切れません(変数の連結・printf -v・エイリアス・関数定義・base64 -d の実行など)。
  • システム管理や SQL の DELETE FROM(WHERE の有無を読まない)・aws s3 rm(--recursive なし)は止めません。
  • 前のコマンドで cd した後の作業フォルダの違い、CDPATH、パスの途中のシンボリックリンクは確認できません。
  • 誤止め(雑音)がありえます。if … ; then cd X && …; fi の中の cd、bash -c "$X" などです。
  • 判定と実行の間の差し替え(TOCTOU)は守れません。$.ui.ask は AskUserQuestion を経由するため、別のプラグインが回答しうる点にも注意してください。
  • zsh 固有の構文は一部(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
  • PATH 上の古い claude(2.1.287 未満)では validate が失敗します。 Claude Code の Desktop 同梱版、または 2.1.287 以上を使ってください(対応は 2.1.287 以上を前提としています)。
  • battery.mjs は実コマンドを走らせません(stat は偽物)。
  • 1 セッションだけ試す: 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-code
Source 2 files
hooks/guard-a.mjs 2899 lines
1// 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}
1200
types/index.d.ts 9 lines
1// 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