diff --git a/.claude/hooks/pre-bash-guard.sh b/.claude/hooks/pre-bash-guard.sh index b86a9a3..6434dd7 100755 --- a/.claude/hooks/pre-bash-guard.sh +++ b/.claude/hooks/pre-bash-guard.sh @@ -82,23 +82,25 @@ fi # 資料外流警示(data-exfil-warning SDD R2):AI 動手把資料變成可被外部呼叫前先擋 # `acr push`(部署 webhook)/ `acr recipe push`(定義資料去向)= 暴露面動作。 # 不含 `acr creds push`(上傳加密 credential 是保護,非暴露)。 -# 已帶 --confirm-exposure / --suppress-warning(人類已明示)→ 放行。 +# +# 信任修正(2026-05-30):無「旗標放行」捷徑——AI 自己能加的旗標 = 自己批准自己。 +# 這類動作一律擋,必須由人類在終端機親自執行(CLI 會跳互動、要人類輸入資源名確認)。 # ───────────────────────────────────────────────────────────────────────────── -if echo "$CMD" | grep -qE "\bacr[[:space:]]+(recipe[[:space:]]+)?push\b" \ - && ! echo "$CMD" | grep -qE "\bacr[[:space:]]+creds[[:space:]]+push\b"; then - if ! echo "$CMD" | grep -qE "\-\-(confirm-exposure|suppress-warning)"; then - cat >&2 <<'EOF' +# 只在「指令本身就是執行 acr push / acr recipe push」時擋(錨定到指令開頭, +# 允許前置 cd .. && 或環境變數)。避免誤判 git commit -m "...acr push..." 這類 +# 「字串裡剛好提到 acr push」的情況(commit message / echo / grep 不該被擋)。 +if echo "$CMD" | grep -qE "(^|&&|;|\|)[[:space:]]*(cd[[:space:]][^&;|]*(&&|;)[[:space:]]*)?([A-Za-z_]+=[^[:space:]]*[[:space:]]+)*acr[[:space:]]+(recipe[[:space:]]+)?push\b" \ + && ! echo "$CMD" | grep -qE "acr[[:space:]]+creds[[:space:]]+push\b"; then + cat >&2 <<'EOF' ❌ BLOCKED:資料外流警示(arcrun data-exfil-warning) 原因:acr push / acr recipe push 會把資料/workflow 變成「可被外部呼叫」(暴露面)。 - 這種動作不該由 AI 自行執行——需人類明示知情同意(法律憑證)。 + 這種動作你(AI)不能自行執行,也沒有旗標捷徑——需人類明示知情同意(法律憑證)。 正確做法: - - 由人類在終端機親自執行(會跳互動警示、要你輸入資源名確認、並提供保護選項) - - 或人類確認後加 --confirm-exposure(你已知悉暴露風險) - - 確定要公開且不再提醒:--suppress-warning + - 把這件事交給人類:請人類在終端機親自執行(CLI 會跳互動、要人類輸入資源名確認) + - 人類第一次確認後 server 會記住,之後同資源不用再確認 參考:.agents/specs/data-exfil-warning/ EOF - exit 2 - fi + exit 2 fi exit 0 diff --git a/cli/src/commands/push.ts b/cli/src/commands/push.ts index 47c0c37..8b262be 100644 --- a/cli/src/commands/push.ts +++ b/cli/src/commands/push.ts @@ -13,7 +13,7 @@ import { loadConfig, getCypherExecutorUrl } from '../lib/config.js'; import { loadWorkflowYaml, parseTriplets, validateRelations } from '../lib/yaml-parser.js'; import { obtainExposureConsent } from '../lib/exposure-warning.js'; -export async function cmdPush(filePath: string, options: { confirmExposure?: boolean; suppressWarning?: boolean } = {}): Promise { +export async function cmdPush(filePath: string): Promise { const config = loadConfig(); if (config.mode === 'local') { @@ -93,14 +93,11 @@ export async function cmdPush(filePath: string, options: { confirmExposure?: boo // 資料外流警示:部署 webhook = 把 workflow 變對外可呼叫 endpoint(暴露面)。 // 已同意過(本機記住)→ 回非 null 自動放行;未同意/取消/非互動 → null → 中止。 // server 端獨立存法律憑證並強制(防 CLI 被繞過)。 - const consent = await obtainExposureConsent( - { - kind: 'workflow', - resourceName: workflow.name, - destination: `${executorUrl}/webhooks/named/${workflow.name}/trigger`, - }, - options, - ); + const consent = await obtainExposureConsent({ + kind: 'workflow', + resourceName: workflow.name, + destination: `${executorUrl}/webhooks/named/${workflow.name}/trigger`, + }); if (!consent) { process.exit(1); } diff --git a/cli/src/commands/recipe.ts b/cli/src/commands/recipe.ts index cc44823..8f1d187 100644 --- a/cli/src/commands/recipe.ts +++ b/cli/src/commands/recipe.ts @@ -7,6 +7,7 @@ import chalk from 'chalk'; import ora from 'ora'; import { readFileSync, existsSync } from 'node:fs'; import { loadConfig, getCypherExecutorUrl } from '../lib/config.js'; +import { obtainExposureConsent } from '../lib/exposure-warning.js'; import yaml from 'js-yaml'; interface RecipeYaml { @@ -32,7 +33,7 @@ interface RecipeDefinition { updated_at: number; } -export async function cmdRecipePush(filePath: string, options: { confirmExposure?: boolean; suppressWarning?: boolean } = {}): Promise { +export async function cmdRecipePush(filePath: string): Promise { const config = loadConfig(); if (!config.api_key) { @@ -68,14 +69,11 @@ export async function cmdRecipePush(filePath: string, options: { confirmExposure // 資料外流警示:recipe 定義一個資料去向(endpoint)。首次 push 需人類明示同意(公私一視同仁)。 // 已同意過(本機記住)→ 回非 null 自動放行;未同意/取消/非互動 → null → 中止。 - const consent = await obtainExposureConsent( - { - kind: 'recipe', - resourceName: recipe.canonical_id, - destination: recipe.endpoint, - }, - options, - ); + const consent = await obtainExposureConsent({ + kind: 'recipe', + resourceName: recipe.canonical_id, + destination: recipe.endpoint, + }); if (!consent) { process.exit(1); } diff --git a/cli/src/lib/exposure-warning.ts b/cli/src/lib/exposure-warning.ts index 9509060..6a0527c 100644 --- a/cli/src/lib/exposure-warning.ts +++ b/cli/src/lib/exposure-warning.ts @@ -17,11 +17,12 @@ export interface ExposureConsent { suppress_future?: boolean; } +// 註(2026-05-30 信任修正):移除 --confirm-exposure / --suppress-warning 旗標。 +// 理由:arcrun 是 AI 的工具,AI 自己能加旗標 = 自己批准自己 = 閘門虛設(違 DECISIONS §7)。 +// 唯一通過 = 人類在 TTY 互動輸入資源名(AI 非互動環境生不出)。「以後不再問」改成互動中詢問。 export interface ExposureWarningOptions { - /** --confirm-exposure:非互動環境跳過 prompt(仍記 consent,understood 標明來源) */ - confirmExposure?: boolean; - /** --suppress-warning:本資源以後不再警示(此選擇本身也 log) */ - suppressWarning?: boolean; + // 預留:未來 CI 用「人類預先簽的 token」(非 AI 能生的 flag)。第一期不做。 + _reserved?: never; } export interface ExposureContext { @@ -58,34 +59,16 @@ export async function obtainExposureConsent( }; } - // --suppress-warning:記偏好(此選擇也是 consent,understood 標明) - if (opts.suppressWarning) { - rememberConsent(memKey, nowIso, true); - return { - confirmed_by_human: true, - understood: `用戶選擇「以後不對 ${ctx.resourceName} 警示」(知悉暴露風險並接受)`, - confirmed_at: nowIso, - suppress_future: true, - }; - } - - // --confirm-exposure:非互動跳過 prompt(CI 等)。仍記 consent,understood 標明來源。 - if (opts.confirmExposure) { - return { - confirmed_by_human: true, - understood: `透過 --confirm-exposure 確認暴露 ${ctx.resourceName}`, - confirmed_at: nowIso, - }; - } - - // 非 TTY(AI 直跑)且無旗標 → 拒絕。AI 不該替人類確認暴露。 + // 非 TTY(AI 直跑)→ 一律拒絕,無捷徑。AI 不該、也不能替人類確認暴露。 + // (移除了 --confirm-exposure 旗標:那是 AI 自己能加的後門,等於自己批准自己。) if (!process.stdin.isTTY) { - console.error(chalk.red('\n⚠️ 此動作會把資源變成可被外部呼叫(暴露/送出資料),需人類確認。')); - console.error(chalk.gray(' 非互動環境無法確認。請人類執行,或加 --confirm-exposure(你已知悉風險)。\n')); + console.error(chalk.red('\n⚠️ 此動作會把資源變成可被外部呼叫(暴露/送出資料),需人類明示同意。')); + console.error(chalk.gray(' 你(AI)無法確認暴露——這必須由人類在終端機親自執行、輸入資源名確認。')); + console.error(chalk.gray(' 請把這件事交給人類做。\n')); return null; } - // 互動式警示 + 打資源名確認 + // 互動式警示 + 打資源名確認(唯一通過路徑,AI 生不出這個輸入) printWarning(ctx); const rl = createInterface({ input: process.stdin, output: process.stdout }); try { @@ -96,11 +79,17 @@ export async function obtainExposureConsent( console.error(chalk.red(`\n 輸入不符(需輸入 "${ctx.resourceName}")。已取消,未暴露。\n`)); return null; } - rememberConsent(memKey, nowIso, false); + // 互動中詢問「以後不再問」(人類選,不是 AI 加旗標) + const suppressAns = (await rl.question( + chalk.gray(` 以後此資源(${ctx.resourceName})的暴露不再提醒?(y/N):`), + )).trim().toLowerCase(); + const suppress = suppressAns === 'y' || suppressAns === 'yes'; + rememberConsent(memKey, nowIso, suppress); return { confirmed_by_human: true, - understood: `用戶輸入資源名 "${ctx.resourceName}" 確認暴露${ctx.destination ? `(去向:${ctx.destination})` : ''}`, + understood: `用戶輸入資源名 "${ctx.resourceName}" 確認暴露${ctx.destination ? `(去向:${ctx.destination})` : ''}${suppress ? ';並選擇以後不再提醒' : ''}`, confirmed_at: nowIso, + suppress_future: suppress, }; } finally { rl.close();