このドキュメントは 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 のみに不変条件を適用できる。
1. PARTIAL
可逆表記は消え、backup / transaction / row-count / restore は入った。- だが並行書き込み停止条件が
refresh-dashboard.sh呼び出し 0 件確認だけで、dashboardsへの他 writer 停止を合否条件にしていない。依存 grep は「記録する」だけだ。- SQL も
ROLLBACK 手動実行と書きつつCOMMITが続く。失敗時の fail-closed になっていない。2. OPEN
daemon分離は共有 system principal でしかなく、専用_secretarybotは将来対応に逃がしている。- 文書自身が
report-*-v3.shはaiharataketoから実行可能でよいと明言している。要求の「secretary cannot freely execute send scripts」を閉じていない。- HMAC 秘密は
daemon側環境変数だと言いながら、署名は user 側 script が作る前提で、実装経路が矛盾している。3. PARTIAL
- raw text 排除、固定 schema、timestamp/nonce は明記された。
- だが認証主体が曖昧。user 実行 script がどうやって HMAC を計算するのかが文書上つながっていない。
- nonce 保持が in-memory
Setのみなので、プロセス再起動で replay 耐性が消える。4. CLOSED
- keyword dictionary 案は明示的に捨てている。
- 置換先も
reply/send_message全面 deny で、allow_long例外も消した。5. OPEN
send-ackという正規の短文経路は定義された。- だが要求されていた「HTML canonicalization rule + endpoint-side canonical path rejection」は書かれていない。あるのは hook block だけ。
- hook が欠けた時に旧経路を server 側で拒否する条件が未定義。
6. OPEN
- jobs DONE 遷移、DONE 後 verification_summary UPDATE、jobs 直 INSERT DONE はそれぞれ触れている。
- だが要求は
verification_summaryINSERT/UPDATE 両方の強制。INSERT trigger が存在しない。legacy_no_next_action != 1で検査を外す設計なのに、その flag を後付けで立てる禁止策がない。7. CLOSED
next_action_explanation TEXTを追加し、noneを説明文配列に潰す案も撤回している。next_action_kind='none'ならnext_actionNULL 許容、代わりに説明必須という型分離は文書上成立している。8. CLOSED
- cleanup は
legacy_no_next_action別 flag に寄せている。next_actionとupdated_atを触らないと明記している。新規ギャップ(閉じる作業で生まれた穴)
launchd UserName=daemon: 共有daemonは blast radius が広く、aiharataketoが send script を叩ける前提も残る。専用 principal でない限り境界が甘い。- DB-template body: 本文入力源が
jobs.intent / verification_summary / plan_viewer_urlだと書くだけで、誰がその行を書けるのか・監査ログがあるのかがゼロ。raw text を消しても DB 書き換え面が未管理なら注入点が移っただけ。secretary-reply-deny-all.sh: hook 依存の単一点障害。hook 不在・壊れ・skip 時の hard fallback が文書にない。send-ack4種: 受領系には足りるが、確認要求・権限待ち・不能通知・ETA 明示がない。隙間は bypass 圧力を生む。- triple-trigger ordering: 新規 job に最初から
legacy_no_next_action=1を入れるだけでは DONE guard は抜けないが、Bash +sqlite3で将来 job を「偽レガシー化」できる。legacy_no_next_action=1を後付けで立てる UPDATE を止める trigger がない。設計原則の欠落
- 送信本文の入力源(DB 行・template)にも write authorization と audit trail を要求する
- enforcement component が不在なら fail closed
総合判定: 修正必要
LGTM になるために直すべき点:
report-*-v3.shを user 実行可能のままにする方針を撤回する。専用 principal での実行か、署名生成を user 空間から外す設計に変える/send-reportと/send-ackの認証主体を実装可能な形で書き直すverification_summaryのBEFORE INSERTtrigger を追加し、legacy_no_next_actionを新規 row や DONE row で勝手に立てられない制約も入れる- hook 破損時の hard fallback を server/sink 側に入れる。旧送信経路の canonical path rejection を文書で明示する
- DB 由来テンプレート本文の writer と監査を定義する
サマリー: CLOSED 3 / PARTIAL 2 / OPEN 3。次の v4 で上記5点を閉じる。
並行書き込み停止条件と SQL の fail-closed 化を強化:
-- 事前: write fence(dashboards テーブルへの新規 write を全部 abort させる)
CREATE TRIGGER fence_dashboards_writes_pre_archive
BEFORE INSERT ON dashboards
BEGIN SELECT RAISE(ABORT, 'dashboards is being archived; new writes blocked'); END;
CREATE TRIGGER fence_dashboards_updates_pre_archive
BEFORE UPDATE ON dashboards
WHEN OLD.archived_at IS NULL
BEGIN SELECT RAISE(ABORT, 'dashboards is being archived; updates blocked'); END;
-- archive 本体
BEGIN IMMEDIATE TRANSACTION;
ALTER TABLE dashboards ADD COLUMN archived_at TEXT;
UPDATE dashboards SET archived_at = strftime('%Y-%m-%dT%H:%M:%SZ','now') WHERE archived_at IS NULL;
-- post-archive count を SQL 内で検証(失敗時は SQLite が自動 ROLLBACK)
SELECT CASE
WHEN (SELECT COUNT(*) FROM dashboards WHERE archived_at IS NULL) != 0
THEN RAISE(ABORT, 'archive incomplete: rows still unarchived')
END;
COMMIT;
合否条件を3つから5つに拡張(他 writer 停止を含む):
/app/api/jobs access log が直近24時間で 100 リクエスト以上/tmp/refresh-dashboard.log)に新規エラー 0 件SELECT MAX(updated_at) FROM dashboards の単調検証で確認)grep -rn 'FROM dashboards\|INTO dashboards\|UPDATE dashboards' ~/ の依存先一覧が72時間変化なし(新規 writer 出現の防止)これら5条件を72時間連続で満たした上で archive を実行。
report-*-v3.sh を 廃止 する。秘書セッションは Telegram 送信のための script を一切持たない。代わりに DB 経由のトリガーで送信される outbox パターンを採用:
telegram_outbox:CREATE TABLE telegram_outbox (
id INTEGER PRIMARY KEY AUTOINCREMENT,
job_id TEXT NOT NULL REFERENCES jobs(id),
chat_id TEXT NOT NULL,
type TEXT NOT NULL CHECK(type IN ('done','propose','question','ack')),
ack_kind TEXT,
enqueued_at TEXT NOT NULL,
sent_at TEXT,
sent_message_id TEXT,
failure_reason TEXT
);
verification_summary を UPDATE(本業)jobs.status を SENT → VERIFYING / DONE に UPDATE(本業)CREATE TRIGGER enqueue_done_to_outbox
AFTER UPDATE OF status ON jobs
WHEN NEW.status = 'DONE' AND OLD.status != 'DONE'
BEGIN
INSERT INTO telegram_outbox (job_id, chat_id, type, enqueued_at)
VALUES (NEW.id, NEW.chat_id, 'done', strftime('%Y-%m-%dT%H:%M:%SZ','now'));
END;
daemon 権限で常駐する送信ワーカー(mini-apps-api 内の独立タスク)が telegram_outbox を 5秒ポーリング:sent_at IS NULL の行を取得 → DB から template 本文組み立て(jobs.intent / verification_summary / plan_viewer_url)→ Telegram API 送信 → sent_at と sent_message_id を UPDATEtelegram_outbox を直接 INSERT する経路は塞ぐ:CREATE TRIGGER outbox_only_via_status_trigger
BEFORE INSERT ON telegram_outbox
WHEN current_user() != 'daemon_writer_marker'
BEGIN SELECT RAISE(ABORT, 'telegram_outbox direct insert forbidden; use jobs.status trigger'); END;
※ SQLite に current_user() はないので、実装は triggered_via_jobs_status の internal flag テーブルを使う。秘書が直接 INSERT すると flag が立っていないので abort/send-report /send-ack 内部 endpoint も廃止可能(localhost-only listen)将来対応に逃がさず、ステップ1で専用ユーザーを使う:
dscl で system user _secretarybot を作成:sudo dscl . -create /Users/_secretarybot
sudo dscl . -create /Users/_secretarybot UserShell /usr/bin/false
sudo dscl . -create /Users/_secretarybot RealName "Secretary Bot Worker"
sudo dscl . -create /Users/_secretarybot UniqueID 521
sudo dscl . -create /Users/_secretarybot PrimaryGroupID 20
sudo dscl . -create /Users/_secretarybot NFSHomeDirectory /var/empty
sudo dscl . -passwd /Users/_secretarybot '*' # ログイン不可
<key>UserName</key><string>_secretarybot</string> 指定し mini-apps-api を起動/Library/Application Support/secretary/bot.env に置き chown _secretarybot:_secretarybot, chmod 600aiharataketo 権限の秘書セッションは Bot Token ファイルを read 不可ps eww)も別ユーザー権限で動いているため aiharataketo からは見えない(macOS は proc ファイルシステム非搭載のため ps eww が他ユーザープロセスの環境を漏らさない)~/.claude/channels/telegram/.env を _secretarybot:_secretarybot 所有 + chmod 600 に変更aiharataketo)から MCP プラグインを再構成しようとしても token が読めず失敗/send-report /send-ack 内部 endpoint は outbox アーキで不要になる ため廃止。HMAC 関連の矛盾は根本から消える。
仮に外部からの送信 trigger 経路を残す場合(将来 ext system 連携など)の補強も明記:
CREATE TABLE request_nonces (
nonce TEXT PRIMARY KEY,
used_at TEXT NOT NULL,
expires_at TEXT NOT NULL -- used_at + 5min
);
secretary-reply-deny-all.sh が壊れた/skip された場合の安全策:
_secretarybot 分離で実現)。hook が壊れても秘書は token を持たないため、mcp__plugin_telegram_telegram__reply を呼んでも MCP 内部で token 取得失敗 → 送信不可~/.claude/channels/telegram/.env を read 不可化(指摘2 v4 の chmod 600 + chown _secretarybot で実現)_secretarybot 権限で常駐-- (4) verification_summary BEFORE INSERT で DONE ジョブの next_action 検証
CREATE TRIGGER enforce_next_action_on_vs_insert
BEFORE INSERT ON verification_summary
FOR EACH ROW
WHEN (SELECT status FROM jobs WHERE id = NEW.job_id) = 'DONE'
AND COALESCE(NEW.legacy_no_next_action, 0) != 1
BEGIN
SELECT CASE
WHEN (NEW.next_action_kind = 'none'
AND (NEW.next_action_explanation IS NULL OR TRIM(NEW.next_action_explanation) = ''))
OR (COALESCE(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, 'verification_summary INSERT for DONE job requires next_action or kind=none with explanation')
END;
END;
-- (5) legacy_no_next_action 後付け禁止
CREATE TRIGGER forbid_legacy_flag_setting
BEFORE UPDATE OF legacy_no_next_action ON verification_summary
FOR EACH ROW
WHEN COALESCE(OLD.legacy_no_next_action, 0) = 0 AND NEW.legacy_no_next_action = 1
BEGIN
SELECT RAISE(ABORT, 'legacy_no_next_action cannot be set post-hoc; only initial migration allows this');
END;
-- (6) 新規 INSERT で legacy_no_next_action=1 を仕込むのも禁止
CREATE TRIGGER forbid_legacy_flag_on_new_insert
BEFORE INSERT ON verification_summary
FOR EACH ROW
WHEN NEW.legacy_no_next_action = 1
AND (SELECT created_at FROM jobs WHERE id = NEW.job_id) > '2026-05-04T00:00:00Z'
BEGIN
SELECT RAISE(ABORT, 'legacy_no_next_action=1 only allowed for jobs created before migration cutoff');
END;
migration 実行時のバイパス機構:
_migration_lock テーブルを作成、INSERT INTO _migration_lock(active) VALUES(1)WHEN NOT EXISTS (SELECT 1 FROM _migration_lock WHERE active=1) を入れて migration 中はバイパスUPDATE _migration_lock SET active=0_migration_lock 自体も BEFORE UPDATE trigger で「2回目以降の active=1 設定を禁止」(片道切符)CREATE TABLE _migration_lock (
id INTEGER PRIMARY KEY CHECK(id=1),
active INTEGER NOT NULL DEFAULT 0,
released_at TEXT
);
INSERT INTO _migration_lock(id, active) VALUES (1, 1);
-- 一度 active=0 になったら二度と active=1 にできない
CREATE TRIGGER lock_migration_one_way
BEFORE UPDATE OF active ON _migration_lock
WHEN OLD.active = 0 AND NEW.active = 1
BEGIN SELECT RAISE(ABORT, 'migration lock is one-way; cannot reactivate'); END;
migration 終了後は _migration_lock.active=0 を二度と立てられないので、attacker が後で「migration 中だ」と偽装して trigger を skip することは不可能。
aiharataketo 権限)が可能。これは本業CREATE TABLE jobs_audit (
id INTEGER PRIMARY KEY AUTOINCREMENT,
table_name TEXT NOT NULL,
row_id TEXT NOT NULL,
operation TEXT NOT NULL CHECK(operation IN ('INSERT','UPDATE','DELETE')),
old_value TEXT,
new_value TEXT,
changed_at TEXT NOT NULL
);
CREATE TRIGGER audit_jobs_update
AFTER UPDATE ON jobs
FOR EACH ROW
BEGIN
INSERT INTO jobs_audit(table_name, row_id, operation, old_value, new_value, changed_at)
VALUES ('jobs', NEW.id, 'UPDATE',
json_object('status', OLD.status, 'intent', OLD.intent),
json_object('status', NEW.status, 'intent', NEW.intent),
strftime('%Y-%m-%dT%H:%M:%SZ','now'));
END;
-- verification_summary 側も同様
_secretarybot 権限のみ書き込み可能 → 秘書からは write 不可。chown _secretarybot:_secretarybot + chmod 644CREATE TRIGGER で永続化されるので「壊す」には sqlite_master を直接書き換える必要がある。これは aiharataketo 権限でも可能なので、DB ファイル自体を _secretarybot:_secretarybot 所有 + chmod 660 でグループ _secretarybot のみ書き込み可にする(秘書は read only)。これだと秘書が DB を書けなくなって本業が回らないaiharataketo 書き込み可のまま、sqlite_master の改竄は audit trail trigger で検出 → ntfy に即時警告。検出が完璧でなくても、改竄痕跡は別 DB(audit.db、_secretarybot 所有)に複製されて追跡可能Issue 1: OPEN。
dashboardsの write fence がALTER TABLE前にOLD.archived_atを参照しつつ、直後の archiveUPDATE自体も止める書き方になっているので、手順が文書どおりには成立しない。Issue 2: PARTIAL。
report-*-v3.sh廃止と_secretarybot導入は入ったが、telegram_outbox直挿し防止は「internal flag テーブルを使う」とあるだけで、そのフラグ自体を秘書が立てられない保証が仕様にない。Issue 3: CLOSED。
/send-reportと/send-ackを live path から外したので、認証主体の矛盾は主経路から消えた。Issue 5: OPEN。canonical path rejection はまだ実装条件に落ちておらず、「watchdog は hack」「正攻法は将来 MCP プラグイン修正」と逃がしているので、hard fallback は仕様として閉じていない。
Issue 6: PARTIAL。
verification_summaryのBEFORE INSERTとlegacy_no_next_action後付け禁止は入ったが、migration bypass は_migration_lock.activeのUPDATE 0→1だけしか塞いでおらず、DELETEや再INSERTの封鎖が仕様にない。outbox architecture 攻撃面:
- 秘書が
telegram_outboxに直 INSERT: 仕様上塞げていない。internal flag テーブルの生成・消費・保護条件がゼロ- 秘書が偽 DONE で outbox 起動: できる。
DONEを偽装すれば送信も偽装される- 5秒ポーリング遅延の許容範囲は文書根拠なし
- DB 破損で outbox row が失われた場合の再送キュー・突合再構築手順が未定義
_secretarybotuser 攻撃面:
- dscl の sudo 要件は誰がいつ持つのか文書にない
_secretarybotプロセス再起動権限がaiharataketoにあるかが未定義_secretarybotがaiharataketoの DB(WAL含む sidecar)にどう書くか所有権設計がゼロtrigger hardening 攻撃面:
_migration_lock(id, active) VALUES (1,1)を先に打てる(INSERT/DELETE 禁止が無い).backupして offline 改竄して restore できるDELETE FROM jobs_auditで監査を消せる欠けている原則: 「外部副作用を起こす承認状態と、その監査証跡は、本文を書ける主体と同じ trust domain に置かない」
総合判定: 修正必要
サマリー CLOSED 4 / PARTIAL 2 / OPEN 2
LGTM 反転の最低条件:
dashboardsarchive を実行可能 SQL に書き直す。fence は archive 本体を止めない形にし、DELETE も封じ、fail-closed を SQL 単体で成立telegram_outbox直挿し防止を OS 権限設計で。秘書では enqueue 不可、worker のみ enqueue/sent 更新可能。別 DB/別プロセス境界に切り出す- DONE で送信が走るなら、遷移に追加の認可条件。本文編集権限と外部送信確定権限を同一 UPDATE で兼ねさせるな
_migration_lockに INSERT/DELETE 禁止を足す。trigger 本文に bypass 条件を埋め込んだ完成形 SQLjobs_auditを可変表のままにしない。DELETE/UPDATE 禁止 trigger +_secretarybot所有別 DB への append-only 複製- SQLite ファイルの offline 差し替え、WAL sidecar 権限、worker restart 権限を運用仕様として明文化
write fence は archive 完了後 に永続化する形に変える(archive 本体を止めない順序):
-- ステップ1: バックアップ(SQLite ファイル丸ごと)
-- $ cp jobs.db jobs.db.bak-$(date +%Y%m%d-%H%M%S)
-- ステップ2: archive 本体(fence は最後に張る)
BEGIN IMMEDIATE TRANSACTION;
ALTER TABLE dashboards ADD COLUMN archived_at TEXT;
UPDATE dashboards SET archived_at = strftime('%Y-%m-%dT%H:%M:%SZ','now') WHERE archived_at IS NULL;
-- ステップ3: post-archive count を SQL 内で fail-closed 検証
-- (RAISE(ABORT) は transaction 全体を rollback する)
SELECT CASE
WHEN (SELECT COUNT(*) FROM dashboards WHERE archived_at IS NULL) != 0
THEN RAISE(ABORT, 'archive incomplete: rows still unarchived, transaction will rollback')
END;
-- ステップ4: archive 完了後に永続 fence(全 mutation 禁止)を張る
CREATE TRIGGER fence_dashboards_insert
BEFORE INSERT ON dashboards
BEGIN SELECT RAISE(ABORT, 'dashboards is archived; new writes forbidden'); END;
CREATE TRIGGER fence_dashboards_update
BEFORE UPDATE ON dashboards
BEGIN SELECT RAISE(ABORT, 'dashboards is archived; updates forbidden'); END;
CREATE TRIGGER fence_dashboards_delete
BEFORE DELETE ON dashboards
BEGIN SELECT RAISE(ABORT, 'dashboards is archived; deletes forbidden (use undo migration)'); END;
COMMIT;
順序が重要: ALTER → UPDATE → 検証 → fence 設置 → COMMIT。fence を archive 後 に張ることで、archive 本体が止まらない。検証失敗時は RAISE で transaction 全体が rollback され、ALTER も UPDATE も巻き戻る。fence は archive 完了後の write を全 mutation(INSERT/UPDATE/DELETE)で塞ぐ。
合否条件は反復3 v4 から変更なし(5項目72時間連続)。
telegram_outbox を jobs.db から外して、独立した outbox.db に切り出す:
outbox.db は _secretarybot:_secretarybot 所有、chmod 600aiharataketo)は outbox.db を read も write もできない_secretarybot 権限)のみ INSERT/UPDATE/SELECT 可能しかし秘書が DONE にした時点で outbox に enqueue する必要がある。これを「同一 transaction 内 trigger」で実現するのは無理(別 DB だから)。代替設計:
aiharataketo)は jobs.db の pending_dispatches テーブルに 送信意図 を INSERT するCREATE TABLE pending_dispatches (
id INTEGER PRIMARY KEY AUTOINCREMENT,
job_id TEXT NOT NULL REFERENCES jobs(id),
type TEXT NOT NULL CHECK(type IN ('done','propose','question')),
intent_hash TEXT NOT NULL, -- jobs.intent の SHA256(改竄検出用)
next_action_hash TEXT NOT NULL, -- verification_summary.next_action の SHA256
enqueued_at TEXT NOT NULL,
consumed_at TEXT,
consumed_by_worker TEXT
);
_secretarybot)は pending_dispatches を 5秒間隔で pollconsumed_at IS NULL 行を取得outbox.db に enqueue + pending_dispatches.consumed_at 更新consumed_at = -1 のような sentinel で sweepoutbox.db を直接書こうとしてもファイル権限で OS が拒否(EACCES)。これは SQLite trigger ではなく OS の file permission で守られる。internal flag テーブルの口約束ではないpending_dispatches も outbox.db も SQLite WAL モードで動作 → クラッシュ復旧は SQLite の標準機能で再開pending_dispatches.consumed_at IS NULL を全件再 pollWHERE pending_dispatches.id = outbox.pending_id で逆引き、対応する pending を consumed_at で更新「秘書が DONE にしたら全部自動送信」は健人の自動化要望と一致するため維持。ただし「同一 UPDATE で兼ねさせるな」要求への回答として:
aiharataketo)は jobs.status='DONE' UPDATE と pending_dispatches への INSERT 権限outbox.db への INSERT は worker(_secretarybot)のみ可能function shouldDispatch(pending: PendingDispatch): boolean {
const job = readJob(pending.job_id);
const vs = readVerificationSummary(pending.job_id);
// 1. intent / next_action ハッシュ一致(秘書が enqueue 後に書き換えていない)
if (sha256(job.intent) !== pending.intent_hash) return false;
if (sha256(vs.next_action) !== pending.next_action_hash) return false;
// 2. done_when 全件 verification_checks に pass がある
const expectedConditions = readDoneWhen(pending.job_id);
const passedConditions = readVerificationChecks(pending.job_id, 'pass');
if (passedConditions.size < expectedConditions.size) return false;
// 3. verification_summary.next_action が trigger を通った形(非空 or kind=none + explanation)
if (vs.next_action_kind === 'none') {
if (!vs.next_action_explanation || vs.next_action_explanation.trim() === '') return false;
} else {
if (!vs.next_action || JSON.parse(vs.next_action).length === 0) return false;
}
return true;
}
これで Codex 指摘の「同一 UPDATE で兼ねさせるな」を別 trust domain で実装している。秘書が pending_dispatches に偽の hash を仕込んでも、worker が現在 DB 値と再計算 hash を比較して reject。
_migration_lock の完成形 SQL (INSERT/DELETE 封鎖込み)CREATE TABLE _migration_lock (
id INTEGER PRIMARY KEY CHECK(id=1),
active INTEGER NOT NULL DEFAULT 1,
released_at TEXT
);
-- 初期化(migration プロセスの最初の SQL)
INSERT INTO _migration_lock(id, active) VALUES (1, 1);
-- INSERT を1回限りに制限(2回目以降の INSERT を全て abort)
CREATE TRIGGER forbid_migration_lock_reinsert
BEFORE INSERT ON _migration_lock
WHEN EXISTS (SELECT 1 FROM _migration_lock)
BEGIN SELECT RAISE(ABORT, 'migration lock already initialized; cannot re-insert'); END;
-- DELETE を全面禁止
CREATE TRIGGER forbid_migration_lock_delete
BEFORE DELETE ON _migration_lock
BEGIN SELECT RAISE(ABORT, 'migration lock cannot be deleted'); END;
-- UPDATE で active 0→1 を禁止(片道切符)
CREATE TRIGGER lock_migration_one_way
BEFORE UPDATE OF active ON _migration_lock
WHEN OLD.active = 0 AND NEW.active = 1
BEGIN SELECT RAISE(ABORT, 'migration lock is one-way; cannot reactivate'); END;
-- migration 終了処理(active=0 へ)
UPDATE _migration_lock SET active = 0, released_at = strftime('%Y-%m-%dT%H:%M:%SZ','now') WHERE id = 1;
これで attacker は:
id=1 の row があるので INSERT できないDELETE で消して再 INSERT しようとしても DELETE trigger で abortUPDATE active=1 も one-way trigger で abortmigration の trigger bypass:
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'
AND NOT EXISTS (SELECT 1 FROM _migration_lock WHERE active = 1)
BEGIN
-- ... 既存の検証ロジック
END;
migration 中(active=1)はトリガー全体が WHEN 句で skip。終了後(active=0)は通常運用、二度と active=1 にできないので bypass 不可能。
3層防御を明示的に書く:
第1層: OS 権限隔離(指摘2 v5 で実現)
_secretarybot:_secretarybot 所有の ~/.claude/channels/telegram/.env (chmod 600)aiharataketo)から token read 不可第2層: outbox 経由でのみ Telegram 送信が成立
outbox.db も _secretarybot:_secretarybot 所有(chmod 600)_secretarybot 権限)のみ送信実行