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
package/SECURITY.md
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
# Security Policy
|
|
2
|
+
|
|
3
|
+
## Reporting a vulnerability
|
|
4
|
+
|
|
5
|
+
**Do not open a public issue for security reports.**
|
|
6
|
+
|
|
7
|
+
Use GitHub's private vulnerability reporting on this repository:
|
|
8
|
+
**Security tab → Report a vulnerability** (https://github.com/odysseykernelt/dsh-dashcam/security/advisories/new).
|
|
9
|
+
|
|
10
|
+
You can also reach the maintainer via a Discussion in the *Ideas* category if the issue is not sensitive. Please include: affected version, reproduction steps, and — if possible — a ledger excerpt or gate report demonstrating the issue.
|
|
11
|
+
|
|
12
|
+
## Supported versions
|
|
13
|
+
|
|
14
|
+
| Version | Supported |
|
|
15
|
+
| --- | --- |
|
|
16
|
+
| 1.12.x | yes |
|
|
17
|
+
| < 1.12 | no (see the 1.11.0 rename migration notes in the README) |
|
|
18
|
+
|
|
19
|
+
## Known boundaries (please read before relying on this project)
|
|
20
|
+
|
|
21
|
+
dsh-dashcam is a judgment chain and audit ledger, **not a sandbox**. The built-in Bridge is a patch-based in-process guard with explicit known gaps:
|
|
22
|
+
|
|
23
|
+
- Native addons, `worker_threads`, `vm`-generated code, and direct syscalls are out of reach of Node-level interception.
|
|
24
|
+
- Function references cached/captured by a plugin before the Bridge is installed are not protected.
|
|
25
|
+
- Static scanning results are "scan results", not security proofs — see `bridge.coverage()` (`guarded` list + `knownGaps`).
|
|
26
|
+
- The load window for legacy plugins is a deliberate, one-time trust concession; see the README section **Security boundaries**.
|
|
27
|
+
|
|
28
|
+
`bridge.coverage().knownGaps` is the authoritative list — treat it as an upper bound of the capability surface, not noise. We make no claim of "full protection"; the ledger's job is to make what happened verifiable after the fact.
|
|
@@ -0,0 +1,574 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
'use strict';
|
|
3
|
+
|
|
4
|
+
const crypto = require('node:crypto');
|
|
5
|
+
const fs = require('node:fs');
|
|
6
|
+
const os = require('node:os');
|
|
7
|
+
const path = require('node:path');
|
|
8
|
+
const { loadPolicy } = require('../src/policy');
|
|
9
|
+
const { loadManifest } = require('../src/contract/manifest');
|
|
10
|
+
const { runInstallPipeline } = require('../src/gates/index');
|
|
11
|
+
const { scanPlugin } = require('../src/gates/safety');
|
|
12
|
+
const { verifyBundle, signBundle } = require('../src/signing/bundle');
|
|
13
|
+
const { createTrust } = require('../src/signing/trust');
|
|
14
|
+
const { buildPortrait, renderPortrait } = require('../src/portrait/portrait');
|
|
15
|
+
const { runAutopsy } = require('../src/autopsy/autopsy');
|
|
16
|
+
const { computeMetrics, monthlyMetrics } = require('../src/governance/metrics');
|
|
17
|
+
const { Blacklist } = require('../src/governance/blacklist');
|
|
18
|
+
const { checkCircuitBreaker } = require('../src/governance/rollout');
|
|
19
|
+
const { LocalHost, resolveLedgerMacKey } = require('../src/host/local-host');
|
|
20
|
+
const { Ledger } = require('../src/runtime/ledger');
|
|
21
|
+
const { summarizeIngress, findIngressMatches } = require('../src/runtime/ingress');
|
|
22
|
+
const { buildBehaviorReport, buildDeclarationDraft } = require('../src/governance/behavior');
|
|
23
|
+
const { createPluginRuntime } = require('../src/index');
|
|
24
|
+
const { normalizeGrant } = require('../src/contract/capabilities');
|
|
25
|
+
const { stableStringify, sha256: sha256Hex } = require('../src/util/stable');
|
|
26
|
+
|
|
27
|
+
function readJson(file) {
|
|
28
|
+
return JSON.parse(fs.readFileSync(file, 'utf8'));
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function parseArgs(argv) {
|
|
32
|
+
const rest = [];
|
|
33
|
+
const flags = {};
|
|
34
|
+
for (let index = 0; index < argv.length; index += 1) {
|
|
35
|
+
const token = argv[index];
|
|
36
|
+
if (token.startsWith('--')) {
|
|
37
|
+
const key = token.slice(2);
|
|
38
|
+
if (key.startsWith('no-')) {
|
|
39
|
+
flags[key.slice(3)] = false;
|
|
40
|
+
} else {
|
|
41
|
+
const next = argv[index + 1];
|
|
42
|
+
if (next !== undefined && !next.startsWith('--')) {
|
|
43
|
+
flags[key] = next;
|
|
44
|
+
index += 1;
|
|
45
|
+
} else {
|
|
46
|
+
flags[key] = true;
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
} else {
|
|
50
|
+
rest.push(token);
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
return { rest, flags };
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function output(value) {
|
|
57
|
+
process.stdout.write(`${JSON.stringify(value, null, 2)}\n`);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* N5 --redact-paths:分享账本前的路径脱敏。把 home 前缀(含正反斜杠两种
|
|
62
|
+
* 形态)折叠成 $HOME,深度遍历 scope 与 reason 里的一切字符串。
|
|
63
|
+
* 归属字段(plugin/pluginVersion/profiled/manifestHash)原样保留——
|
|
64
|
+
* 「谁干的」是取证核心维度,脱敏不能把责任主体一起抹掉。
|
|
65
|
+
* 输出是新链:逐条重算 seq/prevHash/hash(不带 mac)——脱敏改了内容,
|
|
66
|
+
* 旧 hash 必然对不上,与其留一份验不过的副本,不如诚实地立一条新链。
|
|
67
|
+
*/
|
|
68
|
+
function redactValue(value, pairs, counter) {
|
|
69
|
+
if (typeof value === 'string') {
|
|
70
|
+
let result = value;
|
|
71
|
+
for (const [from, to] of pairs) {
|
|
72
|
+
if (!result.includes(from)) continue;
|
|
73
|
+
counter.n += result.split(from).length - 1;
|
|
74
|
+
result = result.split(from).join(to);
|
|
75
|
+
}
|
|
76
|
+
return result;
|
|
77
|
+
}
|
|
78
|
+
if (Array.isArray(value)) return value.map((item) => redactValue(item, pairs, counter));
|
|
79
|
+
if (value && typeof value === 'object') {
|
|
80
|
+
const copy = {};
|
|
81
|
+
for (const key of Object.keys(value)) copy[key] = redactValue(value[key], pairs, counter);
|
|
82
|
+
return copy;
|
|
83
|
+
}
|
|
84
|
+
return value;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function redactLedger(inputPath, outputPath) {
|
|
88
|
+
const home = os.homedir().replace(/[\\/]+$/, '');
|
|
89
|
+
const pairs = home
|
|
90
|
+
? [[home, '$HOME'], [home.replace(/\\/g, '/'), '$HOME']]
|
|
91
|
+
: [];
|
|
92
|
+
const counter = { n: 0 };
|
|
93
|
+
const source = new Ledger(inputPath);
|
|
94
|
+
const records = source.records();
|
|
95
|
+
const target = new Ledger(outputPath);
|
|
96
|
+
for (const record of records) {
|
|
97
|
+
const { hash, mac, prevHash, ...keep } = record;
|
|
98
|
+
const redacted = {
|
|
99
|
+
...keep,
|
|
100
|
+
scope: redactValue(keep.scope || null, pairs, counter),
|
|
101
|
+
reason: typeof keep.reason === 'string' ? redactValue(keep.reason, pairs, counter) : keep.reason,
|
|
102
|
+
};
|
|
103
|
+
target.append(redacted);
|
|
104
|
+
}
|
|
105
|
+
return { records: records.length, homeFolded: home ? '$HOME' : null, replacements: counter.n };
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* 先校验、再统计:在一个校验不过的账本上算指标,等于给篡改后的数据背书。
|
|
110
|
+
* 返回 null 表示已打印错误并设置非零退出码。
|
|
111
|
+
*/
|
|
112
|
+
function loadVerifiedLedger(ledgerPath, flags) {
|
|
113
|
+
const resolvedMac = resolveLedgerMacKey({
|
|
114
|
+
ledgerMacKeyFile: flags['mac-key-file'] || null,
|
|
115
|
+
ledgerMacKeyEnv: flags['mac-key-env'] || null,
|
|
116
|
+
});
|
|
117
|
+
const ledger = new Ledger(ledgerPath, { macKey: resolvedMac.key });
|
|
118
|
+
const verification = ledger.verify(flags.anchor ? { anchor: readJson(flags.anchor) } : {});
|
|
119
|
+
if (!verification.ok) {
|
|
120
|
+
process.stderr.write(`账本校验失败(${verification.errors.length} 处):\n`);
|
|
121
|
+
for (const error of verification.errors.slice(0, 5)) {
|
|
122
|
+
process.stderr.write(` line ${error.line} ${error.code} — ${error.detail}\n`);
|
|
123
|
+
}
|
|
124
|
+
if (verification.errors.some((error) => error.code === 'mac_key_missing')) {
|
|
125
|
+
process.stderr.write('提示:该账本是 v3(带 mac)。请用 --mac-key-file <path> 或 --mac-key-env <NAME> 提供密钥。\n');
|
|
126
|
+
}
|
|
127
|
+
process.exitCode = 1;
|
|
128
|
+
return null;
|
|
129
|
+
}
|
|
130
|
+
return ledger;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
function keygen(outDir) {
|
|
134
|
+
fs.mkdirSync(outDir, { recursive: true });
|
|
135
|
+
const make = (name) => {
|
|
136
|
+
const { privateKey, publicKey } = crypto.generateKeyPairSync('ed25519', {
|
|
137
|
+
privateKeyEncoding: { type: 'pkcs8', format: 'pem' },
|
|
138
|
+
publicKeyEncoding: { type: 'spki', format: 'pem' },
|
|
139
|
+
});
|
|
140
|
+
fs.writeFileSync(path.join(outDir, `${name}.private.pem`), privateKey);
|
|
141
|
+
fs.writeFileSync(path.join(outDir, `${name}.public.pem`), publicKey);
|
|
142
|
+
return publicKey;
|
|
143
|
+
};
|
|
144
|
+
const publisher = make('publisher');
|
|
145
|
+
const reviewer = make('reviewer');
|
|
146
|
+
const trust = createTrust([
|
|
147
|
+
{ keyId: 'publisher-2026a', role: 'publisher', publicKey: publisher, status: 'active' },
|
|
148
|
+
{ keyId: 'reviewer-2026a', role: 'reviewer', publicKey: reviewer, status: 'active' },
|
|
149
|
+
]);
|
|
150
|
+
fs.writeFileSync(path.join(outDir, 'trust.json'), JSON.stringify(trust, null, 2));
|
|
151
|
+
return { outDir, files: fs.readdirSync(outDir), trust };
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
async function main() {
|
|
155
|
+
const { rest, flags } = parseArgs(process.argv.slice(2));
|
|
156
|
+
const command = rest[0];
|
|
157
|
+
if (!command || command === 'help' || flags.help) {
|
|
158
|
+
process.stdout.write([
|
|
159
|
+
'dsh-dashcam CLI',
|
|
160
|
+
' keygen <outDir>',
|
|
161
|
+
' sign <pluginDir> --plugin <id> --version <v> --publisher-key <pem> --publisher-key-id <id> --reviewer-key <pem> --reviewer-key-id <id>',
|
|
162
|
+
' verify <pluginDir> --trust <trust.json>',
|
|
163
|
+
' verify-ledger <ledger.jsonl> [--mac-key <key> | --mac-key-file <path> | --mac-key-env <NAME>] [--anchor anchor.json]',
|
|
164
|
+
' rotate <ledger.jsonl> [--max-bytes <N>] [--mac-key-file <path> | --mac-key-env <NAME>]',
|
|
165
|
+
' redact <ledger.jsonl> --out <file.jsonl> [--mac-key-file <path> | --mac-key-env <NAME>]',
|
|
166
|
+
' behavior <ledger.jsonl> [--manifest <pluginDir>] [--mac-key-file <path> | --mac-key-env <NAME>]',
|
|
167
|
+
' draft <ledger.jsonl> [--manifest <pluginDir>] [--mac-key-file <path> | --mac-key-env <NAME>]',
|
|
168
|
+
' bench [--n <iterations>]',
|
|
169
|
+
' gates <pluginDir> [--channel consumer|enterprise] [--policy policy.json] [--trust trust.json] [--skip-signature] [--host]',
|
|
170
|
+
' portrait <pluginDir> [--channel consumer|enterprise] [--policy policy.json] [--trust trust.json]',
|
|
171
|
+
' autopsy <pluginDir> [--profile profile.json] [--patch patch.json] [--policy policy.json] [--no-signature]',
|
|
172
|
+
' metrics <ledger.jsonl> [--events events.json]',
|
|
173
|
+
' ingress <ledger.jsonl> [--session <id>] [--find <text>] [--mac-key-file <path>]',
|
|
174
|
+
' blacklist <blacklist.json> add|remove|list [plugin] [reason]',
|
|
175
|
+
' rollout <metrics.json>',
|
|
176
|
+
].join('\n') + '\n');
|
|
177
|
+
return;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
if (command === 'keygen') {
|
|
181
|
+
output(keygen(path.resolve(rest[1] || 'keys')));
|
|
182
|
+
return;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
if (command === 'sign') {
|
|
186
|
+
const pluginDir = path.resolve(rest[1]);
|
|
187
|
+
const signatures = signBundle({
|
|
188
|
+
pluginDir,
|
|
189
|
+
plugin: flags.plugin,
|
|
190
|
+
version: flags.version,
|
|
191
|
+
publisher: {
|
|
192
|
+
keyId: flags['publisher-key-id'],
|
|
193
|
+
privateKey: fs.readFileSync(flags['publisher-key'], 'utf8'),
|
|
194
|
+
},
|
|
195
|
+
reviewer: {
|
|
196
|
+
keyId: flags['reviewer-key-id'],
|
|
197
|
+
privateKey: fs.readFileSync(flags['reviewer-key'], 'utf8'),
|
|
198
|
+
},
|
|
199
|
+
});
|
|
200
|
+
output(signatures);
|
|
201
|
+
return;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
if (command === 'verify') {
|
|
205
|
+
const pluginDir = path.resolve(rest[1]);
|
|
206
|
+
const trust = createTrust(readJson(flags.trust).keys || readJson(flags.trust));
|
|
207
|
+
const result = verifyBundle({ pluginDir, trust });
|
|
208
|
+
output(result);
|
|
209
|
+
if (!result.ok) process.exitCode = 1;
|
|
210
|
+
return;
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
if (command === 'verify-ledger') {
|
|
214
|
+
// 独立验链:脱离宿主进程复核账本——逐行重算哈希链 + mac 校验 + 可选外部锚点。
|
|
215
|
+
// 行车记录仪的产物是账本,复核就不应该依赖装着它的那个宿主还活着。
|
|
216
|
+
const ledgerPath = path.resolve(rest[1]);
|
|
217
|
+
if (!fs.existsSync(ledgerPath)) {
|
|
218
|
+
process.stderr.write(`账本不存在:${ledgerPath}\n`);
|
|
219
|
+
process.exitCode = 1;
|
|
220
|
+
return;
|
|
221
|
+
}
|
|
222
|
+
let resolvedMac;
|
|
223
|
+
try {
|
|
224
|
+
resolvedMac = resolveLedgerMacKey({
|
|
225
|
+
ledgerMacKey: typeof flags['mac-key'] === 'string' ? flags['mac-key'] : null,
|
|
226
|
+
ledgerMacKeyFile: flags['mac-key-file'] || null,
|
|
227
|
+
ledgerMacKeyEnv: flags['mac-key-env'] || null,
|
|
228
|
+
});
|
|
229
|
+
} catch (error) {
|
|
230
|
+
process.stderr.write(`${error.message}\n`);
|
|
231
|
+
process.exitCode = 1;
|
|
232
|
+
return;
|
|
233
|
+
}
|
|
234
|
+
const ledger = new Ledger(ledgerPath, { macKey: resolvedMac.key });
|
|
235
|
+
const report = ledger.verify(flags.anchor ? { anchor: readJson(flags.anchor) } : {});
|
|
236
|
+
const records = ledger.records();
|
|
237
|
+
const last = records[records.length - 1] || null;
|
|
238
|
+
output({
|
|
239
|
+
ok: report.ok,
|
|
240
|
+
file: ledgerPath,
|
|
241
|
+
records: report.count,
|
|
242
|
+
lastSeq: last && Number.isInteger(last.seq) ? last.seq : null,
|
|
243
|
+
head: report.head,
|
|
244
|
+
macVerified: Boolean(resolvedMac.key),
|
|
245
|
+
errors: report.errors,
|
|
246
|
+
});
|
|
247
|
+
if (!report.ok) {
|
|
248
|
+
process.stderr.write(`账本校验失败(${report.errors.length} 处):\n`);
|
|
249
|
+
for (const error of report.errors.slice(0, 5)) {
|
|
250
|
+
process.stderr.write(` line ${error.line} ${error.code} — ${error.detail}\n`);
|
|
251
|
+
}
|
|
252
|
+
if (report.errors.some((error) => error.code === 'mac_key_missing')) {
|
|
253
|
+
process.stderr.write('提示:该账本带 mac。请用 --mac-key <key> / --mac-key-file <path> / --mac-key-env <NAME> 提供密钥。\n');
|
|
254
|
+
}
|
|
255
|
+
process.exitCode = 1;
|
|
256
|
+
}
|
|
257
|
+
return;
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
if (command === 'rotate') {
|
|
261
|
+
// N5:账本轮转。当前段改名归档(旧段保留、不删不改),新段第一行写
|
|
262
|
+
// 上一段的首尾 hash——跨段也能锚定。--max-bytes 只做门槛判断:
|
|
263
|
+
// 体积没超就不动账本(轮转是维护动作,不是自动行为)。
|
|
264
|
+
const ledgerPath = path.resolve(rest[1]);
|
|
265
|
+
if (!fs.existsSync(ledgerPath)) {
|
|
266
|
+
process.stderr.write(`账本不存在:${ledgerPath}\n`);
|
|
267
|
+
process.exitCode = 1;
|
|
268
|
+
return;
|
|
269
|
+
}
|
|
270
|
+
const limit = Number(flags['max-bytes']);
|
|
271
|
+
const size = fs.statSync(ledgerPath).size;
|
|
272
|
+
if (Number.isFinite(limit) && limit > 0 && size <= limit) {
|
|
273
|
+
output({ rotated: false, file: ledgerPath, size, maxBytes: limit, reason: '体积未超上限,未轮转' });
|
|
274
|
+
return;
|
|
275
|
+
}
|
|
276
|
+
const ledger = loadVerifiedLedger(ledgerPath, flags);
|
|
277
|
+
if (!ledger) return;
|
|
278
|
+
const result = ledger.rotate();
|
|
279
|
+
if (!result) {
|
|
280
|
+
output({ rotated: false, file: ledgerPath, reason: '空账本,无需轮转' });
|
|
281
|
+
return;
|
|
282
|
+
}
|
|
283
|
+
// 新段链接记录:归档文件只记 basename——新账本不该带着旧段的绝对路径。
|
|
284
|
+
const link = ledger.append({
|
|
285
|
+
plugin: 'dsh-dashcam',
|
|
286
|
+
pluginVersion: require('../package.json').version,
|
|
287
|
+
capability: 'telemetry',
|
|
288
|
+
action: 'segment-rotate',
|
|
289
|
+
scope: {
|
|
290
|
+
archived: path.basename(result.archivedPath),
|
|
291
|
+
records: result.records,
|
|
292
|
+
lastSeq: result.lastSeq,
|
|
293
|
+
prevSegmentFirstHash: result.firstHash,
|
|
294
|
+
prevSegmentLastHash: result.lastHash,
|
|
295
|
+
},
|
|
296
|
+
decision: 'allow',
|
|
297
|
+
ruleId: 'rotate',
|
|
298
|
+
reason: '账本轮转:新段从此开始;上一段首尾 hash 见 scope,验链时可用作跨段锚点',
|
|
299
|
+
intercepted: false,
|
|
300
|
+
profiled: false,
|
|
301
|
+
manifestHash: null,
|
|
302
|
+
});
|
|
303
|
+
output({
|
|
304
|
+
rotated: true,
|
|
305
|
+
file: ledgerPath,
|
|
306
|
+
archived: result.archivedPath,
|
|
307
|
+
records: result.records,
|
|
308
|
+
lastSeq: result.lastSeq,
|
|
309
|
+
prevSegmentFirstHash: result.firstHash,
|
|
310
|
+
prevSegmentLastHash: result.lastHash,
|
|
311
|
+
linkSeq: link.seq,
|
|
312
|
+
});
|
|
313
|
+
return;
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
if (command === 'redact') {
|
|
317
|
+
// N5:分享账本前的路径脱敏(home 前缀折叠成 $HOME)。源账本必须先验过:
|
|
318
|
+
// 在校验不过的账本上脱敏,等于帮篡改者洗数据。
|
|
319
|
+
const input = path.resolve(rest[1]);
|
|
320
|
+
if (!fs.existsSync(input)) {
|
|
321
|
+
process.stderr.write(`账本不存在:${input}\n`);
|
|
322
|
+
process.exitCode = 1;
|
|
323
|
+
return;
|
|
324
|
+
}
|
|
325
|
+
const ledger = loadVerifiedLedger(input, flags);
|
|
326
|
+
if (!ledger) return;
|
|
327
|
+
const out = path.resolve(flags.out || `${input}.redacted.jsonl`);
|
|
328
|
+
const summary = redactLedger(input, out);
|
|
329
|
+
output({ redacted: true, input, out, ...summary });
|
|
330
|
+
return;
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
if (command === 'gates') {
|
|
334
|
+
const pluginDir = path.resolve(rest[1]);
|
|
335
|
+
const policy = flags.policy ? readJson(flags.policy) : loadPolicy({});
|
|
336
|
+
const trustJson = flags.trust ? readJson(flags.trust) : null;
|
|
337
|
+
const trust = trustJson ? createTrust(trustJson.keys || trustJson) : null;
|
|
338
|
+
const host = flags.host ? new LocalHost({ policy }) : null;
|
|
339
|
+
const result = await runInstallPipeline({
|
|
340
|
+
pluginDir,
|
|
341
|
+
channel: flags.channel || 'consumer',
|
|
342
|
+
policy,
|
|
343
|
+
trust,
|
|
344
|
+
host,
|
|
345
|
+
invocation: flags.invocation || 'ping',
|
|
346
|
+
skipSignature: flags['skip-signature'] === true || flags.signature === false,
|
|
347
|
+
});
|
|
348
|
+
// N2:信任根整体哈希随报告落盘——「这份报告是按哪份信任表验的」可核对,
|
|
349
|
+
// keyId 之外再给 SPKI 指纹(归一化时已算进 keys),对的是密钥材料本身。
|
|
350
|
+
if (trustJson) {
|
|
351
|
+
result.trustRoot = {
|
|
352
|
+
file: path.basename(flags.trust),
|
|
353
|
+
hash: sha256Hex(stableStringify(trustJson)),
|
|
354
|
+
keys: (trust.keys || []).map((key) => ({ keyId: key.keyId, fingerprint: key.fingerprint, status: key.status })),
|
|
355
|
+
};
|
|
356
|
+
}
|
|
357
|
+
output(result);
|
|
358
|
+
if (!result.ok) process.exitCode = 1;
|
|
359
|
+
return;
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
if (command === 'behavior') {
|
|
363
|
+
// 行为画像报告:把账本记录按插件聚合成「实际读了什么/连了哪里/跑了什么」。
|
|
364
|
+
// 与 portrait(声明侧画像)互补——一个来自 manifest,一个来自账本。
|
|
365
|
+
const ledger = loadVerifiedLedger(rest[1], flags);
|
|
366
|
+
if (!ledger) return;
|
|
367
|
+
let manifest = null;
|
|
368
|
+
if (flags.manifest) {
|
|
369
|
+
const loaded = loadManifest(path.resolve(flags.manifest));
|
|
370
|
+
manifest = loaded.manifest;
|
|
371
|
+
if (!manifest) {
|
|
372
|
+
process.stderr.write(`无法读取清单:${flags.manifest}(${loaded.errors.map((error) => error.message).join(';')})\n`);
|
|
373
|
+
process.exitCode = 1;
|
|
374
|
+
return;
|
|
375
|
+
}
|
|
376
|
+
}
|
|
377
|
+
output(buildBehaviorReport(ledger.records(), { manifest }));
|
|
378
|
+
return;
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
if (command === 'draft') {
|
|
382
|
+
// 声明草案:从账本观测反推 config.requests / manifest.dashcam.requests。
|
|
383
|
+
// 服务于「零契约先跑一段,攒够观测再升 enforce」的迁移路径——
|
|
384
|
+
// 让人改两行,而不是对着能力表凭空写。
|
|
385
|
+
const ledger = loadVerifiedLedger(rest[1], flags);
|
|
386
|
+
if (!ledger) return;
|
|
387
|
+
let manifest = null;
|
|
388
|
+
if (flags.manifest) {
|
|
389
|
+
const loaded = loadManifest(path.resolve(flags.manifest));
|
|
390
|
+
manifest = loaded.manifest;
|
|
391
|
+
if (!manifest) {
|
|
392
|
+
process.stderr.write(`无法读取清单:${flags.manifest}(${loaded.errors.map((error) => error.message).join(';')})\n`);
|
|
393
|
+
process.exitCode = 1;
|
|
394
|
+
return;
|
|
395
|
+
}
|
|
396
|
+
}
|
|
397
|
+
output(buildDeclarationDraft(ledger.records(), { manifest }));
|
|
398
|
+
return;
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
if (command === 'bench') {
|
|
402
|
+
// N6:bridge 开销微基准。同一进程先跑裸 fs(Hook 未装),再装 Hook 跑同一
|
|
403
|
+
// 组调用,差值就是「被记录」的每次调用代价。guarded 档包含守卫判定与
|
|
404
|
+
// 账本逐条落盘——这是行车记录仪的真实开销,不藏。
|
|
405
|
+
const iterations = Math.max(1, Number(flags.n) || 10000);
|
|
406
|
+
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'dsh-bench-'));
|
|
407
|
+
const target = path.join(dir, 'payload.bin');
|
|
408
|
+
fs.writeFileSync(target, Buffer.alloc(64, 0x61));
|
|
409
|
+
const ledgerPath = path.join(dir, 'bench-ledger.jsonl');
|
|
410
|
+
const ops = [
|
|
411
|
+
{ op: 'fs.statSync', run: () => fs.statSync(target) },
|
|
412
|
+
{ op: 'fs.openSync', run: () => { const fd = fs.openSync(target, 'r'); fs.closeSync(fd); } },
|
|
413
|
+
{ op: 'fs.readFileSync', run: () => fs.readFileSync(target) },
|
|
414
|
+
{ op: 'fs.existsSync', run: () => fs.existsSync(target) },
|
|
415
|
+
];
|
|
416
|
+
const measure = (phase) => ops.map(({ op, run }) => {
|
|
417
|
+
const start = process.hrtime.bigint();
|
|
418
|
+
for (let i = 0; i < iterations; i += 1) run();
|
|
419
|
+
const perCallUs = Number(process.hrtime.bigint() - start) / 1000 / iterations;
|
|
420
|
+
return { op, phase, perCallUs: Math.round(perCallUs * 1000) / 1000 };
|
|
421
|
+
});
|
|
422
|
+
const unpatched = measure('unpatched');
|
|
423
|
+
// 授权范围钉在基准文件本身:判定走 allow + 逐条记账,量的是完整链路。
|
|
424
|
+
// bridge 的 guardPath 走 guard.enforce,manifest 与 grant 缺一不可——
|
|
425
|
+
// 声明与授予同范围,确保每次调用都走「声明 × 授予 × 记账」全链路。
|
|
426
|
+
const benchManifest = {
|
|
427
|
+
id: 'dsh-dashcam-bench',
|
|
428
|
+
version: '0.0.0',
|
|
429
|
+
kind: 'dashcam',
|
|
430
|
+
dashcam: {
|
|
431
|
+
requests: [{ capability: 'fs.read', scope: { paths: [target] }, reason: 'bench 基准授权范围' }],
|
|
432
|
+
},
|
|
433
|
+
};
|
|
434
|
+
const runtime = createPluginRuntime({
|
|
435
|
+
manifest: benchManifest,
|
|
436
|
+
ledgerPath,
|
|
437
|
+
patchBuiltins: true,
|
|
438
|
+
autoSweep: false,
|
|
439
|
+
grants: [normalizeGrant({ capability: 'fs.read', scope: { paths: [target] }, reason: 'bench' })],
|
|
440
|
+
});
|
|
441
|
+
let guarded;
|
|
442
|
+
try {
|
|
443
|
+
guarded = measure('guarded');
|
|
444
|
+
} finally {
|
|
445
|
+
runtime.dispose();
|
|
446
|
+
}
|
|
447
|
+
// 卸载 Hook 后再读账本:确认 guarded 档确实逐条落了账。
|
|
448
|
+
let recorded = null;
|
|
449
|
+
try {
|
|
450
|
+
const benchLedger = new Ledger(ledgerPath);
|
|
451
|
+
recorded = benchLedger.records().length;
|
|
452
|
+
} catch {
|
|
453
|
+
recorded = null;
|
|
454
|
+
}
|
|
455
|
+
fs.rmSync(dir, { recursive: true, force: true });
|
|
456
|
+
output({
|
|
457
|
+
iterations,
|
|
458
|
+
note: 'guarded 含守卫判定与账本逐条落盘;unpatched 为同进程 Hook 安装前的裸 fs。'
|
|
459
|
+
+ 'overhead = guarded − unpatched,即每次调用被记录的额外代价(微秒)。',
|
|
460
|
+
results: ops.map(({ op }, index) => ({
|
|
461
|
+
op,
|
|
462
|
+
unpatchedUsPerCall: unpatched[index].perCallUs,
|
|
463
|
+
guardedUsPerCall: guarded[index].perCallUs,
|
|
464
|
+
overheadUsPerCall: Math.round((guarded[index].perCallUs - unpatched[index].perCallUs) * 1000) / 1000,
|
|
465
|
+
})),
|
|
466
|
+
ledgerRecords: recorded,
|
|
467
|
+
});
|
|
468
|
+
return;
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
if (command === 'portrait') {
|
|
472
|
+
const pluginDir = path.resolve(rest[1]);
|
|
473
|
+
const policy = flags.policy ? loadPolicy(readJson(flags.policy)) : loadPolicy({});
|
|
474
|
+
const trustJson = flags.trust ? readJson(flags.trust) : null;
|
|
475
|
+
const trust = trustJson ? createTrust(trustJson.keys || trustJson) : null;
|
|
476
|
+
const { manifest } = loadManifest(pluginDir);
|
|
477
|
+
// 画像数据源 = manifest 请求 + 扫描结果 + 门禁安全标签。
|
|
478
|
+
const report = await runInstallPipeline({
|
|
479
|
+
pluginDir,
|
|
480
|
+
channel: flags.channel || 'consumer',
|
|
481
|
+
policy,
|
|
482
|
+
trust,
|
|
483
|
+
skipSignature: !trust,
|
|
484
|
+
});
|
|
485
|
+
const scanResult = report.scan || scanPlugin(pluginDir, { policy, manifest });
|
|
486
|
+
const portrait = buildPortrait({
|
|
487
|
+
manifest: report.manifest || manifest,
|
|
488
|
+
scan: scanResult,
|
|
489
|
+
labels: report.labels,
|
|
490
|
+
});
|
|
491
|
+
process.stdout.write(`${renderPortrait(portrait)}\n`);
|
|
492
|
+
return;
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
if (command === 'autopsy') {
|
|
496
|
+
const pluginDir = path.resolve(rest[1]);
|
|
497
|
+
const policy = flags.policy ? readJson(flags.policy) : {};
|
|
498
|
+
const profile = flags.profile ? readJson(flags.profile) : undefined;
|
|
499
|
+
const bundlePatch = flags.patch ? readJson(flags.patch) : [];
|
|
500
|
+
const report = runAutopsy({
|
|
501
|
+
pluginDir,
|
|
502
|
+
profile,
|
|
503
|
+
bundlePatch,
|
|
504
|
+
policy,
|
|
505
|
+
requireSignature: flags.signature !== false,
|
|
506
|
+
});
|
|
507
|
+
output(report);
|
|
508
|
+
if (!report.ok) process.exitCode = 1;
|
|
509
|
+
return;
|
|
510
|
+
}
|
|
511
|
+
|
|
512
|
+
if (command === 'metrics') {
|
|
513
|
+
const ledger = loadVerifiedLedger(rest[1], flags);
|
|
514
|
+
if (!ledger) return;
|
|
515
|
+
const records = ledger.records();
|
|
516
|
+
const events = flags.events ? readJson(flags.events) : [];
|
|
517
|
+
output({
|
|
518
|
+
total: computeMetrics({ ledgerRecords: records, installEvents: events }),
|
|
519
|
+
monthly: monthlyMetrics({ ledgerRecords: records, installEvents: events }),
|
|
520
|
+
});
|
|
521
|
+
return;
|
|
522
|
+
}
|
|
523
|
+
|
|
524
|
+
if (command === 'ingress') {
|
|
525
|
+
const ledger = loadVerifiedLedger(rest[1], flags);
|
|
526
|
+
if (!ledger) return;
|
|
527
|
+
const all = ledger.records().filter((record) => record.capability === 'llm.ingress');
|
|
528
|
+
const scoped = flags.session
|
|
529
|
+
? all.filter((record) => record.scope && record.scope.sessionId === flags.session)
|
|
530
|
+
: all;
|
|
531
|
+
const summary = summarizeIngress(scoped);
|
|
532
|
+
if (flags.find) {
|
|
533
|
+
const needle = String(flags.find);
|
|
534
|
+
output({
|
|
535
|
+
summary,
|
|
536
|
+
find: {
|
|
537
|
+
needle,
|
|
538
|
+
matches: findIngressMatches(scoped, needle),
|
|
539
|
+
// 覆盖范围必须写在输出里:没搜到 ≠ 没发生。
|
|
540
|
+
coverage: '仅检索已采样消息的 head;未采样消息不覆盖',
|
|
541
|
+
},
|
|
542
|
+
});
|
|
543
|
+
return;
|
|
544
|
+
}
|
|
545
|
+
output({ summary });
|
|
546
|
+
return;
|
|
547
|
+
}
|
|
548
|
+
|
|
549
|
+
if (command === 'blacklist') {
|
|
550
|
+
const store = new Blacklist({ file: path.resolve(rest[1]) });
|
|
551
|
+
const action = rest[2];
|
|
552
|
+
if (action === 'add') output(store.add(rest[3], { reason: rest.slice(4).join(' ') }));
|
|
553
|
+
else if (action === 'remove') output({ removed: store.remove(rest[3]) });
|
|
554
|
+
else output({ entries: store.list() });
|
|
555
|
+
return;
|
|
556
|
+
}
|
|
557
|
+
|
|
558
|
+
if (command === 'rollout') {
|
|
559
|
+
output(checkCircuitBreaker(readJson(rest[1]), readJson(rest[1]).thresholds));
|
|
560
|
+
return;
|
|
561
|
+
}
|
|
562
|
+
|
|
563
|
+
process.stderr.write(`未知命令:${command}\n`);
|
|
564
|
+
process.exitCode = 1;
|
|
565
|
+
}
|
|
566
|
+
|
|
567
|
+
main().catch((error) => {
|
|
568
|
+
// 配置类错误(密钥来源不对)只打一行消息:用户要的是「怎么修」,不是调用栈。
|
|
569
|
+
const text = error && error.code === 'mac_key_invalid'
|
|
570
|
+
? `${error.message}\n`
|
|
571
|
+
: `${error.stack || error.message}\n`;
|
|
572
|
+
process.stderr.write(text);
|
|
573
|
+
process.exitCode = 1;
|
|
574
|
+
});
|