@vegastack/skills 0.4.0 → 0.6.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.
Files changed (64) hide show
  1. package/README.md +4 -2
  2. package/dist/index.js +38 -45
  3. package/package.json +1 -1
  4. package/skill/architect/SKILL.md +68 -0
  5. package/skill/architect/agents/openai.yaml +4 -0
  6. package/skill/architect/assets/adr-template.md +21 -0
  7. package/skill/architect/assets/arch-template.md +20 -0
  8. package/skill/architect/references/advisory.md +102 -0
  9. package/skill/architect/references/ai-agents.md +95 -0
  10. package/skill/architect/references/data.md +90 -0
  11. package/skill/architect/references/infra.md +128 -0
  12. package/skill/architect/references/mobile.md +78 -0
  13. package/skill/architect/references/pinned-facts.md +108 -0
  14. package/skill/architect/references/principles.md +91 -0
  15. package/skill/architect/references/project-profile.md +37 -0
  16. package/skill/architect/references/security.md +97 -0
  17. package/skill/architect/references/stack.md +38 -0
  18. package/skill/architect/references/web.md +152 -0
  19. package/skill/architect/refresh/REFRESH.md +29 -0
  20. package/skill/architect/refresh/sources.json +244 -0
  21. package/skill/skill-maintainer/references/release-ops.md +11 -15
  22. package/skill/skill-maintainer/refresh/REFRESH.md +3 -3
  23. package/skill-integrity.json +20 -44
  24. package/skill/arch-guardian/SKILL.md +0 -84
  25. package/skill/arch-guardian/agents/openai.yaml +0 -4
  26. package/skill/arch-guardian/assets/adr-template.md +0 -25
  27. package/skill/arch-guardian/assets/answers-example.json +0 -10
  28. package/skill/arch-guardian/assets/architecture-profile.json +0 -13
  29. package/skill/arch-guardian/assets/architecture-profile.schema.json +0 -31
  30. package/skill/arch-guardian/assets/deployment-review-template.md +0 -24
  31. package/skill/arch-guardian/assets/service-design-template.md +0 -33
  32. package/skill/arch-guardian/assets/threat-model-template.md +0 -34
  33. package/skill/arch-guardian/references/advisory-report.md +0 -65
  34. package/skill/arch-guardian/references/architecture/agent-product.md +0 -22
  35. package/skill/arch-guardian/references/architecture/ai-cost.md +0 -24
  36. package/skill/arch-guardian/references/architecture/ai-data-boundaries.md +0 -21
  37. package/skill/arch-guardian/references/architecture/ai-evals.md +0 -28
  38. package/skill/arch-guardian/references/architecture/connectors-sandbox.md +0 -39
  39. package/skill/arch-guardian/references/architecture/data-memory.md +0 -25
  40. package/skill/arch-guardian/references/architecture/delivery-operations.md +0 -34
  41. package/skill/arch-guardian/references/architecture/durable-execution.md +0 -45
  42. package/skill/arch-guardian/references/architecture/flutter.md +0 -26
  43. package/skill/arch-guardian/references/architecture/foundation.md +0 -31
  44. package/skill/arch-guardian/references/architecture/hosting-reliability.md +0 -37
  45. package/skill/arch-guardian/references/architecture/identity-tenancy.md +0 -37
  46. package/skill/arch-guardian/references/architecture/model-lifecycle.md +0 -20
  47. package/skill/arch-guardian/references/architecture/models-observability.md +0 -23
  48. package/skill/arch-guardian/references/architecture/realtime-channels.md +0 -16
  49. package/skill/arch-guardian/references/architecture/security-privacy.md +0 -27
  50. package/skill/arch-guardian/references/architecture/topology-monorepo.md +0 -47
  51. package/skill/arch-guardian/references/architecture/web.md +0 -29
  52. package/skill/arch-guardian/references/foundation-compatibility.json +0 -44
  53. package/skill/arch-guardian/references/golden-patterns.md +0 -43
  54. package/skill/arch-guardian/references/profile-governance.md +0 -40
  55. package/skill/arch-guardian/references/rule-model.json +0 -36
  56. package/skill/arch-guardian/references/workflows.md +0 -48
  57. package/skill/arch-guardian/refresh/REFRESH.md +0 -47
  58. package/skill/arch-guardian/refresh/sources.json +0 -1171
  59. package/skill/arch-guardian/scripts/lib.mjs +0 -48
  60. package/skill/arch-guardian/scripts/profile-tool.mjs +0 -217
  61. package/skill/arch-guardian/scripts/refresh-evidence.mjs +0 -366
  62. package/skill/arch-guardian/scripts/schema-validate.mjs +0 -63
  63. package/skill/arch-guardian/scripts/validate-profile.mjs +0 -65
  64. package/skill/arch-guardian/scripts/verify-corpus.mjs +0 -136
@@ -1,48 +0,0 @@
1
- import { createHash } from 'node:crypto'
2
- import { lstat, readFile, readdir } from 'node:fs/promises'
3
- import { join, relative } from 'node:path'
4
-
5
- export const sha256 = body => createHash('sha256').update(body).digest('hex')
6
-
7
- // Canonical profile basename first; legacy name accepted with a deprecation notice at call sites.
8
- export const PROFILE_BASENAMES = ['architecture.json', 'architecture.yaml']
9
-
10
- export async function readJsonYaml(path) {
11
- const raw = await readFile(path, 'utf8')
12
- try { return JSON.parse(raw) } catch (error) {
13
- throw new Error(`${path} must contain a JSON document (guardian profiles and registries are JSON; rename legacy .yaml profiles to .json — YAML syntax is not supported): ${error.message}`)
14
- }
15
- }
16
-
17
- export async function pathExists(path) {
18
- try { await lstat(path); return true } catch (error) {
19
- if (error.code === 'ENOENT') return false
20
- throw error
21
- }
22
- }
23
-
24
- // Discover the committed architecture profile. Returns { path, relative, legacy } or null.
25
- export async function resolveProfile(root) {
26
- for (const basename of PROFILE_BASENAMES) {
27
- const candidate = join(root, '.vegastack', basename)
28
- if (await pathExists(candidate)) {
29
- return { path: candidate, relative: `.vegastack/${basename}`, legacy: basename.endsWith('.yaml') }
30
- }
31
- }
32
- return null
33
- }
34
-
35
- export async function listFiles(root, predicate = () => true) {
36
- const output = []
37
- async function walk(directory) {
38
- for (const entry of await readdir(directory, { withFileTypes: true })) {
39
- const path = join(directory, entry.name)
40
- if (entry.isSymbolicLink()) throw new Error(`Refusing symlink during traversal: ${path}`)
41
- if (entry.isDirectory()) await walk(path)
42
- else if (entry.isFile() && predicate(path)) output.push(path)
43
- }
44
- }
45
- await walk(root)
46
- return output.sort((a, b) => relative(root, a).localeCompare(relative(root, b)))
47
- }
48
-
@@ -1,217 +0,0 @@
1
- #!/usr/bin/env node
2
- import { randomUUID } from 'node:crypto'
3
- import { lstat, mkdir, open, readFile, readdir, rename } from 'node:fs/promises'
4
- import { basename, dirname, extname, isAbsolute, join, relative, resolve, sep } from 'node:path'
5
- import { pathToFileURL } from 'node:url'
6
- import { pathExists, readJsonYaml } from './lib.mjs'
7
-
8
- const capabilityNames = ['web', 'flutter', 'agents', 'jobs', 'sandbox', 'connectors', 'knowledge', 'models', 'enterprise-identity', 'realtime', 'notifications']
9
- const observedListCap = 25
10
- // Observation patterns are heuristic evidence, never proof. `secrets` is observed as advisory
11
- // evidence but is not a declarable capability — secret custody guidance activates whenever
12
- // production secrets exist.
13
- const patterns = {
14
- web: /(?:next|@opennextjs\/cloudflare)/i,
15
- flutter: /(?:pubspec\.yaml|package:flutter)/i,
16
- agents: /(?:\beve\b|@workflow\/world|AgentRun)/i,
17
- jobs: /(?:pg-boss|PgBoss)/i,
18
- sandbox: /(?:@cloudflare\/sandbox|SandboxProvider|enableInternet)/i,
19
- connectors: /(?:\bMCP\b|webhook|connector)/i,
20
- knowledge: /(?:pgvector|embedding|\bknowledge\b)/i,
21
- models: /(?:ai-gateway|generateText|streamText|BYOK)/i,
22
- 'enterprise-identity': /(?:\bSCIM\b|\bSSO\b|saml)/i,
23
- realtime: /(?:EventSource|text\/event-stream|WebSocket)/i,
24
- notifications: /(?:firebase_messaging|APNs|notification)/i,
25
- secrets: /(?:OPENBAO|VAULT_ADDR|CLIENT_SECRET|DATABASE_URL)/i
26
- }
27
- const ignored = new Set(['node_modules', '.git', '.turbo', 'dist', 'build', '.next', '.agents', '.claude', 'coverage', 'vendor', 'out', '.output', '.vercel', '.wrangler'])
28
-
29
- async function inspectableFiles(root) {
30
- const output = []
31
- async function walk(directory) {
32
- for (const entry of await readdir(directory, { withFileTypes: true })) {
33
- if (ignored.has(entry.name)) continue
34
- const path = resolve(directory, entry.name)
35
- if (entry.isSymbolicLink()) throw new Error(`Refusing symlink during inspection: ${path}`)
36
- if (entry.isDirectory()) await walk(path)
37
- else if (entry.isFile()) output.push(path)
38
- }
39
- }
40
- await walk(root)
41
- return output.sort()
42
- }
43
-
44
- function baseDraft() {
45
- return {
46
- schemaVersion: 4,
47
- foundationVersion: '0.4.0',
48
- project: { name: 'REQUIRED-CONFIRMED-PROJECT-NAME', kind: 'REQUIRED-CONFIRMED-KIND', tier: 'REQUIRED-CONFIRMED-TIER', tenancy: 'REQUIRED-CONFIRMED-TENANCY' },
49
- hosting: 'REQUIRED-CONFIRMED-HOSTING',
50
- capabilities: [],
51
- notes: []
52
- }
53
- }
54
-
55
- // Only literally exact versions are recorded as facts; ranges ("^1.2.3") are not laundered into
56
- // exact pins — they stay observational.
57
- function exact(value) {
58
- const normalized = String(value ?? '').trim().replace(/^v/, '')
59
- return /^\d+\.\d+(?:\.\d+)?(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?$/.test(normalized) ? normalized : null
60
- }
61
-
62
- async function inspect(root) {
63
- const before = new Map()
64
- const allBefore = await inspectableFiles(root)
65
- for (const path of allBefore) before.set(path, (await lstat(path)).mtimeMs)
66
- const files = allBefore
67
- const packageVersions = {}
68
- for (const path of files.filter(path => basename(path) === 'package.json')) {
69
- try {
70
- const pkg = JSON.parse(await readFile(path, 'utf8'))
71
- for (const [name, value] of Object.entries({ ...(pkg.dependencies ?? {}), ...(pkg.devDependencies ?? {}) })) if (exact(value)) packageVersions[name] = exact(value)
72
- } catch { /* malformed manifests remain observational unknowns */ }
73
- }
74
- const observed = Object.fromEntries(Object.keys(patterns).map(name => [name, []]))
75
- for (const path of files.filter(path => !/[\\/]\.vegastack[\\/]/.test(path) && (['.ts', '.tsx', '.js', '.mjs', '.cjs', '.json', '.yaml', '.yml', '.toml', '.sql', '.dart'].includes(extname(path)) || basename(path) === 'pubspec.yaml'))) {
76
- const rel = relative(root, path).split(sep).join('/')
77
- let body = ''
78
- try { body = await readFile(path, 'utf8') } catch { continue }
79
- for (const [name, pattern] of Object.entries(patterns)) if (pattern.test(`${rel}\n${body}`)) observed[name].push(rel)
80
- }
81
- const profile = baseDraft()
82
- profile.capabilities = capabilityNames.filter(name => observed[name].length)
83
- const artifacts = ['.vegastack/architecture.json']
84
- if (['connectors', 'sandbox', 'agents'].some(name => observed[name].length)) artifacts.push('docs/architecture/threat-model.md')
85
- const after = await inspectableFiles(root)
86
- let changed = after.length !== before.size
87
- for (const path of after) if (!before.has(path) || before.get(path) !== (await lstat(path)).mtimeMs) changed = true
88
- if (changed) throw new Error('inspection mutation guard detected a changed file inventory')
89
- return { mode: 'brownfield-observed', mutated: false, observed, packageVersions, profileDraft: profile, relevantArtifacts: artifacts, caveats: ['Detection is heuristic and does not prove absence, placement, compliance, runtime behavior, or project intent. Every REQUIRED field must be confirmed before use.'] }
90
- }
91
-
92
- // Compact context-friendly view for interactive agent use; --json prints the full result.
93
- function summarizeInspection(result) {
94
- const capabilities = {}
95
- for (const [name, paths] of Object.entries(result.observed)) if (paths.length) capabilities[name] = { count: paths.length, sample: paths.slice(0, 5) }
96
- return { mode: result.mode, mutated: result.mutated, observedCapabilities: capabilities, packageVersionCount: Object.keys(result.packageVersions).length, relevantArtifacts: result.relevantArtifacts, caveats: result.caveats, hint: 'run with --json for the full draft profile and evidence lists' }
97
- }
98
-
99
- function capObserved(result) {
100
- const observed = {}
101
- for (const [name, paths] of Object.entries(result.observed)) {
102
- observed[name] = paths.length > observedListCap ? [...paths.slice(0, observedListCap)] : paths
103
- if (paths.length > observedListCap) observed[`${name}TruncatedCount`] = paths.length - observedListCap
104
- }
105
- return { ...result, observed }
106
- }
107
-
108
- function fromAnswers(answers) {
109
- if (answers.schemaVersion === 4) return answers
110
- const profile = baseDraft()
111
- for (const key of ['project', 'hosting', 'capabilities', 'notes', 'foundationVersion']) if (answers[key] !== undefined) profile[key] = answers[key]
112
- return profile
113
- }
114
-
115
- // Deterministic v3 -> v4 migration: exceptions are dropped (the advisor records deliberate
116
- // deviations in notes/ADR documents, never as suppression machinery), capability detail collapses
117
- // to the enabled list, and versions return to lockfiles.
118
- const v3CapabilityMap = { webControlPlane: 'web', flutter: 'flutter', agents: 'agents', jobs: 'jobs', sandbox: 'sandbox', connectors: 'connectors', knowledge: 'knowledge', modelRouting: 'models', enterpriseIdentity: 'enterprise-identity', realtime: 'realtime', notifications: 'notifications' }
119
- const v3KindMap = { 'saas-product': 'saas', 'internal-product': 'internal-tool', 'public-product': 'saas', 'platform-service': 'api', 'shared-package': 'package' }
120
-
121
- function migrateV3(old) {
122
- if (old.schemaVersion !== 3) throw new Error('migrate requires a schemaVersion 3 profile; older versions need a fresh v4 profile from answers')
123
- const profile = baseDraft()
124
- profile.project = {
125
- name: old.project?.name ?? 'REQUIRED-CONFIRMED-PROJECT-NAME',
126
- kind: v3KindMap[old.project?.kind] ?? 'REQUIRED-CONFIRMED-KIND',
127
- tier: 'REQUIRED-CONFIRMED-TIER',
128
- tenancy: old.project?.tenancy ?? 'REQUIRED-CONFIRMED-TENANCY'
129
- }
130
- profile.hosting = old.environments?.production?.hosting ?? 'REQUIRED-CONFIRMED-HOSTING'
131
- profile.capabilities = Object.entries(old.capabilities ?? {}).filter(([, value]) => value?.status === 'enabled').map(([name]) => v3CapabilityMap[name]).filter(Boolean)
132
- profile.notes = (old.exceptions ?? []).map(exception => `migrated deviation (was exception ${exception.id} on ${exception.ruleId}): ${exception.decision ?? exception.rationale ?? 'see original ADR'}`)
133
- return { profile, guidance: ['Confirm the tier deliberately — it decides which concerns apply, not which tools.', 'v3 exceptions were converted to notes; the advisor has no suppression machinery.', 'Versions now come from lockfiles at advice time.', 'No source file or old profile was changed.'] }
134
- }
135
-
136
- async function assertNoSymlink(path) {
137
- const absolute = resolve(path)
138
- const root = resolve(absolute, sep)
139
- let current = root
140
- for (const part of relative(root, absolute).split(sep).filter(Boolean)) {
141
- current = join(current, part)
142
- try { if ((await lstat(current)).isSymbolicLink()) throw new Error(`Refusing symlink path component: ${current}`) } catch (error) { if (error.code === 'ENOENT') return; throw error }
143
- }
144
- }
145
-
146
- async function atomicWrite(path, body, force) {
147
- await assertNoSymlink(dirname(path))
148
- await mkdir(dirname(path), { recursive: true })
149
- await assertNoSymlink(dirname(path))
150
- if (await pathExists(path)) {
151
- await assertNoSymlink(path)
152
- const current = await readFile(path, 'utf8')
153
- if (current === body) return 'unchanged'
154
- if (!force) throw new Error(`Refusing differing file without --force: ${path}`)
155
- }
156
- const temporary = `${path}.${randomUUID()}.tmp`
157
- const handle = await open(temporary, 'wx')
158
- try { await handle.writeFile(body); await handle.sync() } finally { await handle.close() }
159
- await rename(temporary, path)
160
- return 'written'
161
- }
162
-
163
- function flagValue(argv, flag) {
164
- const value = argv.shift()
165
- if (value === undefined || value.startsWith('-')) throw new Error(`${flag} requires a value`)
166
- return value
167
- }
168
-
169
- function parse(argv) {
170
- const command = argv.shift() ?? 'help'
171
- const input = argv[0] && !argv[0].startsWith('-') ? argv.shift() : undefined
172
- const options = { command, input, dir: process.cwd(), write: false, force: false, json: false, output: undefined }
173
- while (argv.length) {
174
- const flag = argv.shift()
175
- if (flag === '--dir') options.dir = resolve(flagValue(argv, flag))
176
- else if (flag === '--write') options.write = true
177
- else if (flag === '--force') options.force = true
178
- else if (flag === '--json') options.json = true
179
- else if (flag === '--output') options.output = flagValue(argv, flag)
180
- else throw new Error(`Unknown option: ${flag}`)
181
- }
182
- return options
183
- }
184
-
185
- // Output paths are confined to --dir: repository-relative, no .. segments, no symlink components.
186
- function confinedOutput(dir, output) {
187
- if (isAbsolute(output) || output.split(/[\\/]/).includes('..')) throw new Error(`--output must be a repository-relative path inside --dir without ..: ${output}`)
188
- const target = resolve(dir, output)
189
- if (relative(resolve(dir), target).startsWith('..')) throw new Error(`--output escapes --dir: ${output}`)
190
- if (!output || target === resolve(dir)) throw new Error(`--output must name a file inside --dir, not the directory itself: ${JSON.stringify(output)}`)
191
- return target
192
- }
193
-
194
- async function main() {
195
- const options = parse(process.argv.slice(2))
196
- if (options.command === 'help') return console.log('Usage: profile-tool.mjs inspect [DIR] [--json] | scaffold ANSWERS.json --dir DIR [--write] [--force] [--output PATH] | migrate PROFILE --dir DIR [--write] [--force] [--output PATH]\n\ninspect prints a compact summary by default; --json prints the full draft and evidence.\nscaffold answers format: see assets/answers-example.json next to this skill.\nmigrate converts a v3 profile to a v4 draft (exceptions become notes).\nWrites are atomic, refuse symlinks, stay inside --dir, and require --force to replace differing content.')
197
- if (options.command === 'inspect') {
198
- const root = resolve(options.input ?? options.dir)
199
- const result = await inspect(root)
200
- return console.log(JSON.stringify(options.json ? capObserved(result) : summarizeInspection(result), null, 2))
201
- }
202
- if (!['scaffold', 'migrate'].includes(options.command) || !options.input) throw new Error('A supported command and input file are required')
203
- const source = resolve(options.input)
204
- const parsed = await readJsonYaml(source)
205
- const result = options.command === 'migrate' ? migrateV3(parsed) : { profile: fromAnswers(parsed), guidance: ['Generated only from supplied answers; confirm draft facts before relying on them.'] }
206
- const body = `${JSON.stringify(result.profile, null, 2)}\n`
207
- const defaultOutput = options.command === 'migrate' ? '.vegastack/architecture.v4-draft.json' : '.vegastack/architecture.json'
208
- const output = confinedOutput(options.dir, options.output ?? defaultOutput)
209
- const payload = { mode: options.write ? 'authorized-write' : 'dry-run', output, profile: result.profile, guidance: result.guidance }
210
- if (!options.write) return console.log(JSON.stringify(payload, null, 2))
211
- payload.result = await atomicWrite(output, body, options.force)
212
- console.log(JSON.stringify(payload, null, 2))
213
- }
214
-
215
- if (import.meta.url === pathToFileURL(process.argv[1] ?? '').href) main().catch(error => { console.error(`error: ${error.message}`); process.exitCode = 1 })
216
-
217
- export { inspect, migrateV3, fromAnswers, atomicWrite }
@@ -1,366 +0,0 @@
1
- #!/usr/bin/env node
2
- import { randomUUID } from 'node:crypto'
3
- import { lookup } from 'node:dns/promises'
4
- import { lstat, mkdir, readFile, rename, rm, writeFile } from 'node:fs/promises'
5
- import { isIP } from 'node:net'
6
- import { dirname, join, parse, relative, resolve, sep } from 'node:path'
7
- import { spawnSync } from 'node:child_process'
8
- import { fileURLToPath, pathToFileURL } from 'node:url'
9
- import { pathExists, readJsonYaml, sha256 } from './lib.mjs'
10
-
11
- const skillRoot = resolve(dirname(fileURLToPath(import.meta.url)), '..')
12
- const defaultRegistry = join(skillRoot, 'refresh', 'sources.json')
13
- const maximumBytes = 5 * 1024 * 1024
14
- // Every host referenced by refresh/sources.json must appear here; loadRegistry enforces the
15
- // consistency so the allowlist cannot silently drift from the registry.
16
- const approvedHosts = new Set(['agentskills.io', 'ai-sdk.dev', 'ai.google.dev', 'api.flutter.dev', 'api.osv.dev', 'aws.amazon.com', 'better-auth.com', 'bun.sh', 'code.claude.com', 'developer.apple.com', 'developers.cloudflare.com', 'developers.openai.com', 'docs.aws.amazon.com', 'docs.flutter.dev', 'firebase.google.com', 'git.postgresql.org', 'github.com', 'hermes-agent.nousresearch.com', 'learn.chatgpt.com', 'modal.com', 'modelcontextprotocol.io', 'nextjs.org', 'openbao.org', 'openid.github.io', 'opennext.js.org', 'opentelemetry.io', 'platform.claude.com', 'pub.dev', 'pypi.org', 'raw.githubusercontent.com', 'registry.npmjs.org', 'riverpod.dev', 'turborepo.dev', 'workflow-sdk.dev', 'www.cloudflare.com', 'www.npmjs.com', 'www.postgresql.org'])
17
-
18
- function flagValue(argv, flag) {
19
- const value = argv.shift()
20
- if (value === undefined || value.startsWith('-')) throw new Error(`${flag} requires a value`)
21
- return value
22
- }
23
-
24
- function args(argv) {
25
- const result = { registry: defaultRegistry, cache: '.vegastack/evidence-cache.json', report: '.vegastack/evidence-drift.json', topics: [], offline: false, acceptBaselines: false, now: new Date().toISOString(), compatibility: join(skillRoot, 'references', 'foundation-compatibility.json') }
26
- while (argv.length) {
27
- const flag = argv.shift()
28
- if (flag === '--registry') result.registry = resolve(flagValue(argv, flag))
29
- else if (flag === '--cache') result.cache = resolve(flagValue(argv, flag))
30
- else if (flag === '--report') result.report = resolve(flagValue(argv, flag))
31
- else if (flag === '--topics') result.topics = flagValue(argv, flag).split(',').filter(Boolean)
32
- else if (flag === '--offline') result.offline = true
33
- else if (flag === '--accept-baselines') result.acceptBaselines = true
34
- else if (flag === '--verify-baselines') { /* explicit alias for the default online verification run */ }
35
- else if (flag === '--now') result.now = flagValue(argv, flag)
36
- else if (flag === '--compatibility') result.compatibility = resolve(flagValue(argv, flag))
37
- else throw new Error(`Unknown option: ${flag}`)
38
- }
39
- return result
40
- }
41
-
42
- // Security-advisory watch: query OSV.dev for every pinned npm/PyPI package. Advisories against a
43
- // pinned version are the highest-value freshness signal — they surface in the weekly report and
44
- // fail closed for critical sources so a vulnerable pin is never silently kept.
45
- async function osvAdvisories(source, allowHttpLocalhost) {
46
- const detection = source.versionDetection ?? {}
47
- const ecosystem = { npm: 'npm', 'npm-suite': 'npm', pypi: 'PyPI' }[detection.type]
48
- const version = String(source.pinnedVersion ?? '')
49
- if (!ecosystem || !/^\d/.test(version)) return []
50
- const packages = detection.type === 'npm-suite' ? detection.packages : [detection.package]
51
- const findings = []
52
- for (const name of packages) {
53
- const response = await safeFetch('https://api.osv.dev/v1/query', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ package: { name, ecosystem }, version }) }, allowHttpLocalhost)
54
- if (!response.ok) throw new Error(`OSV HTTP ${response.status} for ${name}`)
55
- const data = JSON.parse(new TextDecoder().decode(await readBounded(response)))
56
- for (const vuln of data.vulns ?? []) findings.push({ package: name, version, id: vuln.id, summary: vuln.summary ?? null })
57
- }
58
- return findings
59
- }
60
-
61
- function privateAddress(address) {
62
- if (address === '::1' || address === '::' || address.startsWith('fc') || address.startsWith('fd') || address.startsWith('fe80:')) return true
63
- if (!isIP(address)) return true
64
- // IPv4-mapped IPv6 must be evaluated as its IPv4 payload, not passed as "public IPv6" —
65
- // in both dotted (::ffff:10.0.0.1) and hex-group (::ffff:a00:1) notations.
66
- const mapped = /^::ffff:(\d+\.\d+\.\d+\.\d+)$/i.exec(address)
67
- if (mapped) return privateAddress(mapped[1])
68
- const hexMapped = /^::ffff:([0-9a-f]{1,4}):([0-9a-f]{1,4})$/i.exec(address)
69
- if (hexMapped) {
70
- const high = Number.parseInt(hexMapped[1], 16)
71
- const low = Number.parseInt(hexMapped[2], 16)
72
- return privateAddress(`${high >> 8}.${high & 255}.${low >> 8}.${low & 255}`)
73
- }
74
- if (address.includes(':')) return false
75
- const [a, b] = address.split('.').map(Number)
76
- if (a === 0 || a === 10 || a === 127 || a >= 224) return true
77
- if (a === 169 && b === 254) return true
78
- if (a === 172 && b >= 16 && b <= 31) return true
79
- if (a === 192 && (b === 168 || b === 0)) return true
80
- if (a === 100 && b >= 64 && b <= 127) return true // CGNAT 100.64/10
81
- if (a === 198 && (b === 18 || b === 19 || b === 51)) return true // benchmark + TEST-NET-2
82
- if (a === 203 && b === 0) return true // TEST-NET-3
83
- return false
84
- }
85
-
86
- // NOTE: validation resolves DNS immediately before each fetch (including every redirect hop), but
87
- // Node fetch re-resolves independently, so a hostile authoritative DNS server could still rebind
88
- // between the check and the request. The hard host allowlist above is the primary control; this
89
- // check is defense in depth against allowlisted-host compromise, not a substitute for it.
90
- async function validateNetworkTarget(input, allowHttpLocalhost = false) {
91
- const url = new URL(input)
92
- const loopbackName = ['localhost', '127.0.0.1', '::1', '[::1]'].includes(url.hostname)
93
- if (url.protocol !== 'https:' && !(allowHttpLocalhost && url.protocol === 'http:' && loopbackName)) throw new Error(`Only HTTPS evidence URLs are allowed: ${url}`)
94
- if (!approvedHosts.has(url.hostname) && !(allowHttpLocalhost && loopbackName)) throw new Error(`Unapproved evidence host: ${url.hostname}`)
95
- const addresses = isIP(url.hostname) ? [{ address: url.hostname }] : await lookup(url.hostname, { all: true, verbatim: true })
96
- if ((!allowHttpLocalhost || !loopbackName) && addresses.some(entry => privateAddress(entry.address))) throw new Error(`Private/reserved evidence target refused: ${url.hostname}`)
97
- return url
98
- }
99
-
100
- async function readBounded(response) {
101
- const declared = Number(response.headers.get('content-length') ?? 0)
102
- if (declared > maximumBytes) throw new Error(`Evidence response exceeds ${maximumBytes} bytes`)
103
- if (!response.body) return new Uint8Array()
104
- const reader = response.body.getReader()
105
- const chunks = []
106
- let total = 0
107
- while (true) {
108
- const { done, value } = await reader.read()
109
- if (done) break
110
- total += value.byteLength
111
- if (total > maximumBytes) { await reader.cancel(); throw new Error(`Evidence response exceeds ${maximumBytes} bytes`) }
112
- chunks.push(value)
113
- }
114
- const output = new Uint8Array(total)
115
- let offset = 0
116
- for (const chunk of chunks) { output.set(chunk, offset); offset += chunk.byteLength }
117
- return output
118
- }
119
-
120
- async function safeFetch(input, init = {}, allowHttpLocalhost = false) {
121
- let url = await validateNetworkTarget(input, allowHttpLocalhost)
122
- for (let redirects = 0; redirects <= 5; redirects += 1) {
123
- const response = await fetch(url, { ...init, redirect: 'manual', signal: AbortSignal.timeout(20_000) })
124
- if (![301, 302, 303, 307, 308].includes(response.status)) return response
125
- const location = response.headers.get('location')
126
- if (!location || redirects === 5) throw new Error('Invalid or excessive evidence redirect')
127
- url = await validateNetworkTarget(new URL(location, url).href, allowHttpLocalhost)
128
- }
129
- throw new Error('Evidence redirect limit exceeded')
130
- }
131
-
132
- async function assertSafeWriteTarget(input) {
133
- const target = resolve(input)
134
- const root = parse(target).root
135
- const segments = relative(root, target).split(sep).filter(Boolean)
136
- let current = root
137
- for (const part of segments) {
138
- current = join(current, part)
139
- try {
140
- const entry = await lstat(current)
141
- if (entry.isSymbolicLink()) throw new Error(`Refusing write through symlink: ${current}`)
142
- } catch (error) {
143
- if (error?.code === 'ENOENT') break
144
- throw error
145
- }
146
- }
147
- await mkdir(dirname(target), { recursive: true })
148
- current = root
149
- for (const part of segments) {
150
- current = join(current, part)
151
- try {
152
- if ((await lstat(current)).isSymbolicLink()) throw new Error(`Refusing write through symlink: ${current}`)
153
- } catch (error) {
154
- if (error?.code !== 'ENOENT') throw error
155
- }
156
- }
157
- return target
158
- }
159
-
160
- async function atomicJson(path, value) {
161
- const target = await assertSafeWriteTarget(path)
162
- const temporary = join(dirname(target), `.${randomUUID()}.tmp`)
163
- try {
164
- await writeFile(temporary, `${JSON.stringify(value, null, 2)}\n`, { flag: 'wx' })
165
- await rename(temporary, target)
166
- } finally {
167
- await rm(temporary, { force: true }).catch(() => {})
168
- }
169
- }
170
-
171
- const ownerFor = source => source.owner ?? 'maintainers'
172
-
173
- function item(source, extra = {}) {
174
- return { id: source.id, critical: Boolean(source.critical), owner: ownerFor(source), affected: source.affected, ...extra }
175
- }
176
-
177
- function evidenceChecksum(source, policy, bytes) {
178
- const scope = source.checksumScope ?? policy?.defaultChecksumScope
179
- if (scope === 'http-body') return sha256(bytes)
180
- if (scope === 'html-text-v1') {
181
- const document = new TextDecoder().decode(bytes)
182
- const claimSurface = document.match(/<main\b[^>]*>([\s\S]*?)<\/main>/i)?.[1] ?? document
183
- const normalized = claimSurface
184
- .replace(/<script\b[^>]*>[\s\S]*?<\/script>/gi, '')
185
- .replace(/<style\b[^>]*>[\s\S]*?<\/style>/gi, '')
186
- .replace(/<[^>]+>/g, ' ')
187
- .replace(/&(?:nbsp|#x27|quot|amp|lt|gt);/g, ' ')
188
- .replace(/\s+/g, ' ')
189
- .trim()
190
- return sha256(normalized)
191
- }
192
- throw new Error(`Unsupported checksum scope for ${source.id}: ${scope}`)
193
- }
194
-
195
- async function detectVersion(source, allowHttpLocalhost) {
196
- const detection = source.versionDetection ?? {}
197
- if (detection.type === 'npm' || detection.type === 'npm-suite') {
198
- const packages = detection.type === 'npm-suite' ? detection.packages : [detection.package]
199
- const tag = detection.tag ?? 'latest'
200
- const versions = []
201
- for (const packageName of packages) {
202
- const response = await safeFetch(`https://registry.npmjs.org/${encodeURIComponent(packageName)}/${encodeURIComponent(tag)}`, {}, allowHttpLocalhost)
203
- if (!response.ok) throw new Error(`npm version detection HTTP ${response.status} for ${packageName}`)
204
- const metadata = JSON.parse(new TextDecoder().decode(await readBounded(response)))
205
- versions.push(metadata.version ?? null)
206
- }
207
- const unique = [...new Set(versions)]
208
- return unique.length === 1 ? unique[0] : versions.map((version, index) => `${packages[index]}@${version}`).join(',')
209
- }
210
- if (detection.type === 'pypi') {
211
- const response = await safeFetch(`https://pypi.org/pypi/${encodeURIComponent(detection.package)}/json`, {}, allowHttpLocalhost)
212
- if (!response.ok) throw new Error(`PyPI version detection HTTP ${response.status}`)
213
- return JSON.parse(new TextDecoder().decode(await readBounded(response))).info?.version ?? null
214
- }
215
- return null
216
- }
217
-
218
- function validAgeDays(prior, now) {
219
- const retrieved = prior?.retrievedAt ? new Date(prior.retrievedAt) : null
220
- if (!retrieved || Number.isNaN(retrieved.getTime()) || retrieved.getTime() > now.getTime()) return Infinity
221
- return (now.getTime() - retrieved.getTime()) / 86_400_000
222
- }
223
-
224
- async function verifyLocalIntegrity(source) {
225
- if (!source.path) return null
226
- if (source.treeChecksumAlgorithm === 'git-sha1-tree' || source.checksumAlgorithm === 'git-sha1-tree') {
227
- const expected = source.treeChecksum ?? source.checksum
228
- const result = spawnSync('git', ['-C', source.path, 'rev-parse', 'HEAD^{tree}'], { encoding: 'utf8' })
229
- if (result.status !== 0) throw new Error(result.stderr.trim() || 'git tree checksum failed')
230
- return { expected, actual: result.stdout.trim(), algorithm: 'git-sha1-tree' }
231
- }
232
- if (source.refreshable === false) return { expected: source.checksum, actual: sha256(await readFile(source.path)), algorithm: 'sha256' }
233
- return null
234
- }
235
-
236
- function validateRegistry(registry, options) {
237
- if (registry.schemaVersion !== 1 || !Array.isArray(registry.sources)) throw new Error('Invalid evidence registry schema')
238
- const sourceIds = new Set()
239
- for (const source of registry.sources) {
240
- if (!source.id || sourceIds.has(source.id) || !Array.isArray(source.topics) || !Number.isFinite(source.thresholdDays) || source.thresholdDays < 1) throw new Error(`Invalid evidence registry entry: ${source.id ?? 'unknown'}`)
241
- sourceIds.add(source.id)
242
- if (source.refreshable !== false) {
243
- const scope = source.checksumScope ?? registry.policy?.defaultChecksumScope
244
- if (!['http-body', 'html-text-v1'].includes(scope)) throw new Error(`Refreshable source ${source.id} has unsupported checksum scope ${scope}`)
245
- // --accept-baselines seeds missing baselines for newly added sources; every other mode requires them.
246
- if (!/^[a-f0-9]{64}$/.test(source.checksum ?? '') && !options.acceptBaselines) throw new Error(`Refreshable source ${source.id} requires an explicit supported SHA-256 baseline`)
247
- for (const url of Object.values(source.urls ?? {})) {
248
- const hostname = new URL(url).hostname
249
- const loopback = ['localhost', '127.0.0.1', '::1', '[::1]'].includes(hostname)
250
- if (!approvedHosts.has(hostname) && !(options.allowHttpLocalhost && loopback)) throw new Error(`Registry source ${source.id} references unapproved host ${hostname}; update approvedHosts in refresh-evidence.mjs deliberately`)
251
- }
252
- }
253
- }
254
- }
255
-
256
- export async function refreshEvidence(options) {
257
- const registry = await readJsonYaml(options.registry)
258
- validateRegistry(registry, options)
259
- const cache = await pathExists(options.cache) ? JSON.parse(await readFile(options.cache, 'utf8')) : { schemaVersion: 1, sources: {} }
260
- const now = new Date(options.now)
261
- if (Number.isNaN(now.getTime())) throw new Error(`Invalid --now timestamp: ${options.now}`)
262
- const selected = registry.sources.filter(source => !options.topics.length || source.topics.some(topic => options.topics.includes(topic)))
263
- const report = { schemaVersion: 1, generatedAt: now.toISOString(), offline: options.offline, acceptBaselines: options.acceptBaselines, selected: selected.map(source => source.id), drift: [], versionDrift: [], stale: [], unavailable: [], unaffected: [], manualVersionReview: [], acceptedBaselines: [], advisories: [], advisoryCheckFailed: [], reviewOverdue: [] }
264
- // Baseline-adoption nags: a reviewBy date that passed without a human decision, or a critical
265
- // pin lagging a known-newer current version, is how pins rot politely — surface both on every
266
- // run (warning, never fail-closed).
267
- if (options.compatibility && await pathExists(options.compatibility)) {
268
- const compatibility = JSON.parse(await readFile(options.compatibility, 'utf8'))
269
- for (const [name, baseline] of Object.entries(compatibility.baselines ?? {})) {
270
- if (baseline.reviewBy && Date.parse(`${baseline.reviewBy}T23:59:59Z`) < now.getTime()) report.reviewOverdue.push({ baseline: name, reviewBy: baseline.reviewBy, state: baseline.state })
271
- }
272
- }
273
- report.pinLag = registry.sources.filter(source => source.critical && /^\d/.test(String(source.pinnedVersion ?? '')) && /^\d/.test(String(source.currentVersion ?? '')) && source.pinnedVersion !== source.currentVersion).map(source => ({ id: source.id, pinned: source.pinnedVersion, current: source.currentVersion }))
274
- const baselineUpdates = new Map()
275
- for (const source of selected) {
276
- const prior = cache.sources[source.id]
277
- const ageDays = validAgeDays(prior, now)
278
- try {
279
- const local = await verifyLocalIntegrity(source)
280
- if (local && local.actual !== local.expected) {
281
- report.drift.push(item(source, { from: local.expected, to: local.actual, integrityFailure: true, scope: local.algorithm }))
282
- continue
283
- }
284
- if (source.refreshable === false) {
285
- report.unaffected.push(source.id)
286
- cache.sources[source.id] = { retrievedAt: now.toISOString(), checksum: local.actual, local: true, path: source.path }
287
- continue
288
- }
289
- if (options.offline) {
290
- if (!prior || !Number.isFinite(ageDays) || ageDays > source.thresholdDays) report.stale.push(item(source, { ageDays: Number.isFinite(ageDays) ? Math.floor(ageDays) : null }))
291
- else report.unaffected.push(source.id)
292
- continue
293
- }
294
- try {
295
- for (const advisory of await osvAdvisories(source, options.allowHttpLocalhost)) report.advisories.push(item(source, advisory))
296
- } catch (error) {
297
- report.advisoryCheckFailed.push(item(source, { error: error.message }))
298
- }
299
- const detectedVersion = await detectVersion(source, options.allowHttpLocalhost)
300
- if (detectedVersion && detectedVersion !== source.currentVersion) {
301
- // Under --accept-baselines a verified version change is accepted, not re-reported as drift —
302
- // the same single-code-path rule the checksum branch follows.
303
- if (options.acceptBaselines) baselineUpdates.set(source.id, { ...(baselineUpdates.get(source.id) ?? {}), currentVersion: detectedVersion, versionCheckedAt: now.toISOString() })
304
- else report.versionDrift.push(item(source, { from: source.currentVersion, to: detectedVersion, detection: source.versionDetection.type }))
305
- } else if (!detectedVersion && !['parent', 'git-commit'].includes(source.versionDetection?.type)) {
306
- const reviewedAgeDays = validAgeDays({ retrievedAt: source.retrievedAt }, now)
307
- report.manualVersionReview.push({ id: source.id, critical: Boolean(source.critical), owner: ownerFor(source), mechanism: source.versionDetection?.type ?? 'unspecified', due: !Number.isFinite(reviewedAgeDays) || reviewedAgeDays > source.thresholdDays, ageDays: Number.isFinite(reviewedAgeDays) ? Math.floor(reviewedAgeDays) : null })
308
- }
309
- const headers = {}
310
- if (prior?.etag) headers['If-None-Match'] = prior.etag
311
- if (prior?.lastModified) headers['If-Modified-Since'] = prior.lastModified
312
- const response = await safeFetch(source.urls.primary, { headers }, options.allowHttpLocalhost)
313
- if (response.status === 304 && prior) {
314
- if (prior.checksum !== source.checksum) {
315
- if (!options.acceptBaselines) {
316
- report.drift.push(item(source, { from: source.checksum, to: prior.checksum, baseline: 'registry-vs-304-cache' }))
317
- continue
318
- }
319
- baselineUpdates.set(source.id, { ...(baselineUpdates.get(source.id) ?? {}), checksum: prior.checksum, retrievedAt: now.toISOString() })
320
- }
321
- prior.retrievedAt = now.toISOString()
322
- report.unaffected.push(source.id)
323
- continue
324
- }
325
- if (!response.ok) throw new Error(`HTTP ${response.status}`)
326
- const checksum = evidenceChecksum(source, registry.policy, await readBounded(response))
327
- const comparison = prior?.checksum ?? source.checksum
328
- if (comparison && comparison !== checksum && !options.acceptBaselines) report.drift.push(item(source, { from: comparison, to: checksum, baseline: prior?.checksum ? 'cache' : 'registry' }))
329
- else report.unaffected.push(source.id)
330
- if (options.acceptBaselines && source.checksum !== checksum) {
331
- baselineUpdates.set(source.id, { ...(baselineUpdates.get(source.id) ?? {}), checksum, retrievedAt: now.toISOString() })
332
- }
333
- cache.sources[source.id] = { retrievedAt: now.toISOString(), checksum, etag: response.headers.get('etag'), lastModified: response.headers.get('last-modified'), url: response.url, detectedVersion }
334
- } catch (error) {
335
- report.unavailable.push(item(source, { error: error.message }))
336
- }
337
- }
338
- // Accepted baselines are written back to the registry in the same run that produced the report,
339
- // so the registry snapshot, cache, and drift report can never disagree (single code path).
340
- if (options.acceptBaselines && baselineUpdates.size) {
341
- for (const source of registry.sources) {
342
- const update = baselineUpdates.get(source.id)
343
- if (!update) continue
344
- Object.assign(source, update)
345
- report.acceptedBaselines.push({ id: source.id, ...update })
346
- }
347
- // A manual-review flag raised earlier in this same run is satisfied by the acceptance that
348
- // just refreshed the source's retrievedAt — it must not fail-close the run that fixed it.
349
- for (const entry of report.manualVersionReview) if (baselineUpdates.get(entry.id)?.retrievedAt) { entry.due = false; entry.ageDays = 0 }
350
- await atomicJson(options.registry, registry)
351
- }
352
- await atomicJson(options.cache, cache)
353
- await atomicJson(options.report, report)
354
- const failClosed = [...report.stale, ...report.unavailable, ...report.drift, ...report.versionDrift, ...report.advisories, ...report.manualVersionReview.filter(entry => entry.due)].some(entry => entry.critical || entry.integrityFailure)
355
- return { report, failClosed }
356
- }
357
-
358
- if (import.meta.url === pathToFileURL(process.argv[1] ?? '').href) {
359
- const options = args(process.argv.slice(2))
360
- const { report, failClosed } = await refreshEvidence(options)
361
- console.log(`refresh-evidence: selected=${report.selected.length} drift=${report.drift.length} version-drift=${report.versionDrift.length} stale=${report.stale.length} unavailable=${report.unavailable.length} advisories=${report.advisories.length}${report.reviewOverdue.length ? ` review-overdue=${report.reviewOverdue.length}` : ''}${report.acceptedBaselines.length ? ` accepted=${report.acceptedBaselines.length}` : ''}`)
362
- for (const overdue of report.reviewOverdue) console.error(`warning: baseline ${overdue.baseline} (${overdue.state}) passed its reviewBy ${overdue.reviewBy} — a human adoption decision is overdue`)
363
- for (const lag of report.pinLag ?? []) console.error(`warning: critical source ${lag.id} pin ${lag.pinned} lags current ${lag.current} — review adoption or record the deliberate hold`)
364
- for (const advisory of report.advisories) console.error(`advisory: ${advisory.package}@${advisory.version} — ${advisory.id}${advisory.summary ? `: ${advisory.summary}` : ''}`)
365
- if (failClosed) process.exitCode = 1
366
- }