dsh-dashcam 0.0.0-stage → 2.0.0
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/LICENSE +21 -0
- package/README.md +624 -2
- package/README.zh-CN.md +835 -0
- package/SECURITY.md +28 -0
- package/bin/dsh-dashcam.js +574 -0
- package/cordis.patch.yml +5 -0
- package/docs/COMPLIANCE.md +112 -0
- package/docs/ROLLOUT.md +150 -0
- package/docs/SPEC.md +441 -0
- package/docs/ghost-in-the-llm/README.md +21 -0
- package/docs/ghost-in-the-llm/dashcam-ingress-audit-design.md +223 -0
- package/docs/ghost-in-the-llm/dashcam-ingress-record-plan.md +418 -0
- package/docs/ghost-in-the-llm/ghost-message-forensics.md +214 -0
- package/docs/ghost-in-the-llm/ghost-message-third-party-review.md +174 -0
- package/docs/multiprocess.md +292 -0
- package/dsh-dashcam-host/README.md +177 -0
- package/dsh-dashcam-host/README.zh-CN.md +181 -0
- package/dsh-dashcam-host/lib/client.js +1206 -0
- package/dsh-dashcam-host/lib/index.js +971 -0
- package/dsh-dashcam-host/lib/ingress.js +218 -0
- package/dsh-dashcam-host/lib/panel.js +487 -0
- package/dsh-dashcam-host/package.json +29 -0
- package/examples/child-plugin.js +25 -0
- package/examples/compliant-plugin/index.js +30 -0
- package/examples/compliant-plugin/manifest.json +47 -0
- package/examples/demo-policy.json +33 -0
- package/examples/negative-plugin/index.js +20 -0
- package/examples/negative-plugin/manifest.json +36 -0
- package/package.json +73 -4
- package/src/autopsy/activation.js +90 -0
- package/src/autopsy/autopsy.js +222 -0
- package/src/contract/capabilities.js +437 -0
- package/src/contract/charter.js +230 -0
- package/src/contract/manifest.js +162 -0
- package/src/contract/two-key.js +89 -0
- package/src/errors.js +34 -0
- package/src/gates/index.js +289 -0
- package/src/gates/loadability.js +87 -0
- package/src/gates/report.js +78 -0
- package/src/gates/safety.js +194 -0
- package/src/gates/structure.js +161 -0
- package/src/governance/behavior.js +231 -0
- package/src/governance/blacklist.js +66 -0
- package/src/governance/metrics.js +249 -0
- package/src/governance/rollback.js +66 -0
- package/src/governance/rollout.js +88 -0
- package/src/host/child-host.js +216 -0
- package/src/host/local-host.js +441 -0
- package/src/index.js +261 -0
- package/src/policy.js +239 -0
- package/src/portrait/portrait.js +129 -0
- package/src/runtime/bridge.js +1281 -0
- package/src/runtime/derive.js +208 -0
- package/src/runtime/facade.js +40 -0
- package/src/runtime/guards.js +583 -0
- package/src/runtime/handles/fs.js +193 -0
- package/src/runtime/handles/index.js +30 -0
- package/src/runtime/handles/inject.js +129 -0
- package/src/runtime/handles/net.js +177 -0
- package/src/runtime/handles/proc.js +78 -0
- package/src/runtime/ingress.js +717 -0
- package/src/runtime/ipc/child.js +88 -0
- package/src/runtime/ipc/protocol.js +127 -0
- package/src/runtime/ledger.js +582 -0
- package/src/runtime/one-seal.js +122 -0
- package/src/runtime/redirects.js +102 -0
- package/src/signing/bundle.js +290 -0
- package/src/signing/trust.js +90 -0
- package/src/util/paths.js +187 -0
- package/src/util/stable.js +25 -0
- package/test/attack-surface.test.js +774 -0
- package/test/audit.test.js +636 -0
- package/test/autopsy.test.js +104 -0
- package/test/batch-guard.test.js +81 -0
- package/test/behavior.test.js +132 -0
- package/test/bypass.test.js +231 -0
- package/test/child-host.test.js +127 -0
- package/test/child.test.js +183 -0
- package/test/contract.test.js +157 -0
- package/test/gates.test.js +275 -0
- package/test/glob.test.js +83 -0
- package/test/governance.test.js +102 -0
- package/test/handle.test.js +132 -0
- package/test/helpers/fixtures.js +124 -0
- package/test/ingress.test.js +416 -0
- package/test/invariants.test.js +314 -0
- package/test/ledger-mac.test.js +134 -0
- package/test/mac-custody.test.js +86 -0
- package/test/mandate.test.js +129 -0
- package/test/metrics-batch.test.js +234 -0
- package/test/metrics-telemetry.test.js +217 -0
- package/test/net-proc-handle.test.js +183 -0
- package/test/observe.test.js +169 -0
- package/test/options.test.js +174 -0
- package/test/portrait-labels.test.js +72 -0
- package/test/portrait.test.js +65 -0
- package/test/protocol.test.js +105 -0
- package/test/quick-wins.test.js +245 -0
- package/test/review-fixes.test.js +260 -0
- package/test/review2-fixes.test.js +191 -0
- package/test/review3-fixes.test.js +226 -0
- package/test/review4-fixes.test.js +486 -0
- package/test/review5-fixes.test.js +301 -0
- package/test/review6-fixes.test.js +311 -0
- package/test/runtime.test.js +185 -0
- package/test/sandbox.test.js +236 -0
- package/test/signatures.test.js +170 -0
- package/test/symlink.test.js +288 -0
- package/test/three-lens-fixes.test.js +397 -0
- package/test/worker.test.js +73 -0
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const test = require('node:test');
|
|
4
|
+
const assert = require('node:assert/strict');
|
|
5
|
+
const fs = require('node:fs');
|
|
6
|
+
const path = require('node:path');
|
|
7
|
+
const { tempDir, createPlugin, cleanup } = require('./helpers/fixtures');
|
|
8
|
+
const { Ledger } = require('../src/runtime/ledger');
|
|
9
|
+
const { LocalHost } = require('../src/host/local-host');
|
|
10
|
+
const { createGuardChain } = require('../src/runtime/guards');
|
|
11
|
+
const { runInstallPipeline } = require('../src/gates/index');
|
|
12
|
+
|
|
13
|
+
function writePlugin(dir, { id = 'com.example.observe', source }) {
|
|
14
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
15
|
+
fs.writeFileSync(path.join(dir, 'manifest.json'), JSON.stringify({
|
|
16
|
+
id,
|
|
17
|
+
name: 'Observe',
|
|
18
|
+
version: '1.0.0',
|
|
19
|
+
entry: 'index.js',
|
|
20
|
+
dashcam: {
|
|
21
|
+
spec: 'dsh-dashcam/1',
|
|
22
|
+
capabilities: { requests: [] },
|
|
23
|
+
content: null,
|
|
24
|
+
},
|
|
25
|
+
}, null, 2));
|
|
26
|
+
fs.writeFileSync(path.join(dir, 'index.js'), source);
|
|
27
|
+
return dir;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
test('守卫链:observe 窗口内记 deny 但不抛出,窗口结束后恢复 fail-closed', () => {
|
|
31
|
+
const ledger = { append() { return { seq: 1 }; } };
|
|
32
|
+
const guard = createGuardChain({
|
|
33
|
+
manifest: { id: 'p', name: 'P', version: '1.0.0', entry: 'i.js', kind: 'dashcam', dashcam: { spec: 'dsh-dashcam/1', requests: [], content: null } },
|
|
34
|
+
grants: [],
|
|
35
|
+
ledger,
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
guard.beginObserve();
|
|
39
|
+
try {
|
|
40
|
+
const decision = guard.enforce('fs.read', { path: '/tmp/secret' });
|
|
41
|
+
assert.equal(decision.allowed, true);
|
|
42
|
+
assert.equal(decision.observed, true);
|
|
43
|
+
assert.equal(decision.observedCode, 'not_declared');
|
|
44
|
+
} finally {
|
|
45
|
+
guard.endObserve();
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
assert.equal(guard.isObserving(), false);
|
|
49
|
+
assert.throws(() => guard.enforce('fs.read', { path: '/tmp/secret' }), /未声明|manifest/);
|
|
50
|
+
assert.equal(guard.stats.observed, 1);
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
test('装载窗口:顶层代码的越界读取被记账,但不阻断安装', async () => {
|
|
54
|
+
const dir = tempDir('observe-host-');
|
|
55
|
+
const outsideDir = tempDir('observe-out-');
|
|
56
|
+
const outside = path.join(outsideDir, 'secret.txt');
|
|
57
|
+
fs.writeFileSync(outside, 'TOP-SECRET');
|
|
58
|
+
|
|
59
|
+
const pluginDir = writePlugin(path.join(dir, 'plugin'), {
|
|
60
|
+
source: [
|
|
61
|
+
"'use strict';",
|
|
62
|
+
"const fs = require('node:fs');",
|
|
63
|
+
'let stolen = null;',
|
|
64
|
+
'try { stolen = fs.readFileSync(' + JSON.stringify(outside) + ", 'utf8'); } catch (error) { stolen = 'DENIED'; }",
|
|
65
|
+
'module.exports = { stolen, activate() {}, invoke() { return { stolen }; }, deactivate() {} };',
|
|
66
|
+
].join('\n'),
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
const ledgerPath = path.join(dir, 'ledger.jsonl');
|
|
70
|
+
const host = new LocalHost({ ledgerPath });
|
|
71
|
+
try {
|
|
72
|
+
await host.install(pluginDir);
|
|
73
|
+
await host.register('com.example.observe');
|
|
74
|
+
|
|
75
|
+
const module = host.modules.get('com.example.observe');
|
|
76
|
+
assert.equal(module.stolen, 'TOP-SECRET', '装载窗口内不阻断(否则 legacy 插件根本装不上)');
|
|
77
|
+
|
|
78
|
+
const records = new Ledger(ledgerPath).records();
|
|
79
|
+
const intercepted = records.filter((record) => record.intercepted === true);
|
|
80
|
+
assert.ok(intercepted.length >= 1, '窗口内的越界读取必须留下记录');
|
|
81
|
+
assert.ok(intercepted.some((record) => record.ruleId === 'not_declared'));
|
|
82
|
+
|
|
83
|
+
// 窗口已经关闭:同样的调用现在必须被拒绝
|
|
84
|
+
assert.throws(
|
|
85
|
+
() => host.active.guard.enforce('fs.read', { path: outside }),
|
|
86
|
+
/未声明|manifest/,
|
|
87
|
+
);
|
|
88
|
+
} finally {
|
|
89
|
+
try { await host.uninstall('com.example.observe'); } catch {}
|
|
90
|
+
cleanup(dir);
|
|
91
|
+
cleanup(outsideDir);
|
|
92
|
+
}
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
test('装载窗口:不把加载器自己的读文件记成插件行为', async () => {
|
|
96
|
+
const dir = tempDir('observe-entry-');
|
|
97
|
+
const pluginDir = writePlugin(path.join(dir, 'plugin'), {
|
|
98
|
+
source: "'use strict';\nmodule.exports = { activate() {}, invoke() { return { ok: true }; }, deactivate() {} };\n",
|
|
99
|
+
});
|
|
100
|
+
const ledgerPath = path.join(dir, 'ledger.jsonl');
|
|
101
|
+
const host = new LocalHost({ ledgerPath });
|
|
102
|
+
try {
|
|
103
|
+
await host.install(pluginDir);
|
|
104
|
+
await host.register('com.example.observe');
|
|
105
|
+
const records = new Ledger(ledgerPath).records();
|
|
106
|
+
const entryRead = records.some((record) => JSON.stringify(record.scope || '').includes('index.js'));
|
|
107
|
+
assert.equal(entryRead, false, '入口文件的读取是宿主行为,不该记在插件账上');
|
|
108
|
+
} finally {
|
|
109
|
+
try { await host.uninstall('com.example.observe'); } catch {}
|
|
110
|
+
cleanup(dir);
|
|
111
|
+
}
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
test('LocalHost:admitting 是私有字段,外部写不进去也绕不过去', () => {
|
|
115
|
+
const dir = tempDir('observe-private-');
|
|
116
|
+
const host = new LocalHost({ ledgerPath: path.join(dir, 'ledger.jsonl') });
|
|
117
|
+
assert.equal('admitting' in host, false);
|
|
118
|
+
assert.equal(Object.getOwnPropertyNames(host).includes('admitting'), false);
|
|
119
|
+
host.admitting = true;
|
|
120
|
+
assert.equal('admitting' in host, true);
|
|
121
|
+
assert.equal(host.admitting, true);
|
|
122
|
+
cleanup(dir);
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
test('strict 模式:前置门禁未通过时不执行插件代码', async () => {
|
|
126
|
+
const dir = tempDir('observe-strict-');
|
|
127
|
+
const marker = path.join(dir, 'executed.txt');
|
|
128
|
+
const pluginDir = writePlugin(path.join(dir, 'plugin'), {
|
|
129
|
+
source: [
|
|
130
|
+
"'use strict';",
|
|
131
|
+
"require('node:fs').writeFileSync(" + JSON.stringify(marker) + ", 'executed');",
|
|
132
|
+
'module.exports = { activate() {}, invoke() { return { ok: true }; }, deactivate() {} };',
|
|
133
|
+
].join('\n'),
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
const host = new LocalHost({ ledgerPath: path.join(dir, 'ledger.jsonl'), channel: 'enterprise' });
|
|
137
|
+
const report = await runInstallPipeline({
|
|
138
|
+
pluginDir,
|
|
139
|
+
trust: null,
|
|
140
|
+
channel: 'enterprise',
|
|
141
|
+
host,
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
assert.equal(report.ok, false);
|
|
145
|
+
assert.equal(fs.existsSync(marker), false, '验签没过就不该执行插件代码');
|
|
146
|
+
const loadStage = report.stages.find((stage) => stage.name === 'loadability');
|
|
147
|
+
assert.equal(loadStage.status, 'warn');
|
|
148
|
+
assert.ok(loadStage.labels.some((item) => item.code === 'loadability_skipped'));
|
|
149
|
+
cleanup(dir);
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
test('coverage:Hook 装好之后 guard.coverage() 能给出受控入口清单', () => {
|
|
153
|
+
const guard = createGuardChain({
|
|
154
|
+
manifest: { id: 'p', name: 'P', version: '1.0.0', entry: 'i.js', kind: 'dashcam', dashcam: { spec: 'dsh-dashcam/1', requests: [], content: null } },
|
|
155
|
+
grants: [],
|
|
156
|
+
});
|
|
157
|
+
const before = guard.coverage();
|
|
158
|
+
assert.equal(before.entries, 0);
|
|
159
|
+
assert.ok(before.knownGaps.length >= 1);
|
|
160
|
+
|
|
161
|
+
const hooks = guard.installNativeHooks();
|
|
162
|
+
try {
|
|
163
|
+
const after = guard.coverage();
|
|
164
|
+
assert.ok(after.entries > 50);
|
|
165
|
+
assert.ok(after.guarded.includes('fs.openSync'));
|
|
166
|
+
} finally {
|
|
167
|
+
hooks.uninstall();
|
|
168
|
+
}
|
|
169
|
+
});
|
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* policy.limits / policy.enforcement.strictPatch 的可配置性回归。
|
|
5
|
+
* 这些值以前是各模块里写死的常量,现在必须真的能通过 policy 改。
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
const test = require('node:test');
|
|
9
|
+
const assert = require('node:assert/strict');
|
|
10
|
+
const fs = require('node:fs');
|
|
11
|
+
const path = require('node:path');
|
|
12
|
+
const { tempDir, createPlugin, cleanup } = require('./helpers/fixtures');
|
|
13
|
+
const { loadManifest } = require('../src/contract/manifest');
|
|
14
|
+
const { normalizeGrant } = require('../src/contract/capabilities');
|
|
15
|
+
const { loadPolicy, resolveLimits, DEFAULT_LIMITS } = require('../src/policy');
|
|
16
|
+
const { Ledger } = require('../src/runtime/ledger');
|
|
17
|
+
const { createGuardChain } = require('../src/runtime/guards');
|
|
18
|
+
const { createBridge } = require('../src/runtime/bridge');
|
|
19
|
+
const { createNetHandle } = require('../src/runtime/handles/net');
|
|
20
|
+
const { LocalHost, resolveLedgerMacKey } = require('../src/host/local-host');
|
|
21
|
+
|
|
22
|
+
const MANIFEST = {
|
|
23
|
+
id: 'com.example.options',
|
|
24
|
+
name: 'Options',
|
|
25
|
+
version: '1.0.0',
|
|
26
|
+
entry: 'index.js',
|
|
27
|
+
kind: 'dashcam',
|
|
28
|
+
dashcam: { spec: 'dsh-dashcam/1', content: null, requests: [] },
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
function runtimeFor(policy = {}) {
|
|
32
|
+
const dir = tempDir('options-');
|
|
33
|
+
const ledger = new Ledger(path.join(dir, 'ledger.jsonl'));
|
|
34
|
+
const guard = createGuardChain({
|
|
35
|
+
manifest: MANIFEST, grants: [], ledger, pluginRoot: dir, policy,
|
|
36
|
+
});
|
|
37
|
+
return { dir, ledger, guard };
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
test('limits:默认值集中在一处,未覆盖的键保持默认', () => {
|
|
41
|
+
const merged = resolveLimits({ limits: { batchTargets: 5 } });
|
|
42
|
+
assert.equal(merged.batchTargets, 5);
|
|
43
|
+
assert.equal(merged.fetchTimeoutMs, DEFAULT_LIMITS.fetchTimeoutMs);
|
|
44
|
+
assert.equal(merged.integrityEvery, DEFAULT_LIMITS.integrityEvery);
|
|
45
|
+
|
|
46
|
+
const loaded = loadPolicy({ limits: { globBatchSize: 7 } });
|
|
47
|
+
assert.equal(loaded.limits.globBatchSize, 7);
|
|
48
|
+
assert.equal(loaded.limits.batchTargets, DEFAULT_LIMITS.batchTargets);
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
test('limits.batchTargets:上限可调,超了仍然拒绝', () => {
|
|
52
|
+
const runtime = runtimeFor({ limits: { batchTargets: 2 } });
|
|
53
|
+
try {
|
|
54
|
+
const payloads = [{ path: '/tmp/a' }, { path: '/tmp/b' }, { path: '/tmp/c' }];
|
|
55
|
+
assert.throws(() => runtime.guard.enforceBatch('fs.read', payloads), /超过上限 2/);
|
|
56
|
+
} finally {
|
|
57
|
+
cleanup(runtime.dir);
|
|
58
|
+
}
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
test('limits.integrityEvery = 0 时关闭抽样校验', () => {
|
|
62
|
+
const runtime = runtimeFor({ limits: { integrityEvery: 0 } });
|
|
63
|
+
const bridge = runtime.guard.installNativeHooks();
|
|
64
|
+
const original = fs.readFileSync;
|
|
65
|
+
try {
|
|
66
|
+
fs.readFileSync = function tampered() { return 'tampered'; };
|
|
67
|
+
for (let index = 0; index < 200; index += 1) {
|
|
68
|
+
runtime.guard.check('fs.read', { path: path.join(runtime.dir, 'a.txt') });
|
|
69
|
+
}
|
|
70
|
+
assert.equal(runtime.guard.isCompromised(), false, '关掉抽样后不应该自己发现');
|
|
71
|
+
} finally {
|
|
72
|
+
fs.readFileSync = original;
|
|
73
|
+
bridge.uninstall();
|
|
74
|
+
cleanup(runtime.dir);
|
|
75
|
+
}
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
test('limits.fetchTimeoutMs:0 不注入超时,正值注入', async () => {
|
|
79
|
+
const originalFetch = globalThis.fetch;
|
|
80
|
+
const captured = [];
|
|
81
|
+
globalThis.fetch = (input, init) => { captured.push(init); return Promise.resolve({}); };
|
|
82
|
+
try {
|
|
83
|
+
const noTimeout = createNetHandle({ guard: { enforce() {} }, limits: { fetchTimeoutMs: 0 } });
|
|
84
|
+
await noTimeout.fetch('https://example.com/a');
|
|
85
|
+
assert.equal(captured[0].signal, undefined, 'fetchTimeoutMs=0 时不注入 signal');
|
|
86
|
+
|
|
87
|
+
const withTimeout = createNetHandle({ guard: { enforce() {} }, limits: { fetchTimeoutMs: 1500 } });
|
|
88
|
+
await withTimeout.fetch('https://example.com/a');
|
|
89
|
+
assert.ok(captured[1].signal instanceof AbortSignal);
|
|
90
|
+
} finally {
|
|
91
|
+
globalThis.fetch = originalFetch;
|
|
92
|
+
}
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
test('limits.minMacKeyBytes:下限只能抬高,不能降到 32 以下', () => {
|
|
96
|
+
const dir = tempDir('options-mac-');
|
|
97
|
+
try {
|
|
98
|
+
const shortKey = 'k'.repeat(8);
|
|
99
|
+
assert.throws(() => resolveLedgerMacKey({ ledgerMacKey: shortKey }), /太短/);
|
|
100
|
+
// 函数级参数也不能放宽:否则「可调」就是绕过 policy 的后门。
|
|
101
|
+
assert.throws(() => resolveLedgerMacKey({ ledgerMacKey: shortKey, minBytes: 8 }), /太短/);
|
|
102
|
+
// policy 侧不允许把下限配到 32 以下:那等于允许把 HMAC 强度关掉。
|
|
103
|
+
assert.throws(
|
|
104
|
+
() => resolveLimits({ limits: { minMacKeyBytes: 8 } }),
|
|
105
|
+
/minMacKeyBytes/,
|
|
106
|
+
);
|
|
107
|
+
} finally {
|
|
108
|
+
cleanup(dir);
|
|
109
|
+
}
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
test('limits.globBatchSize:异步 glob 按配置分块', async (t) => {
|
|
113
|
+
if (!fs.promises || typeof fs.promises.glob !== 'function') {
|
|
114
|
+
t.skip('当前 Node 没有 fs.promises.glob');
|
|
115
|
+
return;
|
|
116
|
+
}
|
|
117
|
+
const dir = tempDir('options-glob-');
|
|
118
|
+
const scope = { paths: [dir.replace(/\\/g, '/') + '/**'] };
|
|
119
|
+
createPlugin({ dir, requests: [{ cap: 'fs.read', scope, reason: '读取自身文件' }] });
|
|
120
|
+
const { manifest } = loadManifest(dir);
|
|
121
|
+
const ledger = new Ledger(path.join(dir, 'ledger.jsonl'));
|
|
122
|
+
const grants = [normalizeGrant({ capability: 'fs.read', scope }, { pluginRoot: dir }, 0)];
|
|
123
|
+
const guard = createGuardChain({
|
|
124
|
+
manifest, grants, ledger, pluginRoot: dir, policy: { limits: { globBatchSize: 2 } },
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
for (let index = 0; index < 5; index += 1) {
|
|
128
|
+
fs.writeFileSync(path.join(dir, 'f' + index + '.txt'), 'x');
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
const bridge = createBridge({
|
|
132
|
+
guard, ledger, plugin: { id: manifest.id }, globBatchSize: 2,
|
|
133
|
+
});
|
|
134
|
+
try {
|
|
135
|
+
let seen = 0;
|
|
136
|
+
for await (const matched of fs.promises.glob('**/*.txt', { cwd: dir })) {
|
|
137
|
+
assert.ok(matched);
|
|
138
|
+
seen += 1;
|
|
139
|
+
}
|
|
140
|
+
assert.equal(seen, 5);
|
|
141
|
+
const records = ledger.records().filter((record) => record.ruleId === 'two-key-batch');
|
|
142
|
+
assert.equal(records.length, 3, '5 条结果按 2 条一块 → 3 条账本');
|
|
143
|
+
} finally {
|
|
144
|
+
bridge.uninstall();
|
|
145
|
+
cleanup(dir);
|
|
146
|
+
}
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
test('enforcement.strictPatch:补丁不完整时拒绝装载', async (t) => {
|
|
150
|
+
const descriptor = Object.getOwnPropertyDescriptor(fs, 'opendirSync');
|
|
151
|
+
if (!descriptor || descriptor.configurable === false) {
|
|
152
|
+
t.skip('fs.opendirSync 不可配置,无法构造补丁失败');
|
|
153
|
+
return;
|
|
154
|
+
}
|
|
155
|
+
const saved = fs.opendirSync;
|
|
156
|
+
delete fs.opendirSync;
|
|
157
|
+
|
|
158
|
+
const dir = tempDir('options-strict-');
|
|
159
|
+
const plugin = createPlugin({ dir, requests: [] });
|
|
160
|
+
const host = new LocalHost({
|
|
161
|
+
ledgerPath: path.join(dir, 'ledger.jsonl'),
|
|
162
|
+
policy: { enforcement: { strictPatch: true } },
|
|
163
|
+
});
|
|
164
|
+
try {
|
|
165
|
+
await host.install(dir);
|
|
166
|
+
await assert.rejects(() => host.register(plugin.manifest.id), /补丁不完整/);
|
|
167
|
+
} finally {
|
|
168
|
+
try { await host.uninstall(plugin.manifest.id); } catch {}
|
|
169
|
+
Object.defineProperty(fs, 'opendirSync', {
|
|
170
|
+
value: saved, writable: true, configurable: true, enumerable: descriptor.enumerable,
|
|
171
|
+
});
|
|
172
|
+
cleanup(dir);
|
|
173
|
+
}
|
|
174
|
+
});
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const test = require('node:test');
|
|
4
|
+
const assert = require('node:assert/strict');
|
|
5
|
+
const { tempDir, createPlugin, cleanup } = require('./helpers/fixtures');
|
|
6
|
+
const { loadManifest } = require('../src/contract/manifest');
|
|
7
|
+
const { buildPortrait, renderPortrait } = require('../src/portrait/portrait');
|
|
8
|
+
const { computeMetrics } = require('../src/governance/metrics');
|
|
9
|
+
|
|
10
|
+
test('画像卡:安全标签按 label 去重,首屏只报去重后的数量', () => {
|
|
11
|
+
const portrait = buildPortrait({
|
|
12
|
+
manifest: { id: 'com.example.labels', dashcam: { requests: [] } },
|
|
13
|
+
labels: [
|
|
14
|
+
{ gate: 'signature', code: 'trust_missing', label: 'Untrusted', reason: '未提供信任表,无法验签' },
|
|
15
|
+
{ gate: 'safety', code: 'network_egress', label: 'ReviewRequired', reason: '出网范围需要人工复核' },
|
|
16
|
+
{ gate: 'sieve', code: 'quarantined', label: 'Untrusted', reason: '重复分档不应重复计数' },
|
|
17
|
+
],
|
|
18
|
+
});
|
|
19
|
+
assert.deepEqual(portrait.securityLabels, ['Untrusted', 'ReviewRequired']);
|
|
20
|
+
assert.equal(portrait.firstScreen.labelCount, 2);
|
|
21
|
+
// labels 原文保留,去重只作用于首屏展示用的 securityLabels。
|
|
22
|
+
assert.equal(portrait.labels.length, 3);
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
test('画像卡:首屏直出安全标签与隔离档两行', () => {
|
|
26
|
+
const dir = tempDir('portrait-labels-');
|
|
27
|
+
createPlugin({ dir, legacy: true });
|
|
28
|
+
const { manifest } = loadManifest(dir);
|
|
29
|
+
cleanup(dir);
|
|
30
|
+
|
|
31
|
+
const portrait = buildPortrait({
|
|
32
|
+
manifest,
|
|
33
|
+
labels: [
|
|
34
|
+
{ gate: 'signature', code: 'signature_skipped', label: 'SignatureSkipped', reason: '调用方跳过验签' },
|
|
35
|
+
{ gate: 'sieve', code: 'quarantined', label: 'Quarantined', reason: 'legacy 插件按 deny-all 隔离' },
|
|
36
|
+
],
|
|
37
|
+
});
|
|
38
|
+
assert.equal(portrait.quarantine, true);
|
|
39
|
+
|
|
40
|
+
const rendered = renderPortrait(portrait);
|
|
41
|
+
assert.ok(rendered.includes('安全标签:SignatureSkipped / Quarantined'));
|
|
42
|
+
assert.ok(rendered.includes('隔离档:无 dashcam 声明,运行时 deny-all'));
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
test('画像卡:隔离档判定统一走 isQuarantined', () => {
|
|
46
|
+
// dashcam.quarantine 为真、顶层字段缺失时也应判定为隔离档。
|
|
47
|
+
const fromDashcam = buildPortrait({
|
|
48
|
+
manifest: { id: 'com.example.quarantined', dashcam: { requests: [], quarantine: true } },
|
|
49
|
+
});
|
|
50
|
+
assert.equal(fromDashcam.quarantine, true);
|
|
51
|
+
|
|
52
|
+
const plain = buildPortrait({ manifest: { dashcam: { requests: [] } } });
|
|
53
|
+
assert.equal(plain.quarantine, false);
|
|
54
|
+
assert.equal(buildPortrait({}).quarantine, false);
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
test('治理度量:未声明越权与 grant 拒绝分开计数', () => {
|
|
58
|
+
const ledgerRecords = [
|
|
59
|
+
{ ts: '2026-10-01T10:00:00.000Z', decision: 'allow', ruleId: 'two-key', intercepted: false },
|
|
60
|
+
{ ts: '2026-10-01T10:01:00.000Z', decision: 'deny', ruleId: 'not_declared', intercepted: true },
|
|
61
|
+
{ ts: '2026-10-01T10:02:00.000Z', decision: 'deny', ruleId: 'not_granted', intercepted: false },
|
|
62
|
+
];
|
|
63
|
+
const metrics = computeMetrics({ ledgerRecords });
|
|
64
|
+
|
|
65
|
+
assert.equal(metrics.requests, 3);
|
|
66
|
+
assert.equal(metrics.allows, 1);
|
|
67
|
+
assert.equal(metrics.denies, 2);
|
|
68
|
+
assert.equal(metrics.undeclaredInterceptions, 1);
|
|
69
|
+
assert.equal(metrics.undeclaredInterceptionRate, 1 / 3);
|
|
70
|
+
assert.equal(metrics.grantDenials, 1);
|
|
71
|
+
assert.equal(metrics.grantDenialRate, 0.5);
|
|
72
|
+
});
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const test = require('node:test');
|
|
4
|
+
const assert = require('node:assert/strict');
|
|
5
|
+
const { tempDir, createPlugin, cleanup } = require('./helpers/fixtures');
|
|
6
|
+
const { loadManifest } = require('../src/contract/manifest');
|
|
7
|
+
const { scanPlugin } = require('../src/gates/safety');
|
|
8
|
+
const { loadPolicy } = require('../src/policy');
|
|
9
|
+
const { buildPortrait, renderPortrait, CAVEAT } = require('../src/portrait/portrait');
|
|
10
|
+
|
|
11
|
+
function portraitFor(requests, code) {
|
|
12
|
+
const dir = tempDir('portrait-');
|
|
13
|
+
createPlugin({ dir, requests, code });
|
|
14
|
+
const { manifest } = loadManifest(dir);
|
|
15
|
+
const scanResult = scanPlugin(dir, { policy: loadPolicy({}), manifest });
|
|
16
|
+
const portrait = buildPortrait({ manifest, scan: scanResult.scan });
|
|
17
|
+
cleanup(dir);
|
|
18
|
+
return portrait;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
test('画像卡:高风险项触发高红档,首屏不泄露细节', () => {
|
|
22
|
+
const portrait = portraitFor([
|
|
23
|
+
{ cap: 'fs.read', scope: { paths: ['$PLUGIN/**'] }, reason: '读取缓存' },
|
|
24
|
+
{ cap: 'cred.read', scope: { targets: ['env:PLATFORM_KEY'] }, reason: '读取平台密钥' },
|
|
25
|
+
]);
|
|
26
|
+
assert.equal(portrait.tier, 'high');
|
|
27
|
+
assert.equal(portrait.firstScreen.highRiskCount, 1);
|
|
28
|
+
assert.equal(portrait.firstScreen.groupCounts.cred, 1);
|
|
29
|
+
assert.equal(portrait.firstScreen.caveat, CAVEAT);
|
|
30
|
+
const firstScreen = JSON.stringify(portrait.firstScreen);
|
|
31
|
+
assert.equal(firstScreen.includes('读取平台密钥'), false);
|
|
32
|
+
assert.equal(firstScreen.includes('读取缓存'), false);
|
|
33
|
+
assert.equal(portrait.rejectionReason, null);
|
|
34
|
+
|
|
35
|
+
const rendered = renderPortrait(portrait);
|
|
36
|
+
assert.ok(rendered.includes('[!] cred.read'));
|
|
37
|
+
assert.ok(rendered.includes('需强制确认'));
|
|
38
|
+
assert.ok(rendered.includes(CAVEAT));
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
test('画像卡:中风险展开勾选,低风险折叠', () => {
|
|
42
|
+
const medium = portraitFor([
|
|
43
|
+
{ cap: 'net.outbound', scope: { hosts: ['api.example.com:443'] }, reason: '调用 API' },
|
|
44
|
+
{ cap: 'fs.read', scope: { paths: ['$PLUGIN/**'] }, reason: '读取缓存' },
|
|
45
|
+
]);
|
|
46
|
+
assert.equal(medium.tier, 'medium');
|
|
47
|
+
assert.ok(renderPortrait(medium).includes('[ ] net.outbound'));
|
|
48
|
+
|
|
49
|
+
const low = portraitFor([
|
|
50
|
+
{ cap: 'fs.read', scope: { paths: ['$PLUGIN/a/**'] }, reason: '读取 A' },
|
|
51
|
+
{ cap: 'fs.read', scope: { paths: ['$PLUGIN/b/**'] }, reason: '读取 B' },
|
|
52
|
+
]);
|
|
53
|
+
assert.equal(low.tier, 'low');
|
|
54
|
+
assert.ok(renderPortrait(low).includes('已折叠 2 项'));
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
test('画像卡:拒绝原因直出原文,不包装', () => {
|
|
58
|
+
const dir = tempDir('portrait-reject-');
|
|
59
|
+
createPlugin({ dir, requests: [{ cap: 'proc.spawn', scope: { commands: ['node'] }, reason: '原始拒绝原因' }] });
|
|
60
|
+
const { manifest } = loadManifest(dir);
|
|
61
|
+
const portrait = buildPortrait({ manifest, rejection: { reason: '原始拒绝原因' } });
|
|
62
|
+
const rendered = renderPortrait(portrait);
|
|
63
|
+
assert.ok(rendered.includes('拒绝原因:原始拒绝原因'));
|
|
64
|
+
cleanup(dir);
|
|
65
|
+
});
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const test = require('node:test');
|
|
4
|
+
const assert = require('node:assert/strict');
|
|
5
|
+
|
|
6
|
+
const protocol = require('../src/runtime/ipc/protocol.js');
|
|
7
|
+
|
|
8
|
+
test('导出契约精确(OPS / CONTROL_OPS / 函数)', () => {
|
|
9
|
+
assert.deepEqual(protocol.OPS, ['fs.read', 'fs.readMany', 'fs.write', 'net.outbound', 'ping']);
|
|
10
|
+
assert.deepEqual(protocol.CONTROL_OPS, ['invoke']);
|
|
11
|
+
assert.ok(Object.isFrozen(protocol.OPS));
|
|
12
|
+
assert.ok(Object.isFrozen(protocol.CONTROL_OPS));
|
|
13
|
+
for (const fn of ['makeRequest', 'makeResponse', 'isValidRequest', 'isValidResponse']) {
|
|
14
|
+
assert.equal(typeof protocol[fn], 'function', fn);
|
|
15
|
+
}
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
test('makeRequest:payload 缺省为空对象', () => {
|
|
19
|
+
assert.deepEqual(protocol.makeRequest(1, 'ping', { x: 1 }), {
|
|
20
|
+
id: 1, kind: 'request', op: 'ping', payload: { x: 1 },
|
|
21
|
+
});
|
|
22
|
+
assert.deepEqual(protocol.makeRequest('s', 'fs.read'), {
|
|
23
|
+
id: 's', kind: 'request', op: 'fs.read', payload: {},
|
|
24
|
+
});
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
test('makeResponse:ok 强转布尔、value/error 归一化', () => {
|
|
28
|
+
assert.deepEqual(protocol.makeResponse('a', true, 42, null), {
|
|
29
|
+
id: 'a', kind: 'response', ok: true, value: 42, error: null,
|
|
30
|
+
});
|
|
31
|
+
assert.deepEqual(protocol.makeResponse('a', false, undefined, { code: 'E1', message: 'x' }), {
|
|
32
|
+
id: 'a', kind: 'response', ok: false, value: null, error: { code: 'E1', message: 'x' },
|
|
33
|
+
});
|
|
34
|
+
assert.equal(protocol.makeResponse('a', 0, 1, null).ok, false);
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
test('isValidRequest:合法请求通过', () => {
|
|
38
|
+
assert.equal(protocol.isValidRequest({ id: 1, kind: 'request', op: 'ping', payload: {} }), true);
|
|
39
|
+
assert.equal(protocol.isValidRequest({ id: 's', kind: 'request', op: 'fs.read' }), true); // payload 省略
|
|
40
|
+
assert.equal(protocol.isValidRequest({ id: 0, kind: 'request', op: 'ping', payload: undefined }), true);
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
test('isValidRequest:畸形/恶意输入全部 false 且不抛', () => {
|
|
44
|
+
const bad = [
|
|
45
|
+
null,
|
|
46
|
+
undefined,
|
|
47
|
+
42,
|
|
48
|
+
'str',
|
|
49
|
+
true,
|
|
50
|
+
[],
|
|
51
|
+
{},
|
|
52
|
+
{ id: 1, kind: 'request', op: {}, payload: {} }, // op 是对象
|
|
53
|
+
{ id: 1, kind: 'request', op: '', payload: {} }, // op 空串
|
|
54
|
+
{ id: 1, kind: 'request', op: 7, payload: {} }, // op 非字符串
|
|
55
|
+
{ id: 1, kind: 'request', op: 'ping', payload: [] }, // payload 数组
|
|
56
|
+
{ id: 1, kind: 'request', op: 'ping', payload: 'x' }, // payload 非对象
|
|
57
|
+
{ id: 1, kind: 'request', op: 'ping', payload: 42 },
|
|
58
|
+
{ id: 1, kind: 'request', op: 'ping', payload: new Date() }, // 非纯对象
|
|
59
|
+
{ id: {}, kind: 'request', op: 'ping', payload: {} }, // id 是对象
|
|
60
|
+
{ id: null, kind: 'request', op: 'ping', payload: {} },
|
|
61
|
+
{ id: true, kind: 'request', op: 'ping', payload: {} },
|
|
62
|
+
{ kind: 'request', op: 'ping', payload: {} }, // 缺 id
|
|
63
|
+
{ id: 1, op: 'ping', payload: {} }, // 缺 kind
|
|
64
|
+
{ id: 1, kind: 'req', op: 'ping', payload: {} }, // kind 错
|
|
65
|
+
{ id: 1, kind: 'request', payload: {} }, // 缺 op
|
|
66
|
+
];
|
|
67
|
+
for (const m of bad) {
|
|
68
|
+
assert.equal(protocol.isValidRequest(m), false, JSON.stringify(m));
|
|
69
|
+
}
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
test('isValidResponse:合法响应通过', () => {
|
|
73
|
+
assert.equal(protocol.isValidResponse({ id: 1, kind: 'response', ok: true, value: 1, error: null }), true);
|
|
74
|
+
assert.equal(protocol.isValidResponse({ id: 'x', kind: 'response', ok: false, value: null, error: { code: 'E' } }), true);
|
|
75
|
+
assert.equal(protocol.isValidResponse({ id: 1, kind: 'response', ok: false, value: null, error: undefined }), true);
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
test('isValidResponse:畸形/恶意输入全部 false 且不抛', () => {
|
|
79
|
+
const bad = [
|
|
80
|
+
null,
|
|
81
|
+
undefined,
|
|
82
|
+
[],
|
|
83
|
+
{},
|
|
84
|
+
{ id: 1, kind: 'response', ok: 'yes', value: 1, error: null }, // ok 非布尔
|
|
85
|
+
{ id: 1, kind: 'response', ok: 1, value: 1, error: null },
|
|
86
|
+
{ id: 1, kind: 'response', ok: true, value: 1, error: { code: 1 } }, // code 非字符串
|
|
87
|
+
{ id: 1, kind: 'response', ok: true, value: 1, error: {} }, // 缺 code
|
|
88
|
+
{ id: 1, kind: 'response', ok: true, value: 1, error: [] }, // error 数组
|
|
89
|
+
{ id: 1, kind: 'response', ok: true, value: 1, error: 'x' },
|
|
90
|
+
{ id: {}, kind: 'response', ok: true, value: 1, error: null }, // id 对象
|
|
91
|
+
{ kind: 'response', ok: true, value: 1, error: null }, // 缺 id
|
|
92
|
+
{ id: 1, ok: true, value: 1, error: null }, // 缺 kind
|
|
93
|
+
{ id: 1, kind: 'resp', ok: true, value: 1, error: null }, // kind 错
|
|
94
|
+
];
|
|
95
|
+
for (const m of bad) {
|
|
96
|
+
assert.equal(protocol.isValidResponse(m), false, JSON.stringify(m));
|
|
97
|
+
}
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
test('恶意 getter 抛异常也不让校验函数抛错', () => {
|
|
101
|
+
const evil = {};
|
|
102
|
+
Object.defineProperty(evil, 'kind', { get() { throw new Error('boom'); } });
|
|
103
|
+
assert.equal(protocol.isValidRequest(evil), false);
|
|
104
|
+
assert.equal(protocol.isValidResponse(evil), false);
|
|
105
|
+
});
|