Files
Arcrun/cypher-executor/src/routes/recipes.ts
T
Leo 3e92d4acf6 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>
2026-05-30 14:45:43 +08:00

260 lines
9.4 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* /recipes — API recipe CRUD
*
* recipe 是「http_request + 參數模板」的具名封裝。
* 不需要 deploy Worker,執行時由 cypher-executor 直接 fetch。
*
* KV 結構:
* recipe:{canonical_id} → RecipeDefinition JSON
* idx:{rec_hash} → canonical_id (反查索引)
*
* 引用方式(workflow config):
* component: "rec_f7e2a1b3" → 永久穩定,不受改名影響
* component: "slack" → 向前兼容,直接用 canonical_id 查
*/
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 }>();
export interface RecipeDefinition {
canonical_id: string;
hash_id: string; // rec_xxxxxxxx
display_name?: string;
description?: string;
endpoint: string;
method?: string; // GET | POST | PUT | PATCH | DELETE,預設 POST
headers?: Record<string, string>;
body?: Record<string, unknown>;
/**
* 此 recipe 要用哪個 auth recipeauth_recipe:{auth_service})。
* 讓多個 recipe 共用同一把 auth(例:kbdb_get / kbdb_create_block 都設 "kbdb")。
* 未設時 auth-dispatcher fallback 到把 canonical_id 當 service name(向後相容)。
*/
auth_service?: string;
credentials_required?: Array<{
key: string;
inject_as: string;
}>;
// 資料外流警示:recipe 定義一個資料去向(endpoint)。push 需人類明示同意(法律憑證)。
// SDD: data-exfil-warning §7(公私一視同仁)
exposure_consent?: ExposureConsent;
created_at: number;
updated_at: number;
}
// POST /recipes — 新增或更新 recipe
recipesRouter.post('/recipes', async (c) => {
let body: Partial<RecipeDefinition>;
try {
body = await c.req.json();
} catch {
return c.json({ success: false, error: 'request body 必須為 JSON' }, 400);
}
const canonicalId = (body.canonical_id ?? '').trim().toLowerCase();
if (!canonicalId) return c.json({ success: false, error: 'canonical_id 必填' }, 400);
if (!body.endpoint) return c.json({ success: false, error: 'endpoint 必填' }, 400);
const hashId = await deriveRecipeHash(canonicalId);
const now = Date.now();
// 讀取現有版本(保留 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,
display_name: body.display_name,
description: body.description,
endpoint: body.endpoint,
method: (body.method ?? 'POST').toUpperCase(),
headers: body.headers,
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,
};
// 寫入兩個 KV key
await Promise.all([
c.env.RECIPES.put(`recipe:${canonicalId}`, JSON.stringify(recipe)),
c.env.RECIPES.put(`idx:${hashId}`, canonicalId),
]);
return c.json({ success: true, recipe });
});
// GET /recipes/:id — 讀取 recipe(支援 canonical_id 或 rec_hash
recipesRouter.get('/recipes/:id', async (c) => {
const id = c.req.param('id');
const recipe = await resolveRecipe(id, c.env.RECIPES);
if (!recipe) return c.json({ success: false, error: `找不到 recipe: ${id}` }, 404);
return c.json({ success: true, recipe });
});
// GET /recipes — 列出所有 recipe
recipesRouter.get('/recipes', async (c) => {
const list = await c.env.RECIPES.list({ prefix: 'recipe:' });
const recipes = await Promise.all(
list.keys.map(k => c.env.RECIPES.get(k.name, 'json'))
);
return c.json({ success: true, recipes: recipes.filter(Boolean), count: recipes.length });
});
// DELETE /recipes/:id — 刪除 recipe
recipesRouter.delete('/recipes/:id', async (c) => {
const id = c.req.param('id');
const recipe = await resolveRecipe(id, c.env.RECIPES);
if (!recipe) return c.json({ success: false, error: `找不到 recipe: ${id}` }, 404);
await Promise.all([
c.env.RECIPES.delete(`recipe:${recipe.canonical_id}`),
c.env.RECIPES.delete(`idx:${recipe.hash_id}`),
]);
return c.json({ success: true, deleted: recipe.canonical_id });
});
/** 用 canonical_id 或 rec_hash 查 recipe */
export async function resolveRecipe(
id: string,
kv: KVNamespace,
): Promise<RecipeDefinition | null> {
// rec_xxxxxxxx → 先查 idx 反查 canonical_id
if (id.startsWith('rec_')) {
const canonicalId = await kv.get(`idx:${id}`);
if (!canonicalId) return null;
return kv.get(`recipe:${canonicalId}`, 'json');
}
// 直接用 canonical_id
return kv.get(`recipe:${id}`, 'json');
}
// ── Auth Recipe ────────────────────────────────────────────────────────────────
export type AuthPrimitive = 'static_key' | 'oauth2' | 'service_account' | 'mtls';
export interface SecretRequirement {
key: string; // CREDENTIALS_KV 的名稱(e.g. "notion_token"
label: string; // CLI/UI 顯示(e.g. "Internal Integration Token"
type?: 'string' | 'json_blob'; // default: string
help?: string;
help_url?: string;
optional?: boolean;
}
export interface AuthInjectSpec {
header?: Record<string, string>; // e.g. { Authorization: "Bearer {{secret.token}}" }
query?: Record<string, string>;
body?: Record<string, string>;
}
export interface AuthRecipeDefinition {
kind: 'auth_recipe';
service: string; // canonical_ide.g. "notion"
version: number;
primitive: AuthPrimitive;
base_url: string;
display_name?: string;
description?: string;
// service_account 專用
service_account_kind?: 'google_jwt';
token_exchange?: {
endpoint: string;
scopes: string[];
};
required_secrets: SecretRequirement[];
inject: AuthInjectSpec;
created_at: number;
updated_at: number;
}
/** 查 auth recipeKV key: auth_recipe:{service}*/
export async function resolveAuthRecipe(
service: string,
kv: KVNamespace,
): Promise<AuthRecipeDefinition | null> {
return kv.get(`auth_recipe:${service}`, 'json');
}
// POST /auth-recipes — 新增或更新 auth recipe
recipesRouter.post('/auth-recipes', async (c) => {
let body: Partial<AuthRecipeDefinition>;
try {
body = await c.req.json();
} catch {
return c.json({ success: false, error: 'request body 必須為 JSON' }, 400);
}
const service = (body.service ?? '').trim().toLowerCase();
if (!service) return c.json({ success: false, error: 'service 必填' }, 400);
if (!body.primitive) return c.json({ success: false, error: 'primitive 必填' }, 400);
if (!body.base_url) return c.json({ success: false, error: 'base_url 必填' }, 400);
if (!body.required_secrets?.length) return c.json({ success: false, error: 'required_secrets 必填' }, 400);
if (!body.inject) return c.json({ success: false, error: 'inject 必填' }, 400);
const now = Date.now();
const existing = await c.env.RECIPES.get(`auth_recipe:${service}`, 'json') as AuthRecipeDefinition | null;
const recipe: AuthRecipeDefinition = {
kind: 'auth_recipe',
service,
version: body.version ?? 1,
primitive: body.primitive,
base_url: body.base_url,
display_name: body.display_name,
description: body.description,
service_account_kind: body.service_account_kind,
token_exchange: body.token_exchange,
required_secrets: body.required_secrets,
inject: body.inject,
created_at: existing?.created_at ?? now,
updated_at: now,
};
await c.env.RECIPES.put(`auth_recipe:${service}`, JSON.stringify(recipe));
return c.json({ success: true, recipe });
});
// GET /auth-recipes — 列出所有 auth recipe
recipesRouter.get('/auth-recipes', async (c) => {
const list = await c.env.RECIPES.list({ prefix: 'auth_recipe:' });
const recipes = await Promise.all(
list.keys.map(k => c.env.RECIPES.get(k.name, 'json'))
);
return c.json({ success: true, recipes: recipes.filter(Boolean), count: recipes.length });
});
// GET /auth-recipes/:service — 讀取單一 auth recipe
recipesRouter.get('/auth-recipes/:service', async (c) => {
const service = c.req.param('service');
const recipe = await resolveAuthRecipe(service, c.env.RECIPES);
if (!recipe) return c.json({ success: false, error: `找不到 auth recipe: ${service}` }, 404);
return c.json({ success: true, recipe });
});
// DELETE /auth-recipes/:service — 刪除 auth recipe
recipesRouter.delete('/auth-recipes/:service', async (c) => {
const service = c.req.param('service');
const recipe = await resolveAuthRecipe(service, c.env.RECIPES);
if (!recipe) return c.json({ success: false, error: `找不到 auth recipe: ${service}` }, 404);
await c.env.RECIPES.delete(`auth_recipe:${service}`);
return c.json({ success: true, deleted: service });
});