@vegastack/skills 0.1.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 +46 -0
- package/dist/index.js +512 -0
- package/package.json +35 -0
- package/skill/vegastack-arch-guardian/SKILL.md +96 -0
- package/skill/vegastack-arch-guardian/agents/openai.yaml +4 -0
- package/skill/vegastack-arch-guardian/assets/adr-template.md +40 -0
- package/skill/vegastack-arch-guardian/assets/answers-example.json +20 -0
- package/skill/vegastack-arch-guardian/assets/architecture-profile.json +25 -0
- package/skill/vegastack-arch-guardian/assets/architecture-profile.schema.json +213 -0
- package/skill/vegastack-arch-guardian/assets/deployment-review-template.md +24 -0
- package/skill/vegastack-arch-guardian/assets/service-design-template.md +33 -0
- package/skill/vegastack-arch-guardian/assets/threat-model-template.md +34 -0
- package/skill/vegastack-arch-guardian/references/architecture/agent-product.md +22 -0
- package/skill/vegastack-arch-guardian/references/architecture/ai-cost.md +22 -0
- package/skill/vegastack-arch-guardian/references/architecture/ai-data-boundaries.md +19 -0
- package/skill/vegastack-arch-guardian/references/architecture/ai-evals.md +26 -0
- package/skill/vegastack-arch-guardian/references/architecture/connectors-sandbox.md +39 -0
- package/skill/vegastack-arch-guardian/references/architecture/data-memory.md +25 -0
- package/skill/vegastack-arch-guardian/references/architecture/delivery-operations.md +34 -0
- package/skill/vegastack-arch-guardian/references/architecture/durable-execution.md +43 -0
- package/skill/vegastack-arch-guardian/references/architecture/flutter.md +26 -0
- package/skill/vegastack-arch-guardian/references/architecture/foundation.md +33 -0
- package/skill/vegastack-arch-guardian/references/architecture/hosting-reliability.md +37 -0
- package/skill/vegastack-arch-guardian/references/architecture/identity-tenancy.md +37 -0
- package/skill/vegastack-arch-guardian/references/architecture/model-lifecycle.md +18 -0
- package/skill/vegastack-arch-guardian/references/architecture/models-observability.md +23 -0
- package/skill/vegastack-arch-guardian/references/architecture/realtime-channels.md +16 -0
- package/skill/vegastack-arch-guardian/references/architecture/security-privacy.md +23 -0
- package/skill/vegastack-arch-guardian/references/architecture/topology-monorepo.md +47 -0
- package/skill/vegastack-arch-guardian/references/architecture/web.md +29 -0
- package/skill/vegastack-arch-guardian/references/control-catalog.json +55 -0
- package/skill/vegastack-arch-guardian/references/foundation-compatibility.json +44 -0
- package/skill/vegastack-arch-guardian/references/golden-patterns.md +43 -0
- package/skill/vegastack-arch-guardian/references/profile-governance.md +54 -0
- package/skill/vegastack-arch-guardian/references/rule-model.json +36 -0
- package/skill/vegastack-arch-guardian/references/workflows.md +45 -0
- package/skill/vegastack-arch-guardian/refresh/REFRESH.md +40 -0
- package/skill/vegastack-arch-guardian/refresh/sources.json +1159 -0
- package/skill/vegastack-arch-guardian/scripts/architecture-check.mjs +323 -0
- package/skill/vegastack-arch-guardian/scripts/lib.mjs +57 -0
- package/skill/vegastack-arch-guardian/scripts/profile-tool.mjs +223 -0
- package/skill/vegastack-arch-guardian/scripts/refresh-evidence.mjs +325 -0
- package/skill/vegastack-arch-guardian/scripts/schema-validate.mjs +63 -0
- package/skill/vegastack-arch-guardian/scripts/validate-profile.mjs +241 -0
- package/skill/vegastack-arch-guardian/scripts/verify-corpus.mjs +148 -0
- package/skill-integrity.json +48 -0
|
@@ -0,0 +1,323 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { readFile, readdir } from 'node:fs/promises'
|
|
3
|
+
import { basename, dirname, extname, relative as relativePath, resolve } from 'node:path'
|
|
4
|
+
import { fileURLToPath, pathToFileURL } from 'node:url'
|
|
5
|
+
import { issue, pathExists, resolveProfile } from './lib.mjs'
|
|
6
|
+
import { canonicalRuleIds, validateProfile } from './validate-profile.mjs'
|
|
7
|
+
|
|
8
|
+
const scriptDirectory = dirname(fileURLToPath(import.meta.url))
|
|
9
|
+
const scanExtensions = new Set(['.ts', '.tsx', '.js', '.mjs', '.cjs', '.json', '.yaml', '.yml', '.toml', '.sql', '.md', '.py', '.go', '.java', '.kt', '.dart', '.rs', '.php', '.rb'])
|
|
10
|
+
const scanNames = new Set(['.env', '.env.local', '.env.production', 'Dockerfile'])
|
|
11
|
+
const ignored = new Set(['node_modules', '.git', '.turbo', 'dist', 'build', '.next', '.agents', '.claude', '.vegastack', 'coverage', 'vendor', 'out', '.output', '.vercel', '.wrangler'])
|
|
12
|
+
// Files this large or with lines this long are treated as generated bundles, not reviewable evidence.
|
|
13
|
+
const bundleBodyBytes = 500_000
|
|
14
|
+
const bundleLineLength = 5_000
|
|
15
|
+
const findingsCapPerControl = 25
|
|
16
|
+
const capabilityPatterns = {
|
|
17
|
+
webControlPlane: /(?:from\s+['"]next(?:\/|['"])|next\.config|@opennextjs\/cloudflare)/i,
|
|
18
|
+
flutter: /(?:pubspec\.yaml|package:flutter|flutter_secure_storage)/i,
|
|
19
|
+
agents: /(?:from\s+['"]eve['"]|@workflow\/world|AgentRun)/i,
|
|
20
|
+
jobs: /(?:from\s+['"]pg-boss['"]|\bPgBoss\b)/i,
|
|
21
|
+
sandbox: /(?:@cloudflare\/sandbox|enableInternet|block_network|SandboxProvider)/i,
|
|
22
|
+
connectors: /(?:\bMCP\b|webhook|connector|refresh_token)/i,
|
|
23
|
+
knowledge: /(?:pgvector|vector\(|\bknowledge\b|embedding)/i,
|
|
24
|
+
modelRouting: /(?:generateText|streamText|AI Gateway|BYOK|model provider)/i,
|
|
25
|
+
enterpriseIdentity: /(?:\bSCIM\b|\bSSO\b|saml)/i,
|
|
26
|
+
realtime: /(?:EventSource|text\/event-stream|WebSocket)/i,
|
|
27
|
+
notifications: /(?:firebase_messaging|\bAPNs\b|notification intent)/i,
|
|
28
|
+
secrets: /(?:OPENBAO|VAULT_ADDR|CLIENT_SECRET|API_KEY|DATABASE_URL)/i
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
async function loadGuardianIgnore(root) {
|
|
32
|
+
const path = resolve(root, '.guardianignore')
|
|
33
|
+
if (!await pathExists(path)) return []
|
|
34
|
+
return (await readFile(path, 'utf8'))
|
|
35
|
+
.split('\n')
|
|
36
|
+
.map(line => line.trim())
|
|
37
|
+
.filter(line => line && !line.startsWith('#'))
|
|
38
|
+
.map(line => line.replace(/\/+$/, ''))
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// Traversal skips (rather than aborts on) symlinks, agent-skill trees, and .guardianignore
|
|
42
|
+
// prefixes; the caller reports skips as a single NOT_VERIFIED finding.
|
|
43
|
+
async function sourceFiles(root, ignorePrefixes, skipped) {
|
|
44
|
+
const files = []
|
|
45
|
+
async function walk(directory) {
|
|
46
|
+
const entries = await readdir(directory, { withFileTypes: true })
|
|
47
|
+
if (directory !== root && entries.some(entry => entry.name === 'SKILL.md')) {
|
|
48
|
+
skipped.skillTrees.push(relativePath(root, directory).split('\\').join('/'))
|
|
49
|
+
return
|
|
50
|
+
}
|
|
51
|
+
for (const entry of entries) {
|
|
52
|
+
if (ignored.has(entry.name)) continue
|
|
53
|
+
const path = resolve(directory, entry.name)
|
|
54
|
+
const relative = relativePath(root, path).split('\\').join('/')
|
|
55
|
+
if (ignorePrefixes.some(prefix => relative === prefix || relative.startsWith(`${prefix}/`))) continue
|
|
56
|
+
if (entry.isSymbolicLink()) { skipped.symlinks.push(relative); continue }
|
|
57
|
+
if (entry.isDirectory()) await walk(path)
|
|
58
|
+
else if (entry.isFile() && (scanExtensions.has(extname(path)) || scanNames.has(basename(path)))) files.push(path)
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
await walk(root)
|
|
62
|
+
return files.sort()
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function looksGenerated(body) {
|
|
66
|
+
if (body.length > bundleBodyBytes) return true
|
|
67
|
+
let lineStart = 0
|
|
68
|
+
for (let index = 0; index <= body.length; index += 1) {
|
|
69
|
+
if (index === body.length || body[index] === '\n') {
|
|
70
|
+
if (index - lineStart > bundleLineLength) return true
|
|
71
|
+
lineStart = index + 1
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
return false
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
const lineOf = (body, index) => body.slice(0, Math.max(0, index)).split('\n').length
|
|
78
|
+
const evidence = (path, body, index = 0, type = 'static-sentinel') => ({ path, line: lineOf(body, index), type })
|
|
79
|
+
const enabled = (profile, name) => profile?.capabilities?.[name]?.status === 'enabled'
|
|
80
|
+
const owned = (profile, name) => enabled(profile, name) && profile.capabilities[name].ownership === 'owned'
|
|
81
|
+
const roots = (profile, name) => profile?.capabilities?.[name]?.sourceRoots ?? []
|
|
82
|
+
const inRoots = (path, selected = []) => selected.some(root => path === root || path.startsWith(`${root.replace(/\/$/, '')}/`))
|
|
83
|
+
const moduleSpecifiers = body => [...body.matchAll(/(?:from\s+|require\(\s*|import\(\s*)['"]([^'"]+)['"]/g)].map(match => match[1])
|
|
84
|
+
const nonProductionPath = path => path.split('/').some(part => ['test', 'tests', '__tests__', 'fixture', 'fixtures', 'example', 'examples', 'docs'].includes(part.toLowerCase()))
|
|
85
|
+
const normalizedSqlName = name => name.replaceAll('"', '').toLowerCase()
|
|
86
|
+
|
|
87
|
+
function tenantTables(body) {
|
|
88
|
+
return [...body.matchAll(/CREATE\s+TABLE\s+(?:IF\s+NOT\s+EXISTS\s+)?([\w."-]+)\s*\(([\s\S]*?)\);/gi)]
|
|
89
|
+
.filter(match => /\bworkspace_id\b/i.test(match[2]))
|
|
90
|
+
.map(match => ({ name: match[1].replaceAll('"', ''), definition: match[2], index: match.index ?? 0 }))
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function tablePolicies(sqlFiles) {
|
|
94
|
+
const policies = new Map()
|
|
95
|
+
for (const file of sqlFiles) for (const match of file.body.matchAll(/CREATE\s+POLICY\b[\s\S]*?;/gi)) {
|
|
96
|
+
const statement = match[0]
|
|
97
|
+
const table = statement.match(/\bON\s+(?:ONLY\s+)?([\w."-]+)/i)?.[1]
|
|
98
|
+
if (!table) continue
|
|
99
|
+
const key = normalizedSqlName(table)
|
|
100
|
+
if (!policies.has(key)) policies.set(key, [])
|
|
101
|
+
policies.get(key).push({ statement, path: file.path, body: file.body, index: match.index ?? 0 })
|
|
102
|
+
}
|
|
103
|
+
return policies
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
const tenantClause = (statement, clause) => new RegExp(`\\b${clause}\\s*\\([\\s\\S]*?(?:workspace_id|current_setting)`, 'i').test(statement) && !new RegExp(`\\b${clause}\\s*\\(\\s*true\\s*\\)`, 'i').test(statement)
|
|
107
|
+
|
|
108
|
+
function rawFinding(findings, rule, control, message, path, body = '', index = 0, extra = {}) {
|
|
109
|
+
findings.push(issue('FAIL', rule, control, message, evidence(path, body, index), extra))
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
function applyExceptions(findings, exceptionStates, profilePath) {
|
|
113
|
+
const matched = new Set()
|
|
114
|
+
const output = findings.map(finding => {
|
|
115
|
+
const exceptionType = finding.status === 'FAIL' ? 'static-sentinel' : finding.status === 'NOT_VERIFIED' ? 'manual-qualification' : null
|
|
116
|
+
if (!exceptionType || finding.exceptionEligible === false) return finding
|
|
117
|
+
// An exception without a controls list covers every control under its single rule; paths stay exact.
|
|
118
|
+
const state = exceptionStates.find(item => item.valid && item.exception.verificationType === exceptionType && item.exception.ruleId === finding.rule && (!item.exception.controls || item.exception.controls.includes(finding.control)) && item.exception.paths.includes(finding.path))
|
|
119
|
+
if (!state) return finding
|
|
120
|
+
matched.add(state.exception.id)
|
|
121
|
+
return { ...finding, status: 'EXCEPTED', severity: 'accepted-risk', exceptionId: state.exception.id, acceptedBy: state.exception.projectOwner, message: `${finding.message} — accepted project risk; foundation recommendation remains unmet` }
|
|
122
|
+
})
|
|
123
|
+
for (const state of exceptionStates.filter(item => item.valid && !matched.has(item.exception.id))) output.push(issue('FAIL', 'FOUND-002', 'exception.scope-match', `${state.exception.id} did not match an eligible emitted finding for its rule/path/verification scope (controls, when listed, must also match exactly)`, { path: profilePath, line: 1, type: 'declared-control' }, { verificationType: 'semantic', exceptionEligible: false }))
|
|
124
|
+
return output
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
function capFindings(findings) {
|
|
128
|
+
const groups = new Map()
|
|
129
|
+
const output = []
|
|
130
|
+
for (const finding of findings) {
|
|
131
|
+
if (finding.status !== 'FAIL') { output.push(finding); continue }
|
|
132
|
+
const key = `${finding.rule}/${finding.control}`
|
|
133
|
+
const group = groups.get(key) ?? { kept: 0, suppressed: 0, last: null }
|
|
134
|
+
groups.set(key, group)
|
|
135
|
+
if (group.kept < findingsCapPerControl) {
|
|
136
|
+
group.kept += 1
|
|
137
|
+
group.last = finding
|
|
138
|
+
output.push(finding)
|
|
139
|
+
} else {
|
|
140
|
+
group.suppressed += 1
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
for (const group of groups.values()) if (group.suppressed > 0 && group.last) group.last.suppressedCount = group.suppressed
|
|
144
|
+
return output
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
export async function checkArchitecture(projectRoot, options = {}) {
|
|
148
|
+
const root = resolve(projectRoot)
|
|
149
|
+
const findings = []
|
|
150
|
+
const discovered = options.profile ? null : await resolveProfile(root)
|
|
151
|
+
const profileRelative = options.profile ?? discovered?.relative ?? '.vegastack/architecture.json'
|
|
152
|
+
const profilePath = resolve(root, profileRelative)
|
|
153
|
+
const legacyProfileName = options.profile ? profileRelative.endsWith('.yaml') : Boolean(discovered?.legacy)
|
|
154
|
+
let profile = null
|
|
155
|
+
let exceptionStates = []
|
|
156
|
+
if (!await pathExists(profilePath)) rawFinding(findings, 'FOUND-001', 'profile.present', 'Commit a confirmed v3 architecture profile for CI; read-only review continued without mutation', profileRelative)
|
|
157
|
+
else {
|
|
158
|
+
const validation = await validateProfile(profilePath, { projectRoot: root, now: options.now })
|
|
159
|
+
profile = validation.profile
|
|
160
|
+
exceptionStates = validation.exceptions
|
|
161
|
+
for (const diagnostic of validation.diagnostics) rawFinding(findings, diagnostic.rule, diagnostic.control, diagnostic.message, diagnostic.path, '', 0, { exceptionEligible: diagnostic.exceptionEligible })
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
const skipped = { symlinks: [], skillTrees: [], generated: [] }
|
|
165
|
+
let files = []
|
|
166
|
+
try { files = await sourceFiles(root, await loadGuardianIgnore(root), skipped) } catch (error) {
|
|
167
|
+
rawFinding(findings, 'DEL-001', 'inspection.traversal', `Cannot complete repository traversal: ${error.message}`, '.')
|
|
168
|
+
}
|
|
169
|
+
const content = []
|
|
170
|
+
const observed = Object.fromEntries(Object.keys(capabilityPatterns).map(name => [name, []]))
|
|
171
|
+
for (const path of files) {
|
|
172
|
+
if (resolve(path) === profilePath) continue
|
|
173
|
+
const relative = relativePath(root, path).split('\\').join('/')
|
|
174
|
+
let body
|
|
175
|
+
try { body = await readFile(path, 'utf8') } catch (error) { rawFinding(findings, 'DEL-001', 'inspection.read', `Cannot inspect file: ${error.message}`, relative); continue }
|
|
176
|
+
if (looksGenerated(body)) { skipped.generated.push(relative); continue }
|
|
177
|
+
content.push({ path: relative, body, extension: extname(path) })
|
|
178
|
+
const searchable = `${relative}\n${body}`
|
|
179
|
+
const machineSource = extname(path) !== '.md'
|
|
180
|
+
const productionEvidence = machineSource && !nonProductionPath(relative)
|
|
181
|
+
if (productionEvidence) for (const [name, pattern] of Object.entries(capabilityPatterns)) if (pattern.test(searchable)) observed[name].push(relative)
|
|
182
|
+
|
|
183
|
+
const localSecretAllowance = relative === '.env.local' && profile?.environments?.localDevelopment?.allowances?.includes('local-secrets')
|
|
184
|
+
if (productionEvidence && !localSecretAllowance) {
|
|
185
|
+
for (const match of body.matchAll(/^\s*(?:export\s+)?(?:const\s+)?([A-Z][A-Z0-9_]*(?:SECRET(?:_KEY)?|PASSWORD|PRIVATE_KEY|TOKEN|API_KEY)|API_KEY|CLIENT_SECRET|REFRESH_TOKEN|OPENBAO_TOKEN|DATABASE_URL|BYOK)\s*[:=]\s*(?!process\.env|env\.|os\.environ|System\.getenv|\$\{|['"]?REDACTED)([^\s,;]{8,}|['"][^'"$]{8,}['"])/gim)) rawFinding(findings, 'SEC-002', 'secret.plaintext', 'Probable plaintext credential', relative, body, match.index)
|
|
186
|
+
for (const match of body.matchAll(/-----BEGIN (?:RSA |EC |OPENSSH )?PRIVATE KEY-----/g)) rawFinding(findings, 'SEC-002', 'secret.plaintext', 'Probable plaintext private key', relative, body, match.index)
|
|
187
|
+
for (const match of body.matchAll(/authorization\s*:\s*['"]Bearer\s+[^$'"]{8,}/gi)) rawFinding(findings, 'SEC-002', 'secret.bearer', 'Hard-coded bearer credential', relative, body, match.index)
|
|
188
|
+
for (const match of body.matchAll(/(console\.(?:log|info|warn|error)|logger\.(?:debug|info|warn|error))\s*\([^\n]*(?:TOKEN|SECRET|PASSWORD|DATABASE_URL|API_KEY|AUTHORIZATION|COOKIE|PROMPT|RESTRICTED)/gi)) rawFinding(findings, 'OBS-002', 'telemetry.sensitive-data', 'Probable credential or restricted content emitted to telemetry', relative, body, match.index)
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
const sandboxApplicable = productionEvidence && (enabled(profile, 'sandbox') || observed.sandbox.length > 0 || capabilityPatterns.sandbox.test(searchable))
|
|
192
|
+
if (sandboxApplicable) for (const match of body.matchAll(/enableInternet\s*[:=]\s*true|block_network\s*[:=]\s*false|allowedHosts\s*[:=]\s*\[\s*['"]\*['"]/g)) rawFinding(findings, 'SBX-003', 'sandbox.egress', 'Production sandbox egress must default deny without wildcards', relative, body, match.index)
|
|
193
|
+
|
|
194
|
+
const tenancyApplicable = productionEvidence && (profile?.project?.tenancy === 'multi-tenant-shared-schema' || /app\.workspace_id|workspace_id/.test(body))
|
|
195
|
+
if (tenancyApplicable) {
|
|
196
|
+
for (const match of body.matchAll(/\bSET\s+(?!LOCAL\b)(?:SESSION\s+)?app\.workspace_id\b/gi)) rawFinding(findings, 'TEN-003', 'tenancy.context-local', 'Tenant context must use SET LOCAL in the protected transaction', relative, body, match.index)
|
|
197
|
+
for (const match of body.matchAll(/\bBYPASSRLS\b/gi)) rawFinding(findings, 'TEN-003', 'tenancy.role-bypassrls', 'Application/request roles must not receive BYPASSRLS', relative, body, match.index)
|
|
198
|
+
}
|
|
199
|
+
if (productionEvidence && (enabled(profile, 'agents') || observed.agents.length)) for (const match of body.matchAll(/CREATE\s+TABLE\s+(?:IF\s+NOT\s+EXISTS\s+)?[\w."-]*(?:workflow_steps|workflow_runs|agent_approvals|continuations)\b/gi)) rawFinding(findings, 'DUR-001', 'agents.dual-workflow-state', 'Do not create a second workflow, tool-loop, HITL, or continuation store', relative, body, match.index)
|
|
200
|
+
|
|
201
|
+
const specifiers = moduleSpecifiers(body)
|
|
202
|
+
if (productionEvidence && owned(profile, 'webControlPlane') && inRoots(relative, roots(profile, 'webControlPlane'))) for (const specifier of specifiers) if (specifier === 'eve' || specifier === 'pg-boss' || specifier.startsWith('@workflow/')) rawFinding(findings, specifier === 'pg-boss' ? 'RUN-002' : 'RUN-001', 'runtime.web-bundle-boundary', `Web source root must not depend on long-running runtime package ${specifier}`, relative, body, body.indexOf(specifier))
|
|
203
|
+
if (productionEvidence && owned(profile, 'agents') && inRoots(relative, roots(profile, 'agents')) && specifiers.includes('pg-boss')) rawFinding(findings, 'DUR-003', 'agents.pg-boss-ownership', 'EVE runtime must not own pg-boss orchestration', relative, body, body.indexOf('pg-boss'))
|
|
204
|
+
if (productionEvidence && owned(profile, 'jobs') && inRoots(relative, roots(profile, 'jobs')) && specifiers.some(specifier => specifier === 'eve' || specifier.startsWith('@workflow/'))) rawFinding(findings, 'DUR-001', 'jobs.workflow-ownership', 'Jobs runtime must not own EVE/Workflow state', relative, body, 0)
|
|
205
|
+
|
|
206
|
+
const authApplicable = productionEvidence && ((enabled(profile, 'webControlPlane') && profile.project?.access !== 'public') || /better-auth/.test(body))
|
|
207
|
+
if (authApplicable) {
|
|
208
|
+
for (const match of body.matchAll(/disableCSRFCheck\s*:\s*true|disableOriginCheck\s*:\s*true/g)) rawFinding(findings, 'AUTH-003', 'auth.csrf-origin', 'CSRF and origin checks must remain enabled', relative, body, match.index)
|
|
209
|
+
for (const match of body.matchAll(/trustedOrigins\s*[:=][\s\S]{0,120}(?:['"]\*|\*:\/\/|http:\/\/)/gi)) rawFinding(findings, 'AUTH-003', 'auth.trusted-origins', 'Production trusted origins must be exact HTTPS origins', relative, body, match.index)
|
|
210
|
+
for (const match of body.matchAll(/require_pkce\s*:\s*false/g)) rawFinding(findings, 'AUTH-004', 'auth.pkce', 'Delegated OAuth clients must not opt out of PKCE', relative, body, match.index)
|
|
211
|
+
for (const match of body.matchAll(/defaultSCIM\s*:\s*true|storeSCIMToken\s*:\s*['"]plain['"]/g)) rawFinding(findings, 'AUTH-006', 'identity.scim-storage', 'Production SCIM must be organization-scoped with protected token storage', relative, body, match.index)
|
|
212
|
+
for (const match of body.matchAll(/enableSessionForAPIKeys\s*:\s*true|referenceId\s*:\s*['"]user|storeKey\s*:\s*true/g)) rawFinding(findings, 'AUTH-005', 'auth.api-key-scope', 'Workspace API keys must be organization-scoped, hashed, expiring, and must not create browser sessions', relative, body, match.index)
|
|
213
|
+
}
|
|
214
|
+
if (enabled(profile, 'realtime')) for (const match of body.matchAll(/websocket/gi)) if (/default|primary/i.test(body) && /stream|run/i.test(body)) findings.push(issue('NOT_VERIFIED', 'RT-002', 'realtime.websocket-trigger', 'Verify that bidirectional presence/collaboration justifies WebSockets; default to resumable SSE', evidence(relative, body, match.index), { verificationType: 'manual-qualification', reason: 'intent cannot be proven statically', risk: 'durable state or streaming coupled to WebSocket lifetime', owner: 'project owner', nextAction: 'document and reproduce the bidirectional requirement' }))
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
if (skipped.symlinks.length || skipped.skillTrees.length || skipped.generated.length) {
|
|
218
|
+
const parts = []
|
|
219
|
+
if (skipped.symlinks.length) parts.push(`${skipped.symlinks.length} symlinked path(s)`)
|
|
220
|
+
if (skipped.skillTrees.length) parts.push(`${skipped.skillTrees.length} agent-skill tree(s)`)
|
|
221
|
+
if (skipped.generated.length) parts.push(`${skipped.generated.length} generated/bundled file(s)`)
|
|
222
|
+
findings.push(issue('NOT_VERIFIED', 'DEL-001', 'inspection.traversal', `Skipped without inspection: ${parts.join(', ')} (first: ${[...skipped.symlinks, ...skipped.skillTrees, ...skipped.generated][0]})`, { path: '.', line: 1, type: 'static-sentinel' }, { verificationType: 'manual-qualification', reason: 'paths were excluded from static inspection', risk: 'excluded paths may contain violations', owner: 'project owner', nextAction: 'review excluded paths or extend .guardianignore deliberately', skipped }))
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
if (profile) for (const [name, paths] of Object.entries(observed)) {
|
|
226
|
+
if (paths.length && !enabled(profile, name)) rawFinding(findings, 'FOUND-004', `capability.${name}.undeclared`, `Repository evidence suggests disabled capability ${name}: ${paths.slice(0, 3).join(', ')}`, paths[0])
|
|
227
|
+
if (owned(profile, name) && !content.some(item => inRoots(item.path, roots(profile, name)))) rawFinding(findings, 'FOUND-004', `capability.${name}.missing-code`, `Enabled owned capability ${name} has no inspectable code in declared source roots`, roots(profile, name)[0] ?? profileRelative)
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
if (profile?.project?.tenancy === 'multi-tenant-shared-schema') {
|
|
231
|
+
const sqlFiles = content.filter(item => item.extension === '.sql' && !nonProductionPath(item.path))
|
|
232
|
+
const allSql = sqlFiles.map(item => item.body).join('\n')
|
|
233
|
+
const policiesByTable = tablePolicies(sqlFiles)
|
|
234
|
+
const tables = sqlFiles.flatMap(file => tenantTables(file.body).map(table => ({ ...table, path: file.path, body: file.body })))
|
|
235
|
+
for (const table of tables) {
|
|
236
|
+
const escaped = table.name.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
|
|
237
|
+
const primary = table.definition.match(/PRIMARY\s+KEY\s*\(([^)]*)\)/i)?.[1] ?? ''
|
|
238
|
+
if (!/\bworkspace_id\b/i.test(primary)) rawFinding(findings, 'TEN-001', 'tenancy.composite-primary-key', `Tenant table ${table.name} primary key must include workspace_id`, table.path, table.body, table.index)
|
|
239
|
+
for (const match of table.definition.matchAll(/(?:UNIQUE|REFERENCES\s+[\w."-]+)\s*\(([^)]*)\)/gi)) if (!/\bworkspace_id\b/i.test(match[1])) rawFinding(findings, 'TEN-001', 'tenancy.composite-relationship', `Tenant table ${table.name} unique/foreign relationship must include workspace_id`, table.path, table.body, table.index + (match.index ?? 0))
|
|
240
|
+
if (!new RegExp(`ALTER\\s+TABLE\\s+"?${escaped}"?\\s+ENABLE\\s+ROW\\s+LEVEL\\s+SECURITY`, 'i').test(allSql)) rawFinding(findings, 'TEN-002', 'tenancy.rls-enable', `Tenant table ${table.name} must enable RLS`, table.path, table.body, table.index)
|
|
241
|
+
if (!new RegExp(`ALTER\\s+TABLE\\s+"?${escaped}"?\\s+FORCE\\s+ROW\\s+LEVEL\\s+SECURITY`, 'i').test(allSql)) rawFinding(findings, 'TEN-002', 'tenancy.rls-force', `Tenant table ${table.name} must force RLS`, table.path, table.body, table.index)
|
|
242
|
+
const policies = policiesByTable.get(normalizedSqlName(table.name)) ?? []
|
|
243
|
+
if (!policies.some(policy => tenantClause(policy.statement, 'USING')) || !policies.some(policy => tenantClause(policy.statement, 'WITH\\s+CHECK'))) rawFinding(findings, 'TEN-002', 'tenancy.rls-policy', `Tenant table ${table.name} requires table-bound fail-closed tenant USING and WITH CHECK predicates; command-specific policies may split them`, table.path, table.body, table.index)
|
|
244
|
+
}
|
|
245
|
+
if (!tables.length) rawFinding(findings, 'TEN-002', 'tenancy.rls-evidence', 'Shared-schema tenancy requires reviewed tenant table/RLS migration evidence', 'database migrations')
|
|
246
|
+
const all = content.filter(item => item.extension !== '.md' && !nonProductionPath(item.path)).map(item => item.body).join('\n')
|
|
247
|
+
if (!/(SET\s+LOCAL\s+app\.workspace_id|set_config\s*\(\s*['"]app\.workspace_id['"][^)]*,\s*true\s*\))/i.test(all)) rawFinding(findings, 'TEN-003', 'tenancy.context-evidence', 'Set tenant context locally inside each protected transaction', 'data access')
|
|
248
|
+
for (const file of sqlFiles) for (const match of file.body.matchAll(/SECURITY\s+DEFINER/gi)) {
|
|
249
|
+
const window = file.body.slice(Math.max(0, (match.index ?? 0) - 700), (match.index ?? 0) + 700)
|
|
250
|
+
if (!/SET\s+(?:LOCAL\s+)?search_path\s*(?:=|TO)\s*(?:pg_catalog|['"]?[^,;'"\s]+['"]?\s*,\s*pg_temp)/i.test(window) || !/(workspace_id|current_setting\s*\(\s*['"]app\.workspace_id|authorize)/i.test(window)) rawFinding(findings, 'TEN-004', 'tenancy.security-definer', 'SECURITY DEFINER tenant paths require a fixed search_path and explicit tenant/authorization guard', file.path, file.body, match.index)
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
if (profile && enabled(profile, 'webControlPlane') && profile.project.access !== 'public' && owned(profile, 'webControlPlane')) {
|
|
255
|
+
const nextCode = content.filter(item => item.extension !== '.md' && !nonProductionPath(item.path) && inRoots(item.path, roots(profile, 'webControlPlane'))).map(item => item.body).join('\n')
|
|
256
|
+
if (!/from\s+['"]better-auth['"]/.test(nextCode)) rawFinding(findings, 'AUTH-003', 'auth.library', 'Configure the reviewed Better Auth server library', 'identity configuration')
|
|
257
|
+
if (profile.project.tenancy.startsWith('multi-tenant') && !/organization\s*\(/.test(nextCode)) rawFinding(findings, 'AUTH-001', 'auth.organization-boundary', 'Configure Better Auth Organization as the workspace boundary', 'identity configuration')
|
|
258
|
+
if ((enabled(profile, 'flutter') || enabled(profile, 'connectors')) && !/oauthProvider\s*\(/.test(nextCode)) rawFinding(findings, 'AUTH-004', 'auth.oauth-provider', 'Configure Better Auth OAuth Provider for delegated clients', 'identity configuration')
|
|
259
|
+
if (!/useSecureCookies\s*:\s*true/.test(nextCode)) rawFinding(findings, 'AUTH-003', 'auth.secure-cookies-code', 'Force secure browser cookies in production', 'identity configuration')
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
if (profile) {
|
|
263
|
+
for (const [name, capability] of Object.entries(profile.capabilities ?? {})) if (capability.status === 'enabled' && ['shared-managed', 'external-managed'].includes(capability.ownership)) findings.push(issue('NOT_VERIFIED', 'RUN-003', `contract.${name}.qualification`, `${name} contract is declared but live compatibility, isolation, recovery and exit behavior were not reproduced`, { path: profileRelative, line: 1, type: 'declared-control' }, { capability: name, verificationType: 'manual-qualification', reason: 'environment-bound service behavior is not statically testable', risk: 'dependency contract may not meet declared boundary', owner: capability.contract?.incidentOwner, nextAction: 'run contract, failure and exit qualification' }))
|
|
264
|
+
if (!profile.objectives) findings.push(issue('NOT_VERIFIED', 'REL-001', 'reliability.objectives', 'No measured production objectives are declared', { path: profileRelative, line: 1, type: 'declared-control' }, { verificationType: 'manual-qualification', reason: 'project objectives were not provided', risk: 'capacity and recovery decisions cannot be qualified', owner: 'project owner', nextAction: 'confirm applicable SLI/SLO, RPO and RTO objectives' }))
|
|
265
|
+
if (Object.values(profile.capabilities ?? {}).some(capability => capability.status === 'enabled')) findings.push(issue('NOT_VERIFIED', 'DEL-001', 'qualification.runtime', 'Static sentinels and declarations do not prove runtime isolation, replay, failover, restore, provider security or recovery', { path: profileRelative, line: 1, type: 'static-sentinel' }, { verificationType: 'manual-qualification', reason: 'no environment-bound tests were executed by this checker', risk: 'declared controls may fail at runtime', owner: 'project owner', nextAction: 'run the scoped qualification plan for enabled capabilities' }))
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
const unique = findings.filter((finding, index, all) => all.findIndex(candidate => candidate.status === finding.status && candidate.rule === finding.rule && candidate.control === finding.control && candidate.path === finding.path && candidate.evidence?.line === finding.evidence?.line && candidate.message === finding.message) === index)
|
|
269
|
+
const resolved = capFindings(applyExceptions(unique, exceptionStates, profileRelative))
|
|
270
|
+
const knownRules = await canonicalRuleIds()
|
|
271
|
+
const catalogPath = resolve(scriptDirectory, '../references/control-catalog.json')
|
|
272
|
+
const catalog = JSON.parse(await readFile(catalogPath, 'utf8'))
|
|
273
|
+
const knownControl = finding => catalog.controls.some(item => item.rule === finding.rule && (item.id === finding.control || (item.idPattern && new RegExp(item.idPattern).test(finding.control))))
|
|
274
|
+
for (const finding of resolved) {
|
|
275
|
+
if (!knownRules.has(finding.rule)) throw new Error(`Checker emitted unknown canonical rule ${finding.rule}`)
|
|
276
|
+
if (!knownControl(finding)) throw new Error(`Checker emitted unknown control ${finding.rule}/${finding.control}`)
|
|
277
|
+
}
|
|
278
|
+
if (!resolved.some(item => item.status === 'FAIL')) resolved.unshift(issue('PASS', 'FOUND-004', 'profile.capability-alignment', 'No unexcepted machine-detectable architecture violations were found', { path: profileRelative, line: 1, type: 'static-sentinel' }))
|
|
279
|
+
if (legacyProfileName && profile) console.error(`deprecation: ${profileRelative} uses the legacy .yaml name for a JSON document; rename to .vegastack/architecture.json`)
|
|
280
|
+
return resolved
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
export function summarize(findings) {
|
|
284
|
+
const counts = Object.fromEntries(['PASS', 'FAIL', 'EXCEPTED', 'NOT_VERIFIED'].map(status => [status, findings.filter(item => item.status === status).length]))
|
|
285
|
+
const suppressed = findings.reduce((total, item) => total + (item.suppressedCount ?? 0), 0)
|
|
286
|
+
return { status: counts.FAIL ? 'FAIL' : 'PASS', verdict: counts.FAIL || counts.EXCEPTED ? 'GUARDIAN VERDICT: REJECT' : 'GUARDIAN VERDICT: ACCEPT', counts, ...(suppressed ? { suppressedFindings: suppressed } : {}) }
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
const usage = `Usage: architecture-check.mjs [DIR] [--json | --summary] [--help]
|
|
290
|
+
|
|
291
|
+
Deterministic, read-only architecture checks for the directory (default: cwd).
|
|
292
|
+
Profile discovery: .vegastack/architecture.json (preferred) or legacy .vegastack/architecture.yaml.
|
|
293
|
+
Add repository-relative path prefixes to a .guardianignore file (one per line, # comments) to exclude paths.
|
|
294
|
+
|
|
295
|
+
Output: --summary prints the verdict, counts, and the first 10 findings; --json prints everything.
|
|
296
|
+
Exit codes: 0 = no FAIL findings; 1 = FAIL findings present; 2 = usage or tool error.`
|
|
297
|
+
|
|
298
|
+
if (import.meta.url === pathToFileURL(process.argv[1] ?? '').href) {
|
|
299
|
+
const argv = process.argv.slice(2)
|
|
300
|
+
const flags = new Set(argv.filter(argument => argument.startsWith('-')))
|
|
301
|
+
const positional = argv.filter(argument => !argument.startsWith('-'))
|
|
302
|
+
const known = new Set(['--json', '--summary', '--help', '-h'])
|
|
303
|
+
const unknown = [...flags].filter(flag => !known.has(flag))
|
|
304
|
+
if (flags.has('--help') || flags.has('-h')) { console.log(usage); process.exit(0) }
|
|
305
|
+
if (unknown.length || positional.length > 1) { console.error(`error: unknown arguments: ${[...unknown, ...positional.slice(1)].join(' ')}\n\n${usage}`); process.exit(2) }
|
|
306
|
+
const root = resolve(positional[0] ?? process.cwd())
|
|
307
|
+
let findings
|
|
308
|
+
try { findings = await checkArchitecture(root) } catch (error) { console.error(`error: ${error.message}`); process.exit(2) }
|
|
309
|
+
const summary = summarize(findings)
|
|
310
|
+
if (flags.has('--json')) console.log(JSON.stringify({ schemaVersion: 1, root, ...summary, findings }, null, 2))
|
|
311
|
+
else if (flags.has('--summary')) {
|
|
312
|
+
console.log(summary.verdict)
|
|
313
|
+
console.log(`architecture-check: ${Object.entries(summary.counts).map(([key, value]) => `${value} ${key}`).join(', ')}${summary.suppressedFindings ? `, ${summary.suppressedFindings} suppressed` : ''}`)
|
|
314
|
+
const ranked = [...findings].sort((a, b) => (a.status === 'FAIL' ? 0 : 1) - (b.status === 'FAIL' ? 0 : 1))
|
|
315
|
+
for (const finding of ranked.slice(0, 10)) console.log(`${finding.status} ${finding.rule}/${finding.control}: ${finding.message}${finding.path ? ` (${finding.path}${finding.evidence?.line ? `:${finding.evidence.line}` : ''})` : ''}`)
|
|
316
|
+
if (findings.length > 10) console.log(`(+${findings.length - 10} more findings; use --json for all)`)
|
|
317
|
+
} else {
|
|
318
|
+
console.log(summary.verdict)
|
|
319
|
+
for (const finding of findings) console.log(`${finding.status} ${finding.rule}/${finding.control}: ${finding.message}${finding.path ? ` (${finding.path}${finding.evidence?.line ? `:${finding.evidence.line}` : ''})` : ''}${finding.suppressedCount ? ` (+${finding.suppressedCount} more suppressed)` : ''}`)
|
|
320
|
+
console.log(`architecture-check: ${Object.entries(summary.counts).map(([key, value]) => `${value} ${key}`).join(', ')}`)
|
|
321
|
+
}
|
|
322
|
+
if (summary.status === 'FAIL') process.exitCode = 1
|
|
323
|
+
}
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto'
|
|
2
|
+
import { lstat, readFile, readdir } from 'node:fs/promises'
|
|
3
|
+
import { join, relative, sep } from 'node:path'
|
|
4
|
+
|
|
5
|
+
export const LABELS = ['OBSERVED', 'DOCUMENTED', 'REPRODUCED', 'INFERRED', 'RECOMMENDED', 'NOT VERIFIED']
|
|
6
|
+
export const sha256 = body => createHash('sha256').update(body).digest('hex')
|
|
7
|
+
|
|
8
|
+
// Canonical profile basename first; legacy name accepted with a deprecation notice at call sites.
|
|
9
|
+
export const PROFILE_BASENAMES = ['architecture.json', 'architecture.yaml']
|
|
10
|
+
|
|
11
|
+
export async function readJsonYaml(path) {
|
|
12
|
+
const raw = await readFile(path, 'utf8')
|
|
13
|
+
try { return JSON.parse(raw) } catch (error) {
|
|
14
|
+
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}`)
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export async function pathExists(path) {
|
|
19
|
+
try { await lstat(path); return true } catch (error) {
|
|
20
|
+
if (error.code === 'ENOENT') return false
|
|
21
|
+
throw error
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
// Discover the committed architecture profile. Returns { path, relative, legacy } or null.
|
|
26
|
+
export async function resolveProfile(root) {
|
|
27
|
+
for (const basename of PROFILE_BASENAMES) {
|
|
28
|
+
const candidate = join(root, '.vegastack', basename)
|
|
29
|
+
if (await pathExists(candidate)) {
|
|
30
|
+
return { path: candidate, relative: `.vegastack/${basename}`, legacy: basename.endsWith('.yaml') }
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
return null
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export async function listFiles(root, predicate = () => true) {
|
|
37
|
+
const output = []
|
|
38
|
+
async function walk(directory) {
|
|
39
|
+
for (const entry of await readdir(directory, { withFileTypes: true })) {
|
|
40
|
+
const path = join(directory, entry.name)
|
|
41
|
+
if (entry.isSymbolicLink()) throw new Error(`Refusing symlink during traversal: ${path}`)
|
|
42
|
+
if (entry.isDirectory()) await walk(path)
|
|
43
|
+
else if (entry.isFile() && predicate(path)) output.push(path)
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
await walk(root)
|
|
47
|
+
return output.sort((a, b) => relative(root, a).localeCompare(relative(root, b)))
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export function slashRelative(root, path) {
|
|
51
|
+
return relative(root, path).split(sep).join('/')
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export function issue(status, rule, control, message, evidence, extra = {}) {
|
|
55
|
+
const severity = status === 'FAIL' ? 'fail' : status === 'EXCEPTED' ? 'accepted-risk' : status === 'NOT_VERIFIED' ? 'warning' : 'pass'
|
|
56
|
+
return { status, severity, rule, control, message, verificationType: extra.verificationType ?? 'static-sentinel', ...(evidence ? { evidence, path: evidence.path } : {}), ...extra }
|
|
57
|
+
}
|
|
@@ -0,0 +1,223 @@
|
|
|
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 = ['webControlPlane', 'flutter', 'agents', 'jobs', 'sandbox', 'connectors', 'knowledge', 'modelRouting', 'enterpriseIdentity', 'realtime', 'notifications', 'secrets']
|
|
9
|
+
const observedListCap = 25
|
|
10
|
+
const patterns = {
|
|
11
|
+
webControlPlane: /(?:next|@opennextjs\/cloudflare)/i,
|
|
12
|
+
flutter: /(?:pubspec\.yaml|package:flutter)/i,
|
|
13
|
+
agents: /(?:\beve\b|@workflow\/world|AgentRun)/i,
|
|
14
|
+
jobs: /(?:pg-boss|PgBoss)/i,
|
|
15
|
+
sandbox: /(?:@cloudflare\/sandbox|SandboxProvider|enableInternet)/i,
|
|
16
|
+
connectors: /(?:\bMCP\b|webhook|connector)/i,
|
|
17
|
+
knowledge: /(?:pgvector|embedding|\bknowledge\b)/i,
|
|
18
|
+
modelRouting: /(?:ai-gateway|generateText|streamText|BYOK)/i,
|
|
19
|
+
enterpriseIdentity: /(?:\bSCIM\b|\bSSO\b|saml)/i,
|
|
20
|
+
realtime: /(?:EventSource|text\/event-stream|WebSocket)/i,
|
|
21
|
+
notifications: /(?:firebase_messaging|APNs|notification)/i,
|
|
22
|
+
secrets: /(?:OPENBAO|VAULT_ADDR|CLIENT_SECRET|DATABASE_URL)/i
|
|
23
|
+
}
|
|
24
|
+
const ignored = new Set(['node_modules', '.git', '.turbo', 'dist', 'build', '.next', '.agents', '.claude', 'coverage', 'vendor', 'out', '.output', '.vercel', '.wrangler'])
|
|
25
|
+
|
|
26
|
+
async function inspectableFiles(root) {
|
|
27
|
+
const output = []
|
|
28
|
+
async function walk(directory) {
|
|
29
|
+
for (const entry of await readdir(directory, { withFileTypes: true })) {
|
|
30
|
+
if (ignored.has(entry.name)) continue
|
|
31
|
+
const path = resolve(directory, entry.name)
|
|
32
|
+
if (entry.isSymbolicLink()) throw new Error(`Refusing symlink during inspection: ${path}`)
|
|
33
|
+
if (entry.isDirectory()) await walk(path)
|
|
34
|
+
else if (entry.isFile()) output.push(path)
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
await walk(root)
|
|
38
|
+
return output.sort()
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function baseDraft() {
|
|
42
|
+
return {
|
|
43
|
+
schemaVersion: 3,
|
|
44
|
+
profileStatus: 'draft',
|
|
45
|
+
foundation: { version: '0.3.0', baseline: 'vs-2026-08-07', adoption: 'supported' },
|
|
46
|
+
project: { name: 'REQUIRED-CONFIRMED-PROJECT-NAME', kind: 'REQUIRED-CONFIRMED-PROJECT-KIND', lifecycle: 'brownfield', access: 'REQUIRED-CONFIRMED-ACCESS', tenancy: 'REQUIRED-CONFIRMED-TENANCY' },
|
|
47
|
+
environments: { production: { hosting: 'REQUIRED-CONFIRMED-PRODUCTION-TARGET' }, localDevelopment: { trusted: true, allowances: [] } },
|
|
48
|
+
capabilities: Object.fromEntries(capabilityNames.map(name => [name, { status: 'disabled', ownership: 'not-applicable' }])),
|
|
49
|
+
exceptions: []
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// Only literally exact versions are recorded as facts; ranges ("^1.2.3") are not laundered into
|
|
54
|
+
// exact pins — the draft records them as unconfirmed instead.
|
|
55
|
+
function exact(value) {
|
|
56
|
+
const normalized = String(value ?? '').trim().replace(/^v/, '')
|
|
57
|
+
return /^\d+\.\d+(?:\.\d+)?(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?$/.test(normalized) ? normalized : null
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
async function inspect(root) {
|
|
61
|
+
const before = new Map()
|
|
62
|
+
const allBefore = await inspectableFiles(root)
|
|
63
|
+
for (const path of allBefore) before.set(path, (await lstat(path)).mtimeMs)
|
|
64
|
+
const files = allBefore
|
|
65
|
+
const packageVersions = {}
|
|
66
|
+
for (const path of files.filter(path => basename(path) === 'package.json')) {
|
|
67
|
+
try {
|
|
68
|
+
const pkg = JSON.parse(await readFile(path, 'utf8'))
|
|
69
|
+
for (const [name, value] of Object.entries({ ...(pkg.dependencies ?? {}), ...(pkg.devDependencies ?? {}) })) if (exact(value)) packageVersions[name] = exact(value)
|
|
70
|
+
} catch { /* malformed manifests remain observational unknowns */ }
|
|
71
|
+
}
|
|
72
|
+
const observed = Object.fromEntries(capabilityNames.map(name => [name, []]))
|
|
73
|
+
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'))) {
|
|
74
|
+
const rel = relative(root, path).split(sep).join('/')
|
|
75
|
+
let body = ''
|
|
76
|
+
try { body = await readFile(path, 'utf8') } catch { continue }
|
|
77
|
+
for (const [name, pattern] of Object.entries(patterns)) if (pattern.test(`${rel}\n${body}`)) observed[name].push(rel)
|
|
78
|
+
}
|
|
79
|
+
const profile = baseDraft()
|
|
80
|
+
for (const [name, evidence] of Object.entries(observed)) if (evidence.length) {
|
|
81
|
+
const sourceRoot = evidence[0].includes('/') ? evidence[0].split('/').slice(0, -1).join('/') : '.'
|
|
82
|
+
const versions = {}
|
|
83
|
+
const candidates = {
|
|
84
|
+
webControlPlane: [['next', 'next'], ['openNext', '@opennextjs/cloudflare']],
|
|
85
|
+
agents: [['eve', 'eve'], ['workflowPostgres', '@workflow/world-postgres']],
|
|
86
|
+
jobs: [['pgBoss', 'pg-boss']],
|
|
87
|
+
sandbox: [['cloudflareSandbox', '@cloudflare/sandbox']]
|
|
88
|
+
}[name] ?? []
|
|
89
|
+
for (const [key, pkg] of candidates) if (packageVersions[pkg]) versions[key] = packageVersions[pkg]
|
|
90
|
+
profile.capabilities[name] = { status: 'enabled', ownership: 'REQUIRED-CONFIRMED-OWNERSHIP', versions: Object.keys(versions).length ? versions : { unconfirmed: 'REQUIRED-EXACT-VERSION' }, placement: 'REQUIRED-CONFIRMED-PLACEMENT', sourceRoots: [sourceRoot], controls: { observedSourceRoots: evidence } }
|
|
91
|
+
}
|
|
92
|
+
const artifacts = ['.vegastack/architecture.json']
|
|
93
|
+
if (Object.values(profile.capabilities).some(item => item.status === 'enabled')) artifacts.push('docs/architecture/service-design.md')
|
|
94
|
+
if (profile.environments.production.hosting !== 'none') artifacts.push('docs/architecture/deployment-review.md')
|
|
95
|
+
if (['connectors', 'sandbox', 'agents'].some(name => profile.capabilities[name].status === 'enabled')) artifacts.push('docs/architecture/threat-model.md')
|
|
96
|
+
const after = await inspectableFiles(root)
|
|
97
|
+
let changed = after.length !== before.size
|
|
98
|
+
for (const path of after) if (!before.has(path) || before.get(path) !== (await lstat(path)).mtimeMs) changed = true
|
|
99
|
+
if (changed) throw new Error('inspection mutation guard detected a changed file inventory')
|
|
100
|
+
return { mode: 'brownfield-observed', mutated: false, observed, packageVersions, profileDraft: profile, relevantArtifacts: artifacts, caveats: ['Detection is heuristic and does not prove absence, ownership, placement, compliance, runtime behavior, or project intent. Every REQUIRED field must be confirmed before use.'] }
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// Compact context-friendly view for interactive agent use; --json prints the full result.
|
|
104
|
+
function summarizeInspection(result) {
|
|
105
|
+
const capabilities = {}
|
|
106
|
+
for (const [name, paths] of Object.entries(result.observed)) if (paths.length) capabilities[name] = { count: paths.length, sample: paths.slice(0, 5) }
|
|
107
|
+
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' }
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function capObserved(result) {
|
|
111
|
+
const observed = {}
|
|
112
|
+
for (const [name, paths] of Object.entries(result.observed)) {
|
|
113
|
+
observed[name] = paths.length > observedListCap ? [...paths.slice(0, observedListCap)] : paths
|
|
114
|
+
if (paths.length > observedListCap) observed[`${name}TruncatedCount`] = paths.length - observedListCap
|
|
115
|
+
}
|
|
116
|
+
return { ...result, observed }
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
function fromAnswers(answers) {
|
|
120
|
+
if (answers.schemaVersion === 3) return answers
|
|
121
|
+
const profile = baseDraft()
|
|
122
|
+
for (const key of ['profileStatus', 'project', 'environments', 'capabilities', 'data', 'objectives', 'exceptions']) if (answers[key] !== undefined) profile[key] = answers[key]
|
|
123
|
+
return profile
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
function migrateV2(old) {
|
|
127
|
+
if (old.schemaVersion !== 2) throw new Error('migrate-v2 requires a schemaVersion 2 profile')
|
|
128
|
+
const profile = baseDraft()
|
|
129
|
+
profile.project = { name: old.project ?? 'REQUIRED-CONFIRMED-PROJECT-NAME', kind: 'saas-product', lifecycle: 'migration', access: 'authenticated', tenancy: old.tenancy?.storage === 'shared-schema-composite-keys' ? 'multi-tenant-shared-schema' : 'REQUIRED-CONFIRMED-TENANCY' }
|
|
130
|
+
profile.environments.production.hosting = old.hostingProfile ?? 'REQUIRED-CONFIRMED-HOSTING'
|
|
131
|
+
const owned = (versions, placement, sourceRoots, controls = {}) => ({ status: 'enabled', ownership: 'owned', versions, placement, sourceRoots, controls })
|
|
132
|
+
profile.capabilities.webControlPlane = owned({ bun: old.versions?.bun, node: old.versions?.node, next: old.versions?.next, ...(old.versions?.openNext !== 'not-applicable' ? { openNext: old.versions?.openNext } : {}), betterAuth: old.versions?.betterAuth }, old.runtimePlacement?.next, old.sourceRoots?.next, { canonicalApi: old.capabilities?.api?.canonical, openapiGenerated: old.capabilities?.api?.openapiGenerated, secureCookies: true })
|
|
133
|
+
profile.capabilities.flutter = owned({ flutter: 'REQUIRED-EXACT-VERSION' }, old.runtimePlacement?.flutter, ['REQUIRED-CONFIRMED-FLUTTER-ROOT'], { delegatedOAuthPkce: old.identity?.delegated === 'oauth2.1-oidc-code-pkce', generatedRestClient: old.capabilities?.api?.openapiGenerated === true })
|
|
134
|
+
profile.capabilities.agents = owned({ eve: old.versions?.eve, workflowWorldContract: old.versions?.workflowWorldContract, workflowLocal: old.versions?.workflowLocal, workflowPostgres: old.versions?.workflowPostgres, node: old.versions?.node }, old.runtimePlacement?.eve, old.sourceRoots?.eve, { workflowWorld: 'postgres', agentRun: true, admission: 'pg-boss' })
|
|
135
|
+
profile.capabilities.jobs = owned({ pgBoss: old.versions?.pgBoss, postgres: old.versions?.postgres }, old.runtimePlacement?.jobs, old.sourceRoots?.jobs, { roles: old.capabilities?.jobs?.roles ?? [] })
|
|
136
|
+
profile.capabilities.sandbox = owned({ provider: 'REQUIRED-EXACT-VERSION' }, old.runtimePlacement?.sandboxBroker, ['REQUIRED-CONFIRMED-SANDBOX-ROOT'], { provider: old.capabilities?.sandbox?.provider, egress: old.capabilities?.sandbox?.egress, databaseCredentials: old.capabilities?.sandbox?.databaseCredentials, trustedBroker: true })
|
|
137
|
+
if (old.capabilities?.secrets === 'openbao') profile.capabilities.secrets = owned({ openbao: 'REQUIRED-EXACT-VERSION' }, 'external-service', ['REQUIRED-CONFIRMED-SECRETS-CONFIG-ROOT'], { provider: 'openbao' })
|
|
138
|
+
profile.exceptions = []
|
|
139
|
+
return { profile, guidance: ['The v2 format implied a full platform, so all implied capabilities are enabled in this draft.', 'Confirm project kind/access/tenancy, Flutter and sandbox roots/versions, controls, capability ownership, data/objectives, and migrate each exception to exact v3 rule/control/path scope.', 'No source file or old profile was changed.'] }
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
async function assertNoSymlink(path) {
|
|
143
|
+
const absolute = resolve(path)
|
|
144
|
+
const root = resolve(absolute, sep)
|
|
145
|
+
let current = root
|
|
146
|
+
for (const part of relative(root, absolute).split(sep).filter(Boolean)) {
|
|
147
|
+
current = join(current, part)
|
|
148
|
+
try { if ((await lstat(current)).isSymbolicLink()) throw new Error(`Refusing symlink path component: ${current}`) } catch (error) { if (error.code === 'ENOENT') return; throw error }
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
async function atomicWrite(path, body, force) {
|
|
153
|
+
await assertNoSymlink(dirname(path))
|
|
154
|
+
await mkdir(dirname(path), { recursive: true })
|
|
155
|
+
await assertNoSymlink(dirname(path))
|
|
156
|
+
if (await pathExists(path)) {
|
|
157
|
+
await assertNoSymlink(path)
|
|
158
|
+
const current = await readFile(path, 'utf8')
|
|
159
|
+
if (current === body) return 'unchanged'
|
|
160
|
+
if (!force) throw new Error(`Refusing differing file without --force: ${path}`)
|
|
161
|
+
}
|
|
162
|
+
const temporary = `${path}.${randomUUID()}.tmp`
|
|
163
|
+
const handle = await open(temporary, 'wx')
|
|
164
|
+
try { await handle.writeFile(body); await handle.sync() } finally { await handle.close() }
|
|
165
|
+
await rename(temporary, path)
|
|
166
|
+
return 'written'
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
function flagValue(argv, flag) {
|
|
170
|
+
const value = argv.shift()
|
|
171
|
+
if (value === undefined || value.startsWith('-')) throw new Error(`${flag} requires a value`)
|
|
172
|
+
return value
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
function parse(argv) {
|
|
176
|
+
const command = argv.shift() ?? 'help'
|
|
177
|
+
const input = argv[0] && !argv[0].startsWith('-') ? argv.shift() : undefined
|
|
178
|
+
const options = { command, input, dir: process.cwd(), write: false, force: false, json: false, output: undefined }
|
|
179
|
+
while (argv.length) {
|
|
180
|
+
const flag = argv.shift()
|
|
181
|
+
if (flag === '--dir') options.dir = resolve(flagValue(argv, flag))
|
|
182
|
+
else if (flag === '--write') options.write = true
|
|
183
|
+
else if (flag === '--force') options.force = true
|
|
184
|
+
else if (flag === '--json') options.json = true
|
|
185
|
+
else if (flag === '--output') options.output = flagValue(argv, flag)
|
|
186
|
+
else throw new Error(`Unknown option: ${flag}`)
|
|
187
|
+
}
|
|
188
|
+
return options
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
// Output paths are confined to --dir: repository-relative, no .. segments, no symlink components.
|
|
192
|
+
function confinedOutput(dir, output) {
|
|
193
|
+
if (isAbsolute(output) || output.split(/[\\/]/).includes('..')) throw new Error(`--output must be a repository-relative path inside --dir without ..: ${output}`)
|
|
194
|
+
const target = resolve(dir, output)
|
|
195
|
+
if (relative(resolve(dir), target).startsWith('..')) throw new Error(`--output escapes --dir: ${output}`)
|
|
196
|
+
if (!output || target === resolve(dir)) throw new Error(`--output must name a file inside --dir, not the directory itself: ${JSON.stringify(output)}`)
|
|
197
|
+
return target
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
async function main() {
|
|
201
|
+
const options = parse(process.argv.slice(2))
|
|
202
|
+
if (options.command === 'help') return console.log('Usage: profile-tool.mjs inspect [DIR] [--json] | scaffold ANSWERS.json --dir DIR [--write] [--force] [--output PATH] | migrate-v2 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.\nWrites are atomic, refuse symlinks, stay inside --dir, and require --force to replace differing content.')
|
|
203
|
+
if (options.command === 'inspect') {
|
|
204
|
+
const root = resolve(options.input ?? options.dir)
|
|
205
|
+
const result = await inspect(root)
|
|
206
|
+
return console.log(JSON.stringify(options.json ? capObserved(result) : summarizeInspection(result), null, 2))
|
|
207
|
+
}
|
|
208
|
+
if (!['scaffold', 'migrate-v2'].includes(options.command) || !options.input) throw new Error('A supported command and input file are required')
|
|
209
|
+
const source = resolve(options.input)
|
|
210
|
+
const parsed = await readJsonYaml(source)
|
|
211
|
+
const result = options.command === 'migrate-v2' ? migrateV2(parsed) : { profile: fromAnswers(parsed), guidance: ['Generated only from supplied answers; confirm draft facts before CI.'] }
|
|
212
|
+
const body = `${JSON.stringify(result.profile, null, 2)}\n`
|
|
213
|
+
const defaultOutput = options.command === 'migrate-v2' ? '.vegastack/architecture.v3-draft.json' : '.vegastack/architecture.json'
|
|
214
|
+
const output = confinedOutput(options.dir, options.output ?? defaultOutput)
|
|
215
|
+
const payload = { mode: options.write ? 'authorized-write' : 'dry-run', output, profile: result.profile, guidance: result.guidance }
|
|
216
|
+
if (!options.write) return console.log(JSON.stringify(payload, null, 2))
|
|
217
|
+
payload.result = await atomicWrite(output, body, options.force)
|
|
218
|
+
console.log(JSON.stringify(payload, null, 2))
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
if (import.meta.url === pathToFileURL(process.argv[1] ?? '').href) main().catch(error => { console.error(`error: ${error.message}`); process.exitCode = 1 })
|
|
222
|
+
|
|
223
|
+
export { inspect, migrateV2, fromAnswers, atomicWrite }
|