[Claude Code] 守ってほしい制約はpromptの指示だけでは守られない

背景

GitHub Actions上でClaude Codeを使い、PRの差分を読んで自動でレビューコメントを書くエージェントを運用している。差分を読む範囲、調査に使えるツール呼び出しの上限、出力するJSONの形式など、エージェントに守ってほしい制約はいくつもある。最初はそのすべてをprompt(自然文の指示)で書いていた。「このディレクトリは読むな」「呼び出しは合計何回まで」「出力は必ずこの形式で」というように。

運用を続けるうち、この書き方には共通の弱点があると分かってきた。promptの指示はエージェントへの「お願い」であり、実行のたびに正しく守られる保証ではない。実際に起きた4つの失敗から、制約をどう機械的に強制するかをまとめる。

課題

promptで書いた制約が破られたとき、原因はいずれも同じ形をしていた。「守ってほしいこと」を自然文で説明しただけで、それを検査する仕組みを用意していなかった。エージェントの判断が1回でも外れると、そのまま失敗が本番に出る。

対処: 制約は機械的に強制する

1. 読んでよい範囲をpromptで説明する → permissionsで塞ぐ

レビュー対象のPRファイルを、リポジトリ本体のtrusted workspaceとは別に、read-onlyな一時ディレクトリへ展開して渡していた。promptには次のように書いていた。

# prompt(自然文の指示)の例
以下のディレクトリだけを読んでよい:
- /pr-input/  … このPRで変更された内容(read-only)
- /workspace/ … 差分に含まれない、安定したファイルのみ

/pr-input/ に含まれるファイルを /workspace/ 側から読んではならない。

/workspace/は他のブランチをcheckoutした本体であり、/pr-input/にあるべき「変更後のファイル」も、パスとしては/workspace/側からも読めてしまう。「差分に含まれるか」の判定をエージェント自身にさせていたため、この判定を誤ると、変更前(または無関係な)内容を「レビュー対象」として扱ってしまうことがあった。

対処は、/workspace/配下へのReadそのものを権限で拒否することだった。(Read(/path)の単一スラッシュは起動ディレクトリからの相対パスとして解決されるため、起動ディレクトリに関わらず確実に塞ぐには//workspace/**のように先頭を二重スラッシュにする必要がある)

{
  "permissions": {
    "deny": ["Read(//workspace/**)", "Edit(//workspace/**)"]
  }
}

判定をエージェントに委ねず、ツール権限の層で「そもそも読めない」状態を作る。パスの分類が正しいかどうかをエージェントが考える必要自体をなくしている。

2. 予算をターン数で自己申告させる → tool呼び出し回数を機械的に数える

調査に使えるツール呼び出しの上限を、promptで「ターン(応答)数」として伝えていた。

# prompt(自然文の指示)の例
調査に使えるのは最大14ターン(応答)です。
1回の応答内で複数のtoolを並列に呼んでもよいですが、
残りターン数を自分で数えて、超えないように調整してください。

1回の応答内で複数のtoolを並列に呼び出すと、「応答の回数」と「実際のtool呼び出し回数」は一致しない。1ターンあたり3回のtoolを並列に呼ぶエージェントを想定し、呼び出し回数を機械的に数えるとどうなるか確認した。

let callCount = 0;
const BUDGET = 40;

function onPreToolUse() {
  callCount++;
  if (callCount > BUDGET) {
    return { permissionDecision: 'deny', reason: `budget exceeded (${callCount}/${BUDGET})` };
  }
  return null;
}

// 1ターンにtoolを3回並列で呼ぶエージェントを14ターン分シミュレートする
let denied = null;
for (let turn = 0; turn < 14 && !denied; turn++) {
  for (let parallel = 0; parallel < 3; parallel++) {
    const result = onPreToolUse();
    if (result) {
      denied = { turn, callCount };
      break;
    }
  }
}

console.log(denied); // { turn: 13, callCount: 41 }

14ターンの自己申告では「まだ余裕がある」つもりでも、実際のtool呼び出しはその時点で41回に達し、40回の機械上限を超えている。エージェントが自分の消費量を見積もる限り、この単位のズレは埋まらない。

対処は、予算の単位を「tool呼び出し1回」に統一し、実行環境(PreToolUse hookなど)が数える機械カウンタだけを予算の正とすることだった。並列に呼んでも1回ごとに加算されるため、単位のズレそのものが起きなくなる。

3. 大きな入力をoffset/limitで読ませる → 決定論的に事前分割する

差分ファイルが大きいとき、Readツールのoffset/limitで少しずつ読ませていた。

# prompt(自然文の指示)の例
差分ファイルはoffset/limitで少しずつ読んでください。
最初はoffset=1、次のoffsetは直前に表示された最終行番号+1です。

出力が大きすぎて表示が打ち切られると、「続きはoffset=Nから」という注記が付く。この注記をエージェントが見落とすと、次のoffsetを誤って計算し、一部の行が未読のまま先へ進んでしまう。やっかいなのは、エージェント自身は「最後まで読んだ」つもりで「reviewed」と申告することで、欠落が結果に残らないことだった。

対処は、offsetの計算そのものをエージェントにやらせず、決定論的に固定サイズのchunkへ事前分割することだった。

function splitIntoChunks(text, maxBytesPerChunk) {
  const chunks = [];
  let current = '';
  for (const line of text.split('\n')) {
    if (Buffer.byteLength(current + line, 'utf8') > maxBytesPerChunk && current !== '') {
      chunks.push(current);
      current = '';
    }
    current += line + '\n';
  }
  if (current) chunks.push(current);
  return chunks;
}

50行のダミー差分を1chunkあたり300byte前後で分割すると、7個のchunkに割れる。

const chunks = splitIntoChunks(diffSample, 300);
// chunks.length === 7
// chunks.join('') はdiffSampleと完全に一致する(欠落なし)

エージェントには「chunk1、chunk2、chunk3をそれぞれ1回ずつReadする」という、間違える余地のない操作だけを残す。複数chunkの並行Readも安全にできる。offsetの計算やページ境界の見落としという失敗の種類そのものを、設計から消している。さらに、実際のRead呼び出し回数がchunk数と一致するかをPostToolUse hookなどで数え、一致しなければ「未読了」として扱うと、offset計算のミスだけでなく読み飛ばし自体も機械的に検知できる。

4. 出力形式をpromptで指定する → schemaでassertする

出力するJSONの形式を、promptで指定していた。

# prompt(自然文の指示)の例
出力は必ず次のJSON形式にしてください:
{ "id": "...", "verdict": "confirmed" | "rejected", "reason": "..." }

同じpromptで同じエージェント定義を使っても、出力形式はぶれた。idが抜けたり、verdictが自由記述の文章になったり、配列でラップされたりする。実際に、同一のエージェント定義を6回実行したところ、6回とも出力形式が異なっていたという計測結果もある。

対処は、出力を「守らせる」のではなく、守っているかを機械的に検査することだった。

const REQUIRED_FIELDS = ['id', 'verdict', 'reason'];
const VALID_VERDICTS = ['confirmed', 'rejected'];

function assertShape(output) {
  for (const field of REQUIRED_FIELDS) {
    if (!(field in output)) throw new Error(`missing field: ${field}`);
  }
  if (!VALID_VERDICTS.includes(output.verdict)) {
    throw new Error(`invalid verdict: ${output.verdict}`);
  }
}

idを含めなかった出力は、検査するとmissing field: idという例外になることを確認した。verdictを自由記述で書いた出力も、検査するとinvalid verdictという例外になる。検査に落ちた出力は受理せず、再実行かエラーとして扱う。schemaの必須フィールドと、それを検査する決定論的なスクリプトの組が、契約そのものになる。

まとめ

  • promptの指示はエージェントへの「お願い」であり、実行のたびに正しく守られる保証ではない
  • 「読んでほしくない場所」「予算」「入力の分割」「出力形式」の4つで、prompt指示だけに頼った制約が実際に破られた
  • 対処はいずれも同じ形をしている: 判定をエージェントの判断から切り離し、権限システム・機械的カウンタ・決定論的な前処理・schemaの検査など、エージェントを経由しない場所で強制する
  • 「守られなければ困る」制約ほど、prompt以外の層に置く価値が大きい

参考