feat(data-exfil-warning): 資料外流警示 — 暴露動作需人類明示同意

新 SDD .agents/specs/data-exfil-warning/(richblack review 過)。
觸發策略:只在「資料變成可被外部呼叫」時警示(webhook 部署 / recipe push),
不管出站打別人 API(高頻低風險)。

- C 同意憑證(exposure-consent.ts):ExposureConsent{confirmed_by_human, understood,
  confirmed_at, suppress_future};同意=法律憑證,存 record 可審
- A API 層:webhook 部署 + recipe push 首次需 consent,缺→403;首次問記住(server 端)
- B CLI(exposure-warning.ts):仿 GCP 刪 project,要打資源名確認(比 y/n 硬);
  --confirm-exposure(非互動)/ --suppress-warning(不再警示,本選擇也 log);
  非 TTY 無旗標→拒絕(AI 不替人類確認暴露);本機 config 記住已同意(不重問)
- H hook:pre-bash 偵測 acr push/recipe push 無旗標→exit 2(creds push/run 不誤擋)
- 警示是「保護措施入口」:提示 arcrun 可幫加認證/權限/限流(資安優勢)

驗收:非 TTY 拒絕未送出(exit1)、hook 精準擋放、tsc 雙邊綠。

⚠️ A+B 必須一起 deploy(API 層擋 + CLI 帶 consent),否則 push 中間狀態壞。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-05-30 14:45:43 +08:00
parent 5178a6666f
commit 3e92d4acf6
11 changed files with 481 additions and 4 deletions
+18 -1
View File
@@ -11,8 +11,9 @@ import chalk from 'chalk';
import ora from 'ora';
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): Promise<void> {
export async function cmdPush(filePath: string, options: { confirmExposure?: boolean; suppressWarning?: boolean } = {}): Promise<void> {
const config = loadConfig();
if (config.mode === 'local') {
@@ -89,6 +90,21 @@ export async function cmdPush(filePath: string): Promise<void> {
process.exit(1);
}
// 資料外流警示:部署 webhook = 把 workflow 變對外可呼叫 endpoint(暴露面)。
// 已同意過(本機記住)→ 回非 null 自動放行;未同意/取消/非互動 → null → 中止。
// server 端獨立存法律憑證並強制(防 CLI 被繞過)。
const consent = await obtainExposureConsent(
{
kind: 'workflow',
resourceName: workflow.name,
destination: `${executorUrl}/webhooks/named/${workflow.name}/trigger`,
},
options,
);
if (!consent) {
process.exit(1);
}
// POST 至 /webhooks/named
const deploySpinner = ora(`部署 "${workflow.name}" 至 ${executorUrl}`).start();
try {
@@ -100,6 +116,7 @@ export async function cmdPush(filePath: string): Promise<void> {
graph,
config: workflow.config ?? {},
description: workflow.description ?? '',
exposure_consent: consent ?? undefined,
}),
});
+17 -2
View File
@@ -32,7 +32,7 @@ interface RecipeDefinition {
updated_at: number;
}
export async function cmdRecipePush(filePath: string): Promise<void> {
export async function cmdRecipePush(filePath: string, options: { confirmExposure?: boolean; suppressWarning?: boolean } = {}): Promise<void> {
const config = loadConfig();
if (!config.api_key) {
@@ -65,6 +65,21 @@ export async function cmdRecipePush(filePath: string): Promise<void> {
}
const executorUrl = getCypherExecutorUrl(config);
// 資料外流警示:recipe 定義一個資料去向(endpoint)。首次 push 需人類明示同意(公私一視同仁)。
// 已同意過(本機記住)→ 回非 null 自動放行;未同意/取消/非互動 → null → 中止。
const consent = await obtainExposureConsent(
{
kind: 'recipe',
resourceName: recipe.canonical_id,
destination: recipe.endpoint,
},
options,
);
if (!consent) {
process.exit(1);
}
const spinner = ora(`上傳 recipe "${recipe.canonical_id}"`).start();
try {
@@ -74,7 +89,7 @@ export async function cmdRecipePush(filePath: string): Promise<void> {
'Content-Type': 'application/json',
'X-Arcrun-API-Key': config.api_key,
},
body: JSON.stringify(recipe),
body: JSON.stringify({ ...recipe, exposure_consent: consent ?? undefined }),
});
const data = await res.json() as { success: boolean; recipe?: RecipeDefinition; error?: string };
+4
View File
@@ -21,6 +21,10 @@ export interface ArcrunConfig {
wasm_bucket?: string;
// 共用
multi_tenant?: boolean;
// 資料外流警示:本機記住「已同意暴露 / 選擇不再警示」的資源,避免每次 push 重問(§3 首次問記住)。
// key 格式:`{kind}:{resourceName}`(如 "webhook:contacts_lookup" / "recipe:kbdb_get")。
// 注意:這只是 CLI 端 UX(不重問);server 端獨立存法律憑證並強制(防 CLI 被繞過)。
exposure_consented?: Record<string, { confirmed_at: string; suppress_future?: boolean }>;
}
const CONFIG_DIR = join(homedir(), '.arcrun');
+138
View File
@@ -0,0 +1,138 @@
/**
* 資料外流警示 — CLI 互動(data-exfil-warning SDD §1a / B
*
* 觸發策略:只在「資料變成可被外部呼叫」時警示(webhook 部署 / recipe push)。
* 互動形式(richblack):仿 GCP 刪 project —— 要用戶打資源名證明讀了警示(比 y/n 硬,不用打一大串)。
* 同意 = 法律憑證:回傳的 ExposureConsent 帶 understood(用戶打的內容)+ 時間,server 端 log。
* 誠實限制:非 TTYAI 直跑)無 --confirm-exposure → 拒絕(AI 不該替人類確認暴露)。
*/
import { createInterface } from 'node:readline/promises';
import chalk from 'chalk';
import { loadConfig, saveConfig } from './config.js';
export interface ExposureConsent {
confirmed_by_human: true;
understood: string;
confirmed_at: string;
suppress_future?: boolean;
}
export interface ExposureWarningOptions {
/** --confirm-exposure:非互動環境跳過 prompt(仍記 consentunderstood 標明來源) */
confirmExposure?: boolean;
/** --suppress-warning:本資源以後不再警示(此選擇本身也 log) */
suppressWarning?: boolean;
}
export interface ExposureContext {
/** 動作種類,顯示用:'webhook' | 'recipe' */
kind: string;
/** 資源名(用戶要打這個字確認)*/
resourceName: string;
/** 暴露後的 URL / 去向(顯示用,可選) */
destination?: string;
/** 這個資源讀取/送出什麼(盡力盤,盤不出傳 undefined) */
dataSummary?: string;
}
/**
* 取得暴露同意。回傳 ExposureConsent(放進 push 請求 body)。
* 未取得同意 → 印訊息並 return null(呼叫端應中止)。
*/
export async function obtainExposureConsent(
ctx: ExposureContext,
opts: ExposureWarningOptions = {},
): Promise<ExposureConsent | null> {
const nowIso = new Date().toISOString();
const memKey = `${ctx.kind}:${ctx.resourceName}`;
// §3 首次問記住:本機已記錄同意此資源 → 不重問(server 端仍存法律憑證並強制)。
const cfg = loadConfig();
const prior = cfg.exposure_consented?.[memKey];
if (prior) {
return {
confirmed_by_human: true,
understood: `先前已同意暴露 ${ctx.resourceName}${prior.confirmed_at}${prior.suppress_future ? ',已選不再警示' : ''}`,
confirmed_at: prior.confirmed_at,
suppress_future: prior.suppress_future,
};
}
// --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 不該替人類確認暴露。
if (!process.stdin.isTTY) {
console.error(chalk.red('\n⚠️ 此動作會把資源變成可被外部呼叫(暴露/送出資料),需人類確認。'));
console.error(chalk.gray(' 非互動環境無法確認。請人類執行,或加 --confirm-exposure(你已知悉風險)。\n'));
return null;
}
// 互動式警示 + 打資源名確認
printWarning(ctx);
const rl = createInterface({ input: process.stdin, output: process.stdout });
try {
const answer = (await rl.question(
chalk.bold(` 確認暴露?請輸入資源名 "${ctx.resourceName}" 以繼續(或 Ctrl-C 取消):`),
)).trim();
if (answer !== ctx.resourceName) {
console.error(chalk.red(`\n 輸入不符(需輸入 "${ctx.resourceName}")。已取消,未暴露。\n`));
return null;
}
rememberConsent(memKey, nowIso, false);
return {
confirmed_by_human: true,
understood: `用戶輸入資源名 "${ctx.resourceName}" 確認暴露${ctx.destination ? `(去向:${ctx.destination}` : ''}`,
confirmed_at: nowIso,
};
} finally {
rl.close();
}
}
/** 本機記住此資源已同意(避免下次重問;server 端仍獨立存法律憑證並強制) */
function rememberConsent(memKey: string, confirmedAt: string, suppressFuture: boolean): void {
try {
const cfg = loadConfig();
cfg.exposure_consented = cfg.exposure_consented ?? {};
cfg.exposure_consented[memKey] = { confirmed_at: confirmedAt, suppress_future: suppressFuture };
saveConfig(cfg);
} catch {
// 記不住不影響本次同意(server 端仍會擋首次)
}
}
function printWarning(ctx: ExposureContext): void {
console.log(chalk.yellow.bold(`\n⚠️ 資料外流警示`));
console.log(chalk.yellow(` 這個動作會把 ${ctx.kind} "${ctx.resourceName}" 變成可被外部呼叫。`));
if (ctx.destination) {
console.log(chalk.gray(` 去向:${ctx.destination}`));
}
if (ctx.dataSummary) {
console.log(chalk.gray(` 涉及資料:${ctx.dataSummary}`));
} else {
console.log(chalk.gray(` 涉及資料:無法自動判斷,請自行確認此資源是否含敏感資料。`));
}
console.log(chalk.gray(` 任何能呼叫它的人都能取得它的輸出/能力。`));
console.log('');
console.log(chalk.cyan(` arcrun 可幫你保護它:要求呼叫者帶 API Key/設權限/限流(一個動作就能加)。`));
console.log(chalk.gray(` 若這是要公開的資料(如公開 API),可直接確認。`));
console.log('');
}