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:
@@ -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
|
||||
# 只在「指令本身就是執行 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
|
||||
fi
|
||||
|
||||
exit 0
|
||||
|
||||
@@ -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(
|
||||
{
|
||||
const consent = await obtainExposureConsent({
|
||||
kind: 'workflow',
|
||||
resourceName: workflow.name,
|
||||
destination: `${executorUrl}/webhooks/named/${workflow.name}/trigger`,
|
||||
},
|
||||
options,
|
||||
);
|
||||
});
|
||||
if (!consent) {
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
@@ -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(
|
||||
{
|
||||
const consent = await obtainExposureConsent({
|
||||
kind: 'recipe',
|
||||
resourceName: recipe.canonical_id,
|
||||
destination: recipe.endpoint,
|
||||
},
|
||||
options,
|
||||
);
|
||||
});
|
||||
if (!consent) {
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user