dsh-standards 1.1.2 → 1.2.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/40-checker/README.md +15 -0
- package/40-checker/bin/dsh-flow-collect.mjs +3 -0
- package/40-checker/bin/dsh-levels.mjs +13 -2
- package/40-checker/scripts/bundle-smoke.mjs +3 -0
- package/40-checker/scripts/entry18-ci.mjs +4 -3
- package/40-checker/scripts/entry18-web.mjs +4 -1
- package/40-checker/scripts/flow-collect.mjs +494 -0
- package/40-checker/scripts/flow-sample-ci.mjs +4 -4
- package/40-checker/scripts/host-farm.mjs +2 -1
- package/40-checker/scripts/journey-config-observe.mjs +5 -3
- package/40-checker/scripts/journey-runtime.mjs +208 -83
- package/40-checker/scripts/level-audit.mjs +91 -19
- package/40-checker/scripts/m2-runner.mjs +4 -3
- package/40-checker/scripts/runtime-child.mjs +4 -0
- package/40-checker/src/cli.mjs +10 -2
- package/40-checker/src/flow-evidence.mjs +431 -0
- package/40-checker/src/index.mjs +3 -0
- package/40-checker/src/remediation.mjs +132 -0
- package/40-checker/src/report.mjs +19 -2
- package/40-checker/src/sandbox-env.mjs +78 -0
- package/40-checker/test/flow-evidence.test.mjs +295 -0
- package/40-checker/test/remediation.test.mjs +300 -0
- package/CHANGELOG.md +20 -0
- package/bin/dsh-flow-collect.mjs +4 -0
- package/package.json +1 -1
- package/spec/README.md +2 -0
- package/spec/flow/README.md +23 -2
- package/spec/flow-evidence.schema.json +217 -0
- package/spec/levels.json +2 -0
- package/spec/remediation.json +478 -0
package/40-checker/README.md
CHANGED
|
@@ -19,6 +19,8 @@ node bin/dsh-plugin-verify.mjs /path/to/plugin --format json --out report.json
|
|
|
19
19
|
node bin/dsh-plugin-verify.mjs /path/to/plugin --format sarif --out report.sarif
|
|
20
20
|
```
|
|
21
21
|
|
|
22
|
+
`buildReport()` 与 `--format json` 默认带**加性** `advice[]`(来自 `spec/remediation.json` 的 68 条规则;FAIL/WARN detail 优先,无 detail 的运行时 FAIL 用 `check.clause` 兜底)。`--no-advice` 可关闭;它不改变任何 check 的结果或 exit code。
|
|
23
|
+
|
|
22
24
|
## 2. 检查项
|
|
23
25
|
|
|
24
26
|
| ID | 条款 | 层 | 说明 |
|
|
@@ -124,6 +126,19 @@ node scripts/level-audit.mjs ./my-bundle \
|
|
|
124
126
|
- ENTRY 自动;FLOW 只读 `--flow-evidence`(J1–J8),BOOST 只读 `--boost-evidence`(LEV-1–8)。
|
|
125
127
|
- 没有证据就写 `not-run` / `not-measured`,工具不会替作者升级。
|
|
126
128
|
|
|
129
|
+
### FLOW 证据链 `dsh-flow-collect`
|
|
130
|
+
|
|
131
|
+
```bash
|
|
132
|
+
node bin/dsh-flow-collect.mjs --package ./my-plugin --out flow-evidence.json
|
|
133
|
+
# 等价:node bin/dsh-levels.mjs flow collect --package ./my-plugin --out flow-evidence.json
|
|
134
|
+
```
|
|
135
|
+
|
|
136
|
+
- 只接受 `dsh-flow-evidence/2`(`spec/flow-evidence.schema.json`)。`level-audit --flow-evidence` 对 v1(`dsh-flow-journey/1`、`dsh-flow-runtime/1`)和裸 `{Jx:{status}}` 标 `deprecated`,不计 pass。
|
|
137
|
+
- 每个 J 必须至少 1 条 `checks[]` 且全部 `exitCode === 0`、断言成立、至少一条非 `static` 观测检查,才计 pass;`static` preflight 不是 runtime,`warn`/`blocked`/`na` 都不计 8/8。
|
|
138
|
+
- collector 把包复制到临时目录再跑,前后 `treeSha256` 必须一致;子进程 env 走白名单(不含 token/key/secret);不授予 `allowBuilds`,不自动 `allow-version`。
|
|
139
|
+
- 前置缺失(无 dsh、无 `--previous`、宿主兼容门禁拒绝)记 `blocked` + reason,出口码 0;真失败出口码 1,并打印五要素提示(子检查 / 命令 / exitCode / stderr 末 20 行 / 日志绝对路径 + 条款锚)。
|
|
140
|
+
- 本地证据只是 `self-attested`:结构自洽 + 绑定此包 + 未过期,**不证明真跑过**,也不是沙箱、认证或安全审计。
|
|
141
|
+
|
|
127
142
|
## 4. 运行 L1–L3
|
|
128
143
|
|
|
129
144
|
需要一个**已构建**的 `@deepseek-ai/cordis` 入口。检查器不修改宿主,也不从源码构建宿主:
|
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
/**
|
|
3
3
|
* dsh-levels — one entry point for the three-level audit.
|
|
4
4
|
* It forwards to level-audit.mjs so there is a single documented command.
|
|
5
|
+
* `dsh-levels flow collect ...` forwards to the FLOW evidence collector.
|
|
5
6
|
*/
|
|
6
7
|
import path from 'node:path'
|
|
7
8
|
import { spawnSync } from 'node:child_process'
|
|
@@ -9,7 +10,17 @@ import { fileURLToPath } from 'node:url'
|
|
|
9
10
|
|
|
10
11
|
const HERE = path.dirname(fileURLToPath(import.meta.url))
|
|
11
12
|
const AUDIT = path.resolve(HERE, '..', 'scripts', 'level-audit.mjs')
|
|
13
|
+
const FLOW_COLLECT = path.resolve(HERE, 'dsh-flow-collect.mjs')
|
|
12
14
|
const args = process.argv.slice(2)
|
|
13
|
-
|
|
14
|
-
|
|
15
|
+
let target = AUDIT
|
|
16
|
+
if (args[0] === 'flow' && args[1] === 'collect') {
|
|
17
|
+
args.splice(0, 2)
|
|
18
|
+
target = FLOW_COLLECT
|
|
19
|
+
} else if (args[0] === 'flow') {
|
|
20
|
+
console.error('usage: dsh-levels flow collect --package <dir> [--out <file>] [--static] [--previous <dir>]')
|
|
21
|
+
process.exit(2)
|
|
22
|
+
} else if (args[0] === 'audit') {
|
|
23
|
+
args.shift()
|
|
24
|
+
}
|
|
25
|
+
const run = spawnSync(process.execPath, [target, ...args], { stdio: 'inherit' })
|
|
15
26
|
process.exitCode = run.status ?? 1
|
|
@@ -14,6 +14,7 @@ import fs from 'node:fs'
|
|
|
14
14
|
import os from 'node:os'
|
|
15
15
|
import path from 'node:path'
|
|
16
16
|
import { pathToFileURL } from 'node:url'
|
|
17
|
+
import { scrubProcessEnv } from '../src/sandbox-env.mjs'
|
|
17
18
|
|
|
18
19
|
function parse(argv) {
|
|
19
20
|
const options = { package: null, host: null, base: null, web: null, bare: null, json: false, timeout: 30_000 }
|
|
@@ -42,6 +43,8 @@ function withTimeout(promise, ms, label) {
|
|
|
42
43
|
}
|
|
43
44
|
|
|
44
45
|
async function main() {
|
|
46
|
+
// Boot happens in this process: drop secrets before any plugin import.
|
|
47
|
+
scrubProcessEnv()
|
|
45
48
|
const options = parse(process.argv.slice(2))
|
|
46
49
|
const pkg = path.resolve(options.package)
|
|
47
50
|
const manifest = JSON.parse(fs.readFileSync(path.join(pkg, 'package.json'), 'utf8'))
|
|
@@ -58,6 +58,7 @@ import fs from 'node:fs'
|
|
|
58
58
|
import os from 'node:os'
|
|
59
59
|
import path from 'node:path'
|
|
60
60
|
import { spawnSync } from 'node:child_process'
|
|
61
|
+
import { sanitizedEnv } from '../src/sandbox-env.mjs'
|
|
61
62
|
import { fileURLToPath } from 'node:url'
|
|
62
63
|
|
|
63
64
|
const HERE = path.dirname(fileURLToPath(import.meta.url))
|
|
@@ -153,16 +154,16 @@ function run(command, args, { cwd, timeout, env } = {}) {
|
|
|
153
154
|
encoding: 'utf8',
|
|
154
155
|
maxBuffer: 64 * 1024 * 1024,
|
|
155
156
|
killSignal: 'SIGKILL',
|
|
156
|
-
env: {
|
|
157
|
-
...process.env,
|
|
157
|
+
env: sanitizedEnv({
|
|
158
158
|
NO_COLOR: '1',
|
|
159
159
|
npm_config_update_notifier: 'false',
|
|
160
160
|
// Bound registry waits so one unresponsive metadata request cannot wedge
|
|
161
161
|
// a shard past its job timeout.
|
|
162
162
|
npm_config_fetch_timeout: '60000',
|
|
163
163
|
npm_config_fetch_retries: '2',
|
|
164
|
+
...(process.env.DSH_HOME ? { DSH_HOME: process.env.DSH_HOME } : {}),
|
|
164
165
|
...env,
|
|
165
|
-
},
|
|
166
|
+
}),
|
|
166
167
|
})
|
|
167
168
|
return {
|
|
168
169
|
command,
|
|
@@ -17,6 +17,7 @@ import fs from 'node:fs'
|
|
|
17
17
|
import os from 'node:os'
|
|
18
18
|
import path from 'node:path'
|
|
19
19
|
import { spawnSync } from 'node:child_process'
|
|
20
|
+
import { sanitizedEnv, scrubProcessEnv } from '../src/sandbox-env.mjs'
|
|
20
21
|
import { fileURLToPath } from 'node:url'
|
|
21
22
|
|
|
22
23
|
const HERE = path.dirname(fileURLToPath(import.meta.url))
|
|
@@ -87,7 +88,7 @@ function run(command, args, { cwd, timeout, env } = {}) {
|
|
|
87
88
|
timeout,
|
|
88
89
|
encoding: 'utf8',
|
|
89
90
|
maxBuffer: 64 * 1024 * 1024,
|
|
90
|
-
env: { ...process.env
|
|
91
|
+
env: sanitizedEnv({ NO_COLOR: '1', ...(process.env.DSH_HOME ? { DSH_HOME: process.env.DSH_HOME } : {}), ...env }),
|
|
91
92
|
})
|
|
92
93
|
return {
|
|
93
94
|
status: result.status,
|
|
@@ -159,6 +160,8 @@ function targetRows(pkgDir, manifest, loadOverlayPatches, composeEntries) {
|
|
|
159
160
|
|
|
160
161
|
async function main() {
|
|
161
162
|
const options = parseArgs(process.argv.slice(2))
|
|
163
|
+
// This harness boots the sample in-process; drop secrets before any import.
|
|
164
|
+
scrubProcessEnv()
|
|
162
165
|
const startedAt = new Date().toISOString()
|
|
163
166
|
const work = fs.mkdtempSync(path.join(os.tmpdir(), 'dsh-entry18-web-'))
|
|
164
167
|
const home = fs.mkdtempSync(path.join(os.tmpdir(), 'dsh-entry18-home-'))
|
|
@@ -0,0 +1,494 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* flow-collect — produce a `dsh-flow-evidence/2` document for one plugin.
|
|
4
|
+
*
|
|
5
|
+
* What it does:
|
|
6
|
+
* 1. bind the subject: name@version from package.json, `npm pack
|
|
7
|
+
* --ignore-scripts` tarball sha256 + integrity, `git rev-parse HEAD`,
|
|
8
|
+
* and a deterministic tree sha256 of the package directory;
|
|
9
|
+
* 2. copy the package to a temp dir and run every journey against that copy
|
|
10
|
+
* only; the original directory is never written;
|
|
11
|
+
* 3. static preflight (`journey.mjs`) for J1-J8, then the runtime probe
|
|
12
|
+
* (`journey-runtime.mjs`) for J1/J2/J4/J6 with a temp HOME/DSH_HOME and
|
|
13
|
+
* an allowlisted child env;
|
|
14
|
+
* 4. re-hash the original tree and record `subject.treeStable`;
|
|
15
|
+
* 5. write the JSON, per-check logs, the chain hash, and SHA256SUMS.
|
|
16
|
+
*
|
|
17
|
+
* Missing prerequisites (no dsh, no --previous, host compatibility gate
|
|
18
|
+
* rejected the install) are recorded as `blocked` with a reason and exit 0.
|
|
19
|
+
* A failed check exits 1 with the five-element hint: sub-check, command,
|
|
20
|
+
* exitCode, last 20 stderr lines, absolute log path, plus the clause anchor.
|
|
21
|
+
*
|
|
22
|
+
* Usage:
|
|
23
|
+
* node 40-checker/bin/dsh-flow-collect.mjs --package <dir> [--out flow-evidence.json]
|
|
24
|
+
* [--logs <dir>] [--previous <dir>] [--dsh <bin>] [--timeout <ms>]
|
|
25
|
+
* [--static] [--ci] [--json]
|
|
26
|
+
*/
|
|
27
|
+
import fs from 'node:fs'
|
|
28
|
+
import os from 'node:os'
|
|
29
|
+
import path from 'node:path'
|
|
30
|
+
import { spawnSync } from 'node:child_process'
|
|
31
|
+
import { fileURLToPath } from 'node:url'
|
|
32
|
+
import {
|
|
33
|
+
FLOW_IDS, FLOW_EVIDENCE_SCHEMA, clauseAnchor, finalizeEvidence, journeyAggregate,
|
|
34
|
+
sha256File, sha256Value, treeSha256, validateFlowEvidence,
|
|
35
|
+
} from '../src/flow-evidence.mjs'
|
|
36
|
+
import { sanitizedEnv } from '../src/sandbox-env.mjs'
|
|
37
|
+
|
|
38
|
+
const HERE = path.dirname(fileURLToPath(import.meta.url))
|
|
39
|
+
const STD = path.resolve(HERE, '..', '..')
|
|
40
|
+
const JOURNEY_STATIC = path.join(HERE, 'journey.mjs')
|
|
41
|
+
const JOURNEY_RUNTIME = path.join(HERE, 'journey-runtime.mjs')
|
|
42
|
+
const NPM = process.platform === 'win32' ? 'npm.cmd' : 'npm'
|
|
43
|
+
const GIT = process.platform === 'win32' ? 'git.exe' : 'git'
|
|
44
|
+
|
|
45
|
+
const STATIC_CHECK_IDS = {
|
|
46
|
+
J1: 'J1.install', J2: 'J2.toggle', J3: 'J3.config', J4: 'J4.first-run',
|
|
47
|
+
J5: 'J5.permissions', J6: 'J6.upgrade', J7: 'J7.observe', J8: 'J8.client',
|
|
48
|
+
}
|
|
49
|
+
const RUNTIME_JOURNEYS = ['J1', 'J2', 'J4', 'J6']
|
|
50
|
+
const STATIC_COMMAND = [process.execPath, path.relative(STD, JOURNEY_STATIC).split(path.sep).join('/'), '--package', '<pkg>', '--json']
|
|
51
|
+
const NOT_CLAIMED = [
|
|
52
|
+
'certification', 'security audit', 'official endorsement', 'ranking',
|
|
53
|
+
'real network/browser behavior', 'sandbox confinement (local collector)',
|
|
54
|
+
]
|
|
55
|
+
|
|
56
|
+
function usage() {
|
|
57
|
+
console.log(`flow-collect — collect a ${FLOW_EVIDENCE_SCHEMA} document
|
|
58
|
+
|
|
59
|
+
Usage:
|
|
60
|
+
node 40-checker/bin/dsh-flow-collect.mjs --package <dir> [options]
|
|
61
|
+
|
|
62
|
+
Options:
|
|
63
|
+
--package <dir> plugin/bundle directory to inspect (required)
|
|
64
|
+
--out <file> evidence JSON (default: ./flow-evidence.json)
|
|
65
|
+
--logs <dir> log directory (default: <out>.logs)
|
|
66
|
+
--previous <dir> previous version for J6 upgrade/rollback
|
|
67
|
+
--dsh <bin> dsh executable (default: dsh / $DSH_BIN)
|
|
68
|
+
--timeout <ms> per-command budget (default: 120000)
|
|
69
|
+
--static skip the runtime probe (J1/J2/J4/J6 become static/blocked)
|
|
70
|
+
--ci mark producer.kind=ci, claims.claimLevel=ci-attested
|
|
71
|
+
--no-assert-unchanged skip the before/after tree hash comparison (not recommended)
|
|
72
|
+
--json print the evidence JSON instead of the summary
|
|
73
|
+
--help show this help
|
|
74
|
+
|
|
75
|
+
The inspected directory is copied to a temp dir and never written. No
|
|
76
|
+
allowBuilds/allow-version is granted. Missing prerequisites are recorded as
|
|
77
|
+
blocked and exit 0; a failed check exits 1 with a five-element hint.`)
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function parse(argv) {
|
|
81
|
+
const out = {
|
|
82
|
+
package: null, out: path.resolve('flow-evidence.json'), logs: null, previous: null,
|
|
83
|
+
dsh: process.env.DSH_BIN || null, timeout: 120_000, static: false, ci: false,
|
|
84
|
+
assertUnchanged: true, json: false,
|
|
85
|
+
}
|
|
86
|
+
for (let i = 0; i < argv.length; i += 1) {
|
|
87
|
+
const next = () => {
|
|
88
|
+
const value = argv[i + 1]
|
|
89
|
+
if (value === undefined) throw new Error(`${argv[i]} needs a value`)
|
|
90
|
+
i += 1
|
|
91
|
+
return value
|
|
92
|
+
}
|
|
93
|
+
const arg = argv[i]
|
|
94
|
+
if (arg === '--package') out.package = next()
|
|
95
|
+
else if (arg === '--out') out.out = path.resolve(next())
|
|
96
|
+
else if (arg === '--logs') out.logs = path.resolve(next())
|
|
97
|
+
else if (arg === '--previous') out.previous = path.resolve(next())
|
|
98
|
+
else if (arg === '--dsh') out.dsh = next()
|
|
99
|
+
else if (arg === '--timeout') out.timeout = Number(next())
|
|
100
|
+
else if (arg === '--static' || arg === '--no-runtime') out.static = true
|
|
101
|
+
else if (arg === '--ci') out.ci = true
|
|
102
|
+
else if (arg === '--no-assert-unchanged') out.assertUnchanged = false
|
|
103
|
+
else if (arg === '--json') out.json = true
|
|
104
|
+
else if (arg === '--help' || arg === '-h') { usage(); process.exit(0) }
|
|
105
|
+
else throw new Error(`unknown option: ${arg}`)
|
|
106
|
+
}
|
|
107
|
+
if (!out.package) throw new Error('--package is required')
|
|
108
|
+
if (!Number.isFinite(out.timeout) || out.timeout < 1000) throw new Error('--timeout must be >= 1000')
|
|
109
|
+
return out
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
function nowIso() { return new Date().toISOString() }
|
|
113
|
+
function tailLines(text, count = 20) {
|
|
114
|
+
const lines = String(text ?? '').trimEnd().split(/\r?\n/)
|
|
115
|
+
return lines.slice(-count).join('\n')
|
|
116
|
+
}
|
|
117
|
+
function quoteCommand(command) {
|
|
118
|
+
return command.map(part => (/^[A-Za-z0-9_./:@=+-]+$/.test(part) ? part : `'${String(part).replace(/'/g, "'\\''")}'`)).join(' ')
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
function run(command, args, { cwd, env, timeout = 120_000 } = {}) {
|
|
122
|
+
const startedAt = nowIso()
|
|
123
|
+
const t0 = Date.now()
|
|
124
|
+
const result = spawnSync(command, args, {
|
|
125
|
+
cwd, env, encoding: 'utf8', timeout, maxBuffer: 16 * 1024 * 1024, killSignal: 'SIGKILL',
|
|
126
|
+
})
|
|
127
|
+
const endedAt = nowIso()
|
|
128
|
+
return {
|
|
129
|
+
command: [command, ...args],
|
|
130
|
+
exitCode: Number.isInteger(result.status) ? result.status : result.error?.code === 'ETIMEDOUT' ? 124 : 127,
|
|
131
|
+
startedAt,
|
|
132
|
+
endedAt,
|
|
133
|
+
durationMs: Date.now() - t0,
|
|
134
|
+
stdoutText: result.stdout ?? '',
|
|
135
|
+
stderrText: result.stderr ?? '',
|
|
136
|
+
timedOut: result.error?.code === 'ETIMEDOUT',
|
|
137
|
+
error: result.error ? result.error.message : null,
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
function writeLog(logDir, name, text) {
|
|
142
|
+
const file = path.join(logDir, name)
|
|
143
|
+
fs.writeFileSync(file, text ?? '')
|
|
144
|
+
return { file, sha256: sha256Value(text ?? '') }
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
function main() {
|
|
148
|
+
const options = parse(process.argv.slice(2))
|
|
149
|
+
const pkg = path.resolve(options.package)
|
|
150
|
+
if (!fs.existsSync(path.join(pkg, 'package.json'))) throw new Error(`not a package directory: ${pkg}`)
|
|
151
|
+
const manifest = JSON.parse(fs.readFileSync(path.join(pkg, 'package.json'), 'utf8'))
|
|
152
|
+
const generatedAt = nowIso()
|
|
153
|
+
const outFile = path.resolve(options.out)
|
|
154
|
+
const outDir = path.dirname(outFile)
|
|
155
|
+
const logDir = path.resolve(options.logs ?? `${outFile}.logs`)
|
|
156
|
+
fs.mkdirSync(outDir, { recursive: true })
|
|
157
|
+
fs.mkdirSync(logDir, { recursive: true })
|
|
158
|
+
const toEvidencePath = file => path.relative(outDir, file).split(path.sep).join('/')
|
|
159
|
+
const tmpRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'dsh-flow-collect-'))
|
|
160
|
+
const tmpHome = path.join(tmpRoot, 'home')
|
|
161
|
+
const tmpCache = path.join(tmpRoot, 'npm-cache')
|
|
162
|
+
fs.mkdirSync(tmpHome, { recursive: true })
|
|
163
|
+
fs.mkdirSync(tmpCache, { recursive: true })
|
|
164
|
+
const childEnv = () => sanitizedEnv({
|
|
165
|
+
HOME: tmpHome, USERPROFILE: tmpHome, TMPDIR: path.join(tmpRoot, 'tmp'), TEMP: path.join(tmpRoot, 'tmp'), TMP: path.join(tmpRoot, 'tmp'),
|
|
166
|
+
npm_config_cache: tmpCache, npm_config_audit: 'false', npm_config_fund: 'false', npm_config_update_notifier: 'false',
|
|
167
|
+
})
|
|
168
|
+
|
|
169
|
+
const warnings = []
|
|
170
|
+
try {
|
|
171
|
+
// 0. Never let our own output/log dirs count as a tree change.
|
|
172
|
+
const treeExclude = []
|
|
173
|
+
for (const candidate of [outFile, logDir]) {
|
|
174
|
+
const rel = path.relative(pkg, candidate)
|
|
175
|
+
if (rel && !rel.startsWith('..') && !path.isAbsolute(rel)) treeExclude.push(rel.split(path.sep).join('/'))
|
|
176
|
+
}
|
|
177
|
+
const before = treeSha256(pkg, { exclude: treeExclude })
|
|
178
|
+
|
|
179
|
+
// 1. Read-only copy. Everything below runs against the copy.
|
|
180
|
+
const copy = path.join(tmpRoot, 'package')
|
|
181
|
+
fs.cpSync(pkg, copy, {
|
|
182
|
+
recursive: true,
|
|
183
|
+
dereference: false,
|
|
184
|
+
filter: source => path.basename(source) !== '.git',
|
|
185
|
+
})
|
|
186
|
+
let previousCopy = null
|
|
187
|
+
if (options.previous) {
|
|
188
|
+
const previousSource = path.resolve(options.previous)
|
|
189
|
+
if (!fs.existsSync(path.join(previousSource, 'package.json'))) throw new Error(`not a package directory: ${previousSource}`)
|
|
190
|
+
previousCopy = path.join(tmpRoot, 'previous')
|
|
191
|
+
fs.cpSync(previousSource, previousCopy, { recursive: true, dereference: false, filter: source => path.basename(source) !== '.git' })
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
// 2. Subject binding.
|
|
195
|
+
const packed = packSubject(copy, tmpRoot, childEnv, options.timeout)
|
|
196
|
+
const commit = gitCommit(pkg)
|
|
197
|
+
const subject = {
|
|
198
|
+
name: manifest.name,
|
|
199
|
+
version: manifest.version,
|
|
200
|
+
integrity: packed.integrity,
|
|
201
|
+
tarballSha256: packed.tarballSha256,
|
|
202
|
+
commit,
|
|
203
|
+
treeSha256: before.hash,
|
|
204
|
+
treeExclude,
|
|
205
|
+
source: options.ci ? 'ci-checkout' : 'local-directory',
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
// 3. Host.
|
|
209
|
+
const dshBin = options.dsh || (process.platform === 'win32' ? 'dsh.cmd' : 'dsh')
|
|
210
|
+
const versionRun = run(dshBin, ['--version'], { env: childEnv(), timeout: 30_000 })
|
|
211
|
+
const dshVersion = versionRun.exitCode === 0 ? versionRun.stdoutText.trim() || null : null
|
|
212
|
+
const host = { dsh: dshVersion, node: process.version, platform: `${process.platform}-${process.arch}` }
|
|
213
|
+
|
|
214
|
+
// 4. Static preflight (never imports the plugin).
|
|
215
|
+
const staticChecks = {}
|
|
216
|
+
const staticRun = run(process.execPath, [JOURNEY_STATIC, '--package', copy, '--json'], { env: childEnv(), timeout: options.timeout })
|
|
217
|
+
const staticLog = writeLog(logDir, 'preflight-journey.json', staticRun.stdoutText)
|
|
218
|
+
let staticResult = null
|
|
219
|
+
try { staticResult = JSON.parse(staticRun.stdoutText) } catch { /* handled below */ }
|
|
220
|
+
for (const id of FLOW_IDS) {
|
|
221
|
+
const wanted = STATIC_CHECK_IDS[id]
|
|
222
|
+
const mapped = (staticResult?.checks ?? []).find(check => check.id === wanted) ?? null
|
|
223
|
+
const observed = mapped?.result === 'PASS'
|
|
224
|
+
staticChecks[id] = [{
|
|
225
|
+
id: `${wanted}.preflight`,
|
|
226
|
+
kind: 'static',
|
|
227
|
+
command: STATIC_COMMAND.map(part => (part === '<pkg>' ? pkg : part)),
|
|
228
|
+
exitCode: mapped?.result === 'FAIL' || !mapped ? 1 : 0,
|
|
229
|
+
startedAt: staticRun.startedAt,
|
|
230
|
+
endedAt: staticRun.endedAt,
|
|
231
|
+
stdout: { path: toEvidencePath(staticLog.file), sha256: staticLog.sha256 },
|
|
232
|
+
assertions: [{
|
|
233
|
+
claim: mapped ? `${wanted}: ${mapped.reason}` : `${wanted}: static preflight produced no row`,
|
|
234
|
+
observed,
|
|
235
|
+
}],
|
|
236
|
+
}]
|
|
237
|
+
}
|
|
238
|
+
if (!staticResult) warnings.push(`static preflight produced no JSON (exit ${staticRun.exitCode}); J1-J8 recorded as static preflight failure`)
|
|
239
|
+
|
|
240
|
+
// 5. Runtime probe (J1/J2/J4/J6), unless disabled or dsh is missing.
|
|
241
|
+
const runtimeChecks = Object.fromEntries(FLOW_IDS.map(id => [id, []]))
|
|
242
|
+
const runtimeResults = {}
|
|
243
|
+
const blockerReasons = {}
|
|
244
|
+
let runtimeSkipped = null
|
|
245
|
+
if (options.static) runtimeSkipped = 'runtime collection disabled (--static)'
|
|
246
|
+
else if (dshVersion === null) runtimeSkipped = `dsh executable not usable: ${dshBin} (exit ${versionRun.exitCode})`
|
|
247
|
+
let runtimeVerdict = null
|
|
248
|
+
if (runtimeSkipped === null) {
|
|
249
|
+
const args = [JOURNEY_RUNTIME, '--package', copy, '--dsh', dshBin, '--json', '--log-dir', logDir, '--timeout', String(options.timeout)]
|
|
250
|
+
if (previousCopy) args.push('--previous', previousCopy)
|
|
251
|
+
const runtimeRun = run(process.execPath, args, { env: childEnv(), timeout: options.timeout * 2 + 60_000 })
|
|
252
|
+
writeLog(logDir, 'runtime-journey.json', runtimeRun.stdoutText)
|
|
253
|
+
if (runtimeRun.stderrText) writeLog(logDir, 'runtime-journey.err.log', runtimeRun.stderrText)
|
|
254
|
+
try { runtimeVerdict = JSON.parse(runtimeRun.stdoutText) } catch { runtimeSkipped = `runtime probe produced no JSON (exit ${runtimeRun.exitCode}): ${tailLines(runtimeRun.stderrText, 5)}` }
|
|
255
|
+
if (runtimeVerdict) {
|
|
256
|
+
for (const check of runtimeVerdict.checks ?? []) {
|
|
257
|
+
const mapped = toEvidenceCheck(check, logDir, toEvidencePath)
|
|
258
|
+
if (!mapped) continue
|
|
259
|
+
const journey = mapped.id.slice(0, 2)
|
|
260
|
+
if (!runtimeChecks[journey]) continue
|
|
261
|
+
runtimeChecks[journey].push(mapped)
|
|
262
|
+
runtimeResults[mapped.id] = check.result
|
|
263
|
+
}
|
|
264
|
+
for (const blocker of runtimeVerdict.blockers ?? []) {
|
|
265
|
+
blockerReasons[blocker.journey] = blockerReasons[blocker.journey] ?? []
|
|
266
|
+
blockerReasons[blocker.journey].push(blocker.reason)
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
if (runtimeSkipped !== null) warnings.push(`runtime probe skipped: ${runtimeSkipped}`)
|
|
271
|
+
|
|
272
|
+
// 6. Journey statuses + reasons (never upgrade a skip to a pass).
|
|
273
|
+
const journeys = {}
|
|
274
|
+
const hints = []
|
|
275
|
+
for (const id of FLOW_IDS) {
|
|
276
|
+
const checks = [...staticChecks[id], ...runtimeChecks[id]]
|
|
277
|
+
let status
|
|
278
|
+
let reason = null
|
|
279
|
+
if (RUNTIME_JOURNEYS.includes(id)) {
|
|
280
|
+
const results = checks.filter(check => check.kind !== 'static').map(check => runtimeResults[check.id]).filter(Boolean)
|
|
281
|
+
const staticDeclared = staticResult?.checks?.find(check => check.id === STATIC_CHECK_IDS[id])?.result ?? null
|
|
282
|
+
const upstream = id !== 'J1' && blockerReasons.J1?.length
|
|
283
|
+
? `blocked by J1: ${blockerReasons.J1.join('; ')}`
|
|
284
|
+
: null
|
|
285
|
+
if (runtimeSkipped !== null) {
|
|
286
|
+
status = staticDeclared === 'NA' ? 'na' : staticDeclared === 'FAIL' ? 'fail' : checks.length > 0 ? 'static' : 'blocked'
|
|
287
|
+
if (staticDeclared === 'NA') reason = 'no dsh.client (static preflight NA)'
|
|
288
|
+
else if (staticDeclared === 'FAIL') reason = 'static preflight FAIL'
|
|
289
|
+
else reason = runtimeSkipped
|
|
290
|
+
} else if (blockerReasons[id]?.length) {
|
|
291
|
+
status = 'blocked'
|
|
292
|
+
reason = blockerReasons[id].join('; ')
|
|
293
|
+
} else if (id === 'J6' && !previousCopy) {
|
|
294
|
+
status = 'blocked'
|
|
295
|
+
reason = 'no --previous package supplied; upgrade/rollback not exercised'
|
|
296
|
+
} else if (results.includes('FAIL')) {
|
|
297
|
+
status = 'fail'
|
|
298
|
+
reason = `${results.filter(item => item === 'FAIL').length} runtime check(s) failed`
|
|
299
|
+
} else if (results.includes('BLOCKED')) {
|
|
300
|
+
status = 'blocked'
|
|
301
|
+
reason = 'runtime probe reported BLOCKED'
|
|
302
|
+
} else if (results.includes('WARN')) {
|
|
303
|
+
status = 'warn'
|
|
304
|
+
reason = 'runtime probe reported WARN (does not count as pass)'
|
|
305
|
+
} else if (upstream) {
|
|
306
|
+
status = 'blocked'
|
|
307
|
+
reason = upstream
|
|
308
|
+
} else if (results.length === 0 || results.every(item => item === 'not-run')) {
|
|
309
|
+
status = 'not-run'
|
|
310
|
+
reason = 'runtime probe did not cover this journey'
|
|
311
|
+
} else {
|
|
312
|
+
status = journeyAggregate(checks)
|
|
313
|
+
if (status !== 'pass' && status !== 'fail') reason = `checks aggregate to ${status}`
|
|
314
|
+
}
|
|
315
|
+
} else {
|
|
316
|
+
status = journeyAggregate(checks)
|
|
317
|
+
if (id === 'J8' && staticResult?.checks?.find(check => check.id === 'J8.client')?.result === 'NA') status = 'na'
|
|
318
|
+
if (status === 'static') reason = 'static preflight only; not a runtime pass'
|
|
319
|
+
}
|
|
320
|
+
const journey = { status, checks }
|
|
321
|
+
if (reason) journey.reason = reason
|
|
322
|
+
if (id === 'J8' && status === 'na') journey.limitations = ['no dsh.client: server-only shape is expected']
|
|
323
|
+
journeys[id] = journey
|
|
324
|
+
if (status === 'fail') collectHints(id, checks, hints, outDir, logDir, toEvidencePath)
|
|
325
|
+
else if (status === 'blocked') hints.push(`BLOCKED ${id} — 前置缺失: ${reason ?? 'unknown'}\n 条款锚: ${clauseAnchor(id)}`)
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
// 7. The inspected tree must be unchanged; the copy is disposed afterwards.
|
|
329
|
+
const after = options.assertUnchanged ? treeSha256(pkg, { exclude: treeExclude }) : null
|
|
330
|
+
const treeStable = after ? after.hash === before.hash : true
|
|
331
|
+
subject.treeStable = treeStable
|
|
332
|
+
if (after) subject.treeSha256After = after.hash
|
|
333
|
+
if (!treeStable) warnings.push(`package tree changed during collection (before ${before.hash}, after ${after.hash}); evidence is not trustworthy`)
|
|
334
|
+
|
|
335
|
+
// 8. Assemble, chain, validate.
|
|
336
|
+
const statuses = FLOW_IDS.map(id => journeys[id].status)
|
|
337
|
+
const counts = statuses.reduce((acc, status) => {
|
|
338
|
+
acc[status] = (acc[status] ?? 0) + 1
|
|
339
|
+
return acc
|
|
340
|
+
}, {})
|
|
341
|
+
const conclusion = statuses.includes('fail') || !treeStable
|
|
342
|
+
? 'flow-fail'
|
|
343
|
+
: statuses.includes('blocked') ? 'flow-blocked' : statuses.every(status => status === 'pass') ? 'flow-pass' : 'flow-warn'
|
|
344
|
+
const producerCommand = [process.execPath, path.relative(STD, fileURLToPath(import.meta.url)).split(path.sep).join('/'), ...process.argv.slice(2)]
|
|
345
|
+
const document = finalizeEvidence({
|
|
346
|
+
schema: FLOW_EVIDENCE_SCHEMA,
|
|
347
|
+
generatedAt,
|
|
348
|
+
producer: { command: producerCommand, at: generatedAt, kind: options.ci ? 'ci' : 'local', runner: 'flow-collect/1' },
|
|
349
|
+
subject,
|
|
350
|
+
host,
|
|
351
|
+
journeys,
|
|
352
|
+
claims: {
|
|
353
|
+
claimLevel: options.ci ? 'ci-attested' : 'self-attested',
|
|
354
|
+
notClaimed: NOT_CLAIMED,
|
|
355
|
+
method: 'local-collector: read-only copy, allowlisted child env, no allowBuilds, no exemption',
|
|
356
|
+
},
|
|
357
|
+
conclusion,
|
|
358
|
+
})
|
|
359
|
+
const verdict = validateFlowEvidence(document)
|
|
360
|
+
fs.writeFileSync(outFile, `${JSON.stringify(document, null, 2)}\n`)
|
|
361
|
+
|
|
362
|
+
// 9. SHA256SUMS for the logs (the evidence file itself is listed too).
|
|
363
|
+
const sums = []
|
|
364
|
+
for (const name of fs.readdirSync(logDir).sort()) {
|
|
365
|
+
const file = path.join(logDir, name)
|
|
366
|
+
if (!fs.statSync(file).isFile()) continue
|
|
367
|
+
sums.push(`${sha256File(file).slice('sha256:'.length)} ${toEvidencePath(file)}`)
|
|
368
|
+
}
|
|
369
|
+
sums.push(`${sha256File(outFile).slice('sha256:'.length)} ${path.basename(outFile)}`)
|
|
370
|
+
fs.writeFileSync(path.join(outDir, 'SHA256SUMS'), `${sums.join('\n')}\n`)
|
|
371
|
+
|
|
372
|
+
// 10. Report.
|
|
373
|
+
if (options.json) {
|
|
374
|
+
process.stdout.write(`${JSON.stringify(document, null, 2)}\n`)
|
|
375
|
+
} else {
|
|
376
|
+
const lines = [
|
|
377
|
+
`FLOW evidence: ${outFile}`,
|
|
378
|
+
` schema : ${document.schema}`,
|
|
379
|
+
` subject : ${subject.name}@${subject.version}`,
|
|
380
|
+
` integrity=${subject.integrity ?? 'null'} tarball=${subject.tarballSha256}`,
|
|
381
|
+
` tree=${subject.treeSha256} stable=${treeStable}${after ? ` after=${after.hash}` : ''}`,
|
|
382
|
+
` commit=${subject.commit ?? 'null'}`,
|
|
383
|
+
` host : dsh=${host.dsh ?? 'null'} node=${host.node} platform=${host.platform}`,
|
|
384
|
+
` journeys : ${FLOW_IDS.map(id => `${id}=${journeys[id].status}`).join(' ')}`,
|
|
385
|
+
` totals : ${JSON.stringify(counts)}`,
|
|
386
|
+
` conclusion: ${conclusion}`,
|
|
387
|
+
` chain : ${document.chain.evidenceHash}`,
|
|
388
|
+
` logs : ${logDir} (+ ${path.join(outDir, 'SHA256SUMS')})`,
|
|
389
|
+
'Claims: self-attested structure + binding only; no certification, no security audit, not a sandbox.',
|
|
390
|
+
]
|
|
391
|
+
if (warnings.length > 0) {
|
|
392
|
+
lines.push('Warnings:')
|
|
393
|
+
for (const warning of warnings) lines.push(` - ${warning}`)
|
|
394
|
+
}
|
|
395
|
+
if (hints.length > 0) {
|
|
396
|
+
lines.push('Journey hints (five elements + clause anchor):')
|
|
397
|
+
for (const hint of hints) lines.push(hint)
|
|
398
|
+
}
|
|
399
|
+
lines.push(`Next: node 40-checker/scripts/level-audit.mjs ${pkg} --flow-evidence ${outFile} --format json`)
|
|
400
|
+
process.stdout.write(`${lines.join('\n')}\n`)
|
|
401
|
+
}
|
|
402
|
+
if (!verdict.valid) {
|
|
403
|
+
for (const error of verdict.errors) console.error(`flow-collect: evidence invalid: ${error}`)
|
|
404
|
+
process.exitCode = 3
|
|
405
|
+
} else {
|
|
406
|
+
process.exitCode = conclusion === 'flow-fail' ? 1 : 0
|
|
407
|
+
}
|
|
408
|
+
} finally {
|
|
409
|
+
try { fs.rmSync(tmpRoot, { recursive: true, force: true }) } catch { /* ignore */ }
|
|
410
|
+
}
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
function packSubject(copy, tmpRoot, childEnv, timeout) {
|
|
414
|
+
const destination = fs.mkdtempSync(path.join(tmpRoot, 'pack-'))
|
|
415
|
+
const runResult = run(NPM, ['pack', '--ignore-scripts', '--json', '--pack-destination', destination], { cwd: copy, env: childEnv(), timeout })
|
|
416
|
+
if (runResult.exitCode !== 0) {
|
|
417
|
+
throw new Error(`npm pack failed (exit ${runResult.exitCode}): ${tailLines(runResult.stderrText, 10)}`)
|
|
418
|
+
}
|
|
419
|
+
let parsed
|
|
420
|
+
try { parsed = JSON.parse(runResult.stdoutText) } catch {
|
|
421
|
+
throw new Error(`npm pack did not print JSON: ${tailLines(runResult.stdoutText, 5)}`)
|
|
422
|
+
}
|
|
423
|
+
const info = Array.isArray(parsed) ? parsed[0] : parsed
|
|
424
|
+
if (!info?.filename) throw new Error('npm pack did not report a tarball filename')
|
|
425
|
+
const tarball = path.join(destination, info.filename)
|
|
426
|
+
return {
|
|
427
|
+
integrity: typeof info.integrity === 'string' ? info.integrity : null,
|
|
428
|
+
tarballSha256: sha256File(tarball),
|
|
429
|
+
files: Array.isArray(info.files) ? info.files.length : null,
|
|
430
|
+
filename: info.filename,
|
|
431
|
+
}
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
function gitCommit(dir) {
|
|
435
|
+
const result = run(GIT, ['-C', dir, 'rev-parse', 'HEAD'], { env: sanitizedEnv(), timeout: 10_000 })
|
|
436
|
+
const commit = result.exitCode === 0 ? result.stdoutText.trim() : ''
|
|
437
|
+
return /^[0-9a-f]{40}$/.test(commit) ? commit : null
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
/** Map one journey-runtime check to the v2 check shape (null = not evidence). */
|
|
441
|
+
function toEvidenceCheck(check, logDir, toEvidencePath) {
|
|
442
|
+
if (!check || !Array.isArray(check.command) || check.command.length === 0) return null
|
|
443
|
+
if (!Number.isInteger(check.exitCode)) return null
|
|
444
|
+
if (!check.startedAt || !check.endedAt) return null
|
|
445
|
+
if (!check.stdout?.path) return null
|
|
446
|
+
const output = {
|
|
447
|
+
id: check.id,
|
|
448
|
+
kind: 'observed',
|
|
449
|
+
command: check.command,
|
|
450
|
+
exitCode: check.exitCode,
|
|
451
|
+
startedAt: check.startedAt,
|
|
452
|
+
endedAt: check.endedAt,
|
|
453
|
+
stdout: { path: toEvidencePath(path.join(logDir, check.stdout.path)), sha256: check.stdout.sha256 },
|
|
454
|
+
assertions: Array.isArray(check.assertions) && check.assertions.length > 0
|
|
455
|
+
? check.assertions.map(assertion => ({ claim: String(assertion.claim), observed: assertion.observed === true }))
|
|
456
|
+
: [{ claim: String(check.reason ?? check.id), observed: check.result === 'PASS' }],
|
|
457
|
+
}
|
|
458
|
+
if (check.stderrLog?.path && check.stderrLog?.sha256) {
|
|
459
|
+
output.stderr = { path: toEvidencePath(path.join(logDir, check.stderrLog.path)), sha256: check.stderrLog.sha256 }
|
|
460
|
+
}
|
|
461
|
+
if (Number.isFinite(check.durationMs)) output.durationMs = check.durationMs
|
|
462
|
+
return output
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
/** The fixed five elements + clause anchor. */
|
|
466
|
+
function collectHints(journeyId, checks, hints, outDir, logDir, toEvidencePath) {
|
|
467
|
+
const failing = checks.filter(check => check.exitCode !== 0)
|
|
468
|
+
for (const check of failing) {
|
|
469
|
+
const logFiles = [check.stderr?.path, check.stdout?.path].filter(Boolean)
|
|
470
|
+
const absolute = logFiles.map(rel => path.resolve(outDir, rel))
|
|
471
|
+
const stderrAbs = check.stderr?.path ? path.resolve(outDir, check.stderr.path) : null
|
|
472
|
+
let stderrTail = ''
|
|
473
|
+
if (stderrAbs) {
|
|
474
|
+
try { stderrTail = tailLines(fs.readFileSync(stderrAbs, 'utf8'), 20) } catch { /* keep empty */ }
|
|
475
|
+
}
|
|
476
|
+
const lines = [
|
|
477
|
+
`FAIL ${check.id}`,
|
|
478
|
+
` 1) 子检查: ${check.id}`,
|
|
479
|
+
` 2) 命令: ${quoteCommand(check.command)}`,
|
|
480
|
+
` 3) exitCode: ${check.exitCode}`,
|
|
481
|
+
` 4) stderr(末20行): ${stderrTail ? `\n${stderrTail.split('\n').map(line => ` ${line}`).join('\n')}` : '(empty)'}`,
|
|
482
|
+
` 5) 日志: ${absolute.join(', ') || '(no log written)'}`,
|
|
483
|
+
` 条款锚: ${clauseAnchor(check.id) ?? journeyId}`,
|
|
484
|
+
]
|
|
485
|
+
hints.push(lines.join('\n'))
|
|
486
|
+
}
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
try {
|
|
490
|
+
main()
|
|
491
|
+
} catch (error) {
|
|
492
|
+
console.error(`flow-collect: ${error.message}`)
|
|
493
|
+
process.exitCode = 3
|
|
494
|
+
}
|
|
@@ -24,6 +24,7 @@ import fs from 'node:fs'
|
|
|
24
24
|
import os from 'node:os'
|
|
25
25
|
import path from 'node:path'
|
|
26
26
|
import { spawnSync } from 'node:child_process'
|
|
27
|
+
import { sanitizedEnv } from '../src/sandbox-env.mjs'
|
|
27
28
|
import { fileURLToPath } from 'node:url'
|
|
28
29
|
|
|
29
30
|
const HERE = path.dirname(fileURLToPath(import.meta.url))
|
|
@@ -127,11 +128,10 @@ function removeDir(dir) {
|
|
|
127
128
|
fs.rmSync(assertUnderSampleBase(dir), { recursive: true, force: true })
|
|
128
129
|
}
|
|
129
130
|
|
|
131
|
+
// The journey probe boots the plugin; forward an allowlisted env, never the
|
|
132
|
+
// caller's tokens/keys (the probe also rebuilds this list for the plugin child).
|
|
130
133
|
function childEnv() {
|
|
131
|
-
|
|
132
|
-
delete env.DEEPSEEK_API_KEY
|
|
133
|
-
delete env.OPENAI_API_KEY
|
|
134
|
-
return env
|
|
134
|
+
return sanitizedEnv()
|
|
135
135
|
}
|
|
136
136
|
|
|
137
137
|
function manifestSummary(dir) {
|
|
@@ -20,6 +20,7 @@
|
|
|
20
20
|
import fs from 'node:fs'
|
|
21
21
|
import path from 'node:path'
|
|
22
22
|
import { spawnSync } from 'node:child_process'
|
|
23
|
+
import { sanitizedEnv } from '../src/sandbox-env.mjs'
|
|
23
24
|
|
|
24
25
|
function parse(argv) {
|
|
25
26
|
const options = { plugin: null, farm: null, host: '<dsh-checkout>', install: false, build: false, link: false, quiet: false }
|
|
@@ -124,7 +125,7 @@ function copyPlugin(plugin, farm, asLink) {
|
|
|
124
125
|
}
|
|
125
126
|
|
|
126
127
|
function run(command, args, cwd) {
|
|
127
|
-
const result = spawnSync(command, args, { cwd, stdio: 'inherit', env: {
|
|
128
|
+
const result = spawnSync(command, args, { cwd, stdio: 'inherit', env: sanitizedEnv({ npm_config_audit: 'false', npm_config_fund: 'false' }) })
|
|
128
129
|
if (result.error) return { ok: false, error: String(result.error) }
|
|
129
130
|
return { ok: result.status === 0, status: result.status }
|
|
130
131
|
}
|