このドキュメントは j-20260503-005 の Codex adversarial review で指摘された3点の構造的欠陥に対し、修正設計を提示し、Codex に再レビューを依頼してLGTM(これで進めてよい)を取得するまで反復した記録です。実装は一切していません。
refresh-dashboard.sh の呼び出し元を grep で全件抽出した結果:
complete-job.sh:34 (already DONE 時のダッシュボード念押し更新)complete-job.sh:50 (DONE 遷移後のダッシュボード更新)dispatch-job.sh:216 (失敗時のダッシュボード念押し)dispatch-job.sh:223 (dispatch 後のダッシュボード更新)つまり「完了処理」「dispatch」の二系統が依存している。Codex 指摘の通り、これを最初に消すと両系統が壊れる。
DB の verification_summary 全レコードを集計したところ:
next_action_kind=NULL のレコードが256件、うち next_action NULL が162件、空配列が2件next_action_kind='none' のレコードが28件、うち next_action NULL が5件、空配列が2件つまり既存運用では DONE ジョブ多数が next_action NULL のまま放置されており、CHECK制約強化や migration が「素直には」通らない。クリーンアップが先行する必要がある。
依存解消を1手目に置くのではなく、置換が完了してから消す という不可逆順序に並べ直す。
具体的な順序:
https://mbp.tail863a2a.ts.net/app/ で確認済み)complete-job.sh と dispatch-job.sh から refresh-dashboard.sh 呼び出しを削除する PR を出す。この時点では refresh-dashboard.sh 本体は残す(ロールバック余地)dashboards テーブル(pin message_id を保存している)の sweep。SELECT で残骸 chat_id を確認後 DELETErefresh-dashboard.sh 本体を削除依存グラフを逆向きに辿って消す形なので、各ステップでロールバック可能。Codex 指摘の「依存中の部品を1手目で消す」自爆は構造的に避けられる。
副次効果: dispatch-job.sh の冒頭に mini-apps-ui に DISPATCH イベントを SSE で push する処理 を入れるなら同タイミングで実装。ただし指摘1の射程外なので別ジョブ。
3抜け道を構造的に塞ぐには、「秘書が直接 Telegram API を叩けないようにする」しかない。文面制御では塞げないというのが Codex の核心指摘なので、経路制御を取る。
現状の問題:
~/.claude/channels/telegram/.env に平文で置かれていて、秘書セッションが Read で読めば直接 curl で Telegram API を叩けるmcp__plugin_telegram_telegram__reply などの MCP tool 呼び出しに限定で、Bash 経由の curl は素通り修正案:
TELEGRAM_BOT_TOKEN を入れる(プロセス環境変数のみ)~/.claude/channels/telegram/.env は廃止。または移転後は中身を空にする/app/api/internal/send-report を経由curl https://api.telegram.org/bot... を叩く」経路は Bot Token が手に入らないため構造的に不可能副作用と論点:
realpath 一致拒否」で二重防御現状の問題:
修正案:
完了, done, DONE, ✅, 解決, 終了, 済, success, 成功, OK(大文字小文字区別なし)副作用と論点:
現状の問題:
allow_long=true がついた reply は guard を素通りする。例外運用が常用バイパスになるリスクが Codex 指摘そのもの修正案:
tool_input.allow_long フラグを廃止する。secretary-reply-length-guard.sh から判定ロジックを削除~/plans/ に書く → convert-plan.sh で HTML 化 → URL のみ Telegram に送る修正設計から抽出した4つの原則を明文化する:
NULL を許す書き方では DONE 時の不変条件にならない。代わりに「DONE 遷移時に強制検証する仕組み」を二重で持つ。
CREATE TRIGGER enforce_next_action_on_done
BEFORE UPDATE OF status ON jobs
FOR EACH ROW
WHEN NEW.status = 'DONE' AND OLD.status != 'DONE'
BEGIN
SELECT CASE
WHEN (SELECT next_action FROM verification_summary WHERE job_id = NEW.id) IS NULL
OR (SELECT json_array_length(next_action) FROM verification_summary WHERE job_id = NEW.id) = 0
THEN RAISE(ABORT, 'DONE transition requires non-empty next_action')
END;
END;
特性:
AFTER INSERT WHEN NEW.status='DONE')complete-job.sh JOB_ID --next-action='["next step 1", "next step 2"]'
特性:
これが推奨。秘書がどの経路で UPDATE しようと、トリガーで止まる。スクリプト経由なら引数チェックでも止まる。同じ条件を2層で守ることで、どちらかをすり抜けても他方が捕まえる。
next_action_kind='none' の扱い(指摘の余地への回答)業務上「本当に次がない」DONE があるか:
next_action='["次のアクションは特になし: 理由を明記する"]' の形で配列に1要素入れるルールに統一next_action_kind='none' でも空配列を許す例外は不要migration 適用前に既存 DONE ジョブ(NULL 169件 + 空配列 4件)をクリーンアップする必要がある:
'["過去の DONE: クリーンアップ時点で next_action 未記入"]' で UPDATEupdated_at をクリーンアップタイムスタンプに統一ただし Codex の指摘「制約を通すために偽の next_action を埋める」破壊半径が大きいので、この修正は migration 前に「既存 DONE はトリガーから除外」する条件を入れる手もある:
WHEN NEW.status = 'DONE' AND OLD.status != 'DONE' AND NEW.id NOT IN (SELECT id FROM jobs WHERE created_at < '2026-05-03T00:00:00Z')
→ これも汚いので、クリーンアップを先にやる方を選ぶ。
1. 指摘1の修正案 — 結論: ギャップあり。
- [v2:38-44] の「各ステップでロールバック可能」は虚偽だ。特に [v2:40] の
dashboardssweep をやった時点で pin の対応表を消すので、元に戻すには外部から message_id を再収集するしかない。これはロールバックではなく再構築だ。- [v2:39] の「1週間程度の運用で問題ないことを確認」は判定基準として壊れている。成功条件、監視項目、許容エラー率、低頻度経路のカバレッジがゼロだ。期間だけ書いて合否条件がない設計は、問題を見逃しても「問題なし」と言えてしまう。
- [v2:40] の sweep 手順に transaction も backup も row-count gate もない。途中失敗すると一部だけ消え、残った pin と消えた pin が混在する。しかも対応表を消した後は unpin も追跡不能になる。
- [v2:13-20] で証明しているのは
refresh-dashboard.shの grep ベースの呼び出し元だけだ。dashboardsテーブル自体の読者・書き手の全依存は証明していない。さらに [v2:40] のSELECT→DELETE間に新規 dispatch が絶対来ない保証も文書にない。2. 指摘2 B-1 — 結論: ギャップあり。かなり大きい。
- [v2:62-63] の「launchd plist の EnvironmentVariables に入れる」は、plist に平文を書くなら結局ファイルに秘密を置いている。
- [v2:67] の「HMAC キーは report-*-v3.sh と mini-apps-api のみが知る」も甘い。秘書がその script を実行できるなら、秘密の値を知らなくても script を署名オラクルとして使える。
- [v2:72] はさらに悪い。Bash 直叩き抜けを潰したい文書が、対策としてまた PreToolUse Read hook に寄りかかっている。前回の敗因を別名で再導入しているだけだ。
- [v2:64-67] の
/app/api/internal/send-reportは認証仕様が曖昧だ。HMAC だけ書いて timestamp/nonce がないので replay attack を止められない。- [v2:73] の可用性判断も根拠不足だ。queue、retry、degraded mode が一切ない。
3. 指摘2 B-2 — 結論: 破綻している。
- keyword dictionary は injection vector を閉じない。「片付いた」「反映した」「shipped」「deployed」「送った」など無限に言い換えられる。部分一致辞書で意味分類はできない。
- 文書自身が false positive を認め、その場しのぎとして allow_long 復活を匂わせている。前回潰された bypass を戻す案が残っている時点で閉じていない。
4. 指摘2 B-3 — 結論: ギャップあり。
- HTML 化を「必須化」と書くだけで、どこで強制するか書いていない。
- 短いが意味のある即時応答をどう扱うかが空白で、現場は別 bypass を探す。
- B-3 単体では何も閉じない。
5. 指摘3 C2-C — 結論: まだ閉じていない。
- trigger は
jobs.statusがDONEへ遷移する瞬間しか見ていない。DONE にした後でverification_summary.next_actionをNULLや[]に更新する経路は残る。AFTER INSERT WHEN NEW.status='DONE'は insert 順序によっては正当な処理まで abort する。[" "]で DONE できるなら、前回の NULL 穴を別形式で残しただけ。next_action_kind='none'を説明文1要素へ寄せる案は schema 汚染。- 既存データ cleanup は破壊半径が大きい。
6. 4つの原則 — 不十分。
欠けている原則:
- 信頼境界は OS 上の実体で分離しろ
- 特権 endpoint は caller identity と request freshness を検証しろ
- 不変条件は全 mutation path で守れ
7. 総合判定: 修正必要
LGTM になるために直すべき点:
dashboards廃止手順は「可逆」表記をやめ、backup/transaction/row-count 検証/復元手順/並行書き込み停止条件を明記- Bot Token と HMAC 秘密は別 OS principal に分離、秘書が送信 script を自由実行できない形に
/send-reportは raw text を許さず固定 schema + timestamp/nonce で replay 防止- B-2 keyword dictionary 案は捨てる
- B-3 は HTML 化ルールではなく endpoint 側の canonical path 拒否まで実装条件に落とす。短い即時応答の正規経路も別途定義
- DONE 不変条件は jobs 側遷移 trigger だけで済ませず、verification_summary 側 INSERT/UPDATE でも DONE なら non-NULL/non-空/non-空白要素を強制
next_action_kind='none'は説明文配列へ潰さない。意味を保持する別表現を残す- 既存データ cleanup で偽 next_action と updated_at 上書きをしない。別フラグで「過去データ未記入」を表現
「ロールバック可能」表記を撤廃し、論理 archive + バックアップ + transaction で監査可能化する。
事前準備:
cp ~/secretary-state/jobs.db ~/secretary-state/jobs.db.bak-$(date +%Y%m%d-%H%M%S) で SQLite ファイル丸ごとバックアップrefresh-dashboard.sh 呼び出しコードを git diff で 0 件確認(grep -rn refresh-dashboard ~/secretary-state/)grep -rn 'FROM dashboards\|INTO dashboards\|UPDATE dashboards' ~/ を実行し結果を md に記録archive 手順(transaction + row-count gate):
BEGIN IMMEDIATE TRANSACTION;
ALTER TABLE dashboards ADD COLUMN archived_at TEXT;
SELECT 'pre', COUNT(*) FROM dashboards WHERE archived_at IS NULL;
UPDATE dashboards SET archived_at = strftime('%Y-%m-%dT%H:%M:%SZ','now') WHERE archived_at IS NULL;
SELECT 'post', COUNT(*) FROM dashboards WHERE archived_at IS NULL; -- 0 を期待
-- 期待値と異なれば ROLLBACK 手動実行
COMMIT;
成功合否条件(期間ではなく数値で):
/app/api/jobs access log が直近24時間で 100 リクエスト以上/tmp/refresh-dashboard.log)に新規エラー 0 件ロールバック: cp jobs.db.bak-... jobs.db で SQLite ファイル復元 + dispatch-job.sh / complete-job.sh の git revert。pin 自体は archive 段階で touch しないので追跡可能。
ステップ1(短期):
UserName=daemon 指定し mini-apps-api を別ユーザー権限で起動/Library/Application Support/secretary/bot.env に移転、chown daemon:wheel, chmod 600aiharataketo 権限)からは read 不可ステップ2(中期、別ジョブ起票):
dscl で system user _secretarybot を作成、ホームレスでログインなしUserName=_secretarybot で完全分離「秘書が送信 script を自由実行できない」要求への回答:
aiharataketo)から実行可能で良いJOB_ID, CHAT_ID, --type=done|propose|question)に限定し free text を取らない。本文は mini-apps-api 側が DB(jobs.intent / verification_summary / plan_viewer_url)から template に流し込む/app/api/internal/send-report 認証仕様v3固定 schema 限定:
type SendReportPayload = {
job_id: string;
chat_id: string;
type: 'done' | 'propose' | 'question';
timestamp: number; // Unix epoch sec
nonce: string; // UUIDv4
};
raw text フィールドを置かない。schema を満たさないリクエストは 400 で拒否。
認証:
Authorization: HMAC-SHA256 <hex> ヘッダ。署名対象は ${timestamp}:${nonce}:${JSON.stringify(payload)}daemon 権限)replay 防止:
Set<string> に直近5分保持。重複は 401(プロセス再起動で空になる、副作用は小さい)異常系:
keyword dictionary 案は削除。代わりに「秘書が mcp__plugin_telegram_telegram__reply と send_message を一切使わない」を hook で強制:
secretary-reply-deny-all.sh にリネームし、PreToolUse hook で 常に block/app/api/internal/send-ack 経由に強制/app/api/internal/send-ack:
type SendAckPayload = {
job_id: string;
chat_id: string;
ack_kind: 'received' | 'reading' | 'deferred' | 'noted';
timestamp: number;
nonce: string;
};
ack_kind enum で表現を限定-- (1) jobs.status DONE 遷移時
CREATE TRIGGER enforce_next_action_on_done_jobs
BEFORE UPDATE OF status ON jobs
FOR EACH ROW
WHEN NEW.status = 'DONE' AND OLD.status != 'DONE'
BEGIN
SELECT CASE
WHEN NOT EXISTS (
SELECT 1 FROM verification_summary
WHERE job_id = NEW.id
AND legacy_no_next_action != 1
AND (
(next_action_kind = 'none'
AND next_action_explanation IS NOT NULL
AND TRIM(next_action_explanation) != '')
OR (next_action IS NOT NULL
AND json_array_length(next_action) > 0
AND NOT EXISTS (SELECT 1 FROM json_each(next_action) WHERE TRIM(value) = ''))
)
)
THEN RAISE(ABORT, 'DONE requires non-empty next_action OR next_action_kind=none with explanation')
END;
END;
-- (2) verification_summary 側 UPDATE で DONE ジョブの next_action を空白化させない
CREATE TRIGGER enforce_next_action_on_vs_update
BEFORE UPDATE ON verification_summary
FOR EACH ROW
WHEN (SELECT status FROM jobs WHERE id = NEW.job_id) = 'DONE'
AND NEW.legacy_no_next_action != 1
BEGIN
SELECT CASE
WHEN (NEW.next_action_kind = 'none'
AND (NEW.next_action_explanation IS NULL OR TRIM(NEW.next_action_explanation) = ''))
OR (NEW.next_action_kind != 'none'
AND (NEW.next_action IS NULL
OR json_array_length(NEW.next_action) = 0
OR EXISTS (SELECT 1 FROM json_each(NEW.next_action) WHERE TRIM(value) = '')))
THEN RAISE(ABORT, 'cannot empty/blank next_action of DONE job')
END;
END;
-- (3) jobs INSERT 直で status='DONE' を禁止
CREATE TRIGGER forbid_direct_done_insert
BEFORE INSERT ON jobs
FOR EACH ROW
WHEN NEW.status = 'DONE'
BEGIN
SELECT RAISE(ABORT, 'INSERT with status=DONE forbidden; use PENDING then UPDATE');
END;
これで mutation path 全域が守られる:
INSERT 順序問題は trigger (3) で解決: 新規ジョブは必ず PENDING で先に入れ → verification_summary 記録 → 最後に jobs.status を DONE へ UPDATE する順序が確定。
next_action_kind='none' の schema 分離説明文1要素配列に潰す案を撤回し、schema で型を分離:
verification_summary.next_action_explanation TEXT カラム追加next_action_kind='none' のとき:next_action は NULL 許容(trigger 側で許可)next_action_explanation は必須(trigger でチェック)これで「次がない(説明あり)」と「1件以上の次アクションあり」を schema 上で型分離できる。下流処理は next_action_kind で分岐すればよい。
偽 next_action 注入と updated_at 上書きを廃止:
verification_summary.legacy_no_next_action INTEGER DEFAULT 0 カラム追加UPDATE verification_summary
SET legacy_no_next_action = 1
WHERE job_id IN (
SELECT j.id FROM jobs j
LEFT JOIN verification_summary vs ON j.id = vs.job_id
WHERE j.status = 'DONE'
AND (vs.next_action IS NULL OR vs.next_action = '[]')
);
WHEN NEW.legacy_no_next_action != 1 でレガシー行を制約対象外にしている(上記 trigger 1, 2 参照)legacy_no_next_action=1 を集計し、本当に「次なし」だったか健人がレビューし、必要に応じて手動補完する別ジョブを起票これで「過去データの意味」を保持しつつ、新規 DONE のみに不変条件を適用できる。
CODEX_ITERATION2_PLACEHOLDER
PHASE4_PLACEHOLDER
PHASE_REMAINING_PLACEHOLDER