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
+13 -1
View File
@@ -16,6 +16,8 @@
import { Hono } from 'hono';
import type { Bindings } from '../types';
import { deriveRecipeHash } from '../lib/hash';
import { checkExposureConsent, resolveConsentForRecord } from '../lib/exposure-consent';
import type { ExposureConsent } from '../lib/exposure-consent';
export const recipesRouter = new Hono<{ Bindings: Bindings }>();
@@ -38,6 +40,9 @@ export interface RecipeDefinition {
key: string;
inject_as: string;
}>;
// 資料外流警示:recipe 定義一個資料去向(endpoint)。push 需人類明示同意(法律憑證)。
// SDD: data-exfil-warning §7(公私一視同仁)
exposure_consent?: ExposureConsent;
created_at: number;
updated_at: number;
}
@@ -58,9 +63,15 @@ recipesRouter.post('/recipes', async (c) => {
const hashId = await deriveRecipeHash(canonicalId);
const now = Date.now();
// 讀取現有版本(保留 created_at
// 讀取現有版本(保留 created_at + 既有同意憑證
const existing = await c.env.RECIPES.get(`recipe:${canonicalId}`, 'json') as RecipeDefinition | null;
// 資料外流警示:recipe 定義資料去向(endpoint)。首次 push 需人類明示同意(公私一視同仁)。
const consentError = checkExposureConsent(body.exposure_consent, existing?.exposure_consent);
if (consentError !== null) {
return c.json({ success: false, error: consentError, requires: 'exposure_consent' }, 403);
}
const recipe: RecipeDefinition = {
canonical_id: canonicalId,
hash_id: hashId,
@@ -72,6 +83,7 @@ recipesRouter.post('/recipes', async (c) => {
body: body.body,
auth_service: body.auth_service,
credentials_required: body.credentials_required,
exposure_consent: resolveConsentForRecord(body.exposure_consent, existing?.exposure_consent),
created_at: existing?.created_at ?? now,
updated_at: now,
};