@vegastack/vegafactory 0.19.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 +417 -0
- package/dist/dashboard-artifact.json +21240 -0
- package/dist/index.js +22752 -0
- package/dist/run-wrapper.js +377 -0
- package/package.json +46 -0
- package/skill/dev-architect/SKILL.md +106 -0
- package/skill/dev-architect/agents/openai.yaml +4 -0
- package/skill/dev-architect/references/ai-agents.md +96 -0
- package/skill/dev-architect/references/conventions.md +113 -0
- package/skill/dev-architect/references/data.md +89 -0
- package/skill/dev-architect/references/infra.md +99 -0
- package/skill/dev-architect/references/mobile.md +75 -0
- package/skill/dev-architect/references/pinned-facts.md +133 -0
- package/skill/dev-architect/references/principles.md +117 -0
- package/skill/dev-architect/references/security.md +90 -0
- package/skill/dev-architect/references/stack.md +38 -0
- package/skill/dev-architect/references/web.md +102 -0
- package/skill/dev-architect/refresh/REFRESH.md +31 -0
- package/skill/dev-architect/refresh/sources.json +239 -0
- package/skill/dev-chronicle/SKILL.md +52 -0
- package/skill/dev-chronicle/agents/openai.yaml +4 -0
- package/skill/dev-chronicle/references/conventions.md +113 -0
- package/skill/dev-chronicle/references/styles.md +75 -0
- package/skill/dev-chronicle/refresh/REFRESH.md +3 -0
- package/skill/dev-chronicle/refresh/sources.json +6 -0
- package/skill/dev-debug/SKILL.md +43 -0
- package/skill/dev-debug/agents/openai.yaml +4 -0
- package/skill/dev-debug/references/conventions.md +113 -0
- package/skill/dev-debug/references/loop-ladder.md +20 -0
- package/skill/dev-debug/refresh/REFRESH.md +3 -0
- package/skill/dev-debug/refresh/sources.json +6 -0
- package/skill/dev-implement/SKILL.md +81 -0
- package/skill/dev-implement/agents/openai.yaml +4 -0
- package/skill/dev-implement/assets/workflows/implement-children.js +12 -0
- package/skill/dev-implement/references/ask-route.md +80 -0
- package/skill/dev-implement/references/changelog-and-chronicle.md +21 -0
- package/skill/dev-implement/references/conventions.md +113 -0
- package/skill/dev-implement/references/ledger-and-resume.md +52 -0
- package/skill/dev-implement/references/parallel-children.md +35 -0
- package/skill/dev-implement/references/worktrees.md +63 -0
- package/skill/dev-implement/refresh/REFRESH.md +3 -0
- package/skill/dev-implement/refresh/sources.json +6 -0
- package/skill/dev-implement/scripts/children.mjs +479 -0
- package/skill/dev-implement/scripts/effective-policy.mjs +694 -0
- package/skill/dev-implement/scripts/evidence-check.mjs +107 -0
- package/skill/dev-implement/scripts/evidence-upload.mjs +181 -0
- package/skill/dev-implement/scripts/learning.mjs +41 -0
- package/skill/dev-implement/scripts/lib/approval.mjs +1182 -0
- package/skill/dev-implement/scripts/lib/gh.mjs +101 -0
- package/skill/dev-implement/scripts/preflight.mjs +153 -0
- package/skill/dev-implement/scripts/questions.mjs +381 -0
- package/skill/dev-implement/scripts/reclaim.mjs +116 -0
- package/skill/dev-implement/scripts/recovery.mjs +213 -0
- package/skill/dev-implement/scripts/worktree.mjs +991 -0
- package/skill/dev-intake/SKILL.md +80 -0
- package/skill/dev-intake/agents/openai.yaml +4 -0
- package/skill/dev-intake/references/ask-route.md +80 -0
- package/skill/dev-intake/references/brief-template.md +89 -0
- package/skill/dev-intake/references/conventions.md +113 -0
- package/skill/dev-intake/refresh/REFRESH.md +3 -0
- package/skill/dev-intake/refresh/sources.json +6 -0
- package/skill/dev-intake/scripts/brief-lint.mjs +87 -0
- package/skill/dev-intake/scripts/lib/approval.mjs +1182 -0
- package/skill/dev-intake/scripts/questions.mjs +381 -0
- package/skill/dev-plan/SKILL.md +54 -0
- package/skill/dev-plan/agents/openai.yaml +4 -0
- package/skill/dev-plan/references/ask-route.md +80 -0
- package/skill/dev-plan/references/conventions.md +113 -0
- package/skill/dev-plan/references/plan-format.md +82 -0
- package/skill/dev-plan/refresh/REFRESH.md +3 -0
- package/skill/dev-plan/refresh/sources.json +6 -0
- package/skill/dev-plan/scripts/lib/approval.mjs +1182 -0
- package/skill/dev-plan/scripts/plan-lint.mjs +319 -0
- package/skill/dev-plan/scripts/questions.mjs +381 -0
- package/skill/dev-review/SKILL.md +76 -0
- package/skill/dev-review/agents/openai.yaml +4 -0
- package/skill/dev-review/assets/review-known-patterns.md.template +32 -0
- package/skill/dev-review/references/conventions.md +113 -0
- package/skill/dev-review/references/cross-agent.md +39 -0
- package/skill/dev-review/references/dispatch-prompts.md +121 -0
- package/skill/dev-review/references/security-axis.md +45 -0
- package/skill/dev-review/refresh/REFRESH.md +5 -0
- package/skill/dev-review/refresh/sources.json +6 -0
- package/skill/dev-setup/SKILL.md +113 -0
- package/skill/dev-setup/agents/openai.yaml +4 -0
- package/skill/dev-setup/assets/agents-section.md.template +24 -0
- package/skill/dev-setup/assets/dev-profile.md.template +102 -0
- package/skill/dev-setup/assets/factory-board.yml.template +162 -0
- package/skill/dev-setup/assets/hooks/decision-nudge.mjs +30 -0
- package/skill/dev-setup/assets/hooks/prompt-skill-mention.mjs +29 -0
- package/skill/dev-setup/assets/hooks/session-end.mjs +14 -0
- package/skill/dev-setup/assets/hooks/session-start.mjs +178 -0
- package/skill/dev-setup/assets/hooks/ship-guard.mjs +738 -0
- package/skill/dev-setup/assets/hooks/skill-activated.mjs +47 -0
- package/skill/dev-setup/assets/hooks/stop-heartbeat.mjs +14 -0
- package/skill/dev-setup/assets/workflows/implement-children.js +12 -0
- package/skill/dev-setup/references/ask-route.md +80 -0
- package/skill/dev-setup/references/conventions.md +113 -0
- package/skill/dev-setup/references/github-app.md +286 -0
- package/skill/dev-setup/references/harness-facts.md +169 -0
- package/skill/dev-setup/references/stack-playbooks.md +124 -0
- package/skill/dev-setup/refresh/REFRESH.md +13 -0
- package/skill/dev-setup/refresh/sources.json +602 -0
- package/skill/dev-setup/scripts/effective-policy.mjs +694 -0
- package/skill/dev-setup/scripts/questions.mjs +381 -0
- package/skill/dev-setup/scripts/ship-policy.mjs +195 -0
- package/skill/dev-ship/SKILL.md +56 -0
- package/skill/dev-ship/agents/openai.yaml +4 -0
- package/skill/dev-ship/references/conventions.md +113 -0
- package/skill/dev-ship/references/runbook.md +72 -0
- package/skill/dev-ship/refresh/REFRESH.md +3 -0
- package/skill/dev-ship/refresh/sources.json +6 -0
- package/skill/dev-ship/scripts/lib/approval.mjs +1182 -0
- package/skill/dev-ship/scripts/ship-gate.mjs +485 -0
- package/skill/dev-status/SKILL.md +56 -0
- package/skill/dev-status/agents/openai.yaml +4 -0
- package/skill/dev-status/references/conventions.md +113 -0
- package/skill/dev-status/refresh/REFRESH.md +3 -0
- package/skill/dev-status/refresh/sources.json +6 -0
- package/skill/dev-status/scripts/effective-policy.mjs +694 -0
- package/skill/dev-status/scripts/status.mjs +311 -0
- package/skill/skill-maintainer/SKILL.md +82 -0
- package/skill/skill-maintainer/agents/openai.yaml +4 -0
- package/skill/skill-maintainer/references/release-ops.md +61 -0
- package/skill/skill-maintainer/references/standards.md +137 -0
- package/skill/skill-maintainer/refresh/REFRESH.md +40 -0
- package/skill/skill-maintainer/refresh/sources.json +103 -0
- package/skill/skill-scan/SKILL.md +59 -0
- package/skill/skill-scan/agents/openai.yaml +4 -0
- package/skill/skill-scan/refresh/REFRESH.md +18 -0
- package/skill/skill-scan/refresh/sources.json +133 -0
- package/skill/skill-scan/scripts/lib/skillspector.mjs +226 -0
- package/skill/skill-scan/scripts/skill-scan.mjs +1002 -0
- package/skill/skillify/SKILL.md +108 -0
- package/skill/skillify/agents/openai.yaml +4 -0
- package/skill/skillify/assets/templates/README.md.template +26 -0
- package/skill/skillify/assets/templates/REFRESH.md.template +16 -0
- package/skill/skillify/assets/templates/SKILL.md.template +27 -0
- package/skill/skillify/assets/templates/evals.json.template +12 -0
- package/skill/skillify/assets/templates/openai.yaml.template +4 -0
- package/skill/skillify/assets/templates/skill.test.ts.template +27 -0
- package/skill/skillify/assets/templates/sources.json.template +6 -0
- package/skill/skillify/assets/templates/trigger-queries.json.template +1 -0
- package/skill/skillify/references/authoring.md +103 -0
- package/skill/skillify/references/eval-playbook.md +112 -0
- package/skill/skillify/refresh/REFRESH.md +23 -0
- package/skill/skillify/refresh/sources.json +6 -0
- package/skill/skillify/scripts/scaffold-skill.mjs +334 -0
- package/skill/skillify/scripts/trigger-check.mjs +209 -0
- package/skill/vegafactory-setup/SKILL.md +77 -0
- package/skill/vegafactory-setup/agents/openai.yaml +4 -0
- package/skill/vegafactory-setup/assets/control-room/boards.md.template +17 -0
- package/skill/vegafactory-setup/assets/control-room/decisions.md.template +5 -0
- package/skill/vegafactory-setup/assets/control-room/group.md.template +44 -0
- package/skill/vegafactory-setup/assets/control-room/onboarding/dispatcher-box.md.template +86 -0
- package/skill/vegafactory-setup/assets/control-room/onboarding/new-repo.md.template +13 -0
- package/skill/vegafactory-setup/assets/control-room/onboarding/new-teammate.md.template +12 -0
- package/skill/vegafactory-setup/assets/control-room/org.md.template +60 -0
- package/skill/vegafactory-setup/assets/control-room/people.csv.template +2 -0
- package/skill/vegafactory-setup/assets/control-room/repos.md.template +7 -0
- package/skill/vegafactory-setup/assets/control-room/rules/CODEOWNERS.template +6 -0
- package/skill/vegafactory-setup/assets/control-room/rules/README.md.template +9 -0
- package/skill/vegafactory-setup/assets/control-room/rules/stats-privacy.md.template +44 -0
- package/skill/vegafactory-setup/assets/control-room/templates/README.md.template +9 -0
- package/skill/vegafactory-setup/references/control-room.md +149 -0
- package/skill/vegafactory-setup/references/conventions.md +113 -0
- package/skill/vegafactory-setup/refresh/REFRESH.md +5 -0
- package/skill/vegafactory-setup/refresh/sources.json +6 -0
- package/skill-integrity.json +252 -0
|
@@ -0,0 +1,694 @@
|
|
|
1
|
+
// Canonical, dependency-free policy and authority resolver. Resolution is pure; the explicit
|
|
2
|
+
// snapshot read adapters at the end read verified Git objects and never write or fetch.
|
|
3
|
+
// Markdown is authored state. Callers supply verified identity/registry/snapshot context, never
|
|
4
|
+
// a login claimed in request text. This is a cooperative trusted-host boundary, not authentication.
|
|
5
|
+
import { createHash } from 'node:crypto'
|
|
6
|
+
import { execFileSync } from 'node:child_process'
|
|
7
|
+
import { lstatSync, readFileSync } from 'node:fs'
|
|
8
|
+
import { isAbsolute, join } from 'node:path'
|
|
9
|
+
|
|
10
|
+
const own = (value, key) => Object.hasOwn(value, key)
|
|
11
|
+
const object = value => value !== null && typeof value === 'object' && !Array.isArray(value)
|
|
12
|
+
const forbidden = new Set(['__proto__', 'prototype', 'constructor'])
|
|
13
|
+
const stages = ['intake', 'plan', 'implement', 'review', 'status', 'chronicle']
|
|
14
|
+
const enums = {
|
|
15
|
+
stats: ['on', 'off', 'inherit'], 'stats-people': ['on', 'off'], 'stats-override': ['allowed', 'locked'],
|
|
16
|
+
review: ['subagent', 'cross-agent-risky', 'cross-agent'], tests: ['required', 'logic-only', 'best-effort', 'none'],
|
|
17
|
+
changelog: ['changesets', 'keep-a-changelog', 'pubspec+changelog', 'none'], chronicle: ['on', 'off'],
|
|
18
|
+
merge: ['rebase', 'squash', 'merge'], dispatch: ['off', 'local'], 'provider-mode': ['subscription-only'],
|
|
19
|
+
learning: ['normal-work', 'off'], 'learning-adoption': ['scoped-reversible', 'propose-only'],
|
|
20
|
+
'stats-export': ['off', 'non-attributed', 'attributed'],
|
|
21
|
+
}
|
|
22
|
+
const lockable = new Set(['stats', 'stats-export', 'gates', 'tests', 'review', 'provider-mode', 'learning', 'learning-adoption'])
|
|
23
|
+
const ordinary = new Set([...Object.keys(enums), 'operators', 'harness-policy', 'gates', 'branch', 'labels', 'control-room', 'sync-max-age', 'workflow-labels', 'stats-local-retention-days', 'stats-shared-retention-months', 'stats-spool-warning-mib'])
|
|
24
|
+
const loginPattern = /^[a-z\d](?:[a-z\d-]{0,37}[a-z\d])?$/i
|
|
25
|
+
const groupPattern = /^[a-z0-9][a-z0-9-]{0,63}$/
|
|
26
|
+
const repoPattern = /^[a-z\d][a-z\d-]*\/[a-z\d_.-]+$/i
|
|
27
|
+
const shaPattern = /^[a-f0-9]{40}$/
|
|
28
|
+
const digestPattern = /^[a-f0-9]{64}$/
|
|
29
|
+
const uuidPattern = /^[a-f0-9]{8}-[a-f0-9]{4}-[1-8][a-f0-9]{3}-[89ab][a-f0-9]{3}-[a-f0-9]{12}$/i
|
|
30
|
+
|
|
31
|
+
function sorted(value) {
|
|
32
|
+
if (Array.isArray(value)) return value.map(sorted)
|
|
33
|
+
if (!object(value)) return value
|
|
34
|
+
return Object.fromEntries(Object.keys(value).sort().map(key => [key, sorted(value[key])]))
|
|
35
|
+
}
|
|
36
|
+
export const policyHash = value => createHash('sha256').update(typeof value === 'string' ? value : JSON.stringify(sorted(value))).digest('hex')
|
|
37
|
+
function safeTree(value) {
|
|
38
|
+
if (!value || typeof value !== 'object') return true
|
|
39
|
+
return Object.entries(value).every(([key, child]) => !forbidden.has(key) && safeTree(child))
|
|
40
|
+
}
|
|
41
|
+
const same = (a, b) => JSON.stringify(sorted(a)) === JSON.stringify(sorted(b))
|
|
42
|
+
const strings = (value, pattern) => Array.isArray(value) && value.every(item => typeof item === 'string' && pattern.test(item) && !forbidden.has(item))
|
|
43
|
+
const unique = values => [...new Set(values)]
|
|
44
|
+
|
|
45
|
+
// JSON.parse accepts duplicate object keys. Authority cannot choose the last self-grant, so
|
|
46
|
+
// validate the already syntax-checked token stream before accepting the parsed document.
|
|
47
|
+
function policyJson(text) {
|
|
48
|
+
const parsed = JSON.parse(text)
|
|
49
|
+
const tokens = text.match(/"(?:\\.|[^"\\])*"|[{}\[\],:]|true|false|null|-?\d+(?:\.\d+)?(?:[eE][+-]?\d+)?/g) ?? []
|
|
50
|
+
let at = 0
|
|
51
|
+
function value(depth = 0) {
|
|
52
|
+
if (depth > 64) throw new Error('policy nesting exceeds 64 levels')
|
|
53
|
+
const token = tokens[at++]
|
|
54
|
+
if (token === '{') {
|
|
55
|
+
const keys = new Set()
|
|
56
|
+
while (tokens[at] !== '}') {
|
|
57
|
+
const key = JSON.parse(tokens[at++])
|
|
58
|
+
if (keys.has(key) || forbidden.has(key)) throw new Error('duplicate or prototype policy key')
|
|
59
|
+
keys.add(key); at++; value(depth + 1)
|
|
60
|
+
if (tokens[at] === ',') at++
|
|
61
|
+
}
|
|
62
|
+
at++
|
|
63
|
+
} else if (token === '[') {
|
|
64
|
+
while (tokens[at] !== ']') { value(depth + 1); if (tokens[at] === ',') at++ }
|
|
65
|
+
at++
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
value()
|
|
69
|
+
return parsed
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** @typedef {'needsOperator'|'needsPlan'|'ready'|'working'|'forOperator'} State */
|
|
73
|
+
/** @typedef {Record<State,string>} LabelMap */
|
|
74
|
+
/** @type {LabelMap} */
|
|
75
|
+
export const DEFAULT_LABELS = Object.freeze({ needsOperator: 'needs-operator', needsPlan: 'needs-plan', ready: 'ready', working: 'working', forOperator: 'for-operator' })
|
|
76
|
+
export const WORKFLOW_STATES = Object.freeze(Object.keys(DEFAULT_LABELS))
|
|
77
|
+
|
|
78
|
+
/** @param {unknown} value @returns {LabelMap} */
|
|
79
|
+
export function resolveLabels(value) {
|
|
80
|
+
if (value === undefined) return { ...DEFAULT_LABELS }
|
|
81
|
+
if (typeof value === 'string' || Array.isArray(value)) {
|
|
82
|
+
const names = typeof value === 'string' ? value.trim().split(/[,\s]+/) : value
|
|
83
|
+
if (!names.length || names.some(name => typeof name !== 'string' || !name.trim())
|
|
84
|
+
|| new Set(names).size !== names.length || !Object.values(DEFAULT_LABELS).every(name => names.includes(name))) {
|
|
85
|
+
throw new Error('ambiguous legacy labels: preview an explicit workflow-labels semantic mapping; no labels or board options changed')
|
|
86
|
+
}
|
|
87
|
+
return { ...DEFAULT_LABELS }
|
|
88
|
+
}
|
|
89
|
+
if (!object(value) || Object.keys(value).length !== WORKFLOW_STATES.length
|
|
90
|
+
|| !WORKFLOW_STATES.every(key => own(value, key) && typeof value[key] === 'string' && value[key].trim() === value[key]
|
|
91
|
+
&& value[key].length > 0 && value[key].length <= 50 && !/[\x00-\x1f\x7f]/.test(value[key]))
|
|
92
|
+
|| new Set(Object.values(value).map(name => name.toLowerCase())).size !== WORKFLOW_STATES.length) {
|
|
93
|
+
throw new Error('workflow-labels requires exactly five semantic keys with distinct nonempty label names')
|
|
94
|
+
}
|
|
95
|
+
return Object.fromEntries(WORKFLOW_STATES.map(key => [key, value[key]]))
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/** @param {string[]} labels @param {LabelMap} map @returns {{state:State|null,blocks:string[]}} */
|
|
99
|
+
export function resolveState(labels, map) {
|
|
100
|
+
if (map === undefined || map === null) return { state: null, blocks: ['workflow label map unavailable'] }
|
|
101
|
+
try { map = resolveLabels(map) } catch (error) { return { state: null, blocks: [error.message] } }
|
|
102
|
+
if (!Array.isArray(labels) || labels.some(label => typeof label !== 'string')) return { state: null, blocks: ['unreadable issue labels'] }
|
|
103
|
+
const states = WORKFLOW_STATES.filter(key => labels.includes(map[key]))
|
|
104
|
+
return states.length === 1 ? { state: states[0], blocks: [] }
|
|
105
|
+
: { state: null, blocks: [states.length ? 'conflicting state labels: ' + states.map(key => map[key]).join(', ') : 'no known workflow state label'] }
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
// Resolve just the local label contract for read-only profile tooling. Runtime admission
|
|
109
|
+
// supplies the complete configured policy separately; this helper grants no authority.
|
|
110
|
+
export function readWorkflowLabels(text = '') {
|
|
111
|
+
const layer = parsePolicy(text)
|
|
112
|
+
if (layer.blocks.length) throw new Error(layer.blocks.join('; '))
|
|
113
|
+
return workflowLabelsFromValues(layer.values)
|
|
114
|
+
}
|
|
115
|
+
function workflowLabelsFromValues(values) {
|
|
116
|
+
const map = resolveLabels(values['workflow-labels'] ?? values.labels)
|
|
117
|
+
if (values['workflow-labels'] !== undefined && values.labels !== undefined && !same(map, resolveLabels(values.labels))) throw new Error('labels and workflow-labels disagree; explicit migration required')
|
|
118
|
+
return map
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
export const labelsDigest = labels => policyHash([...new Set(labels)].sort())
|
|
122
|
+
|
|
123
|
+
// Presentation-only preview; callers obtain an explicit mapping before writing any profile.
|
|
124
|
+
export function previewLabelMigration(value, proposed) {
|
|
125
|
+
return { oldNames: typeof value === 'string' ? value.trim().split(/[,\s]+/) : value ?? [], proposed: proposed === undefined ? null : resolveLabels(proposed), writes: false }
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
function parseStage(value) {
|
|
129
|
+
const parts = value.trim().split(/\s+/)
|
|
130
|
+
return parts.length === 3 && ['claude', 'codex'].includes(parts[0]) && parts.every(Boolean)
|
|
131
|
+
? { harness: parts[0], model: parts[1], effort: parts[2] } : null
|
|
132
|
+
}
|
|
133
|
+
function knobValue(key, text) {
|
|
134
|
+
if (enums[key]) return enums[key].includes(text) ? text : undefined
|
|
135
|
+
if (key === 'gates') return /^[123]$/.test(text) ? Number(text) : undefined
|
|
136
|
+
if (key === 'operators') return text && strings(text.split(/[,\s]+/), loginPattern) ? unique(text.toLowerCase().split(/[,\s]+/)) : undefined
|
|
137
|
+
if (key === 'sync-max-age') {
|
|
138
|
+
const m = /^(\d+)([smh])$/.exec(text)
|
|
139
|
+
const seconds = m ? Number(m[1]) * ({ s: 1, m: 60, h: 3600 }[m[2]]) : 0
|
|
140
|
+
return Number.isSafeInteger(seconds) && seconds > 0 ? seconds : undefined
|
|
141
|
+
}
|
|
142
|
+
if (key.startsWith('stats-') && !enums[key]) return /^[1-9]\d*$/.test(text) && Number.isSafeInteger(Number(text)) ? Number(text) : undefined
|
|
143
|
+
if (key === 'control-room') return text === 'none' || /^[a-z\d][a-z\d-]*\/[a-z\d_.-]+(?:#[a-z\d-]+)?(?:@[a-f\d]{7,40})?$/i.test(text) ? text : undefined
|
|
144
|
+
if (key === 'branch') return text && /^[a-z\d_/-]+$/i.test(text.replace(/<(?:type|issue|slug)>/g, 'value')) ? text : undefined
|
|
145
|
+
if (key === 'labels') return text && text.split(/[,\s]+/).every(label => /^[\w-]+$/.test(label)) ? text.split(/[,\s]+/) : undefined
|
|
146
|
+
if (key === 'workflow-labels') {
|
|
147
|
+
try { return resolveLabels(policyJson(text)) } catch { return undefined }
|
|
148
|
+
}
|
|
149
|
+
return undefined
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
export function parsePolicy(text = '', scope = 'repo') {
|
|
153
|
+
const layer = { scope, text, schemaVersion: 1, values: {}, extensions: {}, authority: null, blocks: [], lines: [] }
|
|
154
|
+
if (typeof text !== 'string' || !['org', 'group', 'repo'].includes(scope)) {
|
|
155
|
+
layer.blocks.push('policy layer needs Markdown and a known scope'); return layer
|
|
156
|
+
}
|
|
157
|
+
const seen = new Set()
|
|
158
|
+
let fence = null, policyLines = null
|
|
159
|
+
for (const line of text.split(/\r?\n/)) {
|
|
160
|
+
const boundary = /^\s{0,3}(`{3,}|~{3,})(.*)$/.exec(line)
|
|
161
|
+
if (boundary) {
|
|
162
|
+
if (!fence) { fence = boundary[1]; policyLines = boundary[2].trim() === 'vsk-policy' ? [] : null }
|
|
163
|
+
else if (boundary[1][0] === fence[0] && boundary[1].length >= fence.length && !boundary[2].trim()) {
|
|
164
|
+
if (policyLines) {
|
|
165
|
+
if (layer.authority) layer.blocks.push('duplicate vsk-policy block')
|
|
166
|
+
try {
|
|
167
|
+
const parsed = policyJson(policyLines.join('\n'))
|
|
168
|
+
if (!object(parsed) || !safeTree(parsed) || parsed.schemaVersion !== 2) layer.blocks.push('invalid vsk-policy schema or prototype key')
|
|
169
|
+
else if (scope !== 'org') layer.blocks.push('authority blocks belong only to the organization')
|
|
170
|
+
else layer.authority = parsed
|
|
171
|
+
} catch { layer.blocks.push('malformed vsk-policy JSON') }
|
|
172
|
+
}
|
|
173
|
+
fence = null; policyLines = null
|
|
174
|
+
}
|
|
175
|
+
continue
|
|
176
|
+
}
|
|
177
|
+
if (fence) { if (policyLines) policyLines.push(line); continue }
|
|
178
|
+
layer.lines.push(line)
|
|
179
|
+
const match = /^([a-z][a-z0-9-]*):\s*(.*?)\s*$/.exec(line)
|
|
180
|
+
if (!match) continue
|
|
181
|
+
const key = match[1], value = match[2].replace(/\s+#.*$/, '').trim()
|
|
182
|
+
if (forbidden.has(key)) { layer.blocks.push('prototype key in policy'); continue }
|
|
183
|
+
const stageLine = stages.includes(key) && !enums[key]?.includes(value)
|
|
184
|
+
if (key === 'policy-schema' || ordinary.has(key) || stageLine) {
|
|
185
|
+
if (seen.has(key)) layer.blocks.push(`duplicate policy key: ${key}`)
|
|
186
|
+
seen.add(key)
|
|
187
|
+
} else { layer.extensions[key] = value; continue }
|
|
188
|
+
if (key === 'policy-schema') {
|
|
189
|
+
if (!['1', '2'].includes(value)) layer.blocks.push('unsupported policy-schema')
|
|
190
|
+
else layer.schemaVersion = Number(value)
|
|
191
|
+
continue
|
|
192
|
+
}
|
|
193
|
+
if (key === 'harness-policy' || stageLine) {
|
|
194
|
+
layer.values.stages ??= {}
|
|
195
|
+
for (const segment of key === 'harness-policy' ? value.split('·') : [key + ' ' + value]) {
|
|
196
|
+
const parts = segment.trim().split(/\s+/), name = parts.shift(), parsed = parseStage(parts.join(' '))
|
|
197
|
+
if (!stages.includes(name) || !parsed || own(layer.values.stages, name)) layer.blocks.push(`invalid or duplicate harness stage: ${name}`)
|
|
198
|
+
else layer.values.stages[name] = parsed
|
|
199
|
+
}
|
|
200
|
+
continue
|
|
201
|
+
}
|
|
202
|
+
const parsed = knobValue(key, value)
|
|
203
|
+
if (parsed === undefined) layer.blocks.push(`invalid ${key} value`)
|
|
204
|
+
else if (!(key === 'stats' && parsed === 'inherit')) layer.values[key] = parsed
|
|
205
|
+
}
|
|
206
|
+
if (fence && policyLines) layer.blocks.push('unclosed vsk-policy block')
|
|
207
|
+
if (layer.authority && layer.schemaVersion !== 2) {
|
|
208
|
+
layer.blocks.push('vsk-policy authority requires policy-schema: 2; explicit migration required')
|
|
209
|
+
layer.authority = null
|
|
210
|
+
}
|
|
211
|
+
return layer
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
function validateAuthority(layer, blocks) {
|
|
215
|
+
const authority = layer.authority ?? {}, locked = authority.locked ?? {}, delegations = authority.delegations ?? []
|
|
216
|
+
if (!object(locked) || !Array.isArray(delegations)) { blocks.push('malformed locked/delegations'); return { locked: {}, delegations: [] } }
|
|
217
|
+
const validLocked = {}
|
|
218
|
+
for (const [key, value] of Object.entries(locked)) {
|
|
219
|
+
if (!lockable.has(key) || knobValue(key, String(value)) === undefined || value === 'inherit') blocks.push(`invalid locked key/value: ${key}`)
|
|
220
|
+
else validLocked[key] = knobValue(key, String(value))
|
|
221
|
+
}
|
|
222
|
+
const validDelegations = []
|
|
223
|
+
for (const grant of delegations) {
|
|
224
|
+
if (!object(grant) || !lockable.has(grant.key) || !strings(grant.groups, groupPattern) || !strings(grant.repos, repoPattern)
|
|
225
|
+
|| grant.groups.length === 0 || grant.repos.length === 0 || !Array.isArray(grant.allowedValues) || !grant.allowedValues.length
|
|
226
|
+
|| grant.allowedValues.some(value => knobValue(grant.key, String(value)) === undefined || value === 'inherit')) blocks.push('invalid exact org delegation')
|
|
227
|
+
else validDelegations.push({ ...grant, allowedValues: grant.allowedValues.map(value => knobValue(grant.key, String(value))) })
|
|
228
|
+
}
|
|
229
|
+
if (layer.values['stats-override'] === 'locked') {
|
|
230
|
+
if (layer.values.stats === undefined) blocks.push('legacy stats lock requires an explicit org stats value')
|
|
231
|
+
else if (own(validLocked, 'stats') && validLocked.stats !== layer.values.stats) blocks.push('conflicting legacy stats lock')
|
|
232
|
+
else validLocked.stats = layer.values.stats
|
|
233
|
+
}
|
|
234
|
+
return { locked: validLocked, delegations: validDelegations }
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
/**
|
|
238
|
+
* @param {{org?:string,group?:string,repo?:string,identity?:Record<string,any>,freshness?:Record<string,any>}} input
|
|
239
|
+
* @returns {{ok:boolean,policy:Record<string,any>,blocks:string[]}}
|
|
240
|
+
*/
|
|
241
|
+
export function resolvePolicy({ org = '', group = '', repo = '', identity = {}, freshness = {} } = {}) {
|
|
242
|
+
const layers = [parsePolicy(org, 'org'), parsePolicy(group, 'group'), parsePolicy(repo, 'repo')]
|
|
243
|
+
const blocks = layers.flatMap(layer => layer.blocks.map(block => layer.scope + ': ' + block))
|
|
244
|
+
const { locked, delegations } = validateAuthority(layers[0], blocks)
|
|
245
|
+
const values = { dispatch: 'off', stats: 'on', 'stats-people': 'off', stages: {} }, sources = {}
|
|
246
|
+
const paths = identity.paths ?? {}
|
|
247
|
+
const source = layer => ({ scope: layer.scope, path: paths[layer.scope] ?? (layer.scope === 'org' ? 'org.md' : layer.scope === 'group' ? 'groups/' + (identity.group ?? 'unselected') + '/group.md' : '.vegastack/dev.md'), revision: layer.scope !== 'repo' && shaPattern.test(identity.roomSha ?? '') ? identity.roomSha : policyHash(layer.text), revisionKind: layer.scope !== 'repo' && shaPattern.test(identity.roomSha ?? '') ? 'git' : 'sha256' })
|
|
248
|
+
for (const layer of layers) {
|
|
249
|
+
if (layer.text.trim()) sources[`layer.${layer.scope}`] = source(layer)
|
|
250
|
+
for (const [key, value] of Object.entries(layer.values)) {
|
|
251
|
+
if (key === 'dispatch' && layer.scope !== 'repo') continue
|
|
252
|
+
if (key === 'stages') { for (const [name, stage] of Object.entries(value)) { values.stages[name] = stage; sources['stages.' + name] = source(layer) }; continue }
|
|
253
|
+
const permitted = delegations.some(grant => grant.key === key && grant.groups.includes(identity.group) && grant.repos.includes(identity.repo) && grant.allowedValues.some(allowed => same(allowed, value)))
|
|
254
|
+
if (layer.scope !== 'org' && own(locked, key) && !same(value, locked[key]) && !permitted) {
|
|
255
|
+
blocks.push(layer.scope + ': ' + key + ' override requires exact org delegation'); continue
|
|
256
|
+
}
|
|
257
|
+
if (key === 'stats-export' && value === 'attributed' && layer.scope !== 'org' && layers[0].values[key] !== 'attributed' && locked[key] !== 'attributed' && !permitted) {
|
|
258
|
+
blocks.push('attributed reporting requires explicit org authorization'); continue
|
|
259
|
+
}
|
|
260
|
+
values[key] = value; sources[key] = source(layer)
|
|
261
|
+
}
|
|
262
|
+
if (layer.scope === 'org') for (const [key, value] of Object.entries(locked)) { values[key] = value; sources[key] = source(layer) }
|
|
263
|
+
}
|
|
264
|
+
try { values['workflow-labels'] = workflowLabelsFromValues(values) } catch (error) { blocks.push(error.message) }
|
|
265
|
+
const configured = freshness.configured === true || Boolean(values['control-room'] && values['control-room'] !== 'none')
|
|
266
|
+
const now = typeof freshness.now === 'number' ? freshness.now : Date.parse(freshness.now ?? '')
|
|
267
|
+
const validated = Date.parse(freshness.validatedAt ?? '')
|
|
268
|
+
const maxAgeSeconds = values['sync-max-age'] ?? freshness.maxAgeSeconds ?? 1800
|
|
269
|
+
const ageSeconds = Number.isFinite(now) && Number.isFinite(validated) ? (now - validated) / 1000 : null
|
|
270
|
+
let state = 'local'
|
|
271
|
+
if (configured) {
|
|
272
|
+
state = ageSeconds === null || ageSeconds < 0 || !Number.isSafeInteger(maxAgeSeconds) || maxAgeSeconds <= 0 ? 'unavailable' : ageSeconds >= maxAgeSeconds ? 'stale' : 'fresh'
|
|
273
|
+
if (!org.trim() || !shaPattern.test(identity.roomSha ?? '')) { state = 'unavailable'; blocks.push('configured organization policy or validated source identity unavailable') }
|
|
274
|
+
if (state !== 'fresh') blocks.push(`mandatory policy ${state}: validated refresh required`)
|
|
275
|
+
}
|
|
276
|
+
const authority = layers[0].authority ?? {}
|
|
277
|
+
const registry = { org: identity.org ?? String(identity.repo ?? '').split('/')[0], peopleByScope: identity.peopleByScope ?? {}, repoGroups: identity.repoGroups ?? {}, repositoryIds: identity.repositoryIds ?? {} }
|
|
278
|
+
if ((authority.administration !== undefined || authority.fleet !== undefined)
|
|
279
|
+
&& (!loginPattern.test(registry.org) || Object.keys(registry.repoGroups).some(repo => repo.split('/')[0].toLowerCase() !== registry.org.toLowerCase()))) blocks.push('registry contains an invalid or foreign organization')
|
|
280
|
+
const policy = { schemaVersion: 2, repo: identity.repo ?? null, group: identity.group ?? null, values, sources,
|
|
281
|
+
locked, delegations, administration: null, fleet: null, registry,
|
|
282
|
+
freshness: { configured, state, validatedAt: freshness.validatedAt ?? null, ageSeconds, maxAgeSeconds }, policyDigest: '', blocks }
|
|
283
|
+
if (authority.administration !== undefined) {
|
|
284
|
+
const result = resolveAdministration({ orgLayer: layers[0], peopleByScope: registry.peopleByScope, repoGroups: registry.repoGroups })
|
|
285
|
+
policy.administration = result.administration; blocks.push(...result.blocks)
|
|
286
|
+
sources.administration = source(layers[0])
|
|
287
|
+
}
|
|
288
|
+
if (authority.fleet !== undefined) {
|
|
289
|
+
const result = validateFleet(authority.fleet, registry)
|
|
290
|
+
policy.fleet = result.fleet; blocks.push(...result.blocks)
|
|
291
|
+
sources.fleet = source(layers[0])
|
|
292
|
+
}
|
|
293
|
+
policy.policyDigest = policyHash({ values, sources, locked, delegations, administration: policy.administration, fleet: policy.fleet, registry })
|
|
294
|
+
return { ok: blocks.length === 0, policy, blocks }
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
const capabilities = ['group.members.manage', 'group.repos.manage', 'group.defaults.manage', 'group.people.read']
|
|
298
|
+
function directory(peopleByScope, repoGroups) {
|
|
299
|
+
const people = new Map(), groups = new Set(Object.values(repoGroups ?? {}))
|
|
300
|
+
if (!object(peopleByScope) || !object(repoGroups) || !safeTree(peopleByScope) || !safeTree(repoGroups)) return { people, groups, blocks: ['invalid confirmed registry'] }
|
|
301
|
+
const blocks = []
|
|
302
|
+
for (const scope of Object.keys(peopleByScope)) if (scope !== 'org' && groupPattern.test(scope) && !forbidden.has(scope)) groups.add(scope)
|
|
303
|
+
for (const [repo, group] of Object.entries(repoGroups)) if (!repoPattern.test(repo) || !groupPattern.test(group) || forbidden.has(group)) blocks.push('invalid repository/group registration')
|
|
304
|
+
for (const [scope, rows] of Object.entries(peopleByScope)) {
|
|
305
|
+
if (!Array.isArray(rows)) { blocks.push('people registry scope must contain rows'); continue }
|
|
306
|
+
if (scope !== 'org' && (!groupPattern.test(scope) || forbidden.has(scope))) { blocks.push('invalid people group'); continue }
|
|
307
|
+
if (scope !== 'org') groups.add(scope)
|
|
308
|
+
for (const row of rows) {
|
|
309
|
+
if (!object(row) || !loginPattern.test(row.login ?? '') || forbidden.has(row.login) || !strings(row.groups ?? [], groupPattern)) { blocks.push('invalid confirmed person'); continue }
|
|
310
|
+
const login = row.login.toLowerCase()
|
|
311
|
+
const memberships = row.groups ?? []
|
|
312
|
+
if (memberships.some(group => !groups.has(group))) blocks.push(`unregistered person group: ${login}`)
|
|
313
|
+
// Descriptive group rows never replace a person's independent grants or membership evidence.
|
|
314
|
+
const prior = people.get(login) ?? { login, groups: [] }
|
|
315
|
+
people.set(login, { login, groups: unique([...prior.groups, ...memberships, ...(scope === 'org' ? [] : [scope])]) })
|
|
316
|
+
}
|
|
317
|
+
}
|
|
318
|
+
return { people, groups, blocks }
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
export function resolveAdministration({ orgLayer, peopleByScope = {}, repoGroups = {} } = {}) {
|
|
322
|
+
const blocks = [], layer = typeof orgLayer === 'string' ? parsePolicy(orgLayer, 'org') : orgLayer
|
|
323
|
+
if (!layer || layer.scope !== 'org' || layer.blocks?.length) return { ok: false, administration: null, blocks: ['administration needs a valid organization layer'] }
|
|
324
|
+
const raw = layer.authority?.administration
|
|
325
|
+
if (raw === undefined) return { ok: true, administration: null, blocks: [] }
|
|
326
|
+
const registry = directory(peopleByScope, repoGroups)
|
|
327
|
+
blocks.push(...registry.blocks)
|
|
328
|
+
if (!object(raw) || !safeTree(raw) || !strings(raw.orgAdmins, loginPattern) || !raw.orgAdmins.length || !object(raw.groupAdmins) || !object(raw.groupAdminCapabilities)) {
|
|
329
|
+
return { ok: false, administration: null, blocks: [...blocks, 'administration requires at least one confirmed org admin and explicit group maps'] }
|
|
330
|
+
}
|
|
331
|
+
const orgAdmins = unique(raw.orgAdmins.map(login => login.toLowerCase())), groupAdmins = {}, groupAdminCapabilities = {}
|
|
332
|
+
for (const login of orgAdmins) if (!registry.people.has(login)) blocks.push(`unconfirmed org admin: ${login}`)
|
|
333
|
+
for (const [group, logins] of Object.entries(raw.groupAdmins)) {
|
|
334
|
+
if (!registry.groups.has(group) || !strings(logins, loginPattern)) { blocks.push(`invalid group admin assignment: ${group}`); continue }
|
|
335
|
+
groupAdmins[group] = unique(logins.map(login => login.toLowerCase()))
|
|
336
|
+
for (const login of groupAdmins[group]) if (!registry.people.has(login) || !registry.people.get(login).groups.includes(group)) blocks.push(`unconfirmed group admin membership: ${group}/${login}`)
|
|
337
|
+
}
|
|
338
|
+
for (const [group, grants] of Object.entries(raw.groupAdminCapabilities)) {
|
|
339
|
+
if (!registry.groups.has(group) || !Array.isArray(grants) || grants.some(grant => !capabilities.includes(grant))) { blocks.push(`invalid group capability: ${group}`); continue }
|
|
340
|
+
groupAdminCapabilities[group] = unique(grants)
|
|
341
|
+
}
|
|
342
|
+
const administration = { orgAdmins, groupAdmins, groupAdminCapabilities }
|
|
343
|
+
return { ok: blocks.length === 0, administration: blocks.length ? null : administration, blocks }
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
const fleetDefaults = { pollSeconds: 120, maxRuns: 1, childConcurrent: 3, checkpoints: 'task-branch', recovery: 'verified-transfer' }
|
|
347
|
+
function validFleetValue(key, value) {
|
|
348
|
+
if (key === 'pollSeconds') return Number.isSafeInteger(value) && value >= 30 && value <= 3600
|
|
349
|
+
if (key === 'maxRuns') return Number.isSafeInteger(value) && value > 0
|
|
350
|
+
if (key === 'childConcurrent') return Number.isSafeInteger(value) && value >= 1 && value <= 16
|
|
351
|
+
if (key === 'checkpoints') return ['off', 'task-branch'].includes(value)
|
|
352
|
+
if (key === 'recovery') return ['original-host', 'verified-transfer'].includes(value)
|
|
353
|
+
return false
|
|
354
|
+
}
|
|
355
|
+
function validDefaults(value, full = false) {
|
|
356
|
+
return object(value) && safeTree(value) && Object.entries(value).every(([key, entry]) => validFleetValue(key, entry))
|
|
357
|
+
&& (!full || Object.keys(fleetDefaults).every(key => own(value, key)))
|
|
358
|
+
}
|
|
359
|
+
function validDelegation(value) {
|
|
360
|
+
if (!object(value) || !Array.isArray(value.fields) || value.fields.some(key => !own(fleetDefaults, key))) return false
|
|
361
|
+
const fields = new Set(['fields', 'maxRunsMax', 'childConcurrentMax', 'pollSecondsMin', 'pollSecondsMax', 'checkpointValues', 'recoveryValues'])
|
|
362
|
+
if (Object.keys(value).some(key => !fields.has(key))) return false
|
|
363
|
+
for (const [field, key] of [['maxRunsMax', 'maxRuns'], ['childConcurrentMax', 'childConcurrent'], ['pollSecondsMin', 'pollSeconds'], ['pollSecondsMax', 'pollSeconds']]) {
|
|
364
|
+
if (value[field] !== undefined && !validFleetValue(key, value[field])) return false
|
|
365
|
+
}
|
|
366
|
+
if ((value.pollSecondsMin ?? 30) > (value.pollSecondsMax ?? 3600)) return false
|
|
367
|
+
for (const [field, key] of [['checkpointValues', 'checkpoints'], ['recoveryValues', 'recovery']]) {
|
|
368
|
+
if (value[field] !== undefined && (!Array.isArray(value[field]) || !value[field].length || value[field].some(entry => !validFleetValue(key, entry)))) return false
|
|
369
|
+
}
|
|
370
|
+
return true
|
|
371
|
+
}
|
|
372
|
+
function gitBranch(value) {
|
|
373
|
+
return typeof value === 'string' && value.length > 0 && !value.startsWith('-') && !value.startsWith('/') && !value.endsWith('/')
|
|
374
|
+
&& !/[\s~^:?*\[\\]/.test(value) && !value.includes('..') && !value.includes('@{') && value !== '@'
|
|
375
|
+
&& value.split('/').every(part => part && !part.startsWith('.') && !part.endsWith('.') && !part.endsWith('.lock'))
|
|
376
|
+
}
|
|
377
|
+
function nodeId(value) { return typeof value === 'string' && /^[A-Za-z0-9_=-]{3,200}$/.test(value) }
|
|
378
|
+
|
|
379
|
+
function validateFleet(raw, registry) {
|
|
380
|
+
const blocks = [], people = directory(registry.peopleByScope, registry.repoGroups)
|
|
381
|
+
blocks.push(...people.blocks)
|
|
382
|
+
if (!object(raw) || !safeTree(raw) || raw.schemaVersion !== 1 || !object(raw.coordination)
|
|
383
|
+
|| !validDefaults(raw.defaults, true) || !object(raw.groupDefaults) || !object(raw.machines) || !object(raw.groupDelegations ?? {})) {
|
|
384
|
+
return { fleet: null, blocks: [...blocks, 'invalid fleet schema/defaults/maps'] }
|
|
385
|
+
}
|
|
386
|
+
const localOnly = /^(?:credentials?|secrets?|tokens?|password|privateKey|host(?:name|Id)?|home|(?:local)?path|command|shell)$/i
|
|
387
|
+
function containsLocalData(value) {
|
|
388
|
+
return object(value) && Object.entries(value).some(([key, child]) => localOnly.test(key) || containsLocalData(child))
|
|
389
|
+
}
|
|
390
|
+
if (containsLocalData(raw)) blocks.push('credentials, raw host identifiers, commands and local paths do not belong in fleet policy')
|
|
391
|
+
const c = raw.coordination
|
|
392
|
+
if (!nodeId(c.repositoryId) || !repoPattern.test(c.repository ?? '') || c.repository.split('/')[0].toLowerCase() !== registry.org?.toLowerCase()
|
|
393
|
+
|| !gitBranch(c.branch) || !shaPattern.test(c.rootCommit ?? '') || !uuidPattern.test(c.installationId ?? '')) blocks.push('invalid fleet coordination identity')
|
|
394
|
+
for (const [group, defaults] of Object.entries(raw.groupDefaults)) if (!people.groups.has(group) || !validDefaults(defaults)) blocks.push(`invalid fleet group defaults: ${group}`)
|
|
395
|
+
for (const [group, grant] of Object.entries(raw.groupDelegations ?? {})) if (!people.groups.has(group) || !validDelegation(grant)) blocks.push(`invalid fleet group delegation: ${group}`)
|
|
396
|
+
const installations = new Set(), bindings = new Set()
|
|
397
|
+
for (const [id, machine] of Object.entries(raw.machines)) {
|
|
398
|
+
if (!groupPattern.test(id) || forbidden.has(id) || !object(machine) || !uuidPattern.test(machine.installationId ?? '') || !digestPattern.test(machine.hostBindingDigest ?? '')
|
|
399
|
+
|| !loginPattern.test(machine.executionLogin ?? '') || !people.people.has(machine.executionLogin?.toLowerCase()) || !people.groups.has(machine.group)
|
|
400
|
+
|| !strings(machine.repositories, repoPattern) || !machine.repositories.length || typeof machine.enabled !== 'boolean' || !validDefaults(machine.overrides)) {
|
|
401
|
+
blocks.push(`invalid machine registration: ${id}`); continue
|
|
402
|
+
}
|
|
403
|
+
if (installations.has(machine.installationId.toLowerCase()) || bindings.has(machine.hostBindingDigest)) blocks.push(`duplicate machine identity: ${id}`)
|
|
404
|
+
installations.add(machine.installationId.toLowerCase()); bindings.add(machine.hostBindingDigest)
|
|
405
|
+
if (new Set(machine.repositories).size !== machine.repositories.length) blocks.push(`duplicate machine repositories: ${id}`)
|
|
406
|
+
for (const repo of machine.repositories) if (registry.repoGroups[repo] !== machine.group || !nodeId(registry.repositoryIds[repo])) blocks.push(`unconfirmed or cross-group machine repository: ${id}/${repo}`)
|
|
407
|
+
}
|
|
408
|
+
return { fleet: blocks.length ? null : structuredClone(raw), blocks }
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
function trustedPolicy(policy) {
|
|
412
|
+
return policy?.schemaVersion === 2 && Array.isArray(policy.blocks) && !policy.blocks.length && digestPattern.test(policy.policyDigest ?? '')
|
|
413
|
+
&& ['local', 'fresh'].includes(policy.freshness?.state)
|
|
414
|
+
&& policy.policyDigest === policyHash({ values: policy.values, sources: policy.sources, locked: policy.locked, delegations: policy.delegations, administration: policy.administration, fleet: policy.fleet, registry: policy.registry })
|
|
415
|
+
}
|
|
416
|
+
function verifiedLogin(actor, policy) {
|
|
417
|
+
if (!object(actor) || actor.verified !== true || !loginPattern.test(actor.login ?? '')) return null
|
|
418
|
+
const login = actor.login.toLowerCase()
|
|
419
|
+
return directory(policy.registry?.peopleByScope ?? {}, policy.registry?.repoGroups ?? {}).people.has(login) ? login : null
|
|
420
|
+
}
|
|
421
|
+
const deny = reason => ({ allowed: false, reason })
|
|
422
|
+
const allow = () => ({ allowed: true, reason: null })
|
|
423
|
+
function delegatedFleetChange(changes, delegation) {
|
|
424
|
+
if (!validDefaults(changes) || !validDelegation(delegation) || Object.keys(changes).length === 0) return false
|
|
425
|
+
return Object.entries(changes).every(([key, value]) => {
|
|
426
|
+
if (!delegation.fields.includes(key)) return false
|
|
427
|
+
if (key === 'maxRuns') return value <= (delegation.maxRunsMax ?? Number.MAX_SAFE_INTEGER)
|
|
428
|
+
if (key === 'childConcurrent') return value <= (delegation.childConcurrentMax ?? 16)
|
|
429
|
+
if (key === 'pollSeconds') return value >= (delegation.pollSecondsMin ?? 30) && value <= (delegation.pollSecondsMax ?? 3600)
|
|
430
|
+
if (key === 'checkpoints') return delegation.checkpointValues === undefined || delegation.checkpointValues.includes(value)
|
|
431
|
+
if (key === 'recovery') return delegation.recoveryValues === undefined || delegation.recoveryValues.includes(value)
|
|
432
|
+
return false
|
|
433
|
+
})
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
// target.changes is the proposed field diff; policy/administration are the PREVIOUS trusted
|
|
437
|
+
// configuration. The caller validates/render the resulting complete document before delivery.
|
|
438
|
+
export function authorizeAdministration({ actor, action, target, administration, policy } = {}) {
|
|
439
|
+
if (!trustedPolicy(policy)) return deny('current validated policy required')
|
|
440
|
+
if (!administration || !same(administration, policy.administration)) return deny('previous trusted administration required')
|
|
441
|
+
const login = verifiedLogin(actor, policy)
|
|
442
|
+
if (!login) return deny('verified requester identity required; execution identity is not human authority')
|
|
443
|
+
if (!object(target) || !safeTree(target) || target.org !== policy.registry.org) return deny('exact organization target required')
|
|
444
|
+
if (target.changes !== undefined && !object(target.changes)) return deny('administration requires an explicit field diff')
|
|
445
|
+
const registry = directory(policy.registry.peopleByScope, policy.registry.repoGroups)
|
|
446
|
+
if (target.group !== undefined && !registry.groups.has(target.group)) return deny('unregistered group target')
|
|
447
|
+
if (target.repo !== undefined && (!own(policy.registry.repoGroups, target.repo) || (target.group && policy.registry.repoGroups[target.repo] !== target.group))) return deny('unregistered or mixed repository target')
|
|
448
|
+
const actions = ['administration.manage', 'fleet.enroll', 'fleet.configure', 'fleet.machine.settings', 'fleet.group.settings', ...capabilities]
|
|
449
|
+
if (!actions.includes(action)) return deny('unknown administration action; task and shipping authority are separate')
|
|
450
|
+
const isOrg = administration.orgAdmins.includes(login)
|
|
451
|
+
if (action === 'administration.manage') {
|
|
452
|
+
if (!isOrg) return deny('organization-admin appointment authority required')
|
|
453
|
+
if (target.proposedAdministration !== undefined) {
|
|
454
|
+
const result = resolveAdministration({ orgLayer: { scope: 'org', blocks: [], authority: { administration: target.proposedAdministration } }, peopleByScope: policy.registry.peopleByScope, repoGroups: policy.registry.repoGroups })
|
|
455
|
+
if (!result.ok) return deny(result.blocks.join('; '))
|
|
456
|
+
}
|
|
457
|
+
return allow()
|
|
458
|
+
}
|
|
459
|
+
if (['fleet.enroll', 'fleet.configure'].includes(action)) {
|
|
460
|
+
if (!isOrg) return deny('coordination identity and enrollment are organization-admin owned')
|
|
461
|
+
if (target.registration && (target.registration.enabled !== false || policy.fleet?.machines?.[target.machineId])) return deny('new enrollment must be disabled and have a unique machine identity')
|
|
462
|
+
return allow()
|
|
463
|
+
}
|
|
464
|
+
if (!target.group) return deny('exact group target required')
|
|
465
|
+
if (!isOrg && (!administration.groupAdmins[target.group]?.includes(login) || !administration.groupAdminCapabilities[target.group]?.includes(action.startsWith('fleet.') ? 'group.defaults.manage' : action))) return deny('requester lacks this group capability')
|
|
466
|
+
if (action.startsWith('fleet.')) {
|
|
467
|
+
const machine = target.machineId ? policy.fleet?.machines?.[target.machineId] : null
|
|
468
|
+
if (action === 'fleet.machine.settings' && (!machine || machine.group !== target.group)) return deny('settings require an existing machine in this group')
|
|
469
|
+
if (!validDefaults(target.changes)) return deny('settings cannot change enrollment, repositories, group or identity')
|
|
470
|
+
return isOrg || delegatedFleetChange(target.changes, policy.fleet?.groupDelegations?.[target.group]) ? allow() : deny('fleet setting outside previous org delegation')
|
|
471
|
+
}
|
|
472
|
+
if (action === 'group.defaults.manage') {
|
|
473
|
+
if (!object(target.changes) || !Object.keys(target.changes).length) return deny('explicit setting diff required')
|
|
474
|
+
for (const [key, value] of Object.entries(target.changes)) {
|
|
475
|
+
if (!ordinary.has(key) || ['operators', 'dispatch', 'control-room'].includes(key) || knobValue(key, String(value)) === undefined) return deny('group cannot modify protected authority or unknown settings')
|
|
476
|
+
if (own(policy.locked, key) && !same(policy.locked[key], value)) {
|
|
477
|
+
const delegated = policy.delegations.some(grant => grant.key === key && grant.groups.includes(target.group) && target.repo && grant.repos.includes(target.repo) && grant.allowedValues.some(allowed => same(allowed, value)))
|
|
478
|
+
if (!delegated) return deny('group setting requires exact org delegation')
|
|
479
|
+
}
|
|
480
|
+
}
|
|
481
|
+
}
|
|
482
|
+
if (action === 'group.repos.manage') {
|
|
483
|
+
if (!target.repo || policy.registry.repoGroups[target.repo] !== target.group) return deny('repository management is limited to preauthorized registrations')
|
|
484
|
+
if (target.changes && Object.keys(target.changes).some(key => !['board', 'owner'].includes(key))) return deny('group repository edits cannot change registration scope or authority')
|
|
485
|
+
if (target.changes?.owner !== undefined && !registry.people.has(String(target.changes.owner).toLowerCase())) return deny('repository owner must be a confirmed person')
|
|
486
|
+
}
|
|
487
|
+
if (action === 'group.members.manage' && target.changes) {
|
|
488
|
+
if (Object.keys(target.changes).some(key => !['login', 'name', 'role', 'slack', 'timezone', 'groups'].includes(key))) return deny('ordinary membership management cannot change authority')
|
|
489
|
+
if (target.changes.groups !== undefined && (!strings(target.changes.groups, groupPattern) || target.changes.groups.some(group => group !== target.group))) return deny('membership edit crosses the exact group target')
|
|
490
|
+
if (target.changes.login !== undefined && !loginPattern.test(target.changes.login)) return deny('member login must be a validated GitHub identity')
|
|
491
|
+
}
|
|
492
|
+
return allow()
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
/**
|
|
496
|
+
* @param {{viewer?:any,subject?:string|null,requestedRepos?:string[],administration?:any,policy?:any,repoGroups?:Record<string,string>}} input
|
|
497
|
+
* @returns {{allowedRepos:string[],subject:string|null,refusal:string|null}}
|
|
498
|
+
*/
|
|
499
|
+
export function resolvePeopleReadScope({ viewer, subject = null, requestedRepos, administration, policy, repoGroups } = {}) {
|
|
500
|
+
const refuse = refusal => ({ allowedRepos: [], subject, refusal })
|
|
501
|
+
if (!trustedPolicy(policy)) return refuse('current validated policy required for people data')
|
|
502
|
+
if (!same(repoGroups, policy.registry.repoGroups) || !same(administration ?? null, policy.administration)) return refuse('read scope must use current confirmed registry and administration')
|
|
503
|
+
const login = verifiedLogin(viewer, policy)
|
|
504
|
+
if (!login) return refuse('verified viewer required')
|
|
505
|
+
const registry = directory(policy.registry.peopleByScope, repoGroups)
|
|
506
|
+
if (subject !== null && (typeof subject !== 'string' || !registry.people.has(subject.toLowerCase()))) return refuse('unknown person')
|
|
507
|
+
if (subject !== null) subject = subject.toLowerCase()
|
|
508
|
+
const requested = requestedRepos ?? Object.keys(repoGroups)
|
|
509
|
+
if (!strings(requested, repoPattern) || requested.some(repo => !own(repoGroups, repo))) return refuse('requested repository is unregistered')
|
|
510
|
+
const ownData = subject === login
|
|
511
|
+
if (!ownData && policy.values['stats-people'] !== 'on') return refuse('organization people reporting is off')
|
|
512
|
+
const orgAdmin = administration?.orgAdmins.includes(login) === true
|
|
513
|
+
const groups = Object.keys(administration?.groupAdmins ?? {}).filter(group => administration.groupAdmins[group].includes(login) && administration.groupAdminCapabilities[group]?.includes('group.people.read'))
|
|
514
|
+
const allowedRepos = unique(requested).filter(repo => ownData || orgAdmin || groups.includes(repoGroups[repo]))
|
|
515
|
+
return { allowedRepos, subject, refusal: allowedRepos.length ? null : 'viewer has no permitted repositories for this people query' }
|
|
516
|
+
}
|
|
517
|
+
|
|
518
|
+
export function resolveMachinePolicy({ policy, machineId, installationId, hostBindingDigest, executionLogin } = {}) {
|
|
519
|
+
const refuse = reason => ({ ok: false, machine: null, blocks: [reason] })
|
|
520
|
+
if (!trustedPolicy(policy)) return refuse('current validated policy required for machine resolution')
|
|
521
|
+
if (!policy.fleet) return refuse('no shared fleet configuration; explicit legacy mode must be selected by the caller')
|
|
522
|
+
const checked = validateFleet(policy.fleet, policy.registry)
|
|
523
|
+
if (checked.blocks.length) return { ok: false, machine: null, blocks: checked.blocks }
|
|
524
|
+
if (!groupPattern.test(machineId ?? '') || !own(policy.fleet.machines, machineId)) return refuse('machine is not enrolled')
|
|
525
|
+
const registration = policy.fleet.machines[machineId]
|
|
526
|
+
if (!registration.enabled) return refuse('machine enrollment is disabled')
|
|
527
|
+
if (registration.installationId !== installationId || registration.hostBindingDigest !== hostBindingDigest || registration.executionLogin.toLowerCase() !== executionLogin?.toLowerCase()) return refuse('machine installation, host binding or execution identity mismatch')
|
|
528
|
+
const defaults = { ...policy.fleet.defaults, ...policy.fleet.groupDefaults[registration.group], ...registration.overrides }
|
|
529
|
+
return { ok: true, machine: { id: machineId, ...registration, coordination: policy.fleet.coordination, defaults, allowedRepositories: [...registration.repositories], repositoryIds: Object.fromEntries(registration.repositories.map(repo => [repo, policy.registry.repositoryIds[repo]])), policyDigest: policy.policyDigest }, blocks: [] }
|
|
530
|
+
}
|
|
531
|
+
|
|
532
|
+
// Registry readers share the fixed authored formats. These rows are descriptive input to a
|
|
533
|
+
// verified snapshot; parsing a login is not verification of the requester or GitHub account.
|
|
534
|
+
export function parsePeopleRegistry(text = '') {
|
|
535
|
+
const lines = String(text).split(/\r?\n/).filter(line => line.trim())
|
|
536
|
+
const blocks = [], people = [], seen = new Set()
|
|
537
|
+
if (lines.shift()?.trim() !== 'login,name,role,slack,timezone,groups') return { people: [], blocks: ['people.csv needs its exact six-column header'] }
|
|
538
|
+
for (const line of lines) {
|
|
539
|
+
const cells = line.split(',').map(cell => cell.trim())
|
|
540
|
+
const [login, name, role, slack, timezone, rawGroups] = cells
|
|
541
|
+
const groups = (rawGroups ?? '').split(/[;|]/).map(group => group.trim()).filter(Boolean)
|
|
542
|
+
if (cells.length !== 6 || !loginPattern.test(login ?? '') || forbidden.has(login) || !strings(groups, groupPattern) || seen.has(login.toLowerCase())) {
|
|
543
|
+
blocks.push('invalid or duplicate people.csv row'); continue
|
|
544
|
+
}
|
|
545
|
+
seen.add(login.toLowerCase())
|
|
546
|
+
people.push({ login: login.toLowerCase(), name, role, slack, timezone, groups: unique(groups) })
|
|
547
|
+
}
|
|
548
|
+
return { people: blocks.length ? [] : people, blocks }
|
|
549
|
+
}
|
|
550
|
+
|
|
551
|
+
/** @returns {{repoGroups:Record<string,string>,repositoryIds:Record<string,string>,blocks:string[]}} */
|
|
552
|
+
export function parseRepositoryRegistry(text = '') {
|
|
553
|
+
const repoGroups = {}, repositoryIds = {}, blocks = [], seen = new Set()
|
|
554
|
+
let idColumn = -1
|
|
555
|
+
let fence = null
|
|
556
|
+
for (const line of String(text).split(/\r?\n/)) {
|
|
557
|
+
const boundary = /^\s{0,3}(`{3,}|~{3,})(.*)$/.exec(line)
|
|
558
|
+
if (boundary) {
|
|
559
|
+
if (!fence) fence = boundary[1]
|
|
560
|
+
else if (boundary[1][0] === fence[0] && boundary[1].length >= fence.length && !boundary[2].trim()) fence = null
|
|
561
|
+
continue
|
|
562
|
+
}
|
|
563
|
+
if (fence || !line.startsWith('|')) continue
|
|
564
|
+
const cells = line.split('|').slice(1, -1).map(cell => cell.trim())
|
|
565
|
+
const [repo, group] = cells
|
|
566
|
+
if (repo === 'repo') { idColumn = cells.indexOf('repository-id'); continue }
|
|
567
|
+
if (!repo?.includes('/')) continue
|
|
568
|
+
if (!repoPattern.test(repo) || !groupPattern.test(group ?? '') || forbidden.has(group) || seen.has(repo)) { blocks.push('invalid or duplicate repository registry row'); continue }
|
|
569
|
+
seen.add(repo); repoGroups[repo] = group
|
|
570
|
+
if (idColumn >= 0 && cells[idColumn]) {
|
|
571
|
+
if (!nodeId(cells[idColumn])) blocks.push('invalid repository node ID')
|
|
572
|
+
else repositoryIds[repo] = cells[idColumn]
|
|
573
|
+
}
|
|
574
|
+
}
|
|
575
|
+
return { repoGroups: blocks.length ? {} : repoGroups, repositoryIds: blocks.length ? {} : repositoryIds, blocks }
|
|
576
|
+
}
|
|
577
|
+
|
|
578
|
+
export function parseControlRoomReference(text) {
|
|
579
|
+
const value = parsePolicy(text, 'repo').values['control-room']
|
|
580
|
+
if (!value || value === 'none') return null
|
|
581
|
+
const [room, suffix = ''] = value.split('#')
|
|
582
|
+
const [group, sha] = suffix.split('@')
|
|
583
|
+
return { org: room.split('/')[0], repo: room, group: group || null, sha: sha || null }
|
|
584
|
+
}
|
|
585
|
+
|
|
586
|
+
function gitRead(cwd, args) {
|
|
587
|
+
return execFileSync('git', args, { cwd, encoding: 'utf8', stdio: 'pipe', timeout: 5000, maxBuffer: 4 * 1024 * 1024, env: { ...process.env } })
|
|
588
|
+
}
|
|
589
|
+
|
|
590
|
+
// Git batch output is byte framed, not line-oriented policy text. Keep the existing
|
|
591
|
+
// 4 MiB per-file ceiling, with a separate bounded allowance for the whole snapshot.
|
|
592
|
+
function parseGitBlobBatch(output, oids, blobLimit, aggregateLimit) {
|
|
593
|
+
const blobs = new Map()
|
|
594
|
+
let offset = 0, total = 0
|
|
595
|
+
for (const oid of oids) {
|
|
596
|
+
const end = output.indexOf(10, offset)
|
|
597
|
+
if (end < offset || end - offset > 63) throw new Error('invalid policy blob batch header')
|
|
598
|
+
const header = output.subarray(offset, end).toString('utf8')
|
|
599
|
+
const match = /^([a-f0-9]{40}) blob (0|[1-9][0-9]*)$/.exec(header)
|
|
600
|
+
if (!match || match[1] !== oid) throw new Error('unexpected policy blob batch identity or type')
|
|
601
|
+
const size = Number(match[2])
|
|
602
|
+
total += size
|
|
603
|
+
if (!Number.isSafeInteger(size) || size > blobLimit || total > aggregateLimit) throw new Error('policy blob batch exceeds byte limit')
|
|
604
|
+
offset = end + 1
|
|
605
|
+
if (offset + size >= output.length || output[offset + size] !== 10) throw new Error('truncated or invalid policy blob batch framing')
|
|
606
|
+
blobs.set(oid, output.subarray(offset, offset + size).toString('utf8'))
|
|
607
|
+
offset += size + 1
|
|
608
|
+
}
|
|
609
|
+
if (offset !== output.length) throw new Error('unexpected trailing policy blob batch output')
|
|
610
|
+
return blobs
|
|
611
|
+
}
|
|
612
|
+
|
|
613
|
+
export function gitReadBlobs(cwd, oids, run = execFileSync) {
|
|
614
|
+
const blobLimit = 4 * 1024 * 1024, aggregateLimit = 64 * 1024 * 1024
|
|
615
|
+
const options = {
|
|
616
|
+
cwd, input: oids.join('\n') + '\n', stdio: 'pipe', timeout: 5000,
|
|
617
|
+
maxBuffer: Math.min(oids.length * (blobLimit + 64), aggregateLimit + oids.length * 64),
|
|
618
|
+
env: { ...process.env },
|
|
619
|
+
}
|
|
620
|
+
const read = () => run('git', ['cat-file', '--batch'], options)
|
|
621
|
+
try { return parseGitBlobBatch(read(), oids, blobLimit, aggregateLimit) }
|
|
622
|
+
catch (error) {
|
|
623
|
+
// A busy runner can very rarely yield a short synchronous child-process pipe.
|
|
624
|
+
// Retry only the exact incomplete-frame case;
|
|
625
|
+
// identity, type, size and trailing-output violations remain hard failures.
|
|
626
|
+
if (error?.message !== 'truncated or invalid policy blob batch framing') throw error
|
|
627
|
+
return parseGitBlobBatch(read(), oids, blobLimit, aggregateLimit)
|
|
628
|
+
}
|
|
629
|
+
}
|
|
630
|
+
|
|
631
|
+
// A per-code-repository pointer is supplied by the snapshot owner. Read authoritative blobs
|
|
632
|
+
// from its exact Git commit, not symlink targets or a mutable worktree. No fetch or state write.
|
|
633
|
+
/** @param {{snapshot:any,repo:string,devMd:string,room?:any,expectedOrigin?:string,now?:string|number}} input */
|
|
634
|
+
export function loadSnapshotPolicy({ snapshot, repo, devMd, room = parseControlRoomReference(devMd), expectedOrigin, now = Date.now() }) {
|
|
635
|
+
const refused = reason => {
|
|
636
|
+
const result = resolvePolicy({ repo: devMd, identity: { repo }, freshness: { configured: true, now } })
|
|
637
|
+
result.blocks.unshift(reason); result.ok = false
|
|
638
|
+
return result
|
|
639
|
+
}
|
|
640
|
+
if (!room || !repoPattern.test(repo) || !object(snapshot) || snapshot.schemaVersion !== 2 || snapshot.org !== room.org
|
|
641
|
+
|| snapshot.repository !== room.repo || snapshot.group !== room.group || !shaPattern.test(snapshot.sourceCommit ?? '')
|
|
642
|
+
|| !digestPattern.test(snapshot.policyDigest ?? '') || typeof snapshot.contentPath !== 'string' || !isAbsolute(snapshot.contentPath) || typeof snapshot.origin !== 'string') return refused('no matching per-repository validated policy snapshot')
|
|
643
|
+
const canonicalOrigin = 'https://github.com/' + room.repo + '.git'
|
|
644
|
+
if (snapshot.origin !== (expectedOrigin ?? canonicalOrigin)) return refused('snapshot origin does not match the verified control-room connection')
|
|
645
|
+
try {
|
|
646
|
+
const root = lstatSync(snapshot.contentPath)
|
|
647
|
+
if (!root.isDirectory() || root.isSymbolicLink()) return refused('snapshot content path is not an immutable managed directory')
|
|
648
|
+
if (gitRead(snapshot.contentPath, ['rev-parse', 'HEAD']).trim() !== snapshot.sourceCommit
|
|
649
|
+
|| gitRead(snapshot.contentPath, ['remote', 'get-url', 'origin']).trim() !== snapshot.origin
|
|
650
|
+
|| gitRead(snapshot.contentPath, ['status', '--porcelain', '--untracked-files=all']).trim()) return refused('snapshot content/origin/source identity changed')
|
|
651
|
+
const entries = new Map(gitRead(snapshot.contentPath, ['ls-tree', '-r', '-z', snapshot.sourceCommit]).split('\0').filter(Boolean).map(line => {
|
|
652
|
+
const match = /^(\d{6}) (blob|tree|commit) ([a-f0-9]{40})\t([\s\S]+)$/.exec(line)
|
|
653
|
+
if (!match) throw new Error('invalid snapshot tree entry')
|
|
654
|
+
return [match[4], { mode: match[1], type: match[2], oid: match[3] }]
|
|
655
|
+
}))
|
|
656
|
+
const peoplePaths = [...entries.keys()].filter(path => path === 'people.csv' || /^groups\/[a-z0-9][a-z0-9-]{0,63}\/people\.csv$/.test(path))
|
|
657
|
+
const paths = ['org.md', ...(room.group ? ['groups/' + room.group + '/group.md'] : []), 'repos.md', ...peoplePaths]
|
|
658
|
+
for (const path of paths) {
|
|
659
|
+
const entry = entries.get(path)
|
|
660
|
+
if (!entry || !['100644', '100755'].includes(entry.mode) || entry.type !== 'blob') throw new Error(`snapshot policy file is missing or not a regular blob: ${path}`)
|
|
661
|
+
}
|
|
662
|
+
const blobs = gitReadBlobs(snapshot.contentPath, unique(paths.map(path => entries.get(path).oid)))
|
|
663
|
+
const read = path => blobs.get(entries.get(path).oid)
|
|
664
|
+
const org = read('org.md'), group = room.group ? read('groups/' + room.group + '/group.md') : ''
|
|
665
|
+
const registry = parseRepositoryRegistry(read('repos.md'))
|
|
666
|
+
if (registry.blocks.length || !own(registry.repoGroups, repo) || (room.group !== null && registry.repoGroups[repo] !== room.group)) return refused('code repository/group is missing or conflicts with the snapshot registry')
|
|
667
|
+
const peopleByScope = {}
|
|
668
|
+
for (const path of peoplePaths) {
|
|
669
|
+
const parsed = parsePeopleRegistry(read(path))
|
|
670
|
+
if (parsed.blocks.length) return refused(parsed.blocks.join('; '))
|
|
671
|
+
peopleByScope[path === 'people.csv' ? 'org' : path.split('/')[1]] = parsed.people
|
|
672
|
+
}
|
|
673
|
+
const result = resolvePolicy({ org, group, repo: devMd,
|
|
674
|
+
identity: { org: room.org, repo, group: room.group, roomSha: snapshot.sourceCommit, peopleByScope, repoGroups: registry.repoGroups, repositoryIds: registry.repositoryIds },
|
|
675
|
+
freshness: { configured: true, now, validatedAt: snapshot.validatedAt } })
|
|
676
|
+
if (result.policy.policyDigest !== snapshot.policyDigest) { result.blocks.push('snapshot effective policy digest changed; validate this repository profile again'); result.ok = false }
|
|
677
|
+
return result
|
|
678
|
+
} catch (error) { return refused(`snapshot policy read refused: ${error.message}`) }
|
|
679
|
+
}
|
|
680
|
+
|
|
681
|
+
// Existing settings, separate bindings per code repo. This is a read adapter, not a migration
|
|
682
|
+
// or a snapshot publisher. Missing/legacy/foreign slots cannot refresh validation time.
|
|
683
|
+
/** @param {{home:string,repo:string,devMd:string,now?:string|number}} input */
|
|
684
|
+
export function loadConfiguredPolicy({ home, repo, devMd, now = Date.now() }) {
|
|
685
|
+
const room = parseControlRoomReference(devMd)
|
|
686
|
+
if (!room) return resolvePolicy({ repo: devMd, identity: { repo }, freshness: { configured: false, now } })
|
|
687
|
+
let entry
|
|
688
|
+
try {
|
|
689
|
+
const settings = policyJson(readFileSync(join(home, '.vegastack', 'factory.json'), 'utf8'))
|
|
690
|
+
if (!object(settings) || ![1, 2].includes(settings.schemaVersion)) throw new Error('unknown settings schema')
|
|
691
|
+
entry = settings.controlRooms?.[room.org]
|
|
692
|
+
} catch { /* Missing/malformed settings are an unavailable configured policy, not defaults. */ }
|
|
693
|
+
return loadSnapshotPolicy({ snapshot: entry?.snapshots?.[repo], repo, devMd, room, expectedOrigin: entry?.remote, now })
|
|
694
|
+
}
|