feat(exposure): 完全移除 acr push 暴露 consent 閘 (Arcrun#13 P1)
leo 2026-06-29 拍板:arcrun 是給 AI 用的系統,push/暴露不再需要人類確認。 - 刪 cypher-executor/src/lib/exposure-consent.ts(server 閘,MCP push 的真正擋點) - 刪 cli/src/lib/exposure-warning.ts(CLI 互動 + 非 TTY 拒絕) - recipes.ts / webhooks-named.ts:移除 checkExposureConsent 403 閘,直接放行 - recipe.ts / push.ts:移除 obtainExposureConsent 呼叫,不再 prompt/拒絕 - init-seed / seed-api-recipes:移除種子層級 consent - exposure_consent 欄位降為向後相容(讀舊 record 不報錯,不再寫入/檢查) 不補審計線索、不做替代防護(leo:先拿掉,出問題再設置)。 tsc 全綠(cypher-executor + cli)。 Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -11,7 +11,6 @@ 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> {
|
||||
const config = loadConfig();
|
||||
@@ -96,17 +95,8 @@ 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`,
|
||||
});
|
||||
if (!consent) {
|
||||
process.exit(1);
|
||||
}
|
||||
// 暴露 consent 閘已移除(leo 2026-06-29,Arcrun#13):arcrun 是給 AI 用的系統,
|
||||
// push/暴露不再需要人類確認,AI/MCP 隨時可部署。暴露風險由用戶自負(同 n8n 建 webhook)。
|
||||
|
||||
// POST 至 /webhooks/named
|
||||
const deploySpinner = ora(`部署 "${workflow.name}" 至 ${executorUrl}`).start();
|
||||
@@ -119,7 +109,6 @@ export async function cmdPush(filePath: string): Promise<void> {
|
||||
graph,
|
||||
config: workflow.config ?? {},
|
||||
description: workflow.description ?? '',
|
||||
exposure_consent: consent ?? undefined,
|
||||
}),
|
||||
});
|
||||
|
||||
|
||||
@@ -7,7 +7,6 @@ import chalk from 'chalk';
|
||||
import ora from 'ora';
|
||||
import { readFileSync, existsSync } from 'node:fs';
|
||||
import { loadConfig, getCypherExecutorUrl, DEFAULT_PUBLIC_LIBRARY_URL } from '../lib/config.js';
|
||||
import { obtainExposureConsent } from '../lib/exposure-warning.js';
|
||||
import yaml from 'js-yaml';
|
||||
|
||||
interface RecipeYaml {
|
||||
@@ -70,16 +69,7 @@ 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,
|
||||
});
|
||||
if (!consent) {
|
||||
process.exit(1);
|
||||
}
|
||||
// 暴露 consent 閘已移除(leo 2026-06-29,Arcrun#13):recipe push 不再需要人類確認。
|
||||
|
||||
const spinner = ora(`上傳 recipe "${recipe.canonical_id}"`).start();
|
||||
|
||||
@@ -90,7 +80,7 @@ export async function cmdRecipePush(filePath: string): Promise<void> {
|
||||
'Content-Type': 'application/json',
|
||||
'X-Arcrun-API-Key': config.api_key,
|
||||
},
|
||||
body: JSON.stringify({ ...recipe, exposure_consent: consent ?? undefined }),
|
||||
body: JSON.stringify(recipe),
|
||||
});
|
||||
|
||||
const data = await res.json() as { success: boolean; recipe?: RecipeDefinition; error?: string };
|
||||
@@ -307,7 +297,7 @@ export async function cmdRecipePull(canonicalId: string, author?: string): Promi
|
||||
return;
|
||||
}
|
||||
|
||||
// 2. 寫進自己私庫(POST /recipes,帶 derived_from 溯源 + 種子級同意:pull 公庫公共資料非新暴露)。
|
||||
// 2. 寫進自己私庫(POST /recipes,帶 derived_from 溯源)。
|
||||
const r = pub.recipe;
|
||||
const executorUrl = getCypherExecutorUrl(config);
|
||||
const installRes = await fetch(`${executorUrl}/recipes`, {
|
||||
@@ -316,11 +306,6 @@ export async function cmdRecipePull(canonicalId: string, author?: string): Promi
|
||||
body: JSON.stringify({
|
||||
...r,
|
||||
derived_from: r.uuid, // 溯源:私庫這份來自公庫哪個 uuid
|
||||
exposure_consent: {
|
||||
confirmed_by_human: true,
|
||||
understood: `pull from public library: ${canonicalId}`,
|
||||
confirmed_at: new Date().toISOString(),
|
||||
},
|
||||
}),
|
||||
});
|
||||
const inst = await installRes.json() as { success: boolean; recipe?: RecipeDefinition; error?: string };
|
||||
@@ -355,13 +340,7 @@ export async function cmdRecipeSubmitP(canonicalId: string, author?: string): Pr
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// 2. 投稿到公庫 = 暴露面 → 取得人類明示同意(mindset §6)。
|
||||
const consent = await obtainExposureConsent({
|
||||
kind: 'recipe',
|
||||
resourceName: canonicalId,
|
||||
destination: `公庫(${DEFAULT_PUBLIC_LIBRARY_URL})`,
|
||||
});
|
||||
if (!consent) process.exit(1);
|
||||
// 暴露 consent 閘已移除(leo 2026-06-29,Arcrun#13):投稿公庫不再需要人類確認。
|
||||
|
||||
const spinner = ora(`投稿 recipe「${canonicalId}」到公庫`).start();
|
||||
try {
|
||||
@@ -373,7 +352,6 @@ export async function cmdRecipeSubmitP(canonicalId: string, author?: string): Pr
|
||||
author: author ?? my.recipe.author,
|
||||
derived_from: my.recipe.derived_from ?? my.recipe.uuid,
|
||||
submitter: author ?? config.api_key,
|
||||
exposure_consent: consent,
|
||||
}),
|
||||
});
|
||||
const data = await res.json() as { success: boolean; recipe?: { uuid?: string; author?: string }; error?: string };
|
||||
|
||||
@@ -34,9 +34,8 @@ export interface ArcrunConfig {
|
||||
// 未設/false → base 維持 LIKE keyword(free-tier 友善,不建 index、不花費)。
|
||||
// 開法:設 kbdb_embed:true → redeploy(acr update)。「CC 幫開」=CC 寫此欄 true + 跑 acr update。
|
||||
kbdb_embed?: boolean;
|
||||
// 資料外流警示:本機記住「已同意暴露 / 選擇不再警示」的資源,避免每次 push 重問(§3 首次問記住)。
|
||||
// key 格式:`{kind}:{resourceName}`(如 "webhook:contacts_lookup" / "recipe:kbdb_get")。
|
||||
// 注意:這只是 CLI 端 UX(不重問);server 端獨立存法律憑證並強制(防 CLI 被繞過)。
|
||||
// 暴露 consent 閘已移除(leo 2026-06-29,Arcrun#13)。此欄位保留只為向後相容舊 config.yaml
|
||||
// (讀到不報錯,不再寫入/檢查)。
|
||||
exposure_consented?: Record<string, { confirmed_at: string; suppress_future?: boolean }>;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,127 +0,0 @@
|
||||
/**
|
||||
* 資料外流警示 — 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;
|
||||
}
|
||||
|
||||
// 註(2026-05-30 信任修正):移除 --confirm-exposure / --suppress-warning 旗標。
|
||||
// 理由:arcrun 是 AI 的工具,AI 自己能加旗標 = 自己批准自己 = 閘門虛設(違 DECISIONS §7)。
|
||||
// 唯一通過 = 人類在 TTY 互動輸入資源名(AI 非互動環境生不出)。「以後不再問」改成互動中詢問。
|
||||
export interface ExposureWarningOptions {
|
||||
// 預留:未來 CI 用「人類預先簽的 token」(非 AI 能生的 flag)。第一期不做。
|
||||
_reserved?: never;
|
||||
}
|
||||
|
||||
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,
|
||||
};
|
||||
}
|
||||
|
||||
// 非 TTY(AI 直跑)→ 一律拒絕,無捷徑。AI 不該、也不能替人類確認暴露。
|
||||
// (移除了 --confirm-exposure 旗標:那是 AI 自己能加的後門,等於自己批准自己。)
|
||||
if (!process.stdin.isTTY) {
|
||||
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 {
|
||||
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;
|
||||
}
|
||||
// 互動中詢問「以後不再問」(人類選,不是 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})` : ''}${suppress ? ';並選擇以後不再提醒' : ''}`,
|
||||
confirmed_at: nowIso,
|
||||
suppress_future: suppress,
|
||||
};
|
||||
} 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('');
|
||||
}
|
||||
Reference in New Issue
Block a user