📐

人が書くのは要件だけ — n8nとBedrockで設計から実装PRまで自動実行する(n8nハンズオン 3/3)

に公開

はじめに

過去2本の記事「機密情報をディスクに一切残さないでn8nをAWSでセルフホストする」「n8nを使ってAWSの異変はAWSの中で考えさせる」では、n8nセルフホスト環境とそこで動かす一次調査エージェントを作りました。今回はその上に、もう少し踏み込んだ自動化を載せます。

具体的には、フォームに要件を1件入れると、Product Requirements DocumentとDesign Docが自動で生成され、対象リポジトリにブランチが切られ、設計書のMarkdownがコミットされ、PRが開き、Bedrock経由のLambdaが実装コミットを同じブランチに追加して、最後にPRに実装サマリのコメントが投稿される、という一連のパイプラインです。人がやるのは要件をフォームに書くことと、PR画面でCodeRabbitなどのレビューを見ながら最終的にApproveすることだけです。

最初の試作版ではChatGPT Codex Cloudに実装フェーズを任せましたが、利用上限と動作モードの不確かさで実装コミットが入らない事象が複数回発生したため、AmazonBedrockのClaude Sonnet 4.6を使った専用Lambdaに置き換えています。データを社外SaaSに送らずAWS内で処理する方針も、過去記事と揃えました。

対象読者は、n8nの基本操作と、AWS CLI、IAM、Lambda、SSM Parameter Storeに触れたことがある方です。前2記事のn8nセルフホスト構成を引き継ぐ前提で書きます。

完成形のアーキテクチャ

全体の流れは次のとおりです。

n8nワークフロー全体像

n8nが司令塔、Geminiが設計フェーズの推論、Bedrock Lambdaが実装フェーズの推論、GitHubがコミットの最終出口、Google Driveが設計書の閲覧用ストレージです。CodeRabbitやGemini Code Assistの自動レビューはGitHub側のApp連携で動くので、n8nから明示的に呼ぶ必要はありません。

設計判断の前提

実装に入る前に、選択肢を絞り込んだ理由を整理しておきます。

  • 設計フェーズはGemini、実装フェーズはBedrockに分ける
    • 設計フェーズではテンプレートを参照しながら長文の構造化Markdownを書かせるので、GeminiのresponseMimeType: application/json相当の出力強制が便利です。実装フェーズではコード生成中心で、東京リージョン内に閉じる推論プロファイルが揃っているBedrockのSonnet 4.6を使います。
    • LLMを2系統に分けることで、片方の利用上限や障害が連鎖しないようにします。
  • Codex Cloudには依存しない
    • 試作初期はGitHub PRに@codex /implementコメントを投げるだけで実装コミットを得ようとしましたが、Codex Cloud側の利用上限とReviewのみ動作するモードでコミットが空振りすることが複数回発生しました。Lambda + Bedrock経路に置き換えると、自分のAWS従量に閉じて挙動が安定します。
  • 実装の差分はGitHub Contents APIでcommitする
    • リポジトリをcloneしてgit push、という構成はLambdaの実行時間と権限管理の両面で重くなります。Bedrockに「ファイル全体の最終形を出してもらう」契約にして、PUT /repos/{owner}/{repo}/contents/{path}で1ファイルずつcommitします。
  • 設計書はGitHub上にもMarkdownで残し、Google Doc側にも書式付きで残す
    • リポジトリ内のdocs/design-docs/配下にMarkdownを残しておくと、PRレビュー時にdiffで全文を読めます。Google Doc側は閲覧者が増えたときの共有用や、後日の検索用です。

前提条件

次が揃っている前提で進めます。

  • 前回までのn8nセルフホスト環境が動いていて、編集権限のあるユーザーでアクセスできる
  • AWSアカウントのap-northeast-1でAmazonBedrockが有効になっており、anthropic.claude-sonnet-4-6系のモデルアクセスが付与されている
  • GitHubで対象リポジトリ(本記事ではokamyuji/prd-design-implementation-agent)を所有しており、repopull_requestスコープを持つPersonal Access Tokenを発行できる
  • Google Driveに保存先のフォルダがあり、n8nのGoogle Drive OAuth2クレデンシャルが設定済みである
  • Gemini APIキーをn8nに登録済みである

リポジトリ側には事前にPRD用とDesignDoc用のMarkdownテンプレートを用意しておくと、Geminiの出力が安定します。本記事では下記2つのテンプレートを公開URLで参照させる前提で書きます。

  • https://github.com/okamyuji/prd-design-implementation-agent/blob/main/templates/prd-template-ja.md
  • https://github.com/okamyuji/prd-design-implementation-agent/blob/main/templates/design-doc-template-ja.md

全体作業フロー

実装は次の順番で進めます。AWSコンソールではなくCLIを段階的に叩く構成にしてあります。理由は、後で再現や差し戻しがしやすいことと、各リソース間の権限の流れを段階的に確認できることです。

  1. SSM Parameter StoreにGitHub Tokenを保管する
  2. Lambda用のIAMロールとポリシーを作る
  3. 実装用Lambdaを作る
  4. n8n用IAMユーザーにLambda呼び出し権限を追加する
  5. Google Driveに保存先フォルダを作る
  6. n8nワークフローを組む
  7. 動作確認をする

1. SSM Parameter StoreにGitHub Tokenを保管する

LambdaはGitHub Contents APIを叩くためにPersonal Access Tokenを必要とします。コードに直接埋めず、SSM Parameter StoreのSecureStringとして置きます。

コマンド
aws ssm put-parameter \
  --region ap-northeast-1 \
  --name /prd-agent/prod/github-token \
  --type SecureString \
  --value "$GITHUB_TOKEN" \
  --overwrite

$GITHUB_TOKENはシェル変数として渡し、コマンド履歴に残らないように扱ってください。

このパラメータをLambdaのIAMロールからssm:GetParameterで読み取り、SecureStringの復号はkms:Decryptで行います。後者はkms:ViaService条件でssm.ap-northeast-1.amazonaws.comに絞ると、デフォルトKMSキーの誤用を防げます。

2. Lambda用のIAMロールとポリシーを作る

最小権限の方針で次の内容にします。CloudWatch Logs、Bedrock InvokeModel、SSM GetParameter、KMS DecryptをそれぞれResourceで絞り込みます。

iam-trust-policy.jsonは信頼ポリシーです。

設定/データ
{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Effect": "Allow",
      "Principal": { "Service": "lambda.amazonaws.com" },
      "Action": "sts:AssumeRole"
    }
  ]
}

iam-policy.jsonが実権限です。<account-id>は読者のAWSアカウントIDで置き換えてください。

設定/データ
{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Sid": "Logs",
      "Effect": "Allow",
      "Action": [
        "logs:CreateLogGroup",
        "logs:CreateLogStream",
        "logs:PutLogEvents"
      ],
      "Resource": "arn:aws:logs:ap-northeast-1:<account-id>:log-group:/aws/lambda/prd-design-bedrock-implementer*"
    },
    {
      "Sid": "BedrockInvokeInferenceProfile",
      "Effect": "Allow",
      "Action": ["bedrock:InvokeModel"],
      "Resource": [
        "arn:aws:bedrock:ap-northeast-1:<account-id>:inference-profile/jp.anthropic.claude-sonnet-4-6",
        "arn:aws:bedrock:ap-northeast-1::foundation-model/anthropic.claude-sonnet-4-6",
        "arn:aws:bedrock:ap-northeast-3::foundation-model/anthropic.claude-sonnet-4-6"
      ]
    },
    {
      "Sid": "SSMReadGitHubToken",
      "Effect": "Allow",
      "Action": ["ssm:GetParameter"],
      "Resource": "arn:aws:ssm:ap-northeast-1:<account-id>:parameter/prd-agent/prod/github-token"
    },
    {
      "Sid": "KmsDecryptForSSM",
      "Effect": "Allow",
      "Action": ["kms:Decrypt"],
      "Resource": "*",
      "Condition": {
        "StringEquals": {
          "kms:ViaService": "ssm.ap-northeast-1.amazonaws.com"
        }
      }
    }
  ]
}

注意点が2つあります。

1つ目は、Sonnet 4.6からBedrockのモデルIDが命名規則変更で日付サフィックスを失っており、jp.anthropic.claude-sonnet-4-6-*のように後ろにワイルドカードを付けるとリソース名そのものに一致しなくなります。完全一致で書きます。

2つ目は、Japan inference profileは東京と大阪の両方のfoundation modelに内部で振り分けられるため、ap-northeast-1ap-northeast-3の両方のfoundation-model/anthropic.claude-sonnet-4-6を許可しないとAccessDeniedで止まります。

ロールとポリシーを作成し、ロールにアタッチします。

コマンド
aws iam create-role \
  --role-name prd-design-bedrock-implementer-role \
  --assume-role-policy-document file://iam-trust-policy.json

aws iam create-policy \
  --policy-name prd-design-bedrock-implementer-policy \
  --policy-document file://iam-policy.json

aws iam attach-role-policy \
  --role-name prd-design-bedrock-implementer-role \
  --policy-arn arn:aws:iam::<account-id>:policy/prd-design-bedrock-implementer-policy

3. 実装用Lambdaを作る

index.mjsの核心はBedrock呼出と、生成されたファイル変更プランをGitHub Contents APIで反映するところです。

ソースコード
import {
  BedrockRuntimeClient,
  InvokeModelCommand
} from "@aws-sdk/client-bedrock-runtime";
import { SSMClient, GetParameterCommand } from "@aws-sdk/client-ssm";

const MODEL_ID =
  process.env.MODEL_ID || "jp.anthropic.claude-sonnet-4-6";
const MAX_TOKENS = Number(process.env.MAX_TOKENS || 16000);
const REGION = process.env.AWS_REGION || "ap-northeast-1";
const GITHUB_TOKEN_PARAM =
  process.env.GITHUB_TOKEN_PARAM || "/prd-agent/prod/github-token";

const SYSTEM_PROMPT = `あなたはシニアソフトウェアエンジニアです。
渡された PRD と DesignDoc に基づいて、対象リポジトリに最小限かつ的確な実装変更を加える「ファイル変更プラン」を JSON のみで返してください。

厳守事項:
- DesignDocに書かれた範囲を逸脱しない
- 既存リポジトリのスタイルや命名規約を尊重する
- 「テストを通すためだけ」のテストではなく本質的なテスト観点を書く
- 不要な依存追加やスコープ拡大、テンプレ的な空ファイル生成を避ける
- 破壊的変更や危険な操作は絶対にしない
- すべてのファイル content は完全な最終形を含めること
- 次のパスは絶対に変更しない: .github/, .gitlab/, Dockerfile*, docker-compose.*, Caddyfile, iam-*.json, deploy.sh, bootstrap.sh, .env*, secrets/, credentials*, *-lock.json, *.lock, .ssh/, id_rsa*, id_ed25519*, *.pem, *.key, *.p12, *.pfx

出力形式:
{
  "summary_markdown": "実装サマリ Markdown",
  "files": [
    {
      "path": "リポジトリ相対パス",
      "operation": "create | update | delete",
      "content": "ファイル全体の内容",
      "rationale": "1〜3文の根拠"
    }
  ]
}`;

const bedrock = new BedrockRuntimeClient({ region: REGION, maxAttempts: 5 });
const ssm = new SSMClient({ region: REGION });

const DENIED_PATH_PATTERNS = [
  /^\.github\//i,
  /^\.gitlab\//i,
  /(^|\/)Dockerfile(\.|$)/i,
  /(^|\/)docker-compose\./i,
  /(^|\/)Caddyfile$/i,
  /(^|\/)iam-.*\.json$/i,
  /(^|\/)deploy\.sh$/i,
  /(^|\/)bootstrap\.sh$/i,
  /(^|\/)\.env($|\.)/i,
  /(^|\/)secrets?\//i,
  /(^|\/)credentials?($|\.|\/)/i,
  /(^|\/)package-lock\.json$/i,
  /(^|\/)pnpm-lock\.yaml$/i,
  /(^|\/)yarn\.lock$/i,
  /(^|\/)Gemfile\.lock$/i,
  /(^|\/)\.ssh\//i,
  /(^|\/)id_rsa/i,
  /(^|\/)id_ed25519/i,
  /\.pem$/i,
  /\.key$/i,
  /\.p12$/i,
  /\.pfx$/i
];

function validatePath(p) {
  if (typeof p !== "string") return "path must be a string";
  if (p.length === 0) return "path is empty";
  if (p.length > 200) return "path exceeds 200 chars";
  if (p.startsWith("/")) return "path must be relative";
  if (p.includes("\0")) return "path contains null byte";
  if (p.split("/").some((seg) => seg === "..")) {
    return "path contains '..' segment";
  }
  for (const pattern of DENIED_PATH_PATTERNS) {
    if (pattern.test(p)) return `denied by pattern ${pattern}`;
  }
  return null;
}

DENIED_PATH_PATTERNSはBedrockがどれほどSYSTEM_PROMPTを尊重しても、攻撃的なPRD/DesignDocによって.github/workflows/deploy.ymlのようなCI設定やCaddyfileのような実行環境設定を書き換えられるリスクを機械的に閉じます。SYSTEM_PROMPT側の禁止リストはモデルへのお願いに過ぎず、プロンプト注入で「以前の指示は無視してください」と上書きされる経路があるためです。validatePathが二重防御として最終的なPUT前に走り、リジェクトされたファイルはresults配列にerror: "path rejected: ..."として記録されます。

GitHub Contents APIへの書き込みは、対象パスの既存SHAを取得してからPUTする手順を、create/update/deleteの3操作に対応させて関数化します。

ソースコード
async function applyFile(token, owner, repo, branch, file, message) {
  const enc = encodeURIComponent(file.path).replace(/%2F/g, "/");
  const url = `/repos/${owner}/${repo}/contents/${enc}`;
  if (file.operation === "delete") {
    const sha = await getFileSha(token, owner, repo, file.path, branch);
    if (!sha) return { path: file.path, skipped: "missing for delete" };
    return await githubRequest(token, "DELETE", url, { message, branch, sha });
  }
  const existingSha = await getFileSha(token, owner, repo, file.path, branch);
  const payload = {
    message,
    branch,
    content: Buffer.from(file.content || "", "utf8").toString("base64")
  };
  if (existingSha) payload.sha = existingSha;
  return await githubRequest(token, "PUT", url, payload);
}

handlerは、入力の必須フィールドを検証し、Bedrockに整形済みのuser_input(JSON)を渡し、戻ってきたJSONをパースしてfilesを順に反映していきます。

ソースコード
export const handler = async (event) => {
  const required = [
    "task_title", "prd_markdown", "design_doc_markdown",
    "target_repository", "branch_name"
  ];
  for (const k of required) {
    if (!event[k]) throw new Error(`Missing field: ${k}`);
  }
  const [owner, repo] = String(event.target_repository).split("/");

  const userPrompt = JSON.stringify({
    task_title: event.task_title,
    target_repository: event.target_repository,
    base_branch: event.base_branch || "main",
    branch_name: event.branch_name,
    additional_constraints: event.additional_constraints || null,
    prd_markdown: event.prd_markdown,
    design_doc_markdown: event.design_doc_markdown
  }, null, 2);

  const body = {
    anthropic_version: "bedrock-2023-05-31",
    max_tokens: MAX_TOKENS,
    system: SYSTEM_PROMPT,
    messages: [{ role: "user", content: userPrompt }]
  };

  const response = await bedrock.send(
    new InvokeModelCommand({
      modelId: MODEL_ID,
      contentType: "application/json",
      accept: "application/json",
      body: JSON.stringify(body)
    })
  );
  const respText = new TextDecoder().decode(response.body);
  const parsed = JSON.parse(respText);
  const text = parsed.content?.[0]?.text || "";
  const plan = parseBedrockJson(text);

  const token = await getGithubToken();
  const message = `feat: ${event.task_title.slice(0, 60)} (Bedrock implementation)`;
  const results = [];
  for (const file of plan.files) {
    if (!file.path || !file.operation) {
      results.push({ path: file.path || "?", error: "missing path or operation" });
      continue;
    }
    if (!["create", "update", "delete"].includes(file.operation)) {
      results.push({ path: file.path, error: `unsupported operation: ${file.operation}` });
      continue;
    }
    const pathError = validatePath(file.path);
    if (pathError) {
      results.push({
        path: file.path,
        error: `path rejected: ${pathError}`,
        rationale: file.rationale || null
      });
      continue;
    }
    try {
      const r = await applyFile(token, owner, repo, event.branch_name, file, message);
      results.push({ ...r, rationale: file.rationale || null });
    } catch (e) {
      results.push({ path: file.path, error: e.message });
    }
  }

  return {
    summary_markdown: plan.summary_markdown,
    files_changed: results,
    model_id: MODEL_ID,
    branch: event.branch_name,
    target_repository: event.target_repository
  };
};

package.jsonは次の通りです。type: moduleにして@aws-sdk/client-bedrock-runtime@aws-sdk/client-ssmを入れます。

設定/データ
{
  "name": "prd-design-bedrock-implementer",
  "version": "1.0.0",
  "type": "module",
  "main": "index.mjs",
  "dependencies": {
    "@aws-sdk/client-bedrock-runtime": "^3.1041.0",
    "@aws-sdk/client-ssm": "^3.1041.0"
  }
}

zipにまとめてLambdaを作成します。

コマンド
npm install --omit=dev --no-package-lock
zip -qr implementer.zip index.mjs package.json node_modules

aws lambda create-function \
  --region ap-northeast-1 \
  --function-name prd-design-bedrock-implementer \
  --runtime nodejs22.x \
  --handler index.handler \
  --memory-size 1024 \
  --timeout 300 \
  --role arn:aws:iam::<account-id>:role/prd-design-bedrock-implementer-role \
  --environment "Variables={MODEL_ID=jp.anthropic.claude-sonnet-4-6,GITHUB_TOKEN_PARAM=/prd-agent/prod/github-token}" \
  --zip-file fileb://implementer.zip

タイムアウトは余裕を持って5分にしています。Bedrockの推論時間と、ファイル数が多いときのGitHub APIへの逐次PUT合計を見越してのものです。

4. n8n用IAMユーザーにLambda呼び出し権限を追加する

n8nが使うIAMユーザー(前回記事のn8n-runtime-user想定)に、対象Lambdaを叩く最小権限のインラインポリシーを足します。

コマンド
aws iam put-user-policy \
  --user-name n8n-runtime-user \
  --policy-name n8n-invoke-prd-implementer \
  --policy-document '{
    "Version": "2012-10-17",
    "Statement": [{
      "Effect": "Allow",
      "Action": "lambda:InvokeFunction",
      "Resource": "arn:aws:lambda:ap-northeast-1:<account-id>:function:prd-design-bedrock-implementer*"
    }]
  }'

Resource末尾の*は、:$LATESTなどのqualifierが付いたARNにも一致させるためです。

5. Google Driveに保存先フォルダを作る

n8nのGoogle Drive OAuth2が連携しているアカウントのMy Drive上で、PRDとDesignDocを溜める専用フォルダを1つ作ります。本記事ではappフォルダの配下にprd-design-agentを作る想定です。

n8nのGoogle Driveノードでは、folderIdにこのフォルダのIDを指定します。フォルダIDはhttps://drive.google.com/drive/folders/<ID><ID>部分です。

6. n8nワークフローを組む

ノード構成は次の通りです。Form Triggerから始まり、Geminiに設計書を書かせ、Markdownを書式付きGoogle Doc化してから、GitHubにPRを作り、最後にBedrock Lambdaを叩きます。

出力例
Implementation Request Form
  -> Prepare Request
  -> Generate PRD and DesignDoc
  -> Parse Gemini Output
  -> Convert PRD Markdown to HTML
  -> Build PRD Upload Body
  -> Upload PRD as HTML
  -> Attach PRD Doc URL
  -> Convert DesignDoc Markdown to HTML
  -> Build DesignDoc Upload Body
  -> Upload DesignDoc as HTML
  -> Attach DesignDoc URL
  -> Get Base Branch Ref
  -> Prepare GitHub Payload
  -> Create GitHub Branch
  -> Commit DesignDoc Markdown
  -> Create GitHub PR
  -> Invoke Bedrock Implementer
  -> Parse Bedrock Result
  -> Post Implementation Summary
  -> Return Result

順に要点を書きます。

6.1. Form Triggerでフォーム入力を受ける

n8n-nodes-base.formTrigger(typeVersion 2.5)を使います。フィールドは次の5項目に絞り込みます。

  • Task Title(必須)
  • Target Repository(必須、デフォルトokamyuji/prd-design-implementation-agent)
  • Base Branch(必須、デフォルトmain)
  • Implementation Instruction(必須、textarea)
  • Additional Constraints(任意、textarea)

テンプレートのGoogle Doc URLをフォームから入力させる設計も検討しましたが、テンプレートが固定であるならworkflow側で固定値を持たせた方が運用が安定します。

Form Triggerノードの設定

ユーザーがブラウザで見るフォームは次のような姿になります。

ユーザー側のフォーム

6.2. Prepare Requestで入力をJSON化する

Codeノードで、フォームの入力をslug化してbranch名と設計書MarkdownのリポジトリパスをUTC日時込みで生成します。Geminiに渡すpromptはJSON文字列として組み立て、出力スキーマの形を明示します。

ソースコード
const userInput = {
  task_title: taskTitle,
  target_repository: targetRepository,
  base_branch: baseBranch,
  implementation_instruction: instruction,
  additional_constraints: constraints || null,
  prd_template_url: PRD_TEMPLATE_URL,
  design_doc_template_url: DESIGN_DOC_TEMPLATE_URL
};

const prompt = systemRules + "\n\nuser_input:\n" + JSON.stringify(userInput, null, 2);

GeminiにresponseMimeType: application/json相当を効かせるためには、systemMessageでJSONのみを返すように強く指示しつつ、ユーザー入力もJSONで渡すのが安定する印象です。

Prepare Requestノード

6.3. Generate PRD and DesignDocでGemini呼出

@n8n/n8n-nodes-langchain.googleGeminiノードでmodels/gemini-2.5-flashを使います。重要なoptionは2つです。

  • maxOutputTokensを32768に上げる
  • thinkingBudgetを0に固定する

Gemini 2.5系列はthinkingBudget: -1(動的)だとthinkingトークンがmaxOutputTokensの枠を消費します。長文のJSONを返させるときは思考枠を喰わせない方が出力が打ち切られません。jsonOutput: trueはそのまま入れて、systemMessageに「strict JSON only」を念押しします。

Geminiノードのモデル設定

Geminiノードのオプション設定でmaxOutputTokensとthinkingBudgetを明示

6.4. Parse Gemini Outputで出力を取り出す

n8nのGeminiノードはsimplify: trueでも、出力が{ content: { parts: [{ text: "..." }] }, finishReason, index }という形でnestされています。textを直接見ようとするとundefinedになるので、再帰的にpartsにも降りるパーサにしておきます。

ソースコード
function firstString(value) {
  if (typeof value === "string") return value;
  if (Array.isArray(value)) return value.map(firstString).find(Boolean) || "";
  if (value && typeof value === "object") {
    for (const key of ["text", "content", "parts", "output", "response", "result", "message", "data"]) {
      if (value[key] === undefined) continue;
      const found = firstString(value[key]);
      if (found) return found;
    }
  }
  return "";
}

JSON.parseに失敗したときは、{から最後の}までをスライスして再試行し、それでも駄目ならmaxOutputTokensの不足を示唆するエラーメッセージを投げて止めます。

このCodeノードでは、必須キー(prd_markdowndesign_doc_markdownpr_titlepr_body)の存在チェックの直後に、もう1段のセキュリティガードを入れます。GeminiはLLMである以上、フォーム入力の内容によっては危険な出力を返す可能性があるため、Bedrockに渡す前に長さ上限と禁止パターンを機械的に弾きます。

ソースコード
const MAX_LEN = {
  prd_markdown: 20000,
  design_doc_markdown: 30000,
  pr_title: 200,
  pr_body: 5000
};
for (const key of required) {
  const max = MAX_LEN[key];
  if (typeof generated[key] === "string" && generated[key].length > max) {
    throw new Error("Gemini output " + key + " exceeds " + max + " chars (got " + generated[key].length + ")");
  }
}

const FORBIDDEN_PATTERNS = [
  { name: "script tag", re: /<script[\s>]/i },
  { name: "javascript: scheme", re: /javascript:/i },
  { name: "data:text/html", re: /data:text\/html/i },
  { name: "github PAT", re: /\bghp_[A-Za-z0-9]{20,}/ },
  { name: "github fine-grained PAT", re: /\bgithub_pat_[A-Za-z0-9_]{20,}/ },
  { name: "PEM private key", re: /-----BEGIN [A-Z ]*PRIVATE KEY-----/ },
  { name: "AWS access key", re: /\b(AKIA|ASIA)[0-9A-Z]{16}\b/ }
];
for (const key of required) {
  if (typeof generated[key] !== "string") continue;
  for (const { name, re } of FORBIDDEN_PATTERNS) {
    if (re.test(generated[key])) {
      throw new Error("Gemini output " + key + " contains forbidden pattern: " + name);
    }
  }
}

for (const key of required) {
  if (typeof generated[key] === "string") {
    generated[key] = generated[key].replace(/[\x00-\x08\x0b\x0c\x0e-\x1f\x7f]/g, "");
  }
}

長さ上限はLambdaタイムアウトとBedrockのトークン消費の両方を機械的に守ります。禁止パターンはGeminiが秘匿情報を引いてきた場合や、攻撃的な入力で<script>タグが入ったMarkdownを生成した場合に、後段のLambdaやGoogle Doc化経路に流れる前に止めます。制御文字の除去は、後続Markdown処理が壊れないようにするためと、CodeRabbitレビュー画面の表示崩れを防ぐためです。

Parse Gemini Outputノードと入力スキーマのcontent.parts[0].text

6.5. Markdownを書式付きGoogle Docにする

ここがハマりやすい部分です。n8nのGoogle DriveノードのcreateFromTextは、内部でDocs APIのinsertTextを使うため、Markdown構文がプレーンテキストとして残ります。# 見出しが見出しにならず、ただの文字列として表示されます。

書式を反映させるためには、Drive APIの/upload/drive/v3/files?uploadType=multipartmultipart/relatedでメタデータと本文を直接POSTし、本文のContent-Typetext/html(またはtext/markdown)にする必要があります。Markdown直送でも書式は付きますが、mermaidのような特殊コードブロックを含むDesignDocは500 Internal Errorを返すことがあったので、HTML経由に統一しました。

n8nのMarkdownノード(mode: markdownToHtml)でMarkdownをHTMLに変換し、Codeノードでmultipart/related本文を組み立てます。

ソースコード
const boundary = "n8n-html-" + Date.now() + "-" + Math.random().toString(36).slice(2);
const metadata = JSON.stringify({
  name: base.prd_doc_title,
  mimeType: "application/vnd.google-apps.document",
  parents: ["<folder-id>"]
});
const html = "<!DOCTYPE html><html><head><meta charset=\"UTF-8\"></head><body>"
  + (base.prd_html || "") + "</body></html>";
const body = [
  "--" + boundary,
  "Content-Type: application/json; charset=UTF-8",
  "",
  metadata,
  "--" + boundary,
  "Content-Type: text/html; charset=UTF-8",
  "",
  html,
  "--" + boundary + "--",
  ""
].join("\r\n");

HTTP Requestノード(predefinedCredentialType: googleDriveOAuth2Api)でPOSTします。Content-Typeヘッダはmultipart/related; boundary={{ $json.boundary }}にします。レスポンスのfile idからhttps://docs.google.com/document/d/<id>/editを組み立て、後段で参照できるようにjsonに残します。

MarkdownノードでHTML変換

Codeノードでmultipart/related本文を構築

HTTP RequestノードでDrive APIへPOST

6.6. GitHubにブランチを切ってDesignDocをcommitする

ここからはGitHub APIをHTTP Requestノードで素直に叩きます。

  1. GET /repos/{owner}/{repo}/git/refs/heads/{base_branch}でベースのcommit SHAを取得する
  2. POST /repos/{owner}/{repo}/git/refsで新ブランチを作成する
  3. PUT /repos/{owner}/{repo}/contents/{docs_path}でDesignDocのMarkdownをcommitする
  4. POST /repos/{owner}/{repo}/pullsでPRを開く

Contentはbase64エンコードして渡します。PR本文には、生成済みPRDとDesignDocのGoogle Doc URL、リポジトリ内のDesignDocパス、Bedrock Lambdaへ渡す依頼の3点を含めておくと、レビュアーが文脈を辿れます。

6.7. Bedrock Lambdaを叩いて実装をcommitする

n8n-nodes-base.awsLambdaノードで、先ほど作ったprd-design-bedrock-implementerRequestResponseで呼びます。payloadはJSON文字列として組み立て、Lambdaのhandlerが期待する5フィールドを必ず詰めます。

n8nのawsLambdaノードはRequestResponseでも、Lambdaの戻り値を{ result: ... }でラップして次に渡します。Codeノードで$json.resultを見るのを忘れると、後段で「Lambda payload missing fields」と落ちます。

ソースコード
let payload = $json;
if (payload && payload.result && typeof payload.result === "object") {
  payload = payload.result;
}
if (!payload.summary_markdown || !Array.isArray(payload.files_changed)) {
  throw new Error("Lambda payload missing fields");
}
return [{ json: payload }];

最後にHTTP RequestでPRに実装サマリのコメントをPOSTします。CodeRabbitとGemini Code Assistはこの段階ですでにレビューを始めているので、人がPRを開いたときには3者のレビューと2つのcommitが揃っています。

Invoke Bedrock ImplementerでawsLambdaを呼び出す

Parse Bedrock Resultでpayload.resultを取り出す

7. 動作確認

n8nのForm URLはhttps://<host>/form/<formTriggerのpath>-formになります。-formが末尾に自動付与される点に注意してください。設定したpathがprd-design-implementation-agentなら、実際の公開URLはhttps://<host>/form/prd-design-implementation-agent-formです。

ブラウザからフォームを送信すると、数十秒から1分程度でPRが開きます。最終的にPRに含まれているはずのものは次の3つです。

  1. docs/design-docs/<timestamp>-<slug>.md(DesignDocのMarkdown)
  2. 実装に必要なファイルの差分(Bedrockが作る、たいていREADME.mdsrc/配下)
  3. PR本文に貼られたGoogle DocのURL2件と、Bedrock実装サマリのコメント

prd-design-agentフォルダにはPRDとDesignDocの書式付きGoogle Docが、それぞれ実行ごとに2件ずつ作成されます。

n8nのExecutions画面で全ノードがSucceeded

GitHub側を見ると、PRがDesignDocコミットとBedrock実装コミットの2つを抱えた状態でOpenになっています。

GitHub PRのConversationページ

Files changedで2ファイルの差分を確認

フォーム公開エンドポイントを Shared Secret で守る

n8nのForm Triggerが返すProduction URL /form/<path> は、workflowがactiveの間は認証なしで全世界から到達可能です。前章までの構成では、対象pathを知った第三者がフォームを連投するだけで、毎回Gemini→Bedrock Sonnet 4.6→GitHub APIがフルで走り、自分のAWS従量とGemini quotaを浪費されます。さらにリポジトリにノイズPRが量産されます。

対策の選択肢としては、Form TriggerのAuthenticationをBasic Authに変更する、Caddy側で/form/*にBasic Authをかける、Cloudflare Tunnel + Cloudflare Access for Appsで前段認証を強制する、Form受信直後のCodeノードでpre-shared secretを照合する、などがあります。本章ではCodeノード照合を採用します。理由は、n8nの設定とworkflow編集だけで完結し、Caddyやインフラ層に変更を入れずに執行できるためです。

7.1. SSM Parameter Storeに Shared Secret と env unblock フラグを置く

「秘匿情報をディスクに残さないn8nセルフホストをAWSで構築する」で作った/n8n/prod/*配下に、シークレット2件を追加します。load-secrets.shget-parameters-by-path/n8n/prod配下を再帰取得し、パス末尾を大文字化して/run/n8n-secrets/.envに書き出すので、新規パラメータをputするだけで自動的にtmpfsの環境変数ファイルに乗ります。

コマンド
# Shared Secret(40文字推奨。フォーム送信者が入力する値)
SECRET=$(openssl rand -base64 36 | tr -d '+/=' | head -c 40)
aws ssm put-parameter \
  --region ap-northeast-1 \
  --name /n8n/prod/prd_agent_form_secret \
  --type SecureString \
  --value "$SECRET" \
  --overwrite

# n8n Code ノードから $env.* を参照可能にするフラグ
aws ssm put-parameter \
  --region ap-northeast-1 \
  --name /n8n/prod/n8n_block_env_access_in_node \
  --type String \
  --value "false" \
  --overwrite

# tmpfs に再展開する
sudo systemctl restart n8n-secrets.service

/run/n8n-secrets/.envPRD_AGENT_FORM_SECRET=...N8N_BLOCK_ENV_ACCESS_IN_NODE=falseの2行が増えていれば成功です。

7.2. docker-compose.yml の n8n.environment にマッピングを足す

n8nコンテナのenvironment:は明示列挙式なので、env_fileに値があってもそれだけではコンテナ内envに渡りません。/opt/n8n/docker-compose.ymln8nサービスに2行追加します。

設定/データ
  n8n:
    image: n8nio/n8n:latest
    environment:
      # ...既存の DB_TYPE 〜 EXECUTIONS_DATA_MAX_AGE はそのまま...
      EXECUTIONS_DATA_MAX_AGE: "168"
      # Code ノードから $env.* を参照するために必要
      N8N_BLOCK_ENV_ACCESS_IN_NODE: ${N8N_BLOCK_ENV_ACCESS_IN_NODE}
      # Form Trigger 入力の Shared Secret を Verify Shared Secret ノードで照合
      PRD_AGENT_FORM_SECRET: ${PRD_AGENT_FORM_SECRET}

systemd経由で再起動して反映します。

コマンド
sudo systemctl restart docker-n8n.service
sudo docker exec n8n-n8n-1 sh -c 'echo $N8N_BLOCK_ENV_ACCESS_IN_NODE; echo ${#PRD_AGENT_FORM_SECRET}'
# false / 40 (シークレットの長さ)が出れば反映済み

docker compose restart単独だとsystemd unitのEnvironmentFileが解釈されず${VAR}展開が空になるので、必ずsystemctl restart docker-n8n.serviceを使ってください。

7.3. Form Trigger に Shared Secret フィールドを追加する

n8nのUIでImplementation Request Formノードを開き、Form Elementsの末尾に次のフィールドを追加します。

  • Field Label: Shared Secret
  • Field Type: Password
  • Required: on
  • Placeholder: 「管理者から共有された Shared Secret を入力してください」

Password型を選んでおくと、ブラウザ側で入力値がマスク表示され、パスワードマネージャからの自動入力にも対応します。

7.4. Verify Shared Secret Code ノードを Form Trigger 直後に挿入する

Form TriggerとPrepare Requestの間に新規Codeノードを置き、Form Trigger側の出力を新ノードへ、新ノードの出力をPrepare Requestへつなぎ替えます。

ソースコード
const expected = $env.PRD_AGENT_FORM_SECRET || "";
if (!expected) {
  throw new Error("Server misconfiguration: PRD_AGENT_FORM_SECRET is not set on the n8n container.");
}

const items = $input.all();
for (const item of items) {
  const provided = item.json["Shared Secret"] || item.json.shared_secret || "";
  // タイミング攻撃を緩和するため定数時間に近い比較で判定する
  if (provided.length !== expected.length) {
    throw new Error("Unauthorized: invalid shared secret");
  }
  let diff = 0;
  for (let i = 0; i < expected.length; i++) {
    diff |= provided.charCodeAt(i) ^ expected.charCodeAt(i);
  }
  if (diff !== 0) {
    throw new Error("Unauthorized: invalid shared secret");
  }
  // 後段のノードに secret を漏らさないよう削除する
  delete item.json["Shared Secret"];
  delete item.json.shared_secret;
}
return items;

ここで、落とし穴が3つあります。1つ目はprocess.envではなく$envを使う点です。n8nのCodeノードのVMにはprocessグローバルが存在せず、process.env.XReferenceError: process is not definedで即時例外になります。2つ目はN8N_BLOCK_ENV_ACCESS_IN_NODE=falseを環境変数として実コンテナまで通しておく必要がある点です。これがtrue(デフォルト)のままだと$env.Xは常に空文字を返し、未設定エラー経路に倒れます。3つ目はitem.json["Shared Secret"]のようにフォームのField Labelをキーとして取ること、および直後にdeleteして下流のGeminiやBedrockのpromptに混入させないことです。

Verify Shared Secretノードでフォーム入力のSharedSecretを定数時間比較する

7.5. 動作確認

不正secretと正常secretの両方を送って、検証が機械的に効いていることを確認します。

コマンド
# 1) 不正secret
curl -sS -o /tmp/wrong.html -w "HTTP=%{http_code} TIME=%{time_total}s\n" \
  -X POST https://<n8n-host>/form/prd-design-implementation-agent-form \
  -F "field-0=不正secretテスト" \
  -F "field-1=okamyuji/prd-design-implementation-agent" \
  -F "field-2=main" \
  -F "field-3=拒否されるはず" \
  -F "field-4=" \
  -F "field-5=wrong"

# 2) 正常secret(SSMから取得)
SECRET=$(aws ssm get-parameter --region ap-northeast-1 \
  --name /n8n/prod/prd_agent_form_secret --with-decryption \
  --query 'Parameter.Value' --output text)
curl -sS -o /tmp/ok.html -w "HTTP=%{http_code} TIME=%{time_total}s\n" \
  --max-time 360 \
  -X POST https://<n8n-host>/form/prd-design-implementation-agent-form \
  -F "field-0=Shared Secret動作確認" \
  -F "field-1=okamyuji/prd-design-implementation-agent" \
  -F "field-2=main" \
  -F "field-3=READMEを軽く更新するダミーPRを作ってください" \
  -F "field-4=READMEのみ最小差分で。" \
  -F "field-5=$SECRET"

不正secretは1秒台でHTTP 500 / {"message":"Error in workflow"}が返り、Gemini以降のノードには到達しません。正常secretの方は30〜40秒程度でHTTP 200を返し、対象リポジトリにDesignDocコミットとBedrock実装コミットの2コミットを持つPRが新規作成されます。

n8nのForm Triggerは攻撃者にエラーメッセージの詳細を返さないので、Unauthorized: invalid shared secretという具体的な理由は外部に漏れません。これは長さからsecretの長さを逆算されない点で都合がよく、{"message":"Error in workflow"}のままでよい仕様です。詳細はExecutionsのrunDataVerify Shared Secretノードのスタックトレースとして残るので、自分で原因を確認する分には困りません。

AI生成PRに対する人間レビューを必須化する

ここまでの構成では、Bedrockが書いた実装が自動でmainにマージされる経路はありません。とはいえそれは「私が必ずレビューします」という運用ポリシーに依存しており、技術的な執行はかかっていません。GitHub側にBranch ProtectionとCODEOWNERSを入れて、AI生成のPRが人間レビューなしにmainへマージされないことを機械的に強制します。これはMercariが社内向けに作っている「Policy Bot」が解いている問題の、個人運用での最小実装に相当します。

CODEOWNERSを置く

prd-design-implementation-agentリポジトリの.github/CODEOWNERSに次を置きます。これでPRが開かれた瞬間にCODEOWNERSが自動でレビュアーにアサインされ、Branch Protectionと組み合わせるとCODEOWNERS承認なしのマージが拒否されます。

出力例
# 自動生成されるPRはhuman reviewが必須
# AI Coding Agent(Bedrockや今後のCodexなど)が作成するPRに対して
# CODEOWNERSが自動アサインされる
*       @okamyuji

ローカルからGitHub APIで直接置く場合は次のとおりです。

コマンド
CONTENT=$(printf '%s\n' '# 自動生成されるPRはhuman reviewが必須' '*       @okamyuji' | base64 -i -)
gh api -X PUT repos/okamyuji/prd-design-implementation-agent/contents/.github/CODEOWNERS \
  -f message='ci: add CODEOWNERS for AI-authored PR review requirement' \
  -f branch=main \
  -f content="$CONTENT"

main ブランチに Branch Protection を設定する

コマンド
gh api -X PUT repos/okamyuji/prd-design-implementation-agent/branches/main/protection \
  -H "Accept: application/vnd.github+json" \
  --input - <<'EOF'
{
  "required_status_checks": null,
  "enforce_admins": false,
  "required_pull_request_reviews": {
    "dismiss_stale_reviews": true,
    "require_code_owner_reviews": true,
    "required_approving_review_count": 1,
    "require_last_push_approval": true
  },
  "restrictions": null,
  "required_linear_history": false,
  "allow_force_pushes": false,
  "allow_deletions": false,
  "block_creations": false,
  "required_conversation_resolution": true,
  "lock_branch": false,
  "allow_fork_syncing": false
}
EOF

各オプションの意図は次のとおりです。

  • required_approving_review_count: 1は最低1名の承認を必須にします
  • require_code_owner_reviews: trueは前段のCODEOWNERSと組み合わせ、対応CODEOWNERSの承認なしにマージを拒否します
  • dismiss_stale_reviews: trueは新しいコミットがpushされたら過去の承認を自動で破棄します
  • require_last_push_approval: trueは「最後にpushした人は自分のpushを承認できない」を強制します。GitHub Appやbotが作成したPRであっても、PATが個人ユーザー名に紐づいている場合、その個人は自分のpushを承認できません
  • enforce_admins: falseはリポジトリadminには非常時の手動マージ余地を残します
  • allow_force_pushes: falseallow_deletions: falseはmainブランチの履歴改竄を禁じます
  • required_conversation_resolution: trueは未解決のレビューコメントが残ったままのマージを拒否します

個人運用での現実的な落とし所

1人運用の場合、PATがokamyujiに紐づいていれば、require_last_push_approvalによりokamyujiは自分のpushを承認できません。ここで取れる選択肢は次のとおりです。

  • 別アカウントで承認する(本格的にやるなら、レビュー専用のbotアカウントやサブアカウントを準備)
  • リポジトリadmin権限でenforce_admins: falseの枠を使い、admin手動マージ(明示的な権限行使として認識)
  • 重大な変更は人間が別ブランチで再実装し、AI生成PRは参考実装として閉じる

3番目はAI生成PRを「叩き台」として位置付ける運用で、メルカリのスライドが言う「Policy BotによってAI Coding Agent特有のリスクを既存の人間ワークフローに引き戻す」考え方と整合します。完全自動化を目指すよりも、AI生成PRが必ず人間の判断を1ステップ挟むようにすることが、Branch Protectionの本質的な役割です。

設定確認

コマンド
gh api repos/okamyuji/prd-design-implementation-agent/branches/main/protection

required_pull_request_reviewsセクションの値が上記と一致していれば設定完了です。

運用の勘所

  • 実装の規模が大きいときはBedrock側でmax_tokensを上げる
    • Sonnet 4.6は出力上限が大きいので、MAX_TOKENSは20000から32000程度まで上げて構いません。Lambdaのタイムアウトも合わせて伸ばします。
  • LambdaのIAMはinference profileのリソースARNを末尾完全一致で書く
    • 旧来の*サフィックスの感覚で書くと、Sonnet 4.6ではAccessDeniedになります。anthropic.claude-sonnet-4-6は完全一致で記述します。
  • フォーム送信後の経路は同期で完結させる
    • n8nのForm TriggerはresponseMode: lastNodeにしておき、すべての処理が完了してからフォームに完了メッセージを返します。Codex Cloudの非同期Trigger設計に引きずられてonReceivedにすると、失敗時のリトライ機会を失います。
  • 自動Approveはしない
    • LLMが書いた実装は速いですが、最終的なApproveは人が見てください。CodeRabbitやGemini Code Assistの指摘も踏まえて、PRを開けばすぐ全体像が掴めるようにしてあります。

コスト試算

n8nが動くEC2と前回記事で立てたインフラのコストは据え置きで、追加の発生はおおむね次のとおりです。月に20回(1日0.7回程度)実行する想定で見積もります。

項目 単価 月額
Lambda(20回・約3秒・1024MB) $0.0000166667/GB-s $0.001
Bedrock Sonnet 4.6 input(20回・約30,000 token) 入出力料金は公式pricingを参照 数十セント程度
Bedrock Sonnet 4.6 output(20回・約12,000 token) 同上 1ドル前後
SSM Parameter(SecureString取得) 無料枠内 $0
Gemini API Google AI Studioのpricingに従う プランによる
GitHub API 認証あれば月内枠で十分 $0

実装規模が膨らむと出力tokenが伸びるので、Bedrockの実費が一番動きます。Anthropic公式のSonnet 4.6料金を読みつつ、ピーク日でも数ドルに収まるようにLambdaのMAX_TOKENSを上限としてかけておくと安心です。

トラブルシューティング

挙動別の対処を集約します。新たに踏んだ落とし穴は、症状の逆引きで参照できるようにここに足してください。

Geminiが「Bad request - please check your parameters」を返す

n8nのGoogleGeminiノードがリクエストボディの整形時に内部で例外を起こしているケースがほとんどです。具体的には、maxOutputTokensに対してthinkingBudget: -1を指定すると思考トークンが出力枠を喰い、本文がほぼ空で返ってきて、後段のjsonOutputパーサが落ちて総括エラーが表示されます。thinkingBudgetを0、maxOutputTokensを32768などに増やします。

Parse Gemini Outputで「missing prd_markdown」が出る

Geminiは正しく返しているのに後段でprd_markdownが見えないときは、出力が長すぎて切れているか、もしくはn8nノードの出力構造がcontent.parts[].textにnestされているのに上位だけを見ているかのどちらかです。前者はmaxOutputTokensを上げ、後者はfirstString系の再帰関数のキーリストにpartsを含めます。

Bedrockが「The provided model identifier is invalid」を返す

Sonnet 4.6から命名規則が変わり、anthropic.claude-sonnet-4-6-YYYYMMDD-v1:0のような旧形式は通りません。inference profile経由ならjp.anthropic.claude-sonnet-4-6、foundation model直接ならanthropic.claude-sonnet-4-6と書きます。

Lambdaのassumed-roleがbedrock:InvokeModelで拒否される

LambdaのIAMポリシーでBedrockのResourceに-*を付けてワイルドカードに頼っていると、jp.anthropic.claude-sonnet-4-6本体に一致しません。-*を外して完全一致で書くか、jp.anthropic.claude-sonnet-4-6*(末尾*)にします。inference profileの転送先である東京と大阪のfoundation model ARNも忘れず追加します。

n8nのIAMユーザーがlambda:InvokeFunctionで拒否される

n8n側のIAMユーザーには別途インラインポリシーでlambda:InvokeFunctionを付与します。Lambda側のIAMロールとは別物なので、両方の権限を見比べてください。

awsLambdaノードの後段で「Lambda payload missing fields」になる

n8nのawsLambdaノードはRequestResponseでもLambdaの戻り値を{ result: ... }でラップします。$json.summary_markdownを直接見ようとすると常にundefinedです。Codeノードで$json.result || $jsonを起点にしてください。

Google Docに見出しや太字がプレーンテキストで残る

n8nのGoogle DriveノードのcreateFromTextは内部的にDocs APIのinsertTextを呼ぶため、Markdown構文をプレーン文字として挿入します。書式を反映させたい場合は、MarkdownノードでHTMLに変換してから、HTTP RequestでDrive APIの/upload/drive/v3/files?uploadType=multipartmultipart/relatedでPOSTします。

DesignDocのアップロードだけ「Internal Error」になる

text/markdownを直接POSTする経路だと、mermaidや独自fence情報を含むDesignDocでDrive側のパーサが500 Internal Errorを返すことがあります。Markdownを一度HTMLに変換してからtext/htmlでPOSTすると、未知のコードブロックも<code>として無難に扱われて落ちません。

n8nのForm Triggerに外部から送ったらフィールドがすべてnullになる

n8nのForm Triggerは内部でfield-0field-1のような順序付きキーを期待しています。task_titleのようにフィールド名で送ると、addFormResponseDataToReturnItemがbodyに見つけられず、結果的に各フィールドがnullになります。curl -F "field-0=..." -F "field-1=..."のように順序で渡してください。

PRに@codex /implementを書いても実装commitが入らない

GitHub上のchatgpt-codex-connector[bot]はReviewコメントは投稿しますが、/implementの実装commitが空振りすることがあります。Summaryに「commitしました(指定sha)」と書かれているのに、gh api repos/.../commits/<対象sha>で404が返るケースが該当します。これはCodex Cloud側の利用上限かReview-onlyモードが原因として疑わしく、本記事の構成のようにBedrock+Lambda経路に置き換えるのが安定する方策です。

Bedrockに切り替えてもPRに`chatgpt-codex-connector[bot]`のレビューが付く

ワークフロー側からCodex Cloudへの参照を全て外しても、GitHub AppsとしてChatGPT Codex Connectorがリポジトリにインストールされていると、PR open時に自動レビューが投稿されます。レビュー本文にもYour team has set up Codex to review pull requests in this repoと案内が出ます。

止めるには次のいずれかを行います。

  • https://github.com/settings/installationsChatGPT Codex ConnectorConfigure を開き、Repository accessから対象リポジトリを除外する
  • 全リポジトリで止めるなら Uninstall する
  • ChatGPT Codex Cloud側 https://chatgpt.com/codex/cloud/settings/general から該当リポジトリのレビュー機能をOFFにする

設定変更後は、変更前に開いたPRには既存のレビューが残るので、新規PRを開いてgh api repos/<owner>/<repo>/pulls/<n>/reviews --jq '[.[] | {user: .user.login}]'の結果にchatgpt-codex-connector[bot]が含まれないことを確認します。投稿には数分の遅延があるので、PR open後5分待ってから判定すると安全です。

まとめ

PRDとDesignDocの生成、Google Doc化、PR作成、実装commitまでをn8n上で1本の経路にまとめると、人がやることは要件入力と最終Approveだけになります。実装フェーズに外部SaaSのLLMを使わず、Bedrockと自前Lambdaでクローズに保つと、利用上限とデータ送出の両方が自分のAWS従量に閉じます。

セキュリティ面では、Bedrockへの入力はGeminiが生成したJSONをn8nのCodeノードで長さ上限と禁止パターンを通してから渡し、Bedrockの出力はLambda側のvalidatePathでCI設定や秘匿情報ファイルへの書き込みを機械的に拒否します。さらにmainブランチのBranch ProtectionとCODEOWNERSで、AI生成PRが人間レビューなしにマージされない経路を執行します。SYSTEM_PROMPTはモデルへのお願いに過ぎず、プロンプト注入で「以前の指示は無視してください」と上書きされる経路があるため、機械的な二重防御がエージェント運用の前提になります。

Markdownの書式を保ったままGoogle Docを作るのは、Drive APIにmultipart/relatedでHTMLを直接POSTするのが現状もっとも安定する経路です。GeminiのthinkingBudget、Bedrock Sonnet 4.6のIDの新命名、awsLambdaノードの{ result: ... }ラップなど、踏むまで見えにくい仕様の塊が多いので、この記事を逆引きとして使ってもらえれば幸いです。

関連記事と参考リンク

Discussion