Claude Agent SDK – Subagent

Claude Agent SDKを使って、Subagentを実装してみました。

メインからよびだされるサブエージェントが、サブエージェント専用のコンテキストをもつことができることから、正確なタスクを実行することができます。ここではコード品質、セキュリティの二種類のコードレビューをするデモを実行しました。

demo.ts

/**
 * Claude Agent SDK — Subagent デモ
 *
 * このデモでは以下のsubagentパターンを示します:
 *  1. 複数の専門subagentの定義(code-reviewer / security-scanner)
 *  2. Subagentの自動・明示的呼び出し
 *  3. メッセージストリームからsubagent起動を検出
 *  4. モデル・ツールをsubagent毎に個別設定
 */

import { query, type SDKMessage } from "@anthropic-ai/claude-agent-sdk";

// ─── ターミナルカラー ─────────────────────────────────────────────────────────
const c = {
  reset:   "\x1b[0m",
  bold:    "\x1b[1m",
  dim:     "\x1b[2m",
  cyan:    "\x1b[36m",
  green:   "\x1b[32m",
  yellow:  "\x1b[33m",
  blue:    "\x1b[34m",
  magenta: "\x1b[35m",
  red:     "\x1b[31m",
  gray:    "\x1b[90m",
};

// ─── ユーティリティ ───────────────────────────────────────────────────────────

/** parent_tool_use_id が存在する場合、そのメッセージはsubagent内部から発信 */
function isSubagentMessage(msg: SDKMessage): boolean {
  return !!((msg as any).parent_tool_use_id);
}

/** Task tool呼び出しブロックから、起動されたsubagent名を取得 */
function getInvokedSubagent(msg: SDKMessage): string | undefined {
  for (const block of (msg as any).message?.content ?? []) {
    if (block.type === "tool_use" && block.name === "Task") {
      return block.input?.subagent_type as string | undefined;
    }
  }
  return undefined;
}

/** assistantメッセージからテキストブロックを結合して返す */
function extractText(msg: SDKMessage): string | undefined {
  const blocks: any[] = (msg as any).message?.content ?? [];
  const texts = blocks.filter((b) => b.type === "text").map((b) => b.text as string);
  return texts.length > 0 ? texts.join("\n") : undefined;
}

// ─── メイン ───────────────────────────────────────────────────────────────────

async function main() {
  // ヘッダー表示
  console.log(`
${c.bold}${c.cyan}╔══════════════════════════════════════════════════╗
║       Claude Agent SDK — Subagent Demo           ║
╚══════════════════════════════════════════════════╝${c.reset}

${c.bold}使用するSubagent:${c.reset}
  ${c.magenta}▸ code-reviewer${c.reset}   コード品質・設計・TypeScriptベストプラクティス
  ${c.red}▸ security-scanner${c.reset} セキュリティ脆弱性・機密情報漏洩・インジェクション

${c.gray}src/sample/ のファイルをこれらのSubagentが並列解析します${c.reset}
`);

  // ─── Subagentの定義 ────────────────────────────────────────────────────────
  //
  // agents オプションで名前付きSubagentを定義する。
  // ・description: Claude が「いつこのSubagentを使うか」を判断するための説明文
  // ・prompt:      SubagentのSystemプロンプト(専門知識・行動指針を記述)
  // ・tools:       使用を許可するツール一覧(省略すると親から継承)
  // ・model:       このSubagentだけ別モデルを使用可能("sonnet" | "opus" | "haiku")
  //
  // ※ Subagentは Task ツール経由で起動されるため、allowedTools に "Task" が必要

  const agentDefinitions = {
    "code-reviewer": {
      description:
        "TypeScriptコードの品質・保守性・設計パターンのレビュー専門家。" +
        "コード品質、エラーハンドリング、型安全性の問題を発見するために使用する。",
      prompt: `あなたはTypeScript/Node.jsの上級コードレビュワーです。
以下の観点でコードを詳しく検査してください:

- コードの可読性・命名規則の一貫性
- TypeScriptの型安全性とベストプラクティス
- エラーハンドリングの適切さ(try-catchの使い方、エラーの伝播)
- 設計パターンとSOLID原則への準拠
- 重複コードと抽象化の機会
- ドキュメント・コメントの質

問題箇所は必ずファイル名と行番号を明示し、
具体的な改善案とコードサンプルを示してください。`,
      tools: ["Read", "Grep", "Glob"] as string[],
      model: "sonnet" as const,
    },

    "security-scanner": {
      description:
        "セキュリティ脆弱性スキャン専門家。SQLインジェクション、認証バイパス、" +
        "機密データ漏洩、XSS、コマンドインジェクションなどの問題を発見するために使用する。",
      prompt: `あなたはWebアプリケーションセキュリティの専門家です。
以下のカテゴリを重点的にスキャンしてください:

- インジェクション攻撃(SQL / コマンド / LDAP / Path Traversal)
- 認証・認可の不備(セッション管理、アクセス制御)
- 機密データの露出(パスワード・APIキー・個人情報のハードコード)
- XSSおよびレスポンスへの入力値の直接埋め込み
- 安全でない暗号化・ハッシュ化(MD5, SHA1の使用等)
- 設定ミス(デバッグ情報の露出、過剰なエラー詳細)

各発見事項を Critical / High / Medium / Low で評価し、
攻撃シナリオと具体的な修正方法を提示してください。`,
      tools: ["Read", "Grep", "Glob"] as string[],
      model: "sonnet" as const,
    },
  };

  // ─── プロンプト ────────────────────────────────────────────────────────────
  const prompt = `src/sample/ ディレクトリにあるTypeScriptファイルを解析してください。

ステップ1: code-reviewer エージェントを使用して、src/sample/auth.ts と src/sample/api.ts のコード品質を詳しくレビューしてください。
ステップ2: security-scanner エージェントを使用して、同じファイルのセキュリティ上の問題をスキャンしてください。
ステップ3: 両エージェントの結果をまとめ、優先度順の改善リストを日本語で作成してください。`;

  console.log(`${c.bold}[プロンプト]${c.reset} ${prompt}\n`);
  console.log(`${c.gray}${"─".repeat(52)}${c.reset}\n`);

  // ─── 統計 ─────────────────────────────────────────────────────────────────
  const stats = {
    totalMessages: 0,
    subagentMessages: 0,
    subagentInvocations: [] as string[],
  };

  // ─── クエリ実行 ───────────────────────────────────────────────────────────
  for await (const message of query({
    prompt,
    options: {
      // Task ツールを allowedTools に含めることでSubagentが起動可能になる
      allowedTools: ["Read", "Grep", "Glob", "Task"],
      permissionMode: "bypassPermissions",
      agents: agentDefinitions,
    },
  })) {
    stats.totalMessages++;

    // ── Subagent起動の検出 ─────────────────────────────────────────────────
    const invokedName = getInvokedSubagent(message);
    if (invokedName) {
      stats.subagentInvocations.push(invokedName);
      const color = invokedName === "code-reviewer" ? c.magenta : c.red;
      console.log(`\n${color}${c.bold}▶ Subagent起動: ${invokedName}${c.reset}`);
      console.log(`${c.gray}${"─".repeat(40)}${c.reset}`);
    }

    // ── Subagent内部からのメッセージ ──────────────────────────────────────
    if (isSubagentMessage(message)) {
      stats.subagentMessages++;
      const text = extractText(message);
      if (text?.trim()) {
        // 長いテキストは先頭300文字のみ表示(デモの可読性のため)
        const preview =
          text.length > 300
            ? text.substring(0, 300).trimEnd() + " …"
            : text;
        const indented = preview.split("\n").join("\n  ");
        console.log(`${c.dim}  ${indented}${c.reset}`);
      }
    }

    // ── 最終結果 ──────────────────────────────────────────────────────────
    if ("result" in message && message.result) {
      console.log(`\n${c.bold}${c.green}${"═".repeat(52)}${c.reset}`);
      console.log(`${c.bold}${c.green}最終結果${c.reset}`);
      console.log(`${c.bold}${c.green}${"═".repeat(52)}${c.reset}\n`);
      console.log(message.result);
    }
  }

  // ─── 統計サマリー ─────────────────────────────────────────────────────────
  console.log(`\n${c.gray}${"─".repeat(52)}`);
  console.log(
    `統計: メッセージ数=${stats.totalMessages} ` +
    `| Subagent起動=${stats.subagentInvocations.length} [${stats.subagentInvocations.join(", ")}] ` +
    `| Subagentメッセージ=${stats.subagentMessages}${c.reset}`
  );
}

main().catch((err) => {
  console.error(`${c.red}エラー:${c.reset}`, err);
  process.exit(1);
});

下記はレビュー対象のコードです。

api.ts

// api.ts — APIエンドポイントハンドラー(デモ用・意図的な問題を含む)

import { exec } from "child_process";
import { promisify } from "util";
import fs from "fs";
import path from "path";

const execAsync = promisify(exec);

// リクエスト/レスポンスの簡易型定義
interface Request {
  body: Record<string, any>;
  query: Record<string, string>;
  params: Record<string, string>;
  headers: Record<string, string>;
}

interface Response {
  json: (data: any) => void;
  send: (html: string) => void;
  status: (code: number) => Response;
}

// ⚠️ 認証ミドルウェアなし
class ApiController {
  // ⚠️ コマンドインジェクション: ユーザー入力をshellコマンドに直接渡す
  async ping(req: Request, res: Response): Promise<void> {
    const { host } = req.body;
    // sanitizationなし — 攻撃例: host = "8.8.8.8; rm -rf /var/data"
    const { stdout } = await execAsync(`ping -c 4 ${host}`);
    res.json({ output: stdout });
  }

  // ⚠️ パストラバーサル: ../../../etc/passwd などのパスを許可
  async readFile(req: Request, res: Response): Promise<void> {
    const { filename } = req.query;
    // path.join は ../ を正規化しないためディレクトリ脱出が可能
    const filePath = path.join("/var/app/data", filename);
    const content = fs.readFileSync(filePath, "utf8");
    res.json({ content });
  }

  // ⚠️ XSS: ユーザー入力をエスケープせずHTMLに埋め込む
  renderProfile(req: Request, res: Response): void {
    const { name, bio } = req.query;
    // 攻撃例: name = "<script>document.cookie</script>"
    res.send(`
      <html>
        <body>
          <h1>Welcome, ${name}!</h1>
          <p>${bio}</p>
          <script>var userName = "${name}";</script>
        </body>
      </html>
    `);
  }

  // ⚠️ マスアサインメント: 全フィールドを検証なしで更新
  async updateUser(req: Request, res: Response): Promise<void> {
    const { userId } = req.params;
    const data = req.body; // フィールドのホワイトリストなし

    // ユーザーが isAdmin=true, role=admin 等を送信可能
    const setClause = Object.entries(data)
      .map(([k, v]) => `${k}='${v}'`) // ⚠️ SQLインジェクションも存在
      .join(", ");

    console.log(`UPDATE users SET ${setClause} WHERE id='${userId}'`);
    res.json({ success: true });
  }

  // ⚠️ 安全でない直接オブジェクト参照: 認可チェックなし
  async getDocument(req: Request, res: Response): Promise<void> {
    const { docId } = req.params;
    // ユーザーIDと所有者の照合をしない
    // 任意のユーザーが任意のdocIdにアクセス可能
    res.json({ docId, content: "sensitive document content" });
  }

  // ⚠️ 過剰なデータ露出: パスワードハッシュ等を含む全フィールドを返す
  async listUsers(req: Request, res: Response): Promise<void> {
    // SELECT * — パスワードハッシュ、トークン等も含む
    const users = [
      { id: 1, username: "alice", password_hash: "098f6bcd4621d373", role: "admin", ssn: "123-45-6789" },
      { id: 2, username: "bob", password_hash: "5d41402abc4b2a76", role: "user", ssn: "987-65-4321" },
    ];
    // ⚠️ フィールドフィルタリングなしで全データを返却
    res.json(users);
  }

  // ⚠️ エラーに内部情報を含める
  async processPayment(req: Request, res: Response): Promise<void> {
    try {
      const { amount, cardNumber } = req.body;
      if (!amount) throw new Error(`Payment failed: card=${cardNumber}, db=mongodb://admin:pass@db:27017`);
    } catch (error: any) {
      // ⚠️ スタックトレースと内部情報をクライアントに返す
      res.status(500).json({
        error: error.message,
        stack: error.stack,
      });
    }
  }
}

export { ApiController };

auth.ts

// auth.ts — 認証モジュール(デモ用・意図的な問題を含む)

// ⚠️ ハードコードされた認証情報
const DB_URL = "mongodb://admin:password@localhost:27017/myapp";
const JWT_SECRET = "super-secret-key-do-not-share";
const ADMIN_PASSWORD = "password";

interface User {
  id: string;
  username: string;
  email: string;
  password_hash: string;
  role: string;
  ssn: string; // 社会保障番号(機微情報)
}

class AuthService {
  private db: { query: (sql: string) => Promise<{ rows: any[] }> };

  constructor() {
    // 接続プーリングなし・エラーハンドリングなし
    this.db = {
      query: async (sql: string) => {
        console.log("Executing:", sql); // ⚠️ SQLをログに出力
        return { rows: [] };
      },
    };
  }

  // ⚠️ SQLインジェクション: ユーザー入力を直接SQLに埋め込み
  async login(username: string, password: string): Promise<string | null> {
    const sql = `SELECT * FROM users WHERE username='${username}' AND password='${password}'`;
    const result = await this.db.query(sql);

    if (result.rows.length > 0) {
      return this.generateToken(result.rows[0]);
    }
    return null;
  }

  // ⚠️ 脆弱なMD5ハッシュ: パスワード保存に不適切
  hashPassword(password: string): string {
    const crypto = require("crypto");
    return crypto.createHash("md5").update(password).digest("hex");
  }

  // ⚠️ 有効期限なし・シークレットをペイロードに含む
  generateToken(user: User): string {
    const payload = {
      id: user.id,
      role: user.role,
      secret: JWT_SECRET, // ⚠️ シークレットがトークンに露出
    };
    return Buffer.from(JSON.stringify(payload)).toString("base64");
  }

  // ⚠️ 機微情報(SSN等)を含む全フィールドを返却
  async getUser(id: string): Promise<User | null> {
    // ⚠️ SQLインジェクション + 過剰なデータ露出
    const result = await this.db.query(
      `SELECT * FROM users WHERE id = ${id}`
    );
    return result.rows[0] ?? null;
  }

  // ⚠️ レート制限なし・弱いパスワード生成・機微情報のログ出力
  async resetPassword(email: string): Promise<void> {
    const result = await this.db.query(
      `SELECT * FROM users WHERE email = '${email}'`
    );
    if (result.rows.length > 0) {
      // ⚠️ 予測可能な弱いパスワード
      const newPassword = Math.random().toString(36).substring(2, 10);
      // ⚠️ パスワードをログに出力
      console.log(`Password reset for ${email}: ${newPassword}`);
    }
  }

  // ⚠️ 認証チェックなしで管理者操作を実行
  async deleteUser(userId: string): Promise<void> {
    // 呼び出し元の認証を検証しない
    await this.db.query(`DELETE FROM users WHERE id = ${userId}`);
  }
}

export { AuthService };

結果