@lqc123qwe/car-runtime 1.0.0-rc.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.github/workflows/ci.yml +12 -0
- package/.github/workflows/release.yml +21 -0
- package/README.md +122 -0
- package/adr/ADR-001.md +4 -0
- package/adr/ADR-002.md +5 -0
- package/adr/ADR-003.md +35 -0
- package/adr/host-onboarding.md +68 -0
- package/adr/mlps-audit-checklist.md +33 -0
- package/adr/win32-spike.md +45 -0
- package/package.json +25 -0
- package/scripts/ptc-baseline/run-baseline.ts +118 -0
- package/scripts/release-pipeline.ts +189 -0
- package/scripts/win32-koffi-spike.ts +34 -0
- package/src/authz/authz.ts +96 -0
- package/src/cli.ts +122 -0
- package/src/governance/dump.ts +41 -0
- package/src/host/facade.ts +99 -0
- package/src/host/hostGateway.ts +98 -0
- package/src/host/mappings.ts +74 -0
- package/src/host/stdio.ts +74 -0
- package/src/kernel/context.ts +208 -0
- package/src/kernel/events.ts +99 -0
- package/src/load/loader.ts +165 -0
- package/src/load/registry.ts +88 -0
- package/src/load/verifier.ts +67 -0
- package/src/loop/goal.ts +53 -0
- package/src/loop/recover.ts +47 -0
- package/src/loop/stop.ts +200 -0
- package/src/mcp/gateway.ts +118 -0
- package/src/ptc/budget.ts +37 -0
- package/src/ptc/erasable.ts +28 -0
- package/src/ptc/runCode.ts +127 -0
- package/src/ptc/sdk.ts +28 -0
- package/src/ptc/worker-entry.ts +67 -0
- package/src/sandbox/sandbox.ts +199 -0
- package/src/security/secrets.ts +111 -0
- package/src/session/export.ts +110 -0
- package/src/session/log.ts +120 -0
- package/src/telemetry/metrics.ts +38 -0
- package/test/kernel.spec.ts +136 -0
- package/test/s11.spec.ts +129 -0
- package/test/s12.spec.ts +135 -0
- package/test/s13.spec.ts +117 -0
- package/test/s14.spec.ts +117 -0
- package/test/s15.spec.ts +80 -0
- package/test/s17.spec.ts +102 -0
- package/test/s18.spec.ts +72 -0
- package/test/s2.spec.ts +245 -0
- package/test/s3.spec.ts +179 -0
- package/test/s4.spec.ts +169 -0
- package/test/s5.spec.ts +95 -0
- package/test/s6.spec.ts +169 -0
- package/test/s7.spec.ts +135 -0
- package/test/s9.spec.ts +89 -0
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* F12 · secrets 检测(完整版,M2-S7 实装加载期;运行期输出期挂点预留)
|
|
3
|
+
*
|
|
4
|
+
* 口径(M2 安全设计增补 T-6 + D-3 裁决):
|
|
5
|
+
* - 模式库 6 类(api-key 前缀族 / 私钥块 / 连接串 / 云凭据 / token-Bearer / 自定义正则);
|
|
6
|
+
* - 三层检测降误报:L1 精确前缀(Aho-Corasick 等价的朴素扫描,规模小不构成瓶颈)
|
|
7
|
+
* → L2 Shannon 熵(≥3.5 才升级候选,排除 'aaaa' 类低熵误报)
|
|
8
|
+
* → L3 上下文打分(键名共现 +1、代码引号内 +0、注释 -1;总分 ≥2 判命中);
|
|
9
|
+
* - 可验证交付(D-3):漏报率 <1.5%、FPR ≤5%、precision ≥80%——基准集 1:9 正负比、CI 标定;
|
|
10
|
+
* - 检测不可静默关闭:disable 仅允许显式配置且留痕(本层不提供总开关,红线 §6.3)。
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
export type SecretCategory = 'api-key' | 'private-key' | 'connection-string' | 'cloud-credential' | 'bearer-token' | 'custom'
|
|
14
|
+
|
|
15
|
+
export interface SecretPattern { category: SecretCategory; name: string; re: RegExp; /** 键名共现提示 */ keyHints?: string[] }
|
|
16
|
+
|
|
17
|
+
/** 6 类模式库(v1:30 条精选;自定义正则经 manifest 注入需过安全评审,M3) */
|
|
18
|
+
export const PATTERN_LIBRARY: SecretPattern[] = [
|
|
19
|
+
// ── api-key 前缀族 ──
|
|
20
|
+
{ category: 'api-key', name: 'OpenAI', re: /sk-[A-Za-z0-9_-]{20,}/g, keyHints: ['openai', 'OPENAI_API_KEY'] },
|
|
21
|
+
{ category: 'api-key', name: 'Anthropic', re: /sk-ant-[A-Za-z0-9_-]{20,}/g, keyHints: ['anthropic'] },
|
|
22
|
+
{ category: 'api-key', name: 'GitHub PAT', re: /gh[pousr]_[A-Za-z0-9]{30,}/g, keyHints: ['github', 'GITHUB_TOKEN'] },
|
|
23
|
+
{ category: 'api-key', name: 'Slack', re: /xox[baprs]-[A-Za-z0-9-]{10,}/g, keyHints: ['slack'] },
|
|
24
|
+
{ category: 'api-key', name: 'AWS Access Key', re: /AKIA[0-9A-Z]{16}/g, keyHints: ['aws'] },
|
|
25
|
+
{ category: 'api-key', name: 'Stripe', re: /[sr]k_(live|test)_[A-Za-z0-9]{20,}/g, keyHints: ['stripe'] },
|
|
26
|
+
{ category: 'api-key', name: 'npm', re: /npm_[A-Za-z0-9]{30,}/g, keyHints: ['npm_token'] },
|
|
27
|
+
// ── 私钥块 ──
|
|
28
|
+
{ category: 'private-key', name: 'PEM RSA/EC', re: /-----BEGIN (RSA |EC |OPENSSH |DSA )?PRIVATE KEY-----/g },
|
|
29
|
+
{ category: 'private-key', name: 'PKCS8', re: /-----BEGIN PRIVATE KEY-----/g },
|
|
30
|
+
{ category: 'private-key', name: 'PuTTY PPK', re: /PuTTY-User-Key-File-2:/g },
|
|
31
|
+
// ── 连接串 ──
|
|
32
|
+
{ category: 'connection-string', name: 'postgres', re: /postgres(ql)?:\/\/[^:\s"']+:[^@\s"']+@[^\s"']+/g, keyHints: ['database_url'] },
|
|
33
|
+
{ category: 'connection-string', name: 'mysql', re: /mysql:\/\/[^:\s"']+:[^@\s"']+@[^\s"']+/g },
|
|
34
|
+
{ category: 'connection-string', name: 'mongodb', re: /mongodb(\+srv)?:\/\/[^:\s"']+:[^@\s"']+@[^\s"']+/g, keyHints: ['mongo'] },
|
|
35
|
+
{ category: 'connection-string', name: 'redis', re: /redis:\/\/:[^@\s"']+@[^\s"']+/g },
|
|
36
|
+
{ category: 'connection-string', name: 'jdbc', re: /jdbc:[a-z0-9]+:\/\/[^:\s"']+:[^@\s"']+@/g },
|
|
37
|
+
// ── 云凭据 ──
|
|
38
|
+
{ category: 'cloud-credential', name: 'AWS Secret', re: /aws(.{0,20})?(secret|secretaccesskey)(.{0,10})?[':= ]+[A-Za-z0-9/+=]{40}/gi },
|
|
39
|
+
{ category: 'cloud-credential', name: 'GCP service account', re: /"type":\s*"service_account"/g },
|
|
40
|
+
{ category: 'cloud-credential', name: 'Azure conn', re: /AccountKey=[A-Za-z0-9+/=]{60,}/g },
|
|
41
|
+
{ category: 'cloud-credential', name: 'JWT', re: /eyJ[A-Za-z0-9_-]{10,}\.eyJ[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}/g, keyHints: ['token', 'jwt'] },
|
|
42
|
+
// ── token-Bearer ──
|
|
43
|
+
{ category: 'bearer-token', name: 'Authorization header', re: /Authorization['":= ]+Bearer\s+[A-Za-z0-9._-]{15,}/gi, keyHints: ['authorization'] },
|
|
44
|
+
{ category: 'bearer-token', name: 'api_key kv', re: /['"]?api[_-]?key['"]?\s*[:=]\s*['"][A-Za-z0-9._-]{16,}['"]/gi, keyHints: ['api_key'] },
|
|
45
|
+
{ category: 'bearer-token', name: 'secret kv', re: /['"]?(client[_-]?secret|access[_-]?token)['"]?\s*[:=]\s*['"][A-Za-z0-9._-]{12,}['"]/gi },
|
|
46
|
+
{ category: 'bearer-token', name: 'password kv', re: /['"]?password['"]?\s*[:=]\s*['"][^\s'"]{8,}['"]/gi },
|
|
47
|
+
]
|
|
48
|
+
|
|
49
|
+
/** Shannon 熵(bit/char) */
|
|
50
|
+
export function shannonEntropy(s: string): number {
|
|
51
|
+
if (!s.length) return 0
|
|
52
|
+
const freq = new Map<string, number>()
|
|
53
|
+
for (const ch of s) freq.set(ch, (freq.get(ch) ?? 0) + 1)
|
|
54
|
+
let h = 0
|
|
55
|
+
for (const n of freq.values()) { const p = n / s.length; h -= p * Math.log2(p) }
|
|
56
|
+
return h
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export interface ScanHit { category: SecretCategory; name: string; start: number; end: number; /** 命中摘要(全遮蔽,保留首尾 ≤4) */ masked: string; score: number }
|
|
60
|
+
|
|
61
|
+
const ENTROPY_FLOOR = 3.5
|
|
62
|
+
|
|
63
|
+
function maskToken(t: string): string {
|
|
64
|
+
if (t.length <= 8) return '*'.repeat(t.length)
|
|
65
|
+
return t.slice(0, 4) + '****' + t.slice(-4)
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* 三层检测:前缀命中 → 熵过滤 → 上下文打分。
|
|
70
|
+
* 返回确认命中(score ≥2);低分候选不返回但可通过 returnCandidates 观察(标定用)。
|
|
71
|
+
*/
|
|
72
|
+
export function scanSecrets(text: string, opts: { returnCandidates?: boolean } = {}): { hits: ScanHit[]; candidates: ScanHit[] } {
|
|
73
|
+
const hits: ScanHit[] = []
|
|
74
|
+
const candidates: ScanHit[] = []
|
|
75
|
+
for (const p of PATTERN_LIBRARY) {
|
|
76
|
+
p.re.lastIndex = 0
|
|
77
|
+
let m: RegExpExecArray | null
|
|
78
|
+
while ((m = p.re.exec(text)) !== null) {
|
|
79
|
+
const token = m[0]
|
|
80
|
+
// L2 熵过滤:私钥块/连接串结构化模式豁免熵检查;其余需 ≥3.5
|
|
81
|
+
const structured = p.category === 'private-key' || p.category === 'connection-string' || (p.category === 'cloud-credential' && p.name === 'GCP service account')
|
|
82
|
+
const ent = shannonEntropy(token)
|
|
83
|
+
if (!structured && ent < ENTROPY_FLOOR) continue
|
|
84
|
+
// L3 上下文打分:L1 前缀 + L2 熵双过 = 高精度(基础 2);上下文仅加成/降级
|
|
85
|
+
let score = 2
|
|
86
|
+
const before = text.slice(Math.max(0, m.index - 40), m.index).toLowerCase()
|
|
87
|
+
if (p.keyHints?.some(h => before.includes(h.toLowerCase()))) score += 1
|
|
88
|
+
if (/\b(const|let|var|return|echo|export)\b/.test(before)) score += 1 // 赋值/输出语境
|
|
89
|
+
if (/^\s*(\/\/|#|\*)/.test(text.slice(0, m.index).split('\n').pop() ?? '')) score -= 1 // 注释行降级
|
|
90
|
+
const hit: ScanHit = { category: p.category, name: p.name, start: m.index, end: m.index + token.length, masked: maskToken(token), score }
|
|
91
|
+
if (score >= 2) hits.push(hit)
|
|
92
|
+
else if (opts.returnCandidates) candidates.push(hit)
|
|
93
|
+
if (m.index === p.re.lastIndex) p.re.lastIndex++ // 防零宽死循环
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
hits.sort((a, b) => a.start - b.start)
|
|
97
|
+
return { hits, candidates }
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/** 脱敏钩子:确认命中全遮蔽后返回新文本(M8 redact 的 F12 升级实现) */
|
|
101
|
+
export function redactSecrets(text: string): { text: string; redacted: number } {
|
|
102
|
+
const { hits } = scanSecrets(text)
|
|
103
|
+
let out = ''
|
|
104
|
+
let cursor = 0
|
|
105
|
+
for (const h of hits) {
|
|
106
|
+
out += text.slice(cursor, h.start) + h.masked
|
|
107
|
+
cursor = h.end
|
|
108
|
+
}
|
|
109
|
+
out += text.slice(cursor)
|
|
110
|
+
return { text: out, redacted: hits.length }
|
|
111
|
+
}
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* F15 基础增强 · 合规导出(M2-S9):取证包完整性清单 + 三标分层声明
|
|
3
|
+
*
|
|
4
|
+
* 口径(安全设计 §7.2.4 Q-09 定稿 + 决议⑧三标并集):
|
|
5
|
+
* - 导出包 = 事件 JSONL + manifest 清单(逐文件 SHA-256 + 链头尾锚点 + 审计元数据);
|
|
6
|
+
* 导出前强制 verifyHashChain(断链中止,SQ-06);运行时无上传功能(端点归用户);
|
|
7
|
+
* - 三标分层声明(T-7 程序化版本):13 项审计字段对 SOC 2 CC7.2 / GDPR Art.30 / 等保 2.0
|
|
8
|
+
* 三级的逐字段映射;对外按行业分层声明(合规问卷答复骨架);9 核心字段不可裁剪。
|
|
9
|
+
*/
|
|
10
|
+
import { createHash } from 'node:crypto'
|
|
11
|
+
import type { SessionLog, SessionEvent } from './log.ts'
|
|
12
|
+
|
|
13
|
+
export interface ExportMeta {
|
|
14
|
+
sessionId: string
|
|
15
|
+
exportedBy: string // OS 用户(谁导出)
|
|
16
|
+
exportedAt: string // ISO-8601 UTC
|
|
17
|
+
runtimeVersion: string
|
|
18
|
+
agentPreset?: string
|
|
19
|
+
sessionRange: { fromSeq: number; toSeq: number }
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export interface ForensicsBundle {
|
|
23
|
+
files: { name: string; content: string; sha256: string }[]
|
|
24
|
+
manifest: {
|
|
25
|
+
format: 'car-forensics/1'
|
|
26
|
+
chainHead: string | null
|
|
27
|
+
chainTail: string | null
|
|
28
|
+
eventCount: number
|
|
29
|
+
verifyChainAtExport: 'PASS'
|
|
30
|
+
meta: ExportMeta
|
|
31
|
+
files: { name: string; sha256: string }[]
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function sha256(s: string): string { return createHash('sha256').update(s).digest('hex') }
|
|
36
|
+
|
|
37
|
+
/** 取证包导出:断链即中止(不产出"带病审计包") */
|
|
38
|
+
export function exportForensicsBundle(log: SessionLog, meta: ExportMeta): ForensicsBundle {
|
|
39
|
+
const broken = log.verifyChain()
|
|
40
|
+
if (broken !== null) throw new Error(`CAR-E-EXPORT: hash chain broken at seq=${broken}——断链中止导出(禁止带病审计包外流)`)
|
|
41
|
+
const jsonl = log.exportJSONL()
|
|
42
|
+
const files = [{ name: `sessions/{sessionId}/events.jsonl`.replace('{sessionId}', meta.sessionId), content: jsonl, sha256: sha256(jsonl) }]
|
|
43
|
+
const events = log.events as SessionEvent[]
|
|
44
|
+
const manifest: ForensicsBundle['manifest'] = {
|
|
45
|
+
format: 'car-forensics/1',
|
|
46
|
+
chainHead: log.stats().tailHash ? (events[0]?.hash ?? null) : null,
|
|
47
|
+
chainTail: log.stats().tailHash,
|
|
48
|
+
eventCount: events.length,
|
|
49
|
+
verifyChainAtExport: 'PASS',
|
|
50
|
+
meta,
|
|
51
|
+
files: files.map(f => ({ name: f.name, sha256: f.sha256 })),
|
|
52
|
+
}
|
|
53
|
+
const manifestJson = JSON.stringify(manifest, null, 2)
|
|
54
|
+
files.push({ name: 'manifest.json', content: manifestJson, sha256: sha256(manifestJson) })
|
|
55
|
+
return { files, manifest }
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** 包完整性校验:逐文件 SHA-256 重算 + 链锚点一致 */
|
|
59
|
+
export function verifyBundle(bundle: ForensicsBundle): { ok: boolean; brokenFile: string | null } {
|
|
60
|
+
for (const f of bundle.files) {
|
|
61
|
+
if (sha256(f.content) !== f.sha256) return { ok: false, brokenFile: f.name }
|
|
62
|
+
}
|
|
63
|
+
for (const fm of bundle.manifest.files) {
|
|
64
|
+
const actual = bundle.files.find(f => f.name === fm.name)
|
|
65
|
+
if (!actual || actual.sha256 !== fm.sha256) return { ok: false, brokenFile: fm.name }
|
|
66
|
+
}
|
|
67
|
+
return { ok: true, brokenFile: null }
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// ── 三标分层声明(T-7)──────────────────────────────────────────────
|
|
71
|
+
|
|
72
|
+
/** 13 项审计字段(安全设计 §7.2.2);core9 = 不可裁剪核心字段 */
|
|
73
|
+
export const AUDIT_FIELDS = [
|
|
74
|
+
'seq+sessionId', 'ts', 'actor', 'actorDetail', 'eventType', 'resource', 'payload(redacted)',
|
|
75
|
+
'result/status', 'authorizationId+decidedBy+reason', 'traceId', 'tenantId', 'hash+prevHash', 'runtimeVersion+agentPreset',
|
|
76
|
+
] as const
|
|
77
|
+
const CORE9 = new Set(['seq+sessionId', 'ts', 'actor', 'eventType', 'result/status', 'authorizationId+decidedBy+reason', 'hash+prevHash', 'tenantId', 'payload(redacted)'])
|
|
78
|
+
|
|
79
|
+
export type StandardId = 'soc2' | 'gdpr' | 'mlps'
|
|
80
|
+
|
|
81
|
+
/** 字段 → 标准条款映射(T-7 矩阵的程序化数据源;导出时按 standard 裁剪字段集) */
|
|
82
|
+
// 9 核心字段三标全覆盖(核心公共底座,不可裁剪);非核心为标准特有扩展(分层声明语义)
|
|
83
|
+
const ALL: StandardId[] = ['soc2', 'gdpr', 'mlps']
|
|
84
|
+
export const FIELD_STANDARD_MAP: Record<string, StandardId[]> = {
|
|
85
|
+
'seq+sessionId': ALL, // 个体事件级(SOC2 CC7.2)
|
|
86
|
+
'ts': ALL, // 日期和时间(等保 8.1.4.3 b)
|
|
87
|
+
'actor': ALL, // 用户(GDPR Art.30 归因)
|
|
88
|
+
'actorDetail': ['gdpr'], // 处理活动主体细化(扩展)
|
|
89
|
+
'eventType': ALL, // 事件类型
|
|
90
|
+
'resource': ['soc2', 'mlps'], // 操作对象(扩展)
|
|
91
|
+
'payload(redacted)': ALL, // 完整参数与结果(脱敏后)
|
|
92
|
+
'result/status': ALL, // 事件是否成功
|
|
93
|
+
'authorizationId+decidedBy+reason': ALL, // 授权凭据链(US6 凭什么)
|
|
94
|
+
'traceId': ['mlps'], // 关联排查(扩展)
|
|
95
|
+
'tenantId': ALL, // 归属边界
|
|
96
|
+
'hash+prevHash': ALL, // 不可变自证
|
|
97
|
+
'runtimeVersion+agentPreset': ['gdpr'], // 处理环境快照(扩展)
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/** 分层声明:按标准输出字段集 + 合规声明骨架(合规问卷答复用) */
|
|
101
|
+
export function complianceStatement(std: StandardId): { standard: StandardId; fields: string[]; coreMissing: string[]; statement: string } {
|
|
102
|
+
const fields = AUDIT_FIELDS.filter(f => FIELD_STANDARD_MAP[f]?.includes(std))
|
|
103
|
+
const coreMissing = [...CORE9].filter(f => !fields.includes(f))
|
|
104
|
+
const lines: Record<StandardId, string> = {
|
|
105
|
+
soc2: 'CAR 会话日志逐事件记录(actor/时间戳/工具名/脱敏后参数与结果/授权凭据),SHA-256 哈希链不可变;保留期默认永久(用户自管),建议 ≥1 年(CC7.2)。',
|
|
106
|
+
gdpr: '事件含 actor/actorDetail/eventType/runtimeVersion(GDPR 第 30 条处理活动可归因);数据主体权利由部署方在自有环境行使;遥测默认关、零内容字段。',
|
|
107
|
+
mlps: '对应 GB/T 22239-2019 三级 8.1.4.3 安全审计 a)–d)——审计覆盖四类 actor 全量事件、记录含日期时间/用户/事件类型/是否成功、哈希链防未预期篡改、审计接口零写入;日志留存 ≥6 个月由用户环境承诺(运行时不自动清理)。',
|
|
108
|
+
}
|
|
109
|
+
return { standard: std, fields, coreMissing, statement: lines[std] }
|
|
110
|
+
}
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* append-only 会话日志层(F2 / N1 —— POC-3 断言框架的产品化)
|
|
3
|
+
*
|
|
4
|
+
* 设计口径:
|
|
5
|
+
* - 哈希链(SHA-256)为 CAR 自研增强(dsh 无此物,源码级核实)——合规卖点落地
|
|
6
|
+
* - 「Model-visible means logged」:断言口径 = 请求时快照 === 日志前缀 [0, atSeq) 投影
|
|
7
|
+
* - append-only:本层仅暴露 append/verifyChain/deriveMessages/assertModelVisibleLogged
|
|
8
|
+
*/
|
|
9
|
+
import { createHash } from 'node:crypto'
|
|
10
|
+
import { readFileSync } from 'node:fs'
|
|
11
|
+
|
|
12
|
+
export type EventKind = 'user' | 'assistant' | 'toolCall' | 'toolResult' | 'turnEnd' | 'goalUpdate' | 'hostRaw' // M3-S11 加法扩展:多宿主归一化降级通道(pattern 未命中留痕,零静默);六值 TurnEndReason 不变
|
|
13
|
+
export type Actor = 'user' | 'model' | 'plugin' | 'runtime'
|
|
14
|
+
export interface SessionEvent {
|
|
15
|
+
seq: number
|
|
16
|
+
ts: number
|
|
17
|
+
actor: Actor
|
|
18
|
+
kind: EventKind
|
|
19
|
+
turnId: string
|
|
20
|
+
payload: unknown
|
|
21
|
+
meta?: Record<string, unknown>
|
|
22
|
+
prevHash: string
|
|
23
|
+
hash: string
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
const sha = (s: string) => createHash('sha256').update(s).digest('hex')
|
|
27
|
+
const canonical = (e: Omit<SessionEvent, 'hash'>) =>
|
|
28
|
+
sha(JSON.stringify({ seq: e.seq, ts: e.ts, actor: e.actor, kind: e.kind, turnId: e.turnId, payload: e.payload, meta: e.meta ?? null, prevHash: e.prevHash }))
|
|
29
|
+
|
|
30
|
+
export interface ModelSnapshot {
|
|
31
|
+
/** 快照对应日志前缀边界(事件数),断言时逐字节比对前缀投影 */
|
|
32
|
+
atSeq: number
|
|
33
|
+
request: unknown[]
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export class SessionLog {
|
|
37
|
+
readonly sessionId: string
|
|
38
|
+
events: readonly SessionEvent[] = []
|
|
39
|
+
#tail = 'GENESIS'
|
|
40
|
+
#snapshots: ModelSnapshot[] = []
|
|
41
|
+
|
|
42
|
+
constructor(sessionId = 'S-' + Date.now().toString(36)) {
|
|
43
|
+
this.sessionId = sessionId
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** 唯一写入路径(append-only;无 update/delete API) */
|
|
47
|
+
append(actor: Actor, kind: EventKind, turnId: string, payload: unknown, meta?: Record<string, unknown>): SessionEvent {
|
|
48
|
+
const e: Omit<SessionEvent, 'hash'> = {
|
|
49
|
+
seq: this.events.length, ts: Date.now(), actor, kind, turnId, payload, meta, prevHash: this.#tail,
|
|
50
|
+
}
|
|
51
|
+
const full: SessionEvent = { ...e, hash: canonical(e) }
|
|
52
|
+
this.events = [...this.events, full]
|
|
53
|
+
this.#tail = full.hash
|
|
54
|
+
return full
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** 模型请求发起时调用(agent-loop 集成点):登记「请求时快照」 */
|
|
58
|
+
snapshotModelRequest(): ModelSnapshot {
|
|
59
|
+
const snap: ModelSnapshot = { atSeq: this.events.length, request: structuredClone(this.deriveMessages(this.events.length)) }
|
|
60
|
+
this.#snapshots.push(snap)
|
|
61
|
+
return snap
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** 逐链校验;返回 null=完整,否则断链事件序号(审计导出前置,断链即中止) */
|
|
65
|
+
verifyChain(): number | null {
|
|
66
|
+
let prev = 'GENESIS'
|
|
67
|
+
for (const e of this.events) {
|
|
68
|
+
if (e.prevHash !== prev || e.hash !== canonical(e)) return e.seq
|
|
69
|
+
prev = e.hash
|
|
70
|
+
}
|
|
71
|
+
return null
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** 投影:从日志前缀重建模型可见消息流(不依赖模型运行时状态) */
|
|
75
|
+
deriveMessages(upTo?: number): Array<{ role: string; [k: string]: unknown }> {
|
|
76
|
+
const out: Array<{ role: string; [k: string]: unknown }> = []
|
|
77
|
+
for (const e of this.events) {
|
|
78
|
+
if (upTo !== undefined && e.seq >= upTo) break
|
|
79
|
+
if (e.kind === 'user') out.push({ role: 'user', content: e.payload })
|
|
80
|
+
else if (e.kind === 'assistant') out.push({ role: 'assistant', content: e.payload })
|
|
81
|
+
else if (e.kind === 'toolCall') out.push({ role: 'assistant', toolCall: e.payload })
|
|
82
|
+
else if (e.kind === 'toolResult') out.push({ role: 'toolResult', content: e.payload })
|
|
83
|
+
}
|
|
84
|
+
return out
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** N1 不变量断言(CI 门禁雏形):全部快照必须与当前日志前缀投影逐字节一致 */
|
|
88
|
+
assertModelVisibleLogged(): { ok: boolean; failedAt?: number } {
|
|
89
|
+
for (const s of this.#snapshots) {
|
|
90
|
+
if (JSON.stringify(s.request) !== JSON.stringify(this.deriveMessages(s.atSeq))) {
|
|
91
|
+
return { ok: false, failedAt: s.atSeq }
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
return { ok: true }
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/** 只读统计(供监控/测试) */
|
|
98
|
+
stats() {
|
|
99
|
+
return { events: this.events.length, snapshots: this.#snapshots.length, tailHash: this.#tail }
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/** 导出取证数据(MVP 基础导出:JSONL 全量 + 审计元数据;F15 完整包为 M2) */
|
|
103
|
+
exportJSONL(): string {
|
|
104
|
+
return this.events.map(e => JSON.stringify(e)).join('\n') + '\n'
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/** 从 JSONL 文件加载并校验哈希链(审计回放/跨机迁移入口;断链即拒绝装载) */
|
|
109
|
+
export function loadSessionLog(file: string): { log: SessionLog; brokenAt: number | null } {
|
|
110
|
+
const log = new SessionLog(file)
|
|
111
|
+
const lines = readFileSync(file, 'utf-8').split('\n').filter(l => l.trim())
|
|
112
|
+
for (const line of lines) {
|
|
113
|
+
const e = JSON.parse(line) as SessionEvent
|
|
114
|
+
// 直接重建(绕过 append 的哈希计算),随后用 verifyChain 校验完整性
|
|
115
|
+
;(log.events as SessionEvent[]).push(e)
|
|
116
|
+
}
|
|
117
|
+
return { log, brokenAt: log.verifyChain() }
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
export { sha as sha256Hex }
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* S17 · 采集面(D-2 enforce 数据窗口 S18–S20 前置):零内容计数器
|
|
3
|
+
*
|
|
4
|
+
* 口径(M4系统设计增补 S17 采集面 / Q-08 遥测三原则 / D-3):
|
|
5
|
+
* - 3 个 Counter:car_load_total{result} / car_unsigned_confirmed{confirmed} / car_registry_decision{source};
|
|
6
|
+
* labels 仅枚举值(来源/结果/模式)——**零内容字段**(无代码/提示词/路径/会话 id 明文),Q-08 兼容可机器断言;
|
|
7
|
+
* - 双通道:遥测显式开(OTel 端点归用户)+ 登记表人工填报兜底(snapshot() 导出计数快照);
|
|
8
|
+
* 未开启遥测时仅进程内累计,不落地不缓存出站(BD-05 语义);
|
|
9
|
+
* - 本层是可插拔计数钩子(onCount 注入面),非完整 OTel Metrics——完整面显式排除在 S17 外。
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
export type CounterName = 'car_load_total' | 'car_unsigned_confirmed' | 'car_registry_decision'
|
|
13
|
+
/** labels 仅枚举值——零内容红线由 AllowedLabels 类型约束 */
|
|
14
|
+
export type AllowedLabels = { result?: 'ok' | 'failed'; confirmed?: 'yes' | 'no'; source?: 'registry' | 'npm-fallback' | 'rejected' | 'git' | 'path' }
|
|
15
|
+
|
|
16
|
+
export interface Counters {
|
|
17
|
+
onCount(name: CounterName, labels?: AllowedLabels): void
|
|
18
|
+
/** 计数快照(登记表人工填报兜底通道的数据源;不含任何内容字段) */
|
|
19
|
+
snapshot(): Record<string, number>
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function createCounters(): Counters {
|
|
23
|
+
const store = new Map<string, number>()
|
|
24
|
+
return {
|
|
25
|
+
onCount(name, labels = {}) {
|
|
26
|
+
const key = name + Object.entries(labels).sort().map(([k, v]) => `|${k}=${v}`).join('')
|
|
27
|
+
store.set(key, (store.get(key) ?? 0) + 1)
|
|
28
|
+
},
|
|
29
|
+
snapshot() {
|
|
30
|
+
return Object.fromEntries([...store.entries()].sort())
|
|
31
|
+
},
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** 零内容红线自检:snapshot 键不得包含内容型模式(sk-/路径分隔/空格长串) */
|
|
36
|
+
export function assertZeroContent(snapshot: Record<string, number>): boolean {
|
|
37
|
+
return Object.keys(snapshot).every(k => !/sk-|ghp_|\/|\\|.{60,}/.test(k))
|
|
38
|
+
}
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
import { test } from 'node:test'
|
|
2
|
+
import assert from 'node:assert/strict'
|
|
3
|
+
import { Context } from '../src/kernel/context.ts'
|
|
4
|
+
import { SessionLog } from '../src/session/log.ts'
|
|
5
|
+
|
|
6
|
+
// ==================== 内核 F1/F9 + ADR-002 ====================
|
|
7
|
+
|
|
8
|
+
test('F1: inject 依赖推导——provider 未就绪时 PENDING,就绪后自动挂载', () => {
|
|
9
|
+
const ctx = new Context()
|
|
10
|
+
const order: string[] = []
|
|
11
|
+
ctx.plugin({ name: 'consumer', inject: ['svc'], apply: (c) => { c.get('svc'); order.push('consumer') } })
|
|
12
|
+
ctx.plugin({ name: 'provider', apply: (c) => { order.push('provider'); c.provide('svc', { v: 1 }) } })
|
|
13
|
+
assert.deepEqual(order, ['provider', 'consumer']) // provider 先挂载;provide 同步解锁 consumer
|
|
14
|
+
})
|
|
15
|
+
|
|
16
|
+
test('F1: 缺依赖服务时 get 显式报错(禁静默)', () => {
|
|
17
|
+
const ctx = new Context()
|
|
18
|
+
// apply 同步异常在加载期(plugin() 调用点)显式抛出,调用方直接感知
|
|
19
|
+
assert.throws(() => ctx.plugin({ name: 'bad', apply: (c) => c.get('nope') }), /not provided/)
|
|
20
|
+
assert.throws(() => ctx.get('nope'), /not provided/)
|
|
21
|
+
})
|
|
22
|
+
|
|
23
|
+
test('F1: 重复 provide 同名服务 = 加载期硬报错 + 冲突链', () => {
|
|
24
|
+
const ctx = new Context()
|
|
25
|
+
ctx.provide('dup', 1, 'p1')
|
|
26
|
+
assert.throws(() => ctx.provide('dup', 2, 'p2'), /already provided by "p1".*p2/)
|
|
27
|
+
})
|
|
28
|
+
|
|
29
|
+
test('F1: 重复注册同名插件 = 显式报错', () => {
|
|
30
|
+
const ctx = new Context()
|
|
31
|
+
ctx.plugin({ name: 'x', apply: () => {} })
|
|
32
|
+
assert.throws(() => ctx.plugin({ name: 'x', apply: () => {} }), /already registered/)
|
|
33
|
+
})
|
|
34
|
+
|
|
35
|
+
test('F1: fiber 内 effect 注册逆序回卷(确定性)', async () => {
|
|
36
|
+
const ctx = new Context()
|
|
37
|
+
const marks: string[] = []
|
|
38
|
+
ctx.plugin({
|
|
39
|
+
name: 'p', apply: (c) => {
|
|
40
|
+
for (const l of ['e1', 'e2', 'e3']) c.effect(() => async () => { marks.push(l + ':end') }, l)
|
|
41
|
+
},
|
|
42
|
+
})
|
|
43
|
+
await ctx.disposeRuntime()
|
|
44
|
+
assert.deepEqual(marks, ['e3:end', 'e2:end', 'e1:end'])
|
|
45
|
+
})
|
|
46
|
+
|
|
47
|
+
test('ADR-002 strict-topo:consumer 先于 provider 卸载(逐层排空)', async () => {
|
|
48
|
+
const ctx = new Context()
|
|
49
|
+
const events: string[] = []
|
|
50
|
+
const sleep = (ms: number) => new Promise<void>(r => setTimeout(r, ms))
|
|
51
|
+
ctx.plugin({ name: 'db', apply: (c) => c.effect(() => async () => { await sleep(30); events.push('db:end') }, 'db-teardown') })
|
|
52
|
+
ctx.plugin({ name: 'cache', inject: ['dbConn'], apply: (c) => c.effect(() => async () => { await sleep(20); events.push('cache:end') }, 'cache-teardown') })
|
|
53
|
+
ctx.plugin({ name: 'api', inject: ['dbConn', 'cacheConn'], apply: (c) => c.effect(() => async () => { events.push('api:end') }, 'api-teardown') })
|
|
54
|
+
ctx.provide('dbConn', {}, 'db')
|
|
55
|
+
ctx.provide('cacheConn', {}, 'cache')
|
|
56
|
+
const report = await ctx.disposeRuntime({ order: 'strict-topo' })
|
|
57
|
+
assert.deepEqual(events, ['api:end', 'cache:end', 'db:end']) // 消费者层先排空
|
|
58
|
+
assert.deepEqual(report.order, ['api', 'cache', 'db'])
|
|
59
|
+
})
|
|
60
|
+
|
|
61
|
+
test('ADR-002 concurrent 模式:跨 fiber 并发(对照/降级模式可用)', async () => {
|
|
62
|
+
const ctx = new Context()
|
|
63
|
+
ctx.plugin({ name: 'a', apply: (c) => c.effect(() => async () => {}, 'a') })
|
|
64
|
+
ctx.plugin({ name: 'b', apply: (c) => c.effect(() => async () => {}, 'b') })
|
|
65
|
+
const report = await ctx.disposeRuntime({ order: 'concurrent' })
|
|
66
|
+
assert.equal(report.unloaded.length, 2)
|
|
67
|
+
})
|
|
68
|
+
|
|
69
|
+
test('ADR-002: strict-topo 模式下 disposer 抛错被捕获进 DisposeReport(屏障不悬挂)', async () => {
|
|
70
|
+
const ctx = new Context()
|
|
71
|
+
ctx.plugin({ name: 'boom', apply: (c) => c.effect(() => () => { throw new Error('boom') }, 'boom') })
|
|
72
|
+
ctx.plugin({ name: 'ok', apply: (c) => c.effect(() => () => {}, 'ok') })
|
|
73
|
+
const report = await ctx.disposeRuntime()
|
|
74
|
+
assert.equal(report.errors.length, 1)
|
|
75
|
+
assert.equal(report.errors[0].plugin, 'boom')
|
|
76
|
+
assert.equal(report.unloaded.length, 2)
|
|
77
|
+
})
|
|
78
|
+
|
|
79
|
+
test('F9: 依赖环 = 拓扑分层显式报错(加载期失败,不进入运行)', async () => {
|
|
80
|
+
const ctx = new Context()
|
|
81
|
+
// 通过互 inject 构造环(S1 骨架:环在 disposeRuntime 拓扑分层时暴露)
|
|
82
|
+
ctx.plugin({ name: 'a', inject: ['bSvc'], apply: (c) => {} })
|
|
83
|
+
ctx.plugin({ name: 'b', inject: ['aSvc'], apply: (c) => {} })
|
|
84
|
+
ctx.provide('bSvc', 1, 'b')
|
|
85
|
+
ctx.provide('aSvc', 1, 'a')
|
|
86
|
+
await assert.rejects(() => ctx.disposeRuntime(), /dependency cycle/)
|
|
87
|
+
})
|
|
88
|
+
|
|
89
|
+
test('POC-2 约束:effect 绑定插件 fiber 作用域(不串到根)', async () => {
|
|
90
|
+
const ctx = new Context()
|
|
91
|
+
let disposed = 0
|
|
92
|
+
ctx.plugin({ name: 'scoped', apply: (c) => c.effect(() => () => { disposed++ }, 't') })
|
|
93
|
+
await ctx.disposeRuntime()
|
|
94
|
+
assert.equal(disposed, 1)
|
|
95
|
+
})
|
|
96
|
+
|
|
97
|
+
// ==================== 会话日志 F2 / N1 ====================
|
|
98
|
+
|
|
99
|
+
test('F2: 哈希链篡改检出并定位序号', () => {
|
|
100
|
+
const log = new SessionLog()
|
|
101
|
+
log.append('user', 'user', 'T0', 'hi')
|
|
102
|
+
log.append('model', 'assistant', 'T0', 'hello')
|
|
103
|
+
log.append('runtime', 'turnEnd', 'T0', null, { reason: 'completed' })
|
|
104
|
+
assert.equal(log.verifyChain(), null)
|
|
105
|
+
;(log.events as any)[1] = { ...(log.events as any)[1], payload: 'tampered' }
|
|
106
|
+
assert.equal(log.verifyChain(), 1)
|
|
107
|
+
})
|
|
108
|
+
|
|
109
|
+
test('F2: deriveMessages 前缀投影(upTo 语义)', () => {
|
|
110
|
+
const log = new SessionLog()
|
|
111
|
+
log.append('user', 'user', 'T0', 'q1')
|
|
112
|
+
const snapAt1 = log.deriveMessages(1)
|
|
113
|
+
log.append('model', 'assistant', 'T0', 'a1')
|
|
114
|
+
assert.deepEqual(log.deriveMessages(1), snapAt1)
|
|
115
|
+
assert.equal(log.deriveMessages().length, 2)
|
|
116
|
+
})
|
|
117
|
+
|
|
118
|
+
test('N1: Model-visible means logged 断言(快照=前缀投影)', () => {
|
|
119
|
+
const log = new SessionLog()
|
|
120
|
+
log.append('user', 'user', 'T0', 'q')
|
|
121
|
+
log.snapshotModelRequest()
|
|
122
|
+
log.append('model', 'assistant', 'T0', 'a')
|
|
123
|
+
log.append('plugin', 'toolResult', 'T0', 'r')
|
|
124
|
+
const r = log.assertModelVisibleLogged()
|
|
125
|
+
assert.equal(r.ok, true)
|
|
126
|
+
})
|
|
127
|
+
|
|
128
|
+
test('N1: 断言失败可检出(模拟日志被篡改后投影漂移)', () => {
|
|
129
|
+
const log = new SessionLog()
|
|
130
|
+
log.append('user', 'user', 'T0', 'q')
|
|
131
|
+
log.snapshotModelRequest()
|
|
132
|
+
// 模拟「模型看到了日志外的东西」:快照后直接改历史(绕过 append API 的场景以校验断言灵敏度)
|
|
133
|
+
;(log.events as any)[0] = { ...(log.events as any)[0], payload: 'mutated' }
|
|
134
|
+
const r = log.assertModelVisibleLogged()
|
|
135
|
+
assert.equal(r.ok, false)
|
|
136
|
+
})
|
package/test/s11.spec.ts
ADDED
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
import { test } from 'node:test'
|
|
2
|
+
import assert from 'node:assert/strict'
|
|
3
|
+
import { deriveSessionId, normalizeHostEvent, HOST_MAPPINGS } from '../src/host/mappings.ts'
|
|
4
|
+
import { HostGateway, HOST_TOOLS, ingestHostEvent, type RuntimeFacade } from '../src/host/hostGateway.ts'
|
|
5
|
+
import { readFileSync, readdirSync } from 'node:fs'
|
|
6
|
+
import { join } from 'node:path'
|
|
7
|
+
|
|
8
|
+
// ==================== session id 归一化 ====================
|
|
9
|
+
|
|
10
|
+
test('T-1: deriveSessionId 确定性 + 跨宿主隔离 + 无语义解析', () => {
|
|
11
|
+
const a1 = deriveSessionId('claude-code', 'sess-42')
|
|
12
|
+
const a2 = deriveSessionId('claude-code', 'sess-42')
|
|
13
|
+
assert.equal(a1, a2, '幂等')
|
|
14
|
+
assert.match(a1, /^SH-[0-9a-f]{24}$/)
|
|
15
|
+
assert.notEqual(deriveSessionId('codex', 'sess-42'), a1, '不同宿主同 id → 不同 CAR 会话(跨宿主隔离)')
|
|
16
|
+
assert.notEqual(deriveSessionId('claude-code', 'sess-43'), a1)
|
|
17
|
+
})
|
|
18
|
+
|
|
19
|
+
// ==================== 归一化 schema(host-mappings-v1) ====================
|
|
20
|
+
|
|
21
|
+
test('T-1: 命中映射——两宿主同语义事件归一化结果等价', () => {
|
|
22
|
+
const cc = normalizeHostEvent(HOST_MAPPINGS[0], 'user_message', { content: 'hello' })
|
|
23
|
+
const cx = normalizeHostEvent(HOST_MAPPINGS[1], 'input_item', { text: 'hello' })
|
|
24
|
+
assert.equal(cc.kind, 'user')
|
|
25
|
+
assert.equal(cx.kind, 'user')
|
|
26
|
+
assert.deepEqual(cc.payload, cx.payload) // { text: 'hello' }——等价性核心断言
|
|
27
|
+
assert.equal(cc.degraded, false)
|
|
28
|
+
assert.equal(cx.degraded, false)
|
|
29
|
+
})
|
|
30
|
+
|
|
31
|
+
test('T-1: 未命中 → hostRaw 降级留痕(零静默)', () => {
|
|
32
|
+
const r = normalizeHostEvent(HOST_MAPPINGS[0], 'unknown_future_event', { foo: 1 })
|
|
33
|
+
assert.equal(r.kind, 'hostRaw')
|
|
34
|
+
assert.equal(r.degraded, true)
|
|
35
|
+
assert.equal(r.payload.hostEvent, 'unknown_future_event')
|
|
36
|
+
assert.deepEqual(r.payload.raw, { foo: 1 }, '原样保留可回溯')
|
|
37
|
+
})
|
|
38
|
+
|
|
39
|
+
test('T-1: toolCall 归一化——字段重命名(tool_name→tool / name→tool)', () => {
|
|
40
|
+
const cc = normalizeHostEvent(HOST_MAPPINGS[0], 'tool_use', { tool_name: 'fs', tool_call_id: 'c1' })
|
|
41
|
+
const cx = normalizeHostEvent(HOST_MAPPINGS[1], 'function_call', { name: 'fs', call_id: 'c1' })
|
|
42
|
+
assert.deepEqual(cc.payload, cx.payload) // { tool: 'fs', id: 'c1' }
|
|
43
|
+
})
|
|
44
|
+
|
|
45
|
+
// ==================== HostGateway(9 tool 面 + 登记制) ====================
|
|
46
|
+
|
|
47
|
+
function mockFacade(): RuntimeFacade {
|
|
48
|
+
return {
|
|
49
|
+
sessionStart: async ({ hostSessionId }) => ({ sessionId: deriveSessionId('claude-code', hostSessionId) }),
|
|
50
|
+
sessionTurn: async () => ({ reason: 'completed', steps: 1 }),
|
|
51
|
+
sessionStop: async () => ({ reason: 'aborted' }),
|
|
52
|
+
sessionStatus: async () => ({ state: 'idle' }),
|
|
53
|
+
sessionReplay: async () => ({ messages: [] }),
|
|
54
|
+
sessionVerify: async () => ({ ok: true, brokenAt: null }),
|
|
55
|
+
sessionExport: async () => ({ bundle: {} }),
|
|
56
|
+
toolList: async () => ({ tools: ['t1'] }),
|
|
57
|
+
toolCall: async () => ({ ok: true, result: 'x' }),
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
test('T-1: 登记制——未登记 host 拒绝(A050001)+ 重复登记报错', async () => {
|
|
62
|
+
const audits: string[] = []
|
|
63
|
+
const gw = new HostGateway({ facade: mockFacade(), audit: e => audits.push(e.kind) })
|
|
64
|
+
const r = await gw.handle('ghost', 'session_status', {})
|
|
65
|
+
assert.equal(r.ok, false)
|
|
66
|
+
assert.match(r.error!, /A050001/)
|
|
67
|
+
assert.ok(audits.includes('host-rejected'), '拒绝也留痕')
|
|
68
|
+
gw.registerHost({ hostId: 'claude-code', profile: HOST_MAPPINGS[0], transport: { onRequest: async () => ({ jsonrpc: '2.0', id: 1 }), alive: () => true, close: () => {} } })
|
|
69
|
+
assert.throws(() => gw.registerHost({ hostId: 'claude-code', profile: HOST_MAPPINGS[0], transport: {} as never }), /already registered/)
|
|
70
|
+
})
|
|
71
|
+
|
|
72
|
+
test('T-1: 9 tool 面固定 + 未知 tool 拒绝(step 循环不暴露)', async () => {
|
|
73
|
+
const gw = new HostGateway({ facade: mockFacade(), audit: () => {} })
|
|
74
|
+
assert.deepEqual(gw.listTools().sort(), [...HOST_TOOLS].sort())
|
|
75
|
+
assert.equal(HOST_TOOLS.length, 9)
|
|
76
|
+
gw.registerHost({ hostId: 'claude-code', profile: HOST_MAPPINGS[0], transport: {} as never })
|
|
77
|
+
const r = await gw.handle('claude-code', 'run_step', {})
|
|
78
|
+
assert.equal(r.ok, false)
|
|
79
|
+
assert.match(r.error!, /step 循环不暴露/)
|
|
80
|
+
})
|
|
81
|
+
|
|
82
|
+
test('T-1: 端到端分发——facade 调用 + 审计留痕', async () => {
|
|
83
|
+
const audits: string[] = []
|
|
84
|
+
const gw = new HostGateway({ facade: mockFacade(), audit: e => audits.push(e.kind) })
|
|
85
|
+
gw.registerHost({ hostId: 'claude-code', profile: HOST_MAPPINGS[0], transport: {} as never })
|
|
86
|
+
const r = await gw.handle('claude-code', 'session_start', { hostSessionId: 'sess-42' })
|
|
87
|
+
assert.equal(r.ok, true)
|
|
88
|
+
assert.match((r.result as { sessionId: string }).sessionId, /^SH-/)
|
|
89
|
+
assert.ok(audits.includes('host-call'))
|
|
90
|
+
})
|
|
91
|
+
|
|
92
|
+
// ==================== 双宿主等价:契约测试框架(S13 扩全五类) ====================
|
|
93
|
+
|
|
94
|
+
test('契约测试 v0: 同一 fixture 序列在两宿主 profile 下归一化等价', () => {
|
|
95
|
+
const fixtures: Array<{ semantic: string; cc: [string, Record<string, unknown>]; cx: [string, Record<string, unknown>] }>[] = [
|
|
96
|
+
[{
|
|
97
|
+
semantic: 'user-input',
|
|
98
|
+
cc: ['user_message', { content: 'hi' }],
|
|
99
|
+
cx: ['input_item', { text: 'hi' }],
|
|
100
|
+
}],
|
|
101
|
+
[{
|
|
102
|
+
semantic: 'tool-call',
|
|
103
|
+
cc: ['tool_use', { tool_name: 'fs', tool_call_id: 'c1' }],
|
|
104
|
+
cx: ['function_call', { name: 'fs', call_id: 'c1' }],
|
|
105
|
+
}],
|
|
106
|
+
]
|
|
107
|
+
const flat = fixtures.flat()
|
|
108
|
+
for (const f of flat) {
|
|
109
|
+
const a = normalizeHostEvent(HOST_MAPPINGS[0], f.cc[0], f.cc[1])
|
|
110
|
+
const b = normalizeHostEvent(HOST_MAPPINGS[1], f.cx[0], f.cx[1])
|
|
111
|
+
assert.equal(a.kind, b.kind, `${f.semantic}: kind 等价`)
|
|
112
|
+
assert.deepEqual(a.payload, b.payload, `${f.semantic}: payload 等价`)
|
|
113
|
+
assert.equal(a.degraded, b.degraded)
|
|
114
|
+
}
|
|
115
|
+
})
|
|
116
|
+
|
|
117
|
+
test('静态架构断言: 宿主标识字符串只允许出现在 mappings 数据文件', () => {
|
|
118
|
+
const scan = (dir: string): string[] => readdirSync(dir, { recursive: true } as never)
|
|
119
|
+
.filter(f => String(f).endsWith('.ts'))
|
|
120
|
+
.map(f => join(dir, String(f)))
|
|
121
|
+
// 只扫生产代码(src/)——测试 fixture 自身含宿主标识属预期;mappings.ts 为唯一数据白名单
|
|
122
|
+
const files = scan('src').filter(f => !f.replaceAll('\\', '/').endsWith('src/host/mappings.ts'))
|
|
123
|
+
const offenders = files.filter(f => /claude-code|codex/.test(readFileSync(f, 'utf-8')))
|
|
124
|
+
assert.deepEqual(offenders, [], `宿主标识泄漏到非数据文件: ${offenders.join(', ')}`)
|
|
125
|
+
})
|
|
126
|
+
|
|
127
|
+
test('ingestHostEvent: 未登记宿主归一化拒绝(登记制在归一化之前)', () => {
|
|
128
|
+
assert.throws(() => ingestHostEvent('unregistered', 'user_message', {}), /A050001/)
|
|
129
|
+
})
|