cli-archguard 7.0.18 → 7.0.25
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/README.md +31 -3
- package/archguard-contextbase-hook.mjs +46 -0
- package/archguard-local-runner.mjs +492 -0
- package/archguard-runtime.mjs +286 -0
- package/broker.mjs +301 -0
- package/cli.mjs +76 -11
- package/installer.mjs +57 -154
- package/package.json +12 -3
- package/skill/SKILL.md +52 -17
- package/skill/skill.json +2 -2
|
@@ -0,0 +1,286 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto'
|
|
2
|
+
|
|
3
|
+
const REQUEST_VERSION = 'archguard.skill.request/1.0'
|
|
4
|
+
const RESPONSE_VERSION = 'archguard.skill.response/1.0'
|
|
5
|
+
const COMPILER_VERSION = 'v7.0.25'
|
|
6
|
+
const CONTRACT_VERSION = 'archguard.contract/1.0'
|
|
7
|
+
const LEDGER_VERSION = 'archguard.checkpoint-ledger/1.0'
|
|
8
|
+
const SHA256 = /^[0-9a-f]{64}$/
|
|
9
|
+
const stringSchema = { type: 'string' }
|
|
10
|
+
const booleanSchema = { type: 'boolean' }
|
|
11
|
+
const integerSchema = { type: 'integer' }
|
|
12
|
+
const stringArraySchema = { type: 'array', items: stringSchema }
|
|
13
|
+
const strict = (properties, required = []) => ({ type: 'object', additionalProperties: false, properties, required })
|
|
14
|
+
|
|
15
|
+
const TEMPLATE_DEFINITIONS = Object.freeze({
|
|
16
|
+
'react-ts-vite': { frontend: { language: 'typescript', framework: 'react-18', scaffold: 'vite', uiLibrary: 'antd-5', styling: 'css-modules', stateManagement: 'zustand', forbiddenDeps: ['jquery', 'moment'] }, backend: null },
|
|
17
|
+
'vue3-ts-vite': { frontend: { language: 'typescript', framework: 'vue-3', scaffold: 'vite', uiLibrary: 'element-plus', styling: 'css-modules', stateManagement: 'pinia', forbiddenDeps: ['jquery', 'moment'] }, backend: null },
|
|
18
|
+
'nextjs-ts': { frontend: { language: 'typescript', framework: 'nextjs-15', scaffold: 'nextjs', uiLibrary: 'antd-5', styling: 'css-modules', stateManagement: 'react-context', forbiddenDeps: ['jquery', 'moment'] }, backend: { language: 'nodejs-20', framework: 'nextjs-route-handlers', orm: 'none', forbiddenDeps: ['request'], patterns: { required: ['schema-validation'], forbidden: ['raw-sql-concat'] } } },
|
|
19
|
+
'node-nestjs': { frontend: null, backend: { language: 'nodejs-20', framework: 'nestjs-10', orm: 'prisma', forbiddenDeps: ['request'], patterns: { required: ['dto-validation', 'global-exception-filter'], forbidden: ['raw-sql-concat'] } } },
|
|
20
|
+
'python-fastapi': { frontend: null, backend: { language: 'python-3.12', framework: 'fastapi', orm: 'sqlalchemy', forbiddenDeps: ['pickle5'], patterns: { required: ['schema-validation'], forbidden: ['raw-sql-concat'] } } },
|
|
21
|
+
'java-spring': { frontend: null, backend: { language: 'java-21', framework: 'spring-boot-3', orm: 'jpa', forbiddenDeps: ['commons-logging'], patterns: { required: ['dto-validation', 'global-exception-handler'], forbidden: ['raw-sql-concat'] } } },
|
|
22
|
+
'wechat-miniprogram': { frontend: { language: 'typescript', framework: 'wechat-miniprogram', scaffold: 'miniprogram', uiLibrary: 'native', styling: 'wxss', stateManagement: 'page-state', forbiddenDeps: ['jquery'] }, backend: null },
|
|
23
|
+
'tauri-react': { frontend: { language: 'typescript', framework: 'react-18', scaffold: 'tauri-2', uiLibrary: 'antd-5', styling: 'css-modules', stateManagement: 'zustand', forbiddenDeps: ['electron', 'jquery'] }, backend: { language: 'rust-2024', framework: 'tauri-2', orm: 'none', forbiddenDeps: [], patterns: { required: ['command-boundary'], forbidden: ['unsafe-without-audit'] } } },
|
|
24
|
+
})
|
|
25
|
+
const DEFAULT_BUDGETS = Object.freeze({ maxNestingDepth: 4, maxFileLines: 300, maxFunctionLines: 50, animations: 3, transitions: -1, bundleBudgetKB: 500 })
|
|
26
|
+
const BUILTIN_RULES = Object.freeze([
|
|
27
|
+
{ id: 'no-inline-style', category: 'standard', pattern: /\bstyle\s*=\s*(?:\{\{|["'])/g, message: 'Use the locked styling system instead of inline style.' },
|
|
28
|
+
{ id: 'no-hardcoded-color', category: 'standard', pattern: /#[0-9a-f]{3,8}\b|\brgb(?:a)?\s*\(|\bhsl(?:a)?\s*\(/gi, message: 'Use project theme tokens instead of hardcoded colors.' },
|
|
29
|
+
{ id: 'no-eval', category: 'security', pattern: /\beval\s*\(|new\s+Function\b/g, message: 'Dynamic code evaluation is forbidden.' },
|
|
30
|
+
{ id: 'no-debug', category: 'standard', pattern: /\bdebugger\b|\bconsole\.(?:log|debug)\s*\(/g, message: 'Remove debug statements.' },
|
|
31
|
+
{ id: 'no-magic-number', category: 'standard', pattern: /(?:^|[^\w.])(?:[2-9]|[1-9]\d{1,})(?:\.\d+)?(?:[^\w.]|$)/g, message: 'Name repeated or domain-significant numeric values.' },
|
|
32
|
+
])
|
|
33
|
+
const OPERATIONS = Object.freeze(['capabilities', 'help', 'intake', 'contract-create', 'contract-get', 'contract-update', 'template-list', 'checkpoint', 'rules-scan', 'complexity-report', 'drift-status', 'ledger-query'])
|
|
34
|
+
|
|
35
|
+
const patternSchema = strict({ required: stringArraySchema, forbidden: stringArraySchema }, ['required', 'forbidden'])
|
|
36
|
+
const stackSideSchema = strict({ language: stringSchema, framework: stringSchema, scaffold: stringSchema, uiLibrary: stringSchema, styling: stringSchema, stateManagement: stringSchema, orm: stringSchema, forbiddenDeps: stringArraySchema, patterns: patternSchema }, ['language', 'framework', 'forbiddenDeps'])
|
|
37
|
+
const stackSchema = strict({ frontend: { anyOf: [stackSideSchema, { type: 'null' }] }, backend: { anyOf: [stackSideSchema, { type: 'null' }] } }, ['frontend', 'backend'])
|
|
38
|
+
const ruleSchema = strict({ id: stringSchema, engine: { enum: ['regex', 'ast'] }, pattern: stringSchema, message: stringSchema, blocking: booleanSchema }, ['id', 'engine', 'pattern', 'message', 'blocking'])
|
|
39
|
+
const rulesSchema = strict({ blocking: stringArraySchema, strictStandard: booleanSchema, custom: { type: 'array', items: ruleSchema } }, ['blocking', 'strictStandard', 'custom'])
|
|
40
|
+
const budgetSchema = strict(Object.fromEntries(Object.keys(DEFAULT_BUDGETS).map((key) => [key, integerSchema])), Object.keys(DEFAULT_BUDGETS))
|
|
41
|
+
const auditSchema = { type: 'object', additionalProperties: { type: 'string' }, minProperties: 1 }
|
|
42
|
+
const CONTRACT_SCHEMA = strict({ schemaVersion: { const: CONTRACT_VERSION }, contractId: stringSchema, project: stringSchema, templateId: { enum: Object.keys(TEMPLATE_DEFINITIONS) }, revision: { type: 'integer', minimum: 1 }, stack: stackSchema, rules: rulesSchema, budgets: budgetSchema, audit: auditSchema }, ['schemaVersion', 'contractId', 'project', 'templateId', 'revision', 'stack', 'rules', 'budgets', 'audit'])
|
|
43
|
+
const overrideSchema = strict({ stack: stackSchema, rules: rulesSchema, budgets: budgetSchema, audit: auditSchema })
|
|
44
|
+
const fileSchema = strict({ path: stringSchema, content: stringSchema }, ['path', 'content'])
|
|
45
|
+
const findingSchema = strict({ severity: { enum: ['P0', 'P1', 'P2'] }, ruleId: stringSchema, entityRef: stringSchema, message: stringSchema, category: stringSchema, blocking: booleanSchema, evidence: { type: 'object', additionalProperties: true } }, ['severity', 'ruleId', 'entityRef', 'message', 'category', 'blocking', 'evidence'])
|
|
46
|
+
const metricSchema = strict({ path: stringSchema, fileLines: integerSchema, maxNestingDepth: integerSchema, maxFunctionLines: integerSchema, animations: integerSchema, transitions: integerSchema }, ['path', 'fileLines', 'maxNestingDepth', 'maxFunctionLines', 'animations', 'transitions'])
|
|
47
|
+
const ledgerEntrySchema = strict({ schemaVersion: { const: LEDGER_VERSION }, checkpointId: stringSchema, blockId: stringSchema, path: stringSchema, contractRevision: integerSchema, beforeSha256: { type: 'string', pattern: SHA256.source }, afterSha256: { type: 'string', pattern: SHA256.source }, status: { enum: ['passed', 'blocked'] }, ruleIds: stringArraySchema, blockingRuleIds: stringArraySchema, rollbackRequired: booleanSchema }, ['schemaVersion', 'checkpointId', 'blockId', 'path', 'contractRevision', 'beforeSha256', 'afterSha256', 'status', 'ruleIds', 'blockingRuleIds', 'rollbackRequired'])
|
|
48
|
+
const ledgerSchema = { type: 'array', items: ledgerEntrySchema }
|
|
49
|
+
const digestSchema = { type: 'string', pattern: SHA256.source }
|
|
50
|
+
const nextSchema = strict({ operation: stringSchema, instruction: stringSchema }, ['operation'])
|
|
51
|
+
const driftSchema = strict({ trend: { enum: ['green', 'yellow', 'red'] }, totalBlocks: integerSchema, recentBlocks: integerSchema, blockedBlocks: integerSchema, violationRate: { type: 'number' }, consecutiveBlocks: integerSchema, repeatedRuleIds: stringArraySchema, action: stringSchema }, ['trend', 'totalBlocks', 'recentBlocks', 'blockedBlocks', 'violationRate', 'consecutiveBlocks', 'repeatedRuleIds', 'action'])
|
|
52
|
+
const checkpointOutputSchema = strict({ checkpoint: strict({ status: { enum: ['passed', 'blocked'] }, writeAllowed: booleanSchema, rollbackRequired: booleanSchema, findings: { type: 'array', items: findingSchema }, metrics: metricSchema, drift: driftSchema, instruction: stringSchema }, ['status', 'writeAllowed', 'rollbackRequired', 'findings', 'metrics', 'drift', 'instruction']), ledgerEntry: ledgerEntrySchema }, ['checkpoint', 'ledgerEntry'])
|
|
53
|
+
const scanInputSchema = strict({ contract: CONTRACT_SCHEMA, files: { type: 'array', minItems: 1, items: fileSchema } }, ['contract', 'files'])
|
|
54
|
+
const emptySchema = strict({})
|
|
55
|
+
const objectSchemaDescriptor = strict({ type: { const: 'object' }, additionalProperties: booleanSchema, properties: { type: 'object', additionalProperties: true }, required: stringArraySchema }, ['type', 'additionalProperties', 'properties', 'required'])
|
|
56
|
+
const schemaPair = strict({ input: objectSchemaDescriptor, output: objectSchemaDescriptor }, ['input', 'output'])
|
|
57
|
+
const capabilitiesOutputSchema = strict({ skill: strict({ name: stringSchema, version: stringSchema }, ['name', 'version']), operations: stringArraySchema, operationSchemas: { type: 'object', additionalProperties: schemaPair }, contractSchemaVersion: stringSchema, ledgerSchemaVersion: stringSchema, templates: stringArraySchema, executionBoundary: stringSchema, nextStep: nextSchema }, ['skill', 'operations', 'operationSchemas', 'contractSchemaVersion', 'ledgerSchemaVersion', 'templates', 'executionBoundary', 'nextStep'])
|
|
58
|
+
const OPERATION_SCHEMAS = Object.freeze({
|
|
59
|
+
capabilities: { input: emptySchema, output: capabilitiesOutputSchema },
|
|
60
|
+
help: { input: emptySchema, output: capabilitiesOutputSchema },
|
|
61
|
+
intake: { input: strict({ project: stringSchema, detectedFiles: stringArraySchema }, ['project']), output: strict({ project: stringSchema, questions: { type: 'array', items: strict({ id: stringSchema, prompt: stringSchema, required: booleanSchema, options: { type: 'array' } }, ['id', 'prompt', 'required']) }, nextStep: nextSchema }, ['project', 'questions', 'nextStep']) },
|
|
62
|
+
'contract-create': { input: strict({ project: stringSchema, templateId: { enum: Object.keys(TEMPLATE_DEFINITIONS) }, overrides: overrideSchema }, ['project', 'templateId']), output: strict({ contract: CONTRACT_SCHEMA, digest: digestSchema, nextStep: nextSchema }, ['contract', 'digest', 'nextStep']) },
|
|
63
|
+
'contract-get': { input: strict({ contract: CONTRACT_SCHEMA }, ['contract']), output: strict({ contract: CONTRACT_SCHEMA, digest: digestSchema }, ['contract', 'digest']) },
|
|
64
|
+
'contract-update': { input: strict({ contract: CONTRACT_SCHEMA, expectedDigest: digestSchema, patch: overrideSchema, confirmation: { const: 'confirm-architecture-contract-update' }, reason: stringSchema, actor: stringSchema }, ['contract', 'expectedDigest', 'patch', 'confirmation', 'reason', 'actor']), output: strict({ contract: CONTRACT_SCHEMA, digest: digestSchema, audit: auditSchema }, ['contract', 'digest', 'audit']) },
|
|
65
|
+
'template-list': { input: emptySchema, output: strict({ templates: { type: 'array', items: strict({ templateId: stringSchema, stack: stackSchema }, ['templateId', 'stack']) }, nextStep: nextSchema }, ['templates', 'nextStep']) },
|
|
66
|
+
checkpoint: { input: strict({ contract: CONTRACT_SCHEMA, block: strict({ blockId: stringSchema, path: stringSchema, content: stringSchema, beforeSha256: digestSchema }, ['blockId', 'path', 'content', 'beforeSha256']), history: ledgerSchema }, ['contract', 'block', 'history']), output: checkpointOutputSchema },
|
|
67
|
+
'rules-scan': { input: scanInputSchema, output: strict({ findings: { type: 'array', items: findingSchema }, summary: strict({ total: integerSchema, blocking: integerSchema }, ['total', 'blocking']) }, ['findings', 'summary']) },
|
|
68
|
+
'complexity-report': { input: scanInputSchema, output: strict({ metrics: { type: 'array', items: metricSchema }, findings: { type: 'array', items: findingSchema } }, ['metrics', 'findings']) },
|
|
69
|
+
'drift-status': { input: strict({ ledger: ledgerSchema }, ['ledger']), output: strict({ drift: driftSchema }, ['drift']) },
|
|
70
|
+
'ledger-query': { input: strict({ ledger: ledgerSchema, ruleId: stringSchema, status: { enum: ['passed', 'blocked'] } }, ['ledger']), output: strict({ schemaVersion: { const: LEDGER_VERSION }, entries: ledgerSchema, total: integerSchema }, ['schemaVersion', 'entries', 'total']) },
|
|
71
|
+
})
|
|
72
|
+
|
|
73
|
+
function canonicalJson(value) {
|
|
74
|
+
if (value === null || ['string', 'boolean'].includes(typeof value)) return JSON.stringify(value)
|
|
75
|
+
if (typeof value === 'number') {
|
|
76
|
+
if (!Number.isFinite(value)) throw new Error('JSON contains a non-finite number')
|
|
77
|
+
return JSON.stringify(value)
|
|
78
|
+
}
|
|
79
|
+
if (Array.isArray(value)) return `[${value.map(canonicalJson).join(',')}]`
|
|
80
|
+
if (!value || typeof value !== 'object') throw new Error('Value must be finite JSON')
|
|
81
|
+
return `{${Object.keys(value).sort().map((key) => `${JSON.stringify(key)}:${canonicalJson(value[key])}`).join(',')}}`
|
|
82
|
+
}
|
|
83
|
+
const digest = (value) => createHash('sha256').update(canonicalJson(value)).digest('hex')
|
|
84
|
+
const contentDigest = (value) => createHash('sha256').update(value).digest('hex')
|
|
85
|
+
function text(value, context) {
|
|
86
|
+
if (typeof value !== 'string' || value !== value.trim() || !value) throw new Error(`${context} is required`)
|
|
87
|
+
return value
|
|
88
|
+
}
|
|
89
|
+
function record(value, context) {
|
|
90
|
+
if (!value || typeof value !== 'object' || Array.isArray(value)) throw new Error(`${context} must be an object`)
|
|
91
|
+
return value
|
|
92
|
+
}
|
|
93
|
+
const response = (requestId, status, output, findings = []) => ({ ok: true, schemaVersion: RESPONSE_VERSION, requestId, status, output, findings })
|
|
94
|
+
const finding = (severity, ruleId, entityRef, message, category, blocking = true, evidence = {}) => ({ severity, ruleId, entityRef, message, category, blocking, evidence })
|
|
95
|
+
const pathSafe = (path) => typeof path === 'string' && path === path.normalize('NFC') && !path.startsWith('/') && !path.includes('\\') && path.split('/').every((part) => part && part !== '.' && part !== '..')
|
|
96
|
+
|
|
97
|
+
function normalizeCustomRule(value, index) {
|
|
98
|
+
const rule = record(value, `rules.custom[${index}]`)
|
|
99
|
+
if (!/^[A-Za-z0-9][A-Za-z0-9._:-]*$/.test(text(rule.id, 'custom rule id')) || !['regex', 'ast'].includes(rule.engine) || typeof rule.pattern !== 'string' || rule.pattern.length > 500 || (rule.engine === 'regex' && /\\[1-9]|\(\?<|\(\?=|\(\?!/.test(rule.pattern)) || (rule.engine === 'ast' && !/^[A-Za-z][A-Za-z0-9]*$/.test(rule.pattern))) throw new Error(`rules.custom[${index}] is unsafe or unsupported`)
|
|
100
|
+
if (rule.engine === 'regex') new RegExp(rule.pattern, 'gu')
|
|
101
|
+
return { id: rule.id, engine: rule.engine, pattern: rule.pattern, message: text(rule.message, 'custom rule message'), blocking: rule.blocking === true }
|
|
102
|
+
}
|
|
103
|
+
function normalizeContract(value) {
|
|
104
|
+
const source = record(value, 'contract')
|
|
105
|
+
if (source.schemaVersion !== CONTRACT_VERSION || !TEMPLATE_DEFINITIONS[source.templateId] || !Number.isInteger(source.revision) || source.revision < 1 || !source.stack || !source.rules || !source.budgets || !source.audit) throw new Error('architecture contract is invalid')
|
|
106
|
+
const custom = Array.isArray(source.rules.custom) ? source.rules.custom.map(normalizeCustomRule) : []
|
|
107
|
+
const blocking = Array.isArray(source.rules.blocking) ? [...new Set(source.rules.blocking.map((rule) => text(rule, 'blocking rule')))].sort() : []
|
|
108
|
+
const budgets = Object.fromEntries(Object.keys(DEFAULT_BUDGETS).map((key) => {
|
|
109
|
+
const amount = source.budgets[key]
|
|
110
|
+
if (!Number.isInteger(amount) || amount < -1) throw new Error(`contract.budgets.${key} is invalid`)
|
|
111
|
+
return [key, amount]
|
|
112
|
+
}))
|
|
113
|
+
return { schemaVersion: CONTRACT_VERSION, contractId: text(source.contractId, 'contractId'), project: text(source.project, 'project'), templateId: source.templateId, revision: source.revision, stack: source.stack, rules: { blocking, strictStandard: source.rules.strictStandard === true, custom }, budgets, audit: record(source.audit, 'audit') }
|
|
114
|
+
}
|
|
115
|
+
function baseContract(project, templateId) {
|
|
116
|
+
const stack = TEMPLATE_DEFINITIONS[templateId]
|
|
117
|
+
if (!stack) throw new Error(`Unknown architecture template: ${templateId}`)
|
|
118
|
+
return normalizeContract({ schemaVersion: CONTRACT_VERSION, contractId: `${project}-architecture`, project, templateId, revision: 1, stack, rules: { blocking: ['no-eval', 'no-debug'], strictStandard: false, custom: [] }, budgets: DEFAULT_BUDGETS, audit: { createdBy: 'archguard', reason: 'initial-contract' } })
|
|
119
|
+
}
|
|
120
|
+
function forbiddenDependencies(contract) {
|
|
121
|
+
return [...new Set([...(contract.stack.frontend?.forbiddenDeps ?? []), ...(contract.stack.backend?.forbiddenDeps ?? [])])]
|
|
122
|
+
}
|
|
123
|
+
const dependencyKey = (value) => value.toLowerCase().replace(/[._]+/g, '-')
|
|
124
|
+
function dependencyMatches(actual, forbidden) {
|
|
125
|
+
const actualParts = actual.split(':')
|
|
126
|
+
return dependencyKey(actual) === dependencyKey(forbidden) || dependencyKey(actualParts.at(-1)) === dependencyKey(forbidden)
|
|
127
|
+
}
|
|
128
|
+
function manifestDependencies(file) {
|
|
129
|
+
if (/(^|\/)package\.json$/i.test(file.path)) {
|
|
130
|
+
const manifest = record(JSON.parse(file.content), 'package.json')
|
|
131
|
+
return ['dependencies', 'devDependencies', 'peerDependencies', 'optionalDependencies'].flatMap((key) => Object.keys(manifest[key] ?? {}))
|
|
132
|
+
}
|
|
133
|
+
if (/(^|\/)requirements(?:-[^/]*)?\.txt$/i.test(file.path)) return file.content.split(/\r?\n/).map((line) => line.replace(/\s+#.*$/, '').trim()).filter((line) => line && !line.startsWith('-')).map((line) => line.split(/[<>=!~\[;\s]/, 1)[0])
|
|
134
|
+
if (/(^|\/)pom\.xml$/i.test(file.path)) return [...file.content.matchAll(/<dependency\b[\s\S]*?<artifactId>\s*([^<\s]+)\s*<\/artifactId>[\s\S]*?<\/dependency>/gi)].map((match) => match[1])
|
|
135
|
+
if (/(^|\/)Cargo\.toml$/.test(file.path)) {
|
|
136
|
+
let dependencySection = false
|
|
137
|
+
return file.content.split(/\r?\n/).flatMap((line) => {
|
|
138
|
+
const section = line.trim().match(/^\[([^\]]+)]$/)
|
|
139
|
+
if (section) dependencySection = /(^|\.)((dev|build)-)?dependencies$/.test(section[1])
|
|
140
|
+
const dependency = dependencySection ? line.match(/^\s*([A-Za-z0-9_-]+)\s*=/)?.[1] : undefined
|
|
141
|
+
return dependency ? [dependency] : []
|
|
142
|
+
})
|
|
143
|
+
}
|
|
144
|
+
return []
|
|
145
|
+
}
|
|
146
|
+
function dependencyFindings(contract, file) {
|
|
147
|
+
const forbidden = forbiddenDependencies(contract)
|
|
148
|
+
let declared
|
|
149
|
+
try {
|
|
150
|
+
declared = manifestDependencies(file)
|
|
151
|
+
} catch (error) {
|
|
152
|
+
return [finding('P0', 'ARCH-DEPENDENCY-MANIFEST-INVALID', file.path, error instanceof Error ? error.message : 'Dependency manifest is invalid.', 'architecture')]
|
|
153
|
+
}
|
|
154
|
+
const found = new Set()
|
|
155
|
+
for (const dependency of forbidden) {
|
|
156
|
+
const escaped = dependency.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
|
|
157
|
+
const imported = new RegExp(`(?:from\\s+|require\\(\\s*|import\\(\\s*)["']${escaped}(?:[\\/"'])`).test(file.content)
|
|
158
|
+
if (imported || declared.some((item) => dependencyMatches(item, dependency))) found.add(dependency)
|
|
159
|
+
}
|
|
160
|
+
return [...found].map((dependency) => finding('P0', 'ARCH-FORBIDDEN-DEPENDENCY', file.path, `Dependency ${dependency} is forbidden by the contract.`, 'architecture', true, { dependency }))
|
|
161
|
+
}
|
|
162
|
+
function languageFindings(contract, file) {
|
|
163
|
+
if (!pathSafe(file.path)) return [finding('P0', 'ARCH-PATH', String(file.path), 'File path is unsafe.', 'architecture')]
|
|
164
|
+
const findings = []
|
|
165
|
+
if (file.path === 'arch.contract.yaml') findings.push(finding('P0', 'ARCH-CONTRACT-MUTATION', file.path, 'The locked architecture contract cannot be changed by checkpoint.', 'architecture'))
|
|
166
|
+
const languages = [contract.stack.frontend?.language, contract.stack.backend?.language].filter(Boolean)
|
|
167
|
+
const extensions = new Set(languages.flatMap((language) => language === 'typescript' ? ['.ts', '.tsx'] : language.startsWith('nodejs') ? ['.js', '.mjs', '.cjs'] : language.startsWith('python') ? ['.py'] : language.startsWith('java') ? ['.java'] : language.startsWith('rust') ? ['.rs'] : []))
|
|
168
|
+
const codeExtension = file.path.match(/\.[A-Za-z0-9]+$/)?.[0]
|
|
169
|
+
if (codeExtension && ['.js', '.jsx', '.ts', '.tsx', '.py', '.java', '.rs'].includes(codeExtension) && extensions.size && !extensions.has(codeExtension)) findings.push(finding('P0', 'ARCH-LANGUAGE', file.path, `File extension ${codeExtension} is outside the locked languages.`, 'architecture', true, { allowedExtensions: [...extensions] }))
|
|
170
|
+
return findings.concat(dependencyFindings(contract, file))
|
|
171
|
+
}
|
|
172
|
+
function ruleFindings(contract, file) {
|
|
173
|
+
const findings = []
|
|
174
|
+
for (const rule of BUILTIN_RULES) {
|
|
175
|
+
const matches = [...file.content.matchAll(new RegExp(rule.pattern.source, rule.pattern.flags))]
|
|
176
|
+
if (!matches.length) continue
|
|
177
|
+
const blocking = rule.category === 'security' || contract.rules.strictStandard || contract.rules.blocking.includes(rule.id)
|
|
178
|
+
findings.push(finding(blocking ? 'P1' : 'P2', rule.id, file.path, rule.message, rule.category, blocking, { matches: matches.length }))
|
|
179
|
+
}
|
|
180
|
+
for (const rule of contract.rules.custom) {
|
|
181
|
+
if (rule.engine !== 'regex') continue
|
|
182
|
+
const matches = [...file.content.matchAll(new RegExp(rule.pattern, 'gu'))]
|
|
183
|
+
if (matches.length) findings.push(finding(rule.blocking ? 'P1' : 'P2', rule.id, file.path, rule.message, 'custom', rule.blocking, { matches: matches.length }))
|
|
184
|
+
}
|
|
185
|
+
return findings
|
|
186
|
+
}
|
|
187
|
+
function complexity(file) {
|
|
188
|
+
const lines = file.content.split(/\r?\n/)
|
|
189
|
+
let depth = 0; let maximumDepth = 0; let functionStart = null; let maximumFunctionLines = 0
|
|
190
|
+
for (const [index, line] of lines.entries()) {
|
|
191
|
+
if (functionStart === null && /\bfunction\b|=>\s*\{|\b(?:public|private|protected)?\s*(?:async\s+)?[A-Za-z_$][\w$]*\s*\([^)]*\)\s*\{/.test(line)) functionStart = { line: index, depth }
|
|
192
|
+
for (const character of line.replace(/(['"`]).*?\1/g, '')) {
|
|
193
|
+
if (character === '{') { depth += 1; maximumDepth = Math.max(maximumDepth, depth) } else if (character === '}') depth = Math.max(0, depth - 1)
|
|
194
|
+
}
|
|
195
|
+
if (functionStart && depth <= functionStart.depth) { maximumFunctionLines = Math.max(maximumFunctionLines, index - functionStart.line + 1); functionStart = null }
|
|
196
|
+
}
|
|
197
|
+
if (functionStart) maximumFunctionLines = Math.max(maximumFunctionLines, lines.length - functionStart.line)
|
|
198
|
+
return { path: file.path, fileLines: lines.length, maxNestingDepth: maximumDepth, maxFunctionLines: maximumFunctionLines, animations: (file.content.match(/@keyframes\b|\banimation(?:-name)?\s*:/g) ?? []).length, transitions: (file.content.match(/\btransition(?:-property)?\s*:/g) ?? []).length }
|
|
199
|
+
}
|
|
200
|
+
function complexityFindings(contract, metric) {
|
|
201
|
+
const checks = [['maxFileLines', 'fileLines'], ['maxNestingDepth', 'maxNestingDepth'], ['maxFunctionLines', 'maxFunctionLines'], ['animations', 'animations'], ['transitions', 'transitions']]
|
|
202
|
+
return checks.flatMap(([budgetKey, metricKey]) => contract.budgets[budgetKey] >= 0 && metric[metricKey] > contract.budgets[budgetKey] ? [finding('P1', `BUDGET-${budgetKey.toUpperCase()}`, metric.path, `${metricKey} ${metric[metricKey]} exceeds budget ${contract.budgets[budgetKey]}.`, 'complexity', true, { actual: metric[metricKey], budget: contract.budgets[budgetKey] })] : [])
|
|
203
|
+
}
|
|
204
|
+
function scanFiles(contract, files) {
|
|
205
|
+
if (!Array.isArray(files) || !files.length) throw new Error('files must be non-empty')
|
|
206
|
+
const normalized = files.map((value) => record(value, 'file'))
|
|
207
|
+
const metrics = normalized.map(complexity)
|
|
208
|
+
const findings = normalized.flatMap((file) => [...languageFindings(contract, file), ...ruleFindings(contract, file)]).concat(metrics.flatMap((metric) => complexityFindings(contract, metric)))
|
|
209
|
+
return { findings, metrics }
|
|
210
|
+
}
|
|
211
|
+
function driftState(ledger) {
|
|
212
|
+
if (!Array.isArray(ledger)) throw new Error('ledger history is required')
|
|
213
|
+
const recent = ledger.slice(-10)
|
|
214
|
+
const trailing = []
|
|
215
|
+
for (let index = recent.length - 1; index >= 0 && recent[index]?.status === 'blocked'; index -= 1) trailing.unshift(recent[index])
|
|
216
|
+
const shared = trailing.length ? trailing.map((entry) => new Set(entry.blockingRuleIds ?? [])).reduce((left, right) => new Set([...left].filter((ruleId) => right.has(ruleId)))) : new Set()
|
|
217
|
+
const repeatedRuleIds = [...shared].sort()
|
|
218
|
+
const consecutiveBlocks = repeatedRuleIds.length ? trailing.length : 0
|
|
219
|
+
const trend = consecutiveBlocks >= 3 ? 'red' : consecutiveBlocks >= 2 ? 'yellow' : 'green'
|
|
220
|
+
const blockedBlocks = recent.filter((entry) => entry.status === 'blocked').length
|
|
221
|
+
return { trend, totalBlocks: ledger.length, recentBlocks: recent.length, blockedBlocks, violationRate: recent.length ? Math.round((blockedBlocks / recent.length) * 1e3) / 10 : 0, consecutiveBlocks, repeatedRuleIds, action: trend === 'red' ? 'interrupt-and-request-human-confirmation' : trend === 'yellow' ? 'warn-and-correct-next-block' : 'continue' }
|
|
222
|
+
}
|
|
223
|
+
function checkpoint(contract, blockValue, history, trustedAstFindings) {
|
|
224
|
+
const block = record(blockValue, 'checkpoint block')
|
|
225
|
+
if (!Array.isArray(history)) throw new Error('checkpoint history is required')
|
|
226
|
+
if (!pathSafe(block.path) || typeof block.content !== 'string' || !SHA256.test(block.beforeSha256)) throw new Error('checkpoint block is invalid')
|
|
227
|
+
const scan = scanFiles(contract, [{ path: block.path, content: block.content }])
|
|
228
|
+
const astRules = contract.rules.custom.filter((rule) => rule.engine === 'ast')
|
|
229
|
+
if (astRules.length && !Array.isArray(trustedAstFindings)) scan.findings.push(finding('P1', 'ARCH-AST-LOCAL-RUNNER-REQUIRED', block.path, 'AST custom rules require the bundled trusted local checkpoint runner.', 'custom', true, { ruleIds: astRules.map((rule) => rule.id) }))
|
|
230
|
+
if (Array.isArray(trustedAstFindings)) for (const item of trustedAstFindings) scan.findings.push(record(item, 'trusted AST finding'))
|
|
231
|
+
const blocking = scan.findings.filter((item) => item.blocking)
|
|
232
|
+
const entry = { schemaVersion: LEDGER_VERSION, checkpointId: digest({ contract: digest(contract), block, trustedAstFindingsDigest: trustedAstFindings ? digest(trustedAstFindings) : null }), blockId: text(block.blockId, 'blockId'), path: block.path, contractRevision: contract.revision, beforeSha256: block.beforeSha256, afterSha256: contentDigest(block.content), status: blocking.length ? 'blocked' : 'passed', ruleIds: scan.findings.map((item) => item.ruleId).sort(), blockingRuleIds: blocking.map((item) => item.ruleId).sort(), rollbackRequired: blocking.length > 0 }
|
|
233
|
+
const drift = driftState([...history, entry])
|
|
234
|
+
if (drift.trend === 'red') entry.rollbackRequired = true
|
|
235
|
+
return { checkpoint: { status: entry.rollbackRequired ? 'blocked' : 'passed', writeAllowed: !entry.rollbackRequired, rollbackRequired: entry.rollbackRequired, findings: scan.findings, metrics: scan.metrics[0], drift, instruction: drift.trend === 'red' ? 'rollback-block-and-interrupt-aimlock' : entry.rollbackRequired ? 'rollback-block-and-correct' : 'accept-block' }, ledgerEntry: entry }
|
|
236
|
+
}
|
|
237
|
+
function capabilities() {
|
|
238
|
+
return { skill: { name: 'archguard', version: COMPILER_VERSION }, operations: OPERATIONS, operationSchemas: OPERATION_SCHEMAS, contractSchemaVersion: CONTRACT_VERSION, ledgerSchemaVersion: LEDGER_VERSION, templates: Object.keys(TEMPLATE_DEFINITIONS), executionBoundary: 'AST rules execute only through the bundled trusted local runner; remote JSON cannot supply AST findings.', nextStep: { operation: 'intake', instruction: 'Detect the project stack and lock an architecture contract before code generation.' } }
|
|
239
|
+
}
|
|
240
|
+
function requestEnvelope(request) {
|
|
241
|
+
if (!request || request.schemaVersion !== REQUEST_VERSION || typeof request.requestId !== 'string' || !OPERATIONS.includes(request.operation) || !request.input || typeof request.input !== 'object' || Array.isArray(request.input)) throw new Error('Invalid ArchGuard request envelope')
|
|
242
|
+
return request
|
|
243
|
+
}
|
|
244
|
+
function checkpointResponse(requestId, input, trustedAstFindings) {
|
|
245
|
+
if (Object.hasOwn(record(input.block, 'checkpoint block'), 'astFindings')) throw new Error('checkpoint.astFindings is not accepted from JSON callers')
|
|
246
|
+
const output = checkpoint(normalizeContract(input.contract), input.block, input.history, trustedAstFindings)
|
|
247
|
+
return response(requestId, output.checkpoint.status === 'passed' ? 'succeeded' : 'blocked', output, output.checkpoint.findings)
|
|
248
|
+
}
|
|
249
|
+
async function execute(request, trustedAstFindings) {
|
|
250
|
+
const { requestId, operation, input } = requestEnvelope(request)
|
|
251
|
+
if (operation === 'capabilities' || operation === 'help') return response(requestId, 'succeeded', capabilities())
|
|
252
|
+
if (operation === 'template-list') return response(requestId, 'succeeded', { templates: Object.entries(TEMPLATE_DEFINITIONS).map(([templateId, stack]) => ({ templateId, stack })), nextStep: { operation: 'contract-create' } })
|
|
253
|
+
if (operation === 'intake') return response(requestId, 'succeeded', { questions: [{ id: 'templateId', prompt: 'Which detected stack template must be locked?', required: true, options: Object.keys(TEMPLATE_DEFINITIONS) }, { id: 'strictStandard', prompt: 'Should standard-level findings block writes?', required: true, options: [false, true] }, { id: 'budgets', prompt: 'Confirm file, function, nesting, effect, and bundle budgets.', required: true }], project: text(input.project, 'project'), nextStep: { operation: 'contract-create' } })
|
|
254
|
+
if (operation === 'contract-create') {
|
|
255
|
+
const contract = baseContract(text(input.project, 'project'), text(input.templateId, 'templateId'))
|
|
256
|
+
const merged = input.overrides ? normalizeContract({ ...contract, ...input.overrides, stack: { ...contract.stack, ...(input.overrides.stack ?? {}) }, rules: { ...contract.rules, ...(input.overrides.rules ?? {}) }, budgets: { ...contract.budgets, ...(input.overrides.budgets ?? {}) } }) : contract
|
|
257
|
+
return response(requestId, 'succeeded', { contract: merged, digest: digest(merged), nextStep: { operation: 'checkpoint' } })
|
|
258
|
+
}
|
|
259
|
+
if (operation === 'contract-get') { const contract = normalizeContract(input.contract); return response(requestId, 'succeeded', { contract, digest: digest(contract) }) }
|
|
260
|
+
if (operation === 'contract-update') {
|
|
261
|
+
const contract = normalizeContract(input.contract)
|
|
262
|
+
if (input.expectedDigest !== digest(contract) || input.confirmation !== 'confirm-architecture-contract-update') throw new Error('Contract update authority is invalid')
|
|
263
|
+
const updated = normalizeContract({ ...contract, ...record(input.patch, 'patch'), revision: contract.revision + 1, audit: { updatedBy: text(input.actor, 'actor'), reason: text(input.reason, 'reason'), previousDigest: input.expectedDigest } })
|
|
264
|
+
return response(requestId, 'succeeded', { contract: updated, digest: digest(updated), audit: updated.audit })
|
|
265
|
+
}
|
|
266
|
+
if (operation === 'checkpoint') return checkpointResponse(requestId, input, trustedAstFindings)
|
|
267
|
+
if (operation === 'rules-scan' || operation === 'complexity-report') {
|
|
268
|
+
const scan = scanFiles(normalizeContract(input.contract), input.files)
|
|
269
|
+
const output = operation === 'rules-scan' ? { findings: scan.findings, summary: { total: scan.findings.length, blocking: scan.findings.filter((item) => item.blocking).length } } : { metrics: scan.metrics, findings: scan.findings.filter((item) => item.category === 'complexity') }
|
|
270
|
+
return response(requestId, scan.findings.some((item) => item.blocking) ? 'blocked' : 'succeeded', output, scan.findings)
|
|
271
|
+
}
|
|
272
|
+
if (operation === 'drift-status') return response(requestId, 'succeeded', { drift: driftState(input.ledger) })
|
|
273
|
+
if (!Array.isArray(input.ledger)) throw new Error('ledger is required')
|
|
274
|
+
const entries = input.ledger.filter((entry) => (!input.ruleId || entry.ruleIds?.includes(input.ruleId)) && (!input.status || entry.status === input.status))
|
|
275
|
+
return response(requestId, 'succeeded', { schemaVersion: LEDGER_VERSION, entries, total: entries.length })
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
async function run(request) {
|
|
279
|
+
return execute(request, undefined)
|
|
280
|
+
}
|
|
281
|
+
async function runTrustedLocalCheckpoint(request, trustedAstFindings) {
|
|
282
|
+
if (request?.operation !== 'checkpoint' || !Array.isArray(trustedAstFindings)) throw new Error('trusted local checkpoint authority is invalid')
|
|
283
|
+
return execute(request, trustedAstFindings.map((item) => record(item, 'trusted AST finding')))
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
export { CONTRACT_VERSION, LEDGER_VERSION, OPERATION_SCHEMAS, TEMPLATE_DEFINITIONS, run, runTrustedLocalCheckpoint }
|
package/broker.mjs
ADDED
|
@@ -0,0 +1,301 @@
|
|
|
1
|
+
import { createHash, randomUUID } from 'node:crypto'
|
|
2
|
+
import { constants } from 'node:fs'
|
|
3
|
+
import { lstat, open } from 'node:fs/promises'
|
|
4
|
+
import { resolve, win32 } from 'node:path'
|
|
5
|
+
|
|
6
|
+
export const LOOKUP_TIMEOUT_MS = 8000
|
|
7
|
+
export const CALL_TIMEOUT_MS = 120_000
|
|
8
|
+
const FEEDBACK_API_PATH = '/api/v1/telemetry/skill-usage'
|
|
9
|
+
const TOKEN_FILE_ENV = 'CLITAX_BRAIN_CLIENT_TOKEN_FILE'
|
|
10
|
+
const TOKEN_FILE_VERSION = 'member-brain.client-token-file/1.0'
|
|
11
|
+
const AUTH_SCHEME = 'BrainClient'
|
|
12
|
+
const TOKEN_FILE_MAX_BYTES = 16_384
|
|
13
|
+
const POSIX_TOKEN_FILE_MODE = 0o600
|
|
14
|
+
const WINDOWS_BROKER_DIRECTORY = ['CLI.Tax', 'broker']
|
|
15
|
+
const FEEDBACK_COMMENT_MAX = 500
|
|
16
|
+
const EVALUATION_DURATION_MAX = 86_400_000
|
|
17
|
+
const SCORE_MIN = 0
|
|
18
|
+
const SCORE_MAX = 100
|
|
19
|
+
const TOKEN_PATTERN = /^[A-Za-z0-9_-]{43}$/
|
|
20
|
+
const INVOCATION_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i
|
|
21
|
+
const DIGEST_PATTERN = /^[0-9a-f]{64}$/
|
|
22
|
+
const PROTOCOL_STATUSES = new Set(['succeeded', 'blocked', 'failed'])
|
|
23
|
+
const VALIDATION_STATES = new Set(['passed', 'failed', 'incomplete'])
|
|
24
|
+
const EVALUATION_SCHEMA = 'skill-automatic-evaluation/1.0'
|
|
25
|
+
const IDENTIFIER_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/
|
|
26
|
+
const REQUEST_SCHEMA_PATTERN = /^([A-Za-z0-9.-]+\.skill)\.request\/([0-9]+\.[0-9]+)$/
|
|
27
|
+
|
|
28
|
+
function asObject(value, label) {
|
|
29
|
+
if (!value || typeof value !== 'object' || Array.isArray(value)) {
|
|
30
|
+
throw new Error(`${label} must be an object`)
|
|
31
|
+
}
|
|
32
|
+
return value
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function requiredString(value, label) {
|
|
36
|
+
const text = typeof value === 'string' ? value.trim() : ''
|
|
37
|
+
if (!text) throw new Error(`${label} is required`)
|
|
38
|
+
return text
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function boundedInteger(value, label) {
|
|
42
|
+
if (!Number.isFinite(value) || value < 0 || !Number.isSafeInteger(value)) {
|
|
43
|
+
throw new Error(`${label} must be a non-negative safe integer`)
|
|
44
|
+
}
|
|
45
|
+
return value
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function insideWindowsDirectory(candidate, directory) {
|
|
49
|
+
const relative = win32.relative(directory, candidate)
|
|
50
|
+
return relative === '' || (!relative.startsWith('..\\') && relative !== '..' && !win32.isAbsolute(relative))
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export function brainClientTokenPath(environment, platform = process.platform) {
|
|
54
|
+
const configured = requiredString(environment[TOKEN_FILE_ENV], TOKEN_FILE_ENV)
|
|
55
|
+
if (platform !== 'win32') return resolve(configured)
|
|
56
|
+
if (!win32.isAbsolute(configured)) {
|
|
57
|
+
throw new Error('Windows Brain Client token file path must be absolute')
|
|
58
|
+
}
|
|
59
|
+
const localAppData = requiredString(environment.LOCALAPPDATA, 'LOCALAPPDATA')
|
|
60
|
+
const brokerDirectory = win32.resolve(localAppData, ...WINDOWS_BROKER_DIRECTORY)
|
|
61
|
+
const candidate = win32.resolve(configured)
|
|
62
|
+
if (!insideWindowsDirectory(candidate, brokerDirectory)) {
|
|
63
|
+
throw new Error(`Windows Brain Client token file must be inside ${brokerDirectory}`)
|
|
64
|
+
}
|
|
65
|
+
return candidate
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function assertTokenFileStatus(status, platform, currentUserId) {
|
|
69
|
+
if (!status.isFile() || status.size < 1 || status.size > TOKEN_FILE_MAX_BYTES) {
|
|
70
|
+
throw new Error('Brain Client token file must be a non-empty restricted file')
|
|
71
|
+
}
|
|
72
|
+
if (platform === 'win32') return
|
|
73
|
+
if (!Number.isInteger(currentUserId)) {
|
|
74
|
+
throw new Error('Brain Client token file ownership cannot be verified')
|
|
75
|
+
}
|
|
76
|
+
if (status.uid !== currentUserId || (status.mode & 0o777) !== POSIX_TOKEN_FILE_MODE) {
|
|
77
|
+
throw new Error('Brain Client token file must be owned by the current user with mode 0600')
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function parseTokenFile(source) {
|
|
82
|
+
let tokenFile
|
|
83
|
+
try {
|
|
84
|
+
tokenFile = asObject(JSON.parse(source), 'Brain Client token file')
|
|
85
|
+
} catch {
|
|
86
|
+
throw new Error('Brain Client token file must contain valid JSON')
|
|
87
|
+
}
|
|
88
|
+
const expectedKeys = ['authorizationScheme', 'endpoint', 'schemaVersion', 'token']
|
|
89
|
+
if (Object.keys(tokenFile).sort().join('\n') !== expectedKeys.join('\n')) {
|
|
90
|
+
throw new Error('Brain Client token file contains unknown or missing fields')
|
|
91
|
+
}
|
|
92
|
+
return tokenFile
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
export async function brainClientAuthorization(context, environment, dependencies = {}) {
|
|
96
|
+
const platform = dependencies.platform ?? process.platform
|
|
97
|
+
const tokenFilePath = brainClientTokenPath(environment, platform)
|
|
98
|
+
const inspectPath = dependencies.lstat ?? lstat
|
|
99
|
+
const openPath = dependencies.open ?? open
|
|
100
|
+
const currentUserId = platform === 'win32'
|
|
101
|
+
? null
|
|
102
|
+
: (dependencies.getuid ?? process.getuid)?.()
|
|
103
|
+
const linkStatus = await inspectPath(tokenFilePath)
|
|
104
|
+
if (linkStatus.isSymbolicLink()) throw new Error('Brain Client token file cannot be a symlink')
|
|
105
|
+
const noFollow = platform === 'win32' ? 0 : (constants.O_NOFOLLOW ?? 0)
|
|
106
|
+
const handle = await openPath(tokenFilePath, constants.O_RDONLY | noFollow)
|
|
107
|
+
try {
|
|
108
|
+
const status = await handle.stat()
|
|
109
|
+
assertTokenFileStatus(status, platform, currentUserId)
|
|
110
|
+
const tokenFile = parseTokenFile(await handle.readFile('utf8'))
|
|
111
|
+
const endpoint = new URL(requiredString(tokenFile.endpoint, 'Brain Client endpoint'))
|
|
112
|
+
if (tokenFile.schemaVersion !== TOKEN_FILE_VERSION
|
|
113
|
+
|| tokenFile.authorizationScheme !== AUTH_SCHEME
|
|
114
|
+
|| endpoint.origin !== new URL(context.endpoint).origin
|
|
115
|
+
|| endpoint.pathname !== FEEDBACK_API_PATH || endpoint.search || endpoint.hash
|
|
116
|
+
|| endpoint.username || endpoint.password
|
|
117
|
+
|| !TOKEN_PATTERN.test(tokenFile.token)) {
|
|
118
|
+
throw new Error('Brain Client token file authority is invalid')
|
|
119
|
+
}
|
|
120
|
+
return `${AUTH_SCHEME} ${tokenFile.token}`
|
|
121
|
+
} finally {
|
|
122
|
+
await handle.close()
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
function canonicalJson(value) {
|
|
127
|
+
if (value === null || typeof value === 'string' || typeof value === 'boolean') {
|
|
128
|
+
return JSON.stringify(value)
|
|
129
|
+
}
|
|
130
|
+
if (typeof value === 'number') {
|
|
131
|
+
if (!Number.isFinite(value)) throw new Error('automatic evaluation contains a non-finite number')
|
|
132
|
+
return JSON.stringify(value)
|
|
133
|
+
}
|
|
134
|
+
if (Array.isArray(value)) return `[${value.map(canonicalJson).join(',')}]`
|
|
135
|
+
const record = asObject(value, 'automatic evaluation')
|
|
136
|
+
return `{${Object.keys(record).sort().map((key) => (
|
|
137
|
+
`${JSON.stringify(key)}:${canonicalJson(record[key])}`
|
|
138
|
+
)).join(',')}}`
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
function expectedResponseSchema(requestSchema) {
|
|
142
|
+
const matched = requiredString(requestSchema, 'skill request schemaVersion')
|
|
143
|
+
.match(REQUEST_SCHEMA_PATTERN)
|
|
144
|
+
if (!matched) throw new Error('skill request schemaVersion is invalid')
|
|
145
|
+
return `${matched[1]}.response/${matched[2]}`
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
function protocolResponse(protocolValue, requestEnvelope) {
|
|
149
|
+
const protocol = asObject(protocolValue, 'skill protocol response')
|
|
150
|
+
const responseSchema = expectedResponseSchema(requestEnvelope.schemaVersion)
|
|
151
|
+
if (protocol.schemaVersion !== responseSchema) {
|
|
152
|
+
throw new Error('skill protocol response schemaVersion does not match the request')
|
|
153
|
+
}
|
|
154
|
+
if (protocol.requestId !== requestEnvelope.requestId) {
|
|
155
|
+
throw new Error('skill protocol response requestId does not match the request')
|
|
156
|
+
}
|
|
157
|
+
const status = requiredString(protocol.status, 'skill protocol status')
|
|
158
|
+
if (!PROTOCOL_STATUSES.has(status)) {
|
|
159
|
+
throw new Error('skill protocol status must be succeeded, blocked, or failed')
|
|
160
|
+
}
|
|
161
|
+
return { protocol, responseSchema, status }
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
export function authoritativeEvaluation(value, expected) {
|
|
165
|
+
const evaluation = asObject(value, 'server automatic evaluation')
|
|
166
|
+
const expectedKeys = [
|
|
167
|
+
'digest', 'durationMs', 'findingCount', 'operation', 'p0Count', 'p1Count', 'p2Count',
|
|
168
|
+
'requestId', 'responseSchemaVersion', 'schemaVersion', 'score', 'status', 'userComment',
|
|
169
|
+
'validation',
|
|
170
|
+
]
|
|
171
|
+
if (Object.keys(evaluation).sort().join('\n') !== expectedKeys.join('\n')) {
|
|
172
|
+
throw new Error('server automatic evaluation contains unknown or missing fields')
|
|
173
|
+
}
|
|
174
|
+
if (evaluation.schemaVersion !== EVALUATION_SCHEMA
|
|
175
|
+
|| evaluation.operation !== expected.operation
|
|
176
|
+
|| evaluation.requestId !== expected.requestId
|
|
177
|
+
|| evaluation.responseSchemaVersion !== expected.responseSchema
|
|
178
|
+
|| evaluation.status !== expected.status
|
|
179
|
+
|| !VALIDATION_STATES.has(evaluation.validation)
|
|
180
|
+
|| typeof evaluation.userComment !== 'string' || !evaluation.userComment.trim()
|
|
181
|
+
|| Buffer.byteLength(evaluation.userComment, 'utf8') > FEEDBACK_COMMENT_MAX
|
|
182
|
+
|| typeof evaluation.digest !== 'string' || !DIGEST_PATTERN.test(evaluation.digest)) {
|
|
183
|
+
throw new Error('server automatic evaluation authority is invalid')
|
|
184
|
+
}
|
|
185
|
+
for (const field of ['durationMs', 'findingCount', 'p0Count', 'p1Count', 'p2Count', 'score']) {
|
|
186
|
+
boundedInteger(evaluation[field], `server automatic evaluation ${field}`)
|
|
187
|
+
}
|
|
188
|
+
if (evaluation.score < SCORE_MIN || evaluation.score > SCORE_MAX
|
|
189
|
+
|| evaluation.durationMs > EVALUATION_DURATION_MAX
|
|
190
|
+
|| evaluation.findingCount < evaluation.p0Count + evaluation.p1Count + evaluation.p2Count) {
|
|
191
|
+
throw new Error('server automatic evaluation bounds are invalid')
|
|
192
|
+
}
|
|
193
|
+
if ((evaluation.status !== 'succeeded' || evaluation.validation !== 'passed'
|
|
194
|
+
|| evaluation.p0Count > 0 || evaluation.p1Count > 0) && evaluation.score >= 60) {
|
|
195
|
+
throw new Error('server automatic evaluation cannot report a positive score')
|
|
196
|
+
}
|
|
197
|
+
const { digest, ...core } = evaluation
|
|
198
|
+
const actualDigest = createHash('sha256').update(canonicalJson(core)).digest('hex')
|
|
199
|
+
if (digest !== actualDigest) throw new Error('server automatic evaluation digest is invalid')
|
|
200
|
+
return evaluation
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
async function responsePayload(response, label) {
|
|
204
|
+
try {
|
|
205
|
+
return asObject(await response.json(), label)
|
|
206
|
+
} catch {
|
|
207
|
+
throw new Error(`${label} is not valid JSON (HTTP ${response.status})`)
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
function invocationRequest(context, operation, input) {
|
|
212
|
+
const normalizedOperation = requiredString(operation, 'skill operation')
|
|
213
|
+
if (!IDENTIFIER_PATTERN.test(normalizedOperation)) {
|
|
214
|
+
throw new Error('skill operation is invalid')
|
|
215
|
+
}
|
|
216
|
+
expectedResponseSchema(context.schemaVersion)
|
|
217
|
+
return {
|
|
218
|
+
schemaVersion: context.schemaVersion,
|
|
219
|
+
requestId: `${context.runtimeCode}-${randomUUID()}`,
|
|
220
|
+
operation: normalizedOperation,
|
|
221
|
+
input: asObject(input, 'skill operation input'),
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
export async function invokeOfficialSkill(context, operation, input, dependencies) {
|
|
226
|
+
const environment = asObject(dependencies.environment, 'broker environment')
|
|
227
|
+
if (typeof dependencies.request !== 'function') {
|
|
228
|
+
throw new Error('broker request dependency is required')
|
|
229
|
+
}
|
|
230
|
+
const authorization = await brainClientAuthorization(context, environment, dependencies.credentialAccess)
|
|
231
|
+
const requestEnvelope = invocationRequest(context, operation, input)
|
|
232
|
+
let response
|
|
233
|
+
try {
|
|
234
|
+
response = await dependencies.request(context.endpoint, {
|
|
235
|
+
method: 'POST',
|
|
236
|
+
headers: { 'Content-Type': 'application/json', Authorization: authorization },
|
|
237
|
+
body: JSON.stringify({ input: requestEnvelope }),
|
|
238
|
+
signal: AbortSignal.timeout(CALL_TIMEOUT_MS),
|
|
239
|
+
})
|
|
240
|
+
} catch {
|
|
241
|
+
throw new Error(`${context.displayName} ${operation} invocation failed`)
|
|
242
|
+
}
|
|
243
|
+
const payload = await responsePayload(response, `${context.displayName} ${operation} response`)
|
|
244
|
+
if (!response.ok || payload.ok !== true) {
|
|
245
|
+
throw new Error(`${context.displayName} ${operation} failed: HTTP ${response.status}`)
|
|
246
|
+
}
|
|
247
|
+
const invocationId = payload.feedbackInvocationId
|
|
248
|
+
if (typeof invocationId !== 'string' || !INVOCATION_PATTERN.test(invocationId)) {
|
|
249
|
+
throw new Error(`${context.displayName} ${operation} response is missing a valid feedbackInvocationId`)
|
|
250
|
+
}
|
|
251
|
+
const feedbackReceiptId = payload.feedbackReceiptId
|
|
252
|
+
const feedbackRequestId = payload.feedbackRequestId
|
|
253
|
+
if (typeof feedbackReceiptId !== 'string' || !INVOCATION_PATTERN.test(feedbackReceiptId)
|
|
254
|
+
|| feedbackRequestId !== `automatic-${invocationId}`) {
|
|
255
|
+
throw new Error(`${context.displayName} ${operation} response is missing a committed feedback receipt`)
|
|
256
|
+
}
|
|
257
|
+
const protocolAuthority = protocolResponse(payload.output, requestEnvelope)
|
|
258
|
+
const evaluation = authoritativeEvaluation(payload.feedbackEvaluation, {
|
|
259
|
+
operation: requestEnvelope.operation,
|
|
260
|
+
requestId: requestEnvelope.requestId,
|
|
261
|
+
responseSchema: protocolAuthority.responseSchema,
|
|
262
|
+
status: protocolAuthority.status,
|
|
263
|
+
})
|
|
264
|
+
const feedback = {
|
|
265
|
+
id: feedbackReceiptId,
|
|
266
|
+
requestId: feedbackRequestId,
|
|
267
|
+
duplicated: false,
|
|
268
|
+
}
|
|
269
|
+
return { response: payload, invocationId, evaluation, feedback }
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
export async function callOfficialSkill(context, operation, input, dependencies) {
|
|
273
|
+
return (await invokeOfficialSkill(context, operation, input, dependencies)).response
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
export function invokeCommandInput(args) {
|
|
277
|
+
const operation = requiredString(args[1], 'skill operation')
|
|
278
|
+
const source = args.slice(2).join(' ').trim()
|
|
279
|
+
if (!source) return { operation, input: {} }
|
|
280
|
+
try {
|
|
281
|
+
return { operation, input: asObject(JSON.parse(source), 'skill operation input') }
|
|
282
|
+
} catch {
|
|
283
|
+
throw new Error('skill operation input must be a JSON object')
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
export function brokerCommandInput(source) {
|
|
288
|
+
let parsed
|
|
289
|
+
try {
|
|
290
|
+
parsed = asObject(JSON.parse(source), 'broker request')
|
|
291
|
+
} catch {
|
|
292
|
+
throw new Error('broker request must be a JSON object')
|
|
293
|
+
}
|
|
294
|
+
if (Object.keys(parsed).some((key) => !['operation', 'input'].includes(key))) {
|
|
295
|
+
throw new Error('broker request contains unknown fields')
|
|
296
|
+
}
|
|
297
|
+
return {
|
|
298
|
+
operation: requiredString(parsed.operation, 'skill operation'),
|
|
299
|
+
input: asObject(parsed.input, 'skill operation input'),
|
|
300
|
+
}
|
|
301
|
+
}
|