- CapabilityRegistry: descriptor(id/version/schema/scope/risk/idempotent/preview/replay), invoke = scope limit -> input schema -> server-side authorize -> idempotency key -> handler -> output schema -> audit; capability id <-> tool name mapping (download.submit <-> download__submit) - PiAgentRuntime (pi-agent-core AgentHarness 0.87.1): whitelist tools + before_tool re-check, redacted event stream, JSONL session at sessionDir/<runId>.jsonl, resume/abort/compact, tool-call/duration/token/cost budgets -> abort + run_failed(budget) - AgentSettings -> pi-ai createProvider (anthropic / openai-compatible), compaction mapping - FakeModelProvider + createFakeRuntime (scripted, transcript-derived, survives crash/resume) - buildSystemPrompt, redactForModel; 27 tests incl. SIGKILL + resume Co-Authored-By: Claude Fable 5.1 <[email protected]>
84 lines
3.5 KiB
TypeScript
84 lines
3.5 KiB
TypeScript
/**
|
|
* 敏感信息守卫:工具输入输出进入模型上下文、事件流、审计前统一经过这里。
|
|
* 覆盖 cookie / passkey / token / apiKey / authorization / 含 passkey 等私密参数的 URL / URL 内嵌账号密码。
|
|
* 宁可多遮不可漏遮:误伤只影响可读性,漏遮会把凭证送给外部模型。
|
|
*/
|
|
export const REDACTED = "[REDACTED]";
|
|
|
|
const SENSITIVE_KEY_PARTS = [
|
|
"cookie",
|
|
"passkey",
|
|
"authkey",
|
|
"torrentpass",
|
|
"rsskey",
|
|
"apikey",
|
|
"authorization",
|
|
"secret",
|
|
"password",
|
|
"passwd",
|
|
"credential",
|
|
"privatekey",
|
|
];
|
|
|
|
/** 规范化键名(去掉 - _ 并小写)后判断。以 token 结尾算敏感,tokens(用量统计)不算。 */
|
|
export function isSensitiveKey(key: string): boolean {
|
|
const k = key.toLowerCase().replace(/[-_\s]/g, "");
|
|
if (k.endsWith("token")) return true;
|
|
if (k === "sign" || k === "signature") return true;
|
|
return SENSITIVE_KEY_PARTS.some((part) => k.includes(part));
|
|
}
|
|
|
|
/** URL 查询参数/片段中视为凭证的参数名 */
|
|
const SENSITIVE_PARAM =
|
|
"passkey|authkey|torrent_pass|rsskey|api[_-]?key|access[_-]?token|refresh[_-]?token|token|auth|sign|signature|credential|secret|password|key|uid_key|downhash";
|
|
const PARAM_RE = new RegExp(`([?&;#]|\\b)(${SENSITIVE_PARAM})=([^&;#\\s"'<>]+)`, "gi");
|
|
const URL_USERINFO_RE = /\b([a-z][a-z0-9+.-]*:\/\/)([^/\s:@]+):([^/\s@]+)@/gi;
|
|
const BEARER_RE = /\b(Bearer|Basic|Token)\s+[A-Za-z0-9._~+/=-]{6,}/g;
|
|
const HEADER_RE = /\b(cookie|set-cookie|authorization|x-api-key)\s*:\s*[^\r\n]+/gi;
|
|
const KNOWN_KEY_RE = /\b(sk-[A-Za-z0-9_-]{8,}|sk-ant-[A-Za-z0-9_-]{8,}|gh[pousr]_[A-Za-z0-9]{16,})\b/g;
|
|
|
|
export function redactString(input: string): string {
|
|
return input
|
|
.replace(HEADER_RE, (_m, name: string) => `${name}: ${REDACTED}`)
|
|
.replace(URL_USERINFO_RE, (_m, scheme: string) => `${scheme}${REDACTED}@`)
|
|
.replace(PARAM_RE, (_m, lead: string, name: string) => `${lead}${name}=${REDACTED}`)
|
|
.replace(BEARER_RE, (_m, scheme: string) => `${scheme} ${REDACTED}`)
|
|
.replace(KNOWN_KEY_RE, REDACTED);
|
|
}
|
|
|
|
/** 深度脱敏,返回新对象;不修改入参。循环引用替换为 "[Circular]"。 */
|
|
export function redactForModel<T>(value: T): T {
|
|
return redactValue(value, new WeakSet()) as T;
|
|
}
|
|
|
|
function redactValue(value: unknown, seen: WeakSet<object>): unknown {
|
|
if (typeof value === "string") return redactString(value);
|
|
if (value === null || typeof value !== "object") return value;
|
|
if (value instanceof Date) return value;
|
|
if (value instanceof URL) return redactString(value.toString());
|
|
if (seen.has(value)) return "[Circular]";
|
|
seen.add(value);
|
|
if (Array.isArray(value)) return value.map((item) => redactValue(item, seen));
|
|
if (value instanceof Map) {
|
|
return Object.fromEntries(
|
|
[...value.entries()].map(([k, v]) => [String(k), isSensitiveKey(String(k)) ? REDACTED : redactValue(v, seen)]),
|
|
);
|
|
}
|
|
const out: Record<string, unknown> = {};
|
|
for (const [k, v] of Object.entries(value)) {
|
|
out[k] = isSensitiveKey(k) && v !== null && v !== undefined && v !== "" ? REDACTED : redactValue(v, seen);
|
|
}
|
|
return out;
|
|
}
|
|
|
|
/** 事件/审计用的短摘要:先脱敏再截断 */
|
|
export function summarizeForLog(value: unknown, maxLength = 500): string {
|
|
let text: string;
|
|
try {
|
|
text = typeof value === "string" ? redactString(value) : (JSON.stringify(redactForModel(value)) ?? "undefined");
|
|
} catch {
|
|
text = "[unserializable]";
|
|
}
|
|
return text.length > maxLength ? `${text.slice(0, maxLength)}…(${text.length - maxLength} more)` : text;
|
|
}
|