3e92d4acf6
新 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>
260 lines
9.4 KiB
TypeScript
260 lines
9.4 KiB
TypeScript
/**
|
||
* /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 recipe(auth_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_id,e.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 recipe(KV 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 });
|
||
});
|