fix(data-exfil-warning): 移除 CLI 旗標後門 + 修 hook 誤判(閉環修正)

richblack 2026-05-30:arcrun 是 AI 的工具,AI 自己能加旗標 = 自己批准自己 = 閘門虛設
(違 DECISIONS §7 執行者不能驗證自己)。

- 移除 --confirm-exposure / --suppress-warning(CLI lib/commands/index.ts)
- 唯一通過 = 人類 TTY 互動輸入資源名;「以後不再問」改互動中詢問;非 TTY 一律拒絕「交給人類」
- hook 移除旗標放行捷徑 + 錨定指令開頭(修誤判:commit message 含字串不再被擋)

驗證:真執行=2、cd&&執行=2、commit/echo含字串=0、creds/run/ls=0;非TTY→RC1「交給人類」;CLI build 綠。
self-hosted 誠實限制:AI 直接動其 CF KV 仍可假造,無100%防法,閘門價值=拉高門檻+留痕究責。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-05-30 17:15:45 +08:00
parent 1b36b091a5
commit 51d40ee515
4 changed files with 45 additions and 59 deletions
+13 -11
View File
@@ -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
+6 -9
View File
@@ -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<void> {
export async function cmdPush(filePath: string): Promise<void> {
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);
}
+7 -9
View File
@@ -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<void> {
export async function cmdRecipePush(filePath: string): Promise<void> {
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);
}
+19 -30
View File
@@ -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(仍記 consentunderstood 標明來源) */
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:記偏好(此選擇也是 consentunderstood 標明)
if (opts.suppressWarning) {
rememberConsent(memKey, nowIso, true);
return {
confirmed_by_human: true,
understood: `用戶選擇「以後不對 ${ctx.resourceName} 警示」(知悉暴露風險並接受)`,
confirmed_at: nowIso,
suppress_future: true,
};
}
// --confirm-exposure:非互動跳過 promptCI 等)。仍記 consentunderstood 標明來源。
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();