From 3e92d4acf65859108a76b710f4f93fae00f01acc Mon Sep 17 00:00:00 2001 From: richblack Date: Sat, 30 May 2026 14:45:43 +0800 Subject: [PATCH] =?UTF-8?q?feat(data-exfil-warning):=20=E8=B3=87=E6=96=99?= =?UTF-8?q?=E5=A4=96=E6=B5=81=E8=AD=A6=E7=A4=BA=20=E2=80=94=20=E6=9A=B4?= =?UTF-8?q?=E9=9C=B2=E5=8B=95=E4=BD=9C=E9=9C=80=E4=BA=BA=E9=A1=9E=E6=98=8E?= =?UTF-8?q?=E7=A4=BA=E5=90=8C=E6=84=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 新 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 --- .agents/specs/data-exfil-warning/design.md | 117 +++++++++++++++ .../specs/data-exfil-warning/requirements.md | 56 +++++++ .agents/specs/data-exfil-warning/tasks.md | 40 +++++ .claude/hooks/pre-write-guard.sh | 1 + cli/src/commands/push.ts | 19 ++- cli/src/commands/recipe.ts | 19 ++- cli/src/lib/config.ts | 4 + cli/src/lib/exposure-warning.ts | 138 ++++++++++++++++++ cypher-executor/src/lib/exposure-consent.ts | 60 ++++++++ cypher-executor/src/routes/recipes.ts | 14 +- cypher-executor/src/routes/webhooks-named.ts | 17 +++ 11 files changed, 481 insertions(+), 4 deletions(-) create mode 100644 .agents/specs/data-exfil-warning/design.md create mode 100644 .agents/specs/data-exfil-warning/requirements.md create mode 100644 .agents/specs/data-exfil-warning/tasks.md create mode 100644 cli/src/lib/exposure-warning.ts create mode 100644 cypher-executor/src/lib/exposure-consent.ts diff --git a/.agents/specs/data-exfil-warning/design.md b/.agents/specs/data-exfil-warning/design.md new file mode 100644 index 0000000..f051069 --- /dev/null +++ b/.agents/specs/data-exfil-warning/design.md @@ -0,0 +1,117 @@ +# Design: 資料外流警示(Data Exfiltration Warning) + +> 2026-05-30。實作 requirements.md。**本 design 需 richblack review 後才動 code。** +> 觸發策略(richblack 定):**只在「資料變成可被外部呼叫」時問**(暴露面),不管「我去打別人 API」(出站,高頻低風險)。 + +--- + +## 0. 核心定義:什麼是「資料變成可被外部呼叫」 + +警示的觸發點 = 一個動作**讓某份資料 / 能力變成「別人能呼叫得到」**。這是真正的裸奔動作。 +**不觸發**:「我自己的 workflow 去打別人的 API」(出站)——那是我主動用別人服務,不是把我的東西開放出去。 + +### 哪些動作屬於「變成可被外部呼叫」(要警示) +| 動作 | 為何是暴露 | +|---|---| +| 部署 webhook trigger(`acr push` workflow → 可被 POST 觸發) | workflow 變成一個對外可呼叫的 endpoint。誰打它就能跑它、拿它的輸出 | +| recipe 貢獻到公共庫(未來飛輪項) | recipe(含 endpoint 設定)變成全生態可見可用 | +| 把 workflow / recipe 的可見性改為 public(若未來有此欄位) | 同上 | + +### 哪些動作**不**觸發(避免盲目按 yes) +- `acr run`(本機跑,不暴露) +- `acr recipe push`(存私人 KV,綁 api_key,只有自己 + 自己的 workflow 用——**不是**對外暴露) + - ⚠️ 待 design review 確認:recipe push 現況是私人的,**不暴露**。除非未來加「公共」旗標才觸發。 +- workflow 裡「打別人 API」的 http 節點(出站,不暴露我的資料給別人呼叫) +- 查詢類、creds push(上傳加密 credential 是保護不是暴露) + +### 灰色地帶(design review 要定) +- **webhook trigger 現況**:要 `X-Arcrun-API-Key`(owner 的 key)才能打 → 嚴格說「只有我能打」,不算對全世界暴露。 + 但:(a) key 一旦外流就全開;(b) 用戶可能不知道「部署 webhook = 開了一個 endpoint」。 + → **是否警示 webhook 部署?** 傾向「是」,因為用戶可能不知情它變成 endpoint,且這是「把 workflow 變 API」的那一步(richblack 最早的風險點)。 + +## 1. 兩道防線 + +### 1a. hook(防在前,AI 動手當下) +AI 在本機寫「會變成可被外部呼叫的東西」前,hook 警告 + 要人類確認。可偵測的訊號: +- 寫 workflow YAML 含 webhook trigger / 對外觸發設定。 +- 跑 `acr push`(部署 webhook)、未來的 recipe 公共貢獻指令。 +- pre-bash / pre-write hook 偵測這些 → 輸出警示「這會把 X 變成可被外部呼叫,需人類確認」。 +- **誠實限制**:hook 偵測的是「動作形態」(部署 webhook),不是「資料是否敏感」(機器判不準)。 + +### 1b. API 層(防在後,真的暴露前) +暴露動作打到 server(webhook 部署 endpoint / 未來公共貢獻 endpoint)時,server 要求帶 +「人類已確認暴露」的明示旗標,沒帶 → 拒絕 + 提示。與 component-gatekeeping 的 human_confirmation 同模式: +```ts +exposure_confirmation?: { + confirmed_by_human: true; + understood_exposure: string; // 人類說明「我知道這會把什麼開放給誰」 + confirmed_at: string; +} +``` +缺 → 拒絕,訊息:「部署 webhook = 開一個對外可呼叫的 endpoint。確認你知道這會暴露 [workflow 名] +的能力/輸出?用 `acr push --confirm-exposure` 並說明。」 + +### 為何兩道都要 +- 只有 hook:AI 可繞過 CLI 直接打 server API → API 層補。 +- 只有 API 層:AI 已經寫好暴露的東西才在送出時被擋,浪費 + 用戶較晚看到 → hook 提前。 +- 同 component-gatekeeping 的雙層精神。 + +## 2. 警示要讓人看得懂(R1) + +不是「確認嗎 Y/N」,是說清楚風險: +``` +⚠️ 這個動作會把 workflow "contacts_lookup" 變成可被外部呼叫的 endpoint: + https://cypher.arcrun.dev/webhooks/named/contacts_lookup/trigger + + 任何持有觸發憑證的人都能呼叫它、取得它的輸出。 + 這個 workflow 讀取:[盤出 workflow 用到的資料源,若可得] + 需要保護(要求呼叫者認證)嗎?目前的觸發認證是:[現況] + + 確認部署?(需人類明示) +``` +- 「workflow 讀取什麼資料源」:盡力從 workflow 定義盤(用了哪些 recipe / endpoint),盤不出就標「無法自動判斷,請自行確認」。誠實。 + +## 3. known-destinations / 不重複問(R4 避免盲目按 yes) + +觸發策略已經很窄(只暴露動作),但同一個 workflow 重複部署不該每次問。 +- 首次部署某 workflow 為 webhook → 問。人類確認後記住(該 workflow 標記 exposure-confirmed)。 +- 之後同 workflow 更新 → 不重問(除非暴露面變大,如新增對外觸發)。 +- 記在哪:workflow metadata(WEBHOOKS KV 的 record 加 `exposure_confirmed_at`)。 + +## 4. 與既有一致(R5) +- 同 component-gatekeeping:AI 不可替人類決定有外洩風險的動作;誠實限制(AI 能偽造 confirmed_by_human,靠軌跡可審 + mindset)。 +- 同「arcrun 不做授權判斷」:不判斷「該不該暴露」,只「攔下來讓人類明示同意」。不禁止暴露,要明示同意。 + +## 5. 範圍邊界 +- **動**:webhook 部署路徑(webhooks-named.ts)的 exposure_confirmation + CLI `acr push --confirm-exposure` 互動 + hook。 +- **不動**:用戶 API 入站保護機制(發 key/權限/限流,另列 BACKLOG);recipe 私人 push(不暴露,不擋);出站 http 節點(不擋)。 +- recipe 公共貢獻路徑未實作 → 本系統只要求它**未來**內建 exposure 閘門(記進那條 BACKLOG 項)。 + +## 6. 決議(richblack 2026-05-30 design review) + +- **Q1 → recipe push 也警示,公私一視同仁。** 不是因為 recipe 本身暴露,而是統一原則「凡有資料去向/暴露面的動作都警示」。用戶可選「以後不要警示」(記偏好)。理由見 §7 同意 log。 +- **Q2 → webhook 部署要警示,但角度是「提醒 + 提供保護」不是「擋」。** 用不用認證是用戶決定(如美國氣象 API 本就無 key 公開)。我們警示時**順便提醒「可用 arcrun 提供的保護措施」**(接「用戶 API 保護機制」資安優勢,BACKLOG 待決策)。首次問、記住(§3)。 +- **Q3 → hook 偵測 `acr push` 指令**(簡單版,pre-bash 攔指令)。 + +## 7. 同意 = 法律憑證(richblack 2026-05-30,重要) + +每次人類同意「暴露/送資料」的動作,**留 log(誰、何時、同意了什麼)**。這不只是「軌跡可審」,是**法律保護**: + +- 真發生資料外洩 / 糾紛時,有「用戶在 [時間] 明示知情同意把 [什麼] 暴露給 [誰]」的證據 → 避免訴訟風險(責任在明示同意的用戶,不在 arcrun)。 +- 「以後不要警示」這個選擇**本身也要 log**(用戶在 [時間] 選擇了不再對 [X] 警示 = 他知道風險並接受)。 +- 同意 log 存放:與動作關聯(webhook record / recipe record 的 `exposure_consent: { confirmed_by, understood, confirmed_at, suppress_future }`)。 +- 誠實限制同前:AI 能偽造 confirmed_by_human。但**法律意義上,憑證存在 + 可審 = 用戶有機會知情**,這道防線的價值是法律歸責不是技術防偽。 + +→ 這把 §1b 的 `exposure_confirmation` 升級為**帶法律意義的同意憑證**,所有暴露/送資料動作(recipe push / webhook 部署)共用此機制。 + +## 8. 警示是「保護措施的入口」(不只是攔) + +警示訊息除了說明風險,**主動提供 arcrun 的保護措施**(產品價值,非只防呆): +``` +⚠️ 這個動作會把 [X] 開放/送出。 + arcrun 可以幫你保護它: + - 要求呼叫者帶 API Key(你發給特定對象) + - 設定權限 / 限流 + 一個動作就能加上。要加保護嗎?還是確認公開(如公開資料 API)? +``` +(具體保護機制是 BACKLOG「用戶 API 保護機制」待決策項——本系統先在警示處**提示它存在**,實作後接上。) diff --git a/.agents/specs/data-exfil-warning/requirements.md b/.agents/specs/data-exfil-warning/requirements.md new file mode 100644 index 0000000..7c878c8 --- /dev/null +++ b/.agents/specs/data-exfil-warning/requirements.md @@ -0,0 +1,56 @@ +# Requirements: 資料外流警示(Data Exfiltration Warning) + +> 2026-05-30 建立(richblack 確認)。資安優先、及早做。 +> 判準源:DECISIONS §7(讓 AI 不做歪 + 閉環)、§0(減少不可控依賴 / 風險);BACKLOG 步驟 5b。 + +--- + +## 背景與風險 + +arcrun 讓「產生 API / 把資料送出去」變很簡單(一堆資料 + webhook trigger = API;recipe = 打某 endpoint)。 +**這個「簡單」本身是風險**:簡單到 AI 可能在用戶不知情下,把含個資的東西變成可被呼叫的 endpoint,或把 +敏感資料送到非預期對象。 + +**richblack 的核心情境**:用戶有個 Google Sheets 存所有朋友的個資。正確用法是「call 它查詢」(自用)。 +但若 AI 把它變成一個 recipe / workflow,**送資料到非預期對象**(公開 webhook、公司群、外部 endpoint), +且**沒做認證** → 所有資料裸奔。 + +**關鍵原則(richblack 2026-05-30):** +1. **不分公私庫都要警示**。私人庫(公司用)一樣會出事(不小心把個資 POST 到公司群)。觸發點不是「推公共」, + 是「這動作會讓資料流向某處」。 +2. **不禁止**用戶公開 / 送資料(他要放什麼給誰是他的自由)——**但要確定他自己明示同意了**,不是 AI 替他決定。 +3. **兩道防線**:(a) hook 在 AI 動手前警告(防在前);(b) API 層在資料真送出前攔(防在後)。不論哪條路都攔。 + +## 需求 + +### R1 — API 層警示(資料送出前需人類同意,不分公私庫) +任何「把資料送往某目的地」的 API 動作,在執行/儲存前需人類明示同意。涵蓋(待 design 盤準): +- `acr recipe push`:recipe 定義一個 endpoint(資料去向)。 +- `acr push`(workflow):workflow 可能含「讀敏感源 → 送往某 endpoint」的節點。 +- webhook trigger 部署:把 workflow 變成可被呼叫的 API。 +- 未來「recipe 貢獻公共庫」路徑(BACKLOG 飛輪項)。 + +同意內容要讓人看得懂風險:**這個動作會把 [什麼資料] 送到 [哪個目的地],需不需要 credential 保護?確認?** + +### R2 — hook 在 AI 動手前警告 +AI 要做這類動作(寫含外部 endpoint 的 recipe/workflow、跑會送資料的指令)前,hook 先警告 + 要人類確認。 +這是「防在前」——在 AI 本機動手當下就提醒,不等到 API 層才攔(省一趟、且更早讓人看到)。 + +### R3 — 誠實的偵測範圍(不假裝能全防) +「敏感資料送到非預期對象」機械上難完整偵測。本系統做**可機械判斷的部分**,誠實標明擋不住的: +- 能偵測:動作含「外部 endpoint」(recipe endpoint / workflow http 節點 / webhook 對外)。 +- 難偵測:「這份資料是否敏感」「這個目的地是否非預期」——這需要語境,機器判不準。 +- 做法:偵測到「資料 → 外部目的地」的動作就警示,由**人類**判斷該資料/目的地是否該放行(人類同意是判斷點,機器只負責「攔下來問」)。 + +### R4 — 不擋自用、不製造過度摩擦 +- 純自用、不送資料出去的動作(acr run 本機、查詢類)不該被警示淹沒。 +- 警示要精準在「資料外流」動作,不是每個動作都問(否則用戶會盲目按 yes,警示失效)。 + +### R5 — 與既有閘門一致 +- 與「建零件人類閘門」(component-gatekeeping)同精神:AI 不可替人類決定有外洩風險的動作。 +- 與「arcrun 不做授權判斷」一致:不判斷「該不該送」,只「攔下來讓人類明示同意」。 + +## 非目標 +- 用戶自己的 API 保護機制(入站認證:發 key 給別人 / 權限 / 限流)—— 另列 BACKLOG 待決策。 +- 完整 DLP(資料外洩防護)系統 / 內容掃描判斷敏感度 —— 機器判不準,不做。 +- recipe 公共貢獻路徑本身 —— 未實作(飛輪項),本系統只要求它未來內建同意閘門。 diff --git a/.agents/specs/data-exfil-warning/tasks.md b/.agents/specs/data-exfil-warning/tasks.md new file mode 100644 index 0000000..b6246a6 --- /dev/null +++ b/.agents/specs/data-exfil-warning/tasks.md @@ -0,0 +1,40 @@ +# Tasks: 資料外流警示 + +> 對應 design.md(richblack 已 review,Q1-Q3 + 法律憑證 + 保護入口決議)。 +> 每完成一個 task 立刻標 [x],不批次。 + +--- + +## 共用:同意憑證機制(§7 法律憑證) +- [x] C1 定義 `exposure_consent { confirmed_by, understood, confirmed_at, suppress_future }` 型別 +- [x] C2 同意 log 寫入動作關聯的 record(webhook record / recipe record),可審 +- [x] C3 「以後不要警示」(suppress_future)本身也 log(用戶知風險並接受) + +## API 層警示(R1,防在後) +- [x] A1 webhook 部署(webhooks-named.ts POST)要 exposure_consent,缺且未 suppress → 拒絕 + 提示 +- [x] A2 recipe push(/recipes POST)同上(公私一視同仁) +- [x] A3 首次暴露某資源問、記住(exposure_confirmed / suppress_future)→ 之後不重問(§3) +- [x] A4 警示訊息說明風險 + 盤資料源(盡力,盤不出標「請自行確認」)+ **提示 arcrun 保護措施**(§8) + +## CLI 警示(互動 + 旗標) +- [x] B1 `acr push`:部署前互動式警示(首次某 workflow),人類確認組 exposure_consent 送出;`--confirm-exposure` 跳過互動(CI/非 TTY);`--suppress-warning` 記偏好 +- [x] B2 `acr recipe push`:同上 +- [x] B3 非 TTY(AI 直跑)無 --confirm-exposure → 拒絕並提示「需人類確認暴露」 + +## hook(R2,防在前,Q3=偵測指令) +- [x] H1 pre-bash-guard:偵測 `acr push` / `acr recipe push` → 警示「這會把 X 變可被外部呼叫,需人類確認」 + +## mindset / 文件 +- [ ] M1 誠實限制(AI 能偽造 confirmed_by_human,靠憑證可審 + 法律歸責)寫進 mindset Skill(步驟 7)+ 文件 + +## 驗收 +- [ ] V1 acr push 部署 webhook(首次)→ 互動警示 + 說明暴露 + 提示保護 的終端輸出 +- [x] V2 非 TTY 跑 acr push 無 --confirm-exposure → 拒絕的輸出 +- [ ] V3 webhook 部署 API 無 exposure_consent → 拒絕的輸出 +- [ ] V4 同一 workflow 二次部署 → 不重問(已記住) +- [ ] V5 --suppress-warning 後 → 不再警示,但 suppress 選擇有 log +- [ ] V6 同意後 → exposure_consent 寫進 record 可查(法律憑證) + +## 範圍邊界 +- 不動用戶 API 入站保護機制(發 key/權限/限流)—— BACKLOG 待決策,本系統只在警示處「提示它存在」。 +- 不擋出站 http 節點(不暴露我的資料)、不擋 acr run(本機)。 diff --git a/.claude/hooks/pre-write-guard.sh b/.claude/hooks/pre-write-guard.sh index b37bfcf..e50cefe 100755 --- a/.claude/hooks/pre-write-guard.sh +++ b/.claude/hooks/pre-write-guard.sh @@ -174,6 +174,7 @@ if [[ "$FILE_PATH" == *".agents/specs/"* ]]; then ".agents/specs/u6u-platform-evolution" ".agents/specs/component-registry-canon" ".agents/specs/component-gatekeeping" # 2026-05-29 richblack 確認新建(Phase 3 把關) + ".agents/specs/data-exfil-warning" # 2026-05-30 richblack 確認新建(資料外流警示) ) IN_KNOWN=false for K in "${KNOWN_SDDS[@]}"; do diff --git a/cli/src/commands/push.ts b/cli/src/commands/push.ts index de64458..47c0c37 100644 --- a/cli/src/commands/push.ts +++ b/cli/src/commands/push.ts @@ -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 { +export async function cmdPush(filePath: string, options: { confirmExposure?: boolean; suppressWarning?: boolean } = {}): Promise { const config = loadConfig(); if (config.mode === 'local') { @@ -89,6 +90,21 @@ export async function cmdPush(filePath: string): Promise { 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 { graph, config: workflow.config ?? {}, description: workflow.description ?? '', + exposure_consent: consent ?? undefined, }), }); diff --git a/cli/src/commands/recipe.ts b/cli/src/commands/recipe.ts index 2e8d9ea..cc44823 100644 --- a/cli/src/commands/recipe.ts +++ b/cli/src/commands/recipe.ts @@ -32,7 +32,7 @@ interface RecipeDefinition { updated_at: number; } -export async function cmdRecipePush(filePath: string): Promise { +export async function cmdRecipePush(filePath: string, options: { confirmExposure?: boolean; suppressWarning?: boolean } = {}): Promise { const config = loadConfig(); if (!config.api_key) { @@ -65,6 +65,21 @@ export async function cmdRecipePush(filePath: string): Promise { } 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 { '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 }; diff --git a/cli/src/lib/config.ts b/cli/src/lib/config.ts index ba1bc59..4d25b36 100644 --- a/cli/src/lib/config.ts +++ b/cli/src/lib/config.ts @@ -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; } const CONFIG_DIR = join(homedir(), '.arcrun'); diff --git a/cli/src/lib/exposure-warning.ts b/cli/src/lib/exposure-warning.ts new file mode 100644 index 0000000..9509060 --- /dev/null +++ b/cli/src/lib/exposure-warning.ts @@ -0,0 +1,138 @@ +/** + * 資料外流警示 — CLI 互動(data-exfil-warning SDD §1a / B) + * + * 觸發策略:只在「資料變成可被外部呼叫」時警示(webhook 部署 / recipe push)。 + * 互動形式(richblack):仿 GCP 刪 project —— 要用戶打資源名證明讀了警示(比 y/n 硬,不用打一大串)。 + * 同意 = 法律憑證:回傳的 ExposureConsent 帶 understood(用戶打的內容)+ 時間,server 端 log。 + * 誠實限制:非 TTY(AI 直跑)無 --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(仍記 consent,understood 標明來源) */ + 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 { + 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:記偏好(此選擇也是 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 不該替人類確認暴露。 + 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(''); +} diff --git a/cypher-executor/src/lib/exposure-consent.ts b/cypher-executor/src/lib/exposure-consent.ts new file mode 100644 index 0000000..ba1d113 --- /dev/null +++ b/cypher-executor/src/lib/exposure-consent.ts @@ -0,0 +1,60 @@ +// 資料外流警示 — 同意憑證機制(data-exfil-warning SDD §7 法律憑證 + §1b API 層) +// +// 觸發策略(richblack):只在「資料變成可被外部呼叫」時要求同意(暴露面)。 +// webhook 部署(workflow 變對外 endpoint)、recipe push 都算。 +// +// 同意 = 法律憑證:留 log(誰、何時、同意了什麼),真出事時有「用戶明示知情同意」證據, +// 避免 arcrun 訴訟風險。「以後不要警示」(suppress_future)本身也 log。 +// +// 誠實限制:AI 能偽造 confirmed_by_human。本機制的價值是「法律歸責 + 可審」,不是技術防偽。 + +/** 暴露同意憑證(人類明示知情同意把某資源開放/送出) */ +export interface ExposureConsent { + confirmed_by_human: true; // 必須為 literal true + understood: string; // 人類說明「我知道這會把什麼開放給誰」(非空) + confirmed_at: string; // ISO timestamp + suppress_future?: boolean; // 「以後不要對此資源警示」(本選擇也 log) +} + +/** + * 判斷一個暴露動作是否已取得有效同意。 + * @param consent 本次請求帶的同意憑證 + * @param priorConsent 既有 record 裡存的同意(首次問、記住:§3) + * @returns null = 放行(已同意或已 suppress);string = 拒絕原因 + */ +export function checkExposureConsent( + consent: ExposureConsent | undefined, + priorConsent: ExposureConsent | undefined, +): string | null { + // 既有同意且選了「以後不警示」→ 放行(首次問記住) + if (priorConsent?.suppress_future) return null; + // 既有有效同意(同資源已確認過)→ 放行 + if (priorConsent?.confirmed_by_human === true) return null; + + // 本次請求帶了有效同意 → 放行 + if ( + consent?.confirmed_by_human === true && + typeof consent.understood === 'string' && + consent.understood.trim() !== '' + ) { + return null; + } + + return ( + '此動作會把資源變成可被外部呼叫(暴露/送出資料)。需人類明示同意。\n' + + '請用 CLI 互動確認(acr 會說明風險並提供保護選項),或帶 exposure_consent。\n' + + 'arcrun 可幫你保護:要求呼叫者帶 API Key / 設權限 / 限流。' + ); +} + +/** + * 正規化要存進 record 的同意憑證(法律憑證,可審)。 + * 優先用本次新同意,否則沿用既有。 + */ +export function resolveConsentForRecord( + consent: ExposureConsent | undefined, + priorConsent: ExposureConsent | undefined, +): ExposureConsent | undefined { + if (consent?.confirmed_by_human === true) return consent; + return priorConsent; +} diff --git a/cypher-executor/src/routes/recipes.ts b/cypher-executor/src/routes/recipes.ts index 667865c..511099f 100644 --- a/cypher-executor/src/routes/recipes.ts +++ b/cypher-executor/src/routes/recipes.ts @@ -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, }; diff --git a/cypher-executor/src/routes/webhooks-named.ts b/cypher-executor/src/routes/webhooks-named.ts index 1d3e92f..a71eb73 100644 --- a/cypher-executor/src/routes/webhooks-named.ts +++ b/cypher-executor/src/routes/webhooks-named.ts @@ -28,6 +28,8 @@ import { writeExecutionVerdict } from '../actions/execution-logger'; import type { GraphNode } from '../types'; import { extractCronExpr } from '../lib/cron-match'; import { recordTelemetry } from '../lib/telemetry'; +import { checkExposureConsent, resolveConsentForRecord } from '../lib/exposure-consent'; +import type { ExposureConsent } from '../lib/exposure-consent'; export const webhooksNamedRouter = new Hono<{ Bindings: Bindings }>(); @@ -40,6 +42,9 @@ type NamedWorkflowRecord = { // 若首節點是 cron 零件,extract cron_expr 存進來供 scheduled() 比對 // 對應 SDD: arcrun.md 三-A P1 #3 cron_expr?: string; + // 資料外流警示:部署 webhook = 把 workflow 變對外可呼叫 endpoint(暴露面)。 + // 存人類明示同意憑證(法律憑證,可審)。SDD: data-exfil-warning §7 + exposure_consent?: ExposureConsent; }; function kvKey(apiKey: string, name: string): string { @@ -63,6 +68,7 @@ webhooksNamedRouter.post('/webhooks/named', async (c) => { graph?: Record; config?: Record; description?: string; + exposure_consent?: ExposureConsent; } | null; if (!body?.name || !body.graph) { @@ -74,6 +80,15 @@ webhooksNamedRouter.post('/webhooks/named', async (c) => { return c.json({ error: 'workflow name 只能包含英文字母、數字、底線和連字號' }, 400); } + // 資料外流警示:部署 webhook = 把 workflow 變對外可呼叫 endpoint(暴露面)。 + // 首次部署某 workflow 需人類明示同意;已同意(含 suppress_future)則放行(§3 首次問記住)。 + const priorRaw = await c.env.WEBHOOKS.get(kvKey(apiKey, name)); + const priorRecord = priorRaw ? (JSON.parse(priorRaw) as NamedWorkflowRecord) : null; + const consentError = checkExposureConsent(body.exposure_consent, priorRecord?.exposure_consent); + if (consentError !== null) { + return c.json({ error: consentError, requires: 'exposure_consent' }, 403); + } + // 偵測首節點是 cron 零件 → 抽 cron_expr 存進 record + 建輕量 index 給 scheduled() const cronExpr = extractCronExpr(body.graph); @@ -84,6 +99,8 @@ webhooksNamedRouter.post('/webhooks/named', async (c) => { description: typeof body.description === 'string' ? body.description : '', created_at: new Date().toISOString(), cron_expr: cronExpr ?? undefined, + // 法律憑證:存人類明示同意(本次新同意或沿用既有) + exposure_consent: resolveConsentForRecord(body.exposure_consent, priorRecord?.exposure_consent), }; const start = Date.now();