@mnstry/atelier 0.2.0-alpha.4 → 0.2.0-alpha.6
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/CHANGELOG.md +77 -0
- package/README.md +61 -18
- package/contracts/atelier-repository-observation.v1.schema.json +163 -0
- package/contracts/public-api-baseline.json +57 -0
- package/docs/assurance-controls.md +41 -0
- package/docs/atelier-runtime.md +28 -2
- package/docs/atelier-sync.md +171 -0
- package/docs/blocks/claims.md +23 -12
- package/docs/blocks/will-not-do.md +9 -3
- package/docs/design.md +12 -6
- package/docs/install.md +26 -4
- package/docs/knowledge-graph.md +8 -4
- package/docs/local-services.md +101 -0
- package/docs/release-engineering.md +86 -12
- package/docs/repo-boundary-guard.md +12 -2
- package/docs/upgrade.md +40 -2
- package/fixtures/atelier-repository-observation/invalid/complete-with-blocker.v1.json +18 -0
- package/fixtures/atelier-repository-observation/valid/complete-local.v1.json +48 -0
- package/fixtures/projects/sample-workspace/content/source.html.kg.json +4 -1
- package/fixtures/projects/source-formats-workspace/content/data.json.kg.json +4 -1
- package/fixtures/projects/source-formats-workspace/content/logo.png.kg.json +4 -1
- package/fixtures/projects/source-formats-workspace/content/metrics.csv.kg.json +4 -1
- package/fixtures/projects/source-formats-workspace/content/pipeline.yaml.kg.json +4 -1
- package/package.json +16 -5
- package/skills/claude/atelier-local-service/SKILL.md +47 -0
- package/skills/claude/atelier-public-boundary/SKILL.md +31 -0
- package/skills/codex/atelier-local-service/SKILL.md +47 -0
- package/skills/codex/atelier-public-boundary/SKILL.md +31 -0
- package/src/boundary/content-rules.mjs +283 -20
- package/src/boundary/policy.mjs +162 -72
- package/src/cli/execute-command.mjs +36 -0
- package/src/cli/run.mjs +35 -7
- package/src/collaboration/event-ledger.mjs +365 -0
- package/src/collaboration/index.mjs +17 -0
- package/src/collaboration/proposals.mjs +265 -65
- package/src/commands/attestation.mjs +20 -6
- package/src/commands/disclosure.mjs +133 -0
- package/src/commands/distribution.mjs +2 -1
- package/src/commands/extension-pack.mjs +2 -1
- package/src/commands/init.mjs +2 -1
- package/src/commands/server.mjs +1 -4
- package/src/commands/sync.mjs +100 -0
- package/src/contracts/corpus.mjs +6 -0
- package/src/disclosure/content-scan.mjs +193 -0
- package/src/egress/check.mjs +7 -38
- package/src/egress/forbidden-egress.mjs +32 -18
- package/src/graph/graph.mjs +112 -314
- package/src/graph/knowledge-graph.mjs +94 -18
- package/src/harness/context-client.mjs +9 -1
- package/src/index.mjs +41 -0
- package/src/project/config.mjs +89 -28
- package/src/project/file-class.mjs +14 -0
- package/src/project/package-root.mjs +10 -0
- package/src/project/path-match.mjs +38 -15
- package/src/project/private-state.mjs +110 -0
- package/src/runtime/git-adapter.mjs +189 -0
- package/src/runtime/local-state.mjs +439 -0
- package/src/runtime/repository-observation.mjs +491 -0
- package/src/runtime/supervisor.mjs +788 -0
- package/src/server/local-sidecar.mjs +81 -59
- package/src/server/security.mjs +89 -4
- package/src/server/server.mjs +3 -2
- package/src/support/feedback-report.mjs +4 -3
- package/src/upgrade/upgrade.mjs +2 -1
package/src/boundary/policy.mjs
CHANGED
|
@@ -4,14 +4,14 @@ import path from 'node:path'
|
|
|
4
4
|
import { buildGraph } from '../graph/graph.mjs'
|
|
5
5
|
import { commandProject, firstString, parseArgs, readJson, resolvePathValue, writeJson } from '../project/config.mjs'
|
|
6
6
|
import { matchesPathPattern } from '../project/path-match.mjs'
|
|
7
|
+
import { sanitizedGitEnvironment } from '../runtime/git-adapter.mjs'
|
|
7
8
|
import {
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
parsePushRefUpdates,
|
|
9
|
+
CHECK_AGGREGATE_MAX_BYTES,
|
|
10
|
+
parsePushRefInput,
|
|
11
11
|
resolveContentRules,
|
|
12
|
-
|
|
12
|
+
scanPushUpdate,
|
|
13
|
+
scanStagedRepository,
|
|
13
14
|
scanTree,
|
|
14
|
-
stagedDiff,
|
|
15
15
|
validateContentRuleExceptions,
|
|
16
16
|
validateContentRules,
|
|
17
17
|
} from './content-rules.mjs'
|
|
@@ -207,16 +207,16 @@ export function validateBoundaryPolicy(policy, project = null) {
|
|
|
207
207
|
return errors
|
|
208
208
|
}
|
|
209
209
|
|
|
210
|
-
export function resolveCurrentActor({ policy, project, actor = null, env = process.env } = {}) {
|
|
210
|
+
export function resolveCurrentActor({ policy, project, actor = null, env = process.env, gitExecutable = 'git', allowNetworkActorResolution = true, allowHistoryActorResolution = true } = {}) {
|
|
211
211
|
const actors = policy?.actors ?? {}
|
|
212
212
|
const explicit = actor || env.MNSTRY_ATELIER_ACTOR || env.GITHUB_ACTOR
|
|
213
213
|
if (explicit && actors[explicit]) return { actorId: explicit, source: 'explicit' }
|
|
214
|
-
const gitEmails = gitEmailsForProject(project)
|
|
214
|
+
const gitEmails = gitEmailsForProject(project, { gitExecutable, env, allowHistoryActorResolution })
|
|
215
215
|
for (const [actorId, info] of Object.entries(actors)) {
|
|
216
216
|
const actorEmails = new Set(asArray(info.gitEmails).map((email) => email.toLowerCase()))
|
|
217
217
|
if (gitEmails.some((email) => actorEmails.has(email.toLowerCase()))) return { actorId, source: 'git-email', gitEmails }
|
|
218
218
|
}
|
|
219
|
-
const login = env.GITHUB_ACTOR || ghLogin()
|
|
219
|
+
const login = env.GITHUB_ACTOR || (allowNetworkActorResolution ? ghLogin() : null)
|
|
220
220
|
if (login) {
|
|
221
221
|
for (const [actorId, info] of Object.entries(actors)) {
|
|
222
222
|
if (String(info.githubLogin || '').toLowerCase() === String(login).toLowerCase()) return { actorId, source: 'github-login', githubLogin: login }
|
|
@@ -225,13 +225,17 @@ export function resolveCurrentActor({ policy, project, actor = null, env = proce
|
|
|
225
225
|
return { actorId: null, source: 'unverified', gitEmails, githubLogin: login || null }
|
|
226
226
|
}
|
|
227
227
|
|
|
228
|
-
function gitEmailsForProject(project) {
|
|
228
|
+
function gitEmailsForProject(project, { gitExecutable = 'git', env = process.env, allowHistoryActorResolution = true } = {}) {
|
|
229
229
|
const roots = unique([project?.repoOpsRoot, project?.workspaceRoot, ...managedRepos(project).map((repo) => repo.path)])
|
|
230
230
|
const emails = []
|
|
231
231
|
for (const root of roots) {
|
|
232
232
|
if (!root || !fs.existsSync(root)) continue
|
|
233
|
-
const
|
|
234
|
-
if (
|
|
233
|
+
const probes = [['config', 'user.email']]
|
|
234
|
+
if (allowHistoryActorResolution) probes.push(['log', '-1', '--format=%ae'])
|
|
235
|
+
for (const args of probes) {
|
|
236
|
+
const result = spawnSync(gitExecutable, ['-C', root, ...args], { encoding: 'utf8', env: sanitizedGitEnvironment(env) })
|
|
237
|
+
if (result.status === 0 && result.stdout.trim()) emails.push(result.stdout.trim())
|
|
238
|
+
}
|
|
235
239
|
}
|
|
236
240
|
return unique(emails)
|
|
237
241
|
}
|
|
@@ -276,10 +280,10 @@ function nodePlacementFindings({ node, policy }) {
|
|
|
276
280
|
return findings
|
|
277
281
|
}
|
|
278
282
|
|
|
279
|
-
function actorFindings({ policy, project, actor }) {
|
|
283
|
+
function actorFindings({ policy, project, actor, gitExecutable, allowNetworkActorResolution, allowHistoryActorResolution, forceActorErrors }) {
|
|
280
284
|
const findings = []
|
|
281
|
-
const current = resolveCurrentActor({ policy, project, actor })
|
|
282
|
-
const severity = severityFor(policy)
|
|
285
|
+
const current = resolveCurrentActor({ policy, project, actor, gitExecutable, allowNetworkActorResolution, allowHistoryActorResolution })
|
|
286
|
+
const severity = forceActorErrors ? 'error' : severityFor(policy)
|
|
283
287
|
for (const [repoName, repo] of Object.entries(policy.repos ?? {})) {
|
|
284
288
|
if (repo.kind !== 'private_domain') continue
|
|
285
289
|
if (!repo.ownerActor) continue
|
|
@@ -292,11 +296,11 @@ function actorFindings({ policy, project, actor }) {
|
|
|
292
296
|
return findings
|
|
293
297
|
}
|
|
294
298
|
|
|
295
|
-
export function stagedPathsForProject(project) {
|
|
299
|
+
export function stagedPathsForProject(project, { gitExecutable = 'git' } = {}) {
|
|
296
300
|
const paths = []
|
|
297
301
|
for (const repo of managedRepos(project)) {
|
|
298
302
|
if (!repo.path || !fs.existsSync(path.join(repo.path, '.git'))) continue
|
|
299
|
-
const result = spawnSync(
|
|
303
|
+
const result = spawnSync(gitExecutable, ['-C', repo.path, 'diff', '--cached', '--name-only', '--diff-filter=ACMR'], { encoding: 'utf8', env: sanitizedGitEnvironment() })
|
|
300
304
|
if (result.status !== 0) continue
|
|
301
305
|
for (const rel of result.stdout.split('\n').map((line) => line.trim()).filter(Boolean)) {
|
|
302
306
|
paths.push({ repo: repo.name, repoRoot: repo.path, path: normalize(rel) })
|
|
@@ -308,7 +312,7 @@ export function stagedPathsForProject(project) {
|
|
|
308
312
|
function forbiddenPathFindings({ policy, stagedPaths }) {
|
|
309
313
|
const patterns = [...DEFAULT_FORBIDDEN_PATHS, ...asArray(policy.forbiddenPaths)]
|
|
310
314
|
const findings = []
|
|
311
|
-
const severity =
|
|
315
|
+
const severity = 'error'
|
|
312
316
|
for (const item of stagedPaths) {
|
|
313
317
|
const matched = patterns.find((pattern) => matchesPathPattern(pattern, item.path))
|
|
314
318
|
if (matched) {
|
|
@@ -390,13 +394,20 @@ export function semanticChangesInFile(file) {
|
|
|
390
394
|
return changes
|
|
391
395
|
}
|
|
392
396
|
|
|
393
|
-
function semanticDiffFindings({ policy, project }) {
|
|
397
|
+
function semanticDiffFindings({ policy, project, gitExecutable = 'git' }) {
|
|
394
398
|
const findings = []
|
|
395
|
-
const severity =
|
|
399
|
+
const severity = 'error'
|
|
396
400
|
for (const repo of managedRepos(project)) {
|
|
397
|
-
if (!repo.path || !fs.existsSync(path.join(repo.path, '.git')))
|
|
398
|
-
|
|
399
|
-
|
|
401
|
+
if (!repo.path || !fs.existsSync(path.join(repo.path, '.git'))) {
|
|
402
|
+
findings.push(unscannableRepoFinding(repo, 'configured path is absent or is not a Git checkout'))
|
|
403
|
+
continue
|
|
404
|
+
}
|
|
405
|
+
const result = spawnSync(gitExecutable, ['-C', repo.path, 'diff', '--cached', '--unified=0', '--', '*.md', '*.kg.json'], { encoding: 'utf8', env: sanitizedGitEnvironment() })
|
|
406
|
+
if (result.status !== 0) {
|
|
407
|
+
findings.push(unscannableRepoFinding(repo, 'git diff --cached failed'))
|
|
408
|
+
continue
|
|
409
|
+
}
|
|
410
|
+
if (!result.stdout.trim()) continue
|
|
400
411
|
for (const file of diffFileSections(result.stdout)) {
|
|
401
412
|
const changes = semanticChangesInFile(file)
|
|
402
413
|
if (!changes.length) continue
|
|
@@ -419,27 +430,32 @@ function semanticDiffFindings({ policy, project }) {
|
|
|
419
430
|
return findings
|
|
420
431
|
}
|
|
421
432
|
|
|
422
|
-
function
|
|
433
|
+
function stagedContentFindings({ policy, project, gitExecutable = 'git' }) {
|
|
423
434
|
const rules = resolveContentRules(policy)
|
|
424
435
|
const exceptions = asArray(policy?.contentRuleExceptions)
|
|
425
436
|
const findings = []
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
437
|
+
let totalBytes = 0
|
|
438
|
+
for (const repo of managedRepos(project)) {
|
|
439
|
+
if (!repo.path || !fs.existsSync(path.join(repo.path, '.git'))) {
|
|
440
|
+
findings.push(unscannableRepoFinding(repo, 'configured path is absent or is not a Git checkout'))
|
|
441
|
+
continue
|
|
442
|
+
}
|
|
443
|
+
const result = scanStagedRepository({ repoRoot: repo.path, rules, exceptions, repo: repo.name, gitExecutable })
|
|
444
|
+
findings.push(...result.findings, ...result.diagnostics)
|
|
445
|
+
totalBytes += result.bytes
|
|
446
|
+
if (totalBytes > CHECK_AGGREGATE_MAX_BYTES) {
|
|
447
|
+
findings.push(finding({
|
|
448
|
+
severity: 'error',
|
|
449
|
+
code: 'content-scan-incomplete',
|
|
450
|
+
repo: repo.name,
|
|
451
|
+
message: `staged evidence exceeds the ${CHECK_AGGREGATE_MAX_BYTES}-byte aggregate limit`,
|
|
452
|
+
}))
|
|
453
|
+
break
|
|
431
454
|
}
|
|
432
455
|
}
|
|
433
456
|
return findings
|
|
434
457
|
}
|
|
435
458
|
|
|
436
|
-
function stagedContentFindings({ policy, project }) {
|
|
437
|
-
const files = managedRepos(project)
|
|
438
|
-
.filter((repo) => repo.path && fs.existsSync(path.join(repo.path, '.git')))
|
|
439
|
-
.map((repo) => ({ repo: repo.name, files: parseAddedContent(stagedDiff(repo.path)) }))
|
|
440
|
-
return contentRuleFindings({ policy, project, files })
|
|
441
|
-
}
|
|
442
|
-
|
|
443
459
|
function realPath(value) {
|
|
444
460
|
try {
|
|
445
461
|
return fs.realpathSync(path.resolve(value))
|
|
@@ -467,7 +483,7 @@ function repoForCwd(project, cwd) {
|
|
|
467
483
|
* which reports without blocking, so an accepted usage elsewhere in the repo can
|
|
468
484
|
* never strand unrelated work on the machine.
|
|
469
485
|
*/
|
|
470
|
-
export function checkPushContent({ project, policy, repo, updates, cwd = process.cwd() } = {}) {
|
|
486
|
+
export function checkPushContent({ project, policy, repo, updates, cwd = process.cwd(), gitRunner = spawnSync } = {}) {
|
|
471
487
|
const target = repo ?? repoForCwd(project, cwd)
|
|
472
488
|
if (!target) {
|
|
473
489
|
// Fail closed. This hook is only installed into managed repos, so failing to
|
|
@@ -480,20 +496,41 @@ export function checkPushContent({ project, policy, repo, updates, cwd = process
|
|
|
480
496
|
})
|
|
481
497
|
return { ok: false, repo: null, findings: [unresolved], errors: [unresolved], warnings: [] }
|
|
482
498
|
}
|
|
483
|
-
const
|
|
484
|
-
|
|
485
|
-
const findings =
|
|
499
|
+
const rules = resolveContentRules(policy)
|
|
500
|
+
const exceptions = asArray(policy?.contentRuleExceptions)
|
|
501
|
+
const findings = []
|
|
502
|
+
let totalBytes = 0
|
|
503
|
+
for (const update of updates) {
|
|
504
|
+
const result = scanPushUpdate({ repoRoot: target.path, update, rules, exceptions, repo: target.name, gitRunner })
|
|
505
|
+
findings.push(...result.findings, ...result.diagnostics)
|
|
506
|
+
totalBytes += result.bytes
|
|
507
|
+
if (totalBytes > CHECK_AGGREGATE_MAX_BYTES) {
|
|
508
|
+
findings.push(finding({
|
|
509
|
+
severity: 'error',
|
|
510
|
+
code: 'content-scan-incomplete',
|
|
511
|
+
repo: target.name,
|
|
512
|
+
message: `push evidence exceeds the ${CHECK_AGGREGATE_MAX_BYTES}-byte aggregate limit`,
|
|
513
|
+
}))
|
|
514
|
+
break
|
|
515
|
+
}
|
|
516
|
+
}
|
|
486
517
|
const errors = findings.filter((item) => item.severity === 'error')
|
|
487
518
|
return { ok: errors.length === 0, repo: target.name, updates, findings, errors, warnings: findings.filter((item) => item.severity !== 'error') }
|
|
488
519
|
}
|
|
489
520
|
|
|
490
|
-
export function auditContentRules({ project, policy } = {}) {
|
|
521
|
+
export function auditContentRules({ project, policy, source = 'working-tree' } = {}) {
|
|
491
522
|
const rules = resolveContentRules(policy)
|
|
492
523
|
const exceptions = asArray(policy?.contentRuleExceptions)
|
|
493
524
|
const findings = []
|
|
525
|
+
const diagnostics = []
|
|
494
526
|
for (const repo of managedRepos(project)) {
|
|
495
|
-
if (!repo.path || !fs.existsSync(path.join(repo.path, '.git')))
|
|
496
|
-
|
|
527
|
+
if (!repo.path || !fs.existsSync(path.join(repo.path, '.git'))) {
|
|
528
|
+
diagnostics.push(unscannableRepoFinding(repo, 'configured path is absent or is not a Git checkout'))
|
|
529
|
+
continue
|
|
530
|
+
}
|
|
531
|
+
const result = scanTree(repo.path, { rules, exceptions, repo: repo.name, source })
|
|
532
|
+
findings.push(...result.findings)
|
|
533
|
+
diagnostics.push(...result.diagnostics)
|
|
497
534
|
}
|
|
498
535
|
const accepted = []
|
|
499
536
|
for (const repo of managedRepos(project)) {
|
|
@@ -502,7 +539,16 @@ export function auditContentRules({ project, policy } = {}) {
|
|
|
502
539
|
accepted.push({ repo: repo.name, rule: exception.rule, paths: exception.paths, reason: exception.reason })
|
|
503
540
|
}
|
|
504
541
|
}
|
|
505
|
-
return { schema: BOUNDARY_POLICY_SCHEMA, findings, accepted }
|
|
542
|
+
return { schema: BOUNDARY_POLICY_SCHEMA, source, findings, diagnostics, accepted }
|
|
543
|
+
}
|
|
544
|
+
|
|
545
|
+
function unscannableRepoFinding(repo, reason) {
|
|
546
|
+
return finding({
|
|
547
|
+
severity: 'error',
|
|
548
|
+
code: 'repo-unscannable',
|
|
549
|
+
repo: repo?.name ?? null,
|
|
550
|
+
message: `${repo?.name ?? '(unnamed repo)'} cannot be scanned: ${reason}`,
|
|
551
|
+
})
|
|
506
552
|
}
|
|
507
553
|
|
|
508
554
|
function promotionRecords(project, policy) {
|
|
@@ -557,24 +603,22 @@ function promotionFindings({ policy, project, graph }) {
|
|
|
557
603
|
return findings
|
|
558
604
|
}
|
|
559
605
|
|
|
560
|
-
export function checkBoundaryPolicy({ project, policy, staged = false, stagedOnly = false, actor = null } = {}) {
|
|
606
|
+
export function checkBoundaryPolicy({ project, policy, staged = false, stagedOnly = false, actor = null, gitExecutable = 'git', allowNetworkActorResolution = true, allowHistoryActorResolution = true, forceActorErrors = false } = {}) {
|
|
561
607
|
const validationErrors = validateBoundaryPolicy(policy, project)
|
|
562
608
|
let graph = null
|
|
563
|
-
const findings = validationErrors.map((message) => finding({ severity:
|
|
564
|
-
if (
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
findings.push(...stagedContentFindings({ policy, project }))
|
|
577
|
-
}
|
|
609
|
+
const findings = validationErrors.map((message) => finding({ severity: 'error', code: 'boundary-policy-invalid', message }))
|
|
610
|
+
if (!stagedOnly) {
|
|
611
|
+
graph = buildGraph(project)
|
|
612
|
+
findings.push(...graph.errors.map((message) => finding({ severity: 'error', code: 'knowledge-graph-invalid', message })))
|
|
613
|
+
for (const node of graph.nodes ?? []) findings.push(...nodePlacementFindings({ node, policy }))
|
|
614
|
+
findings.push(...promotionFindings({ policy, project, graph }))
|
|
615
|
+
}
|
|
616
|
+
findings.push(...actorFindings({ policy, project, actor, gitExecutable, allowNetworkActorResolution, allowHistoryActorResolution, forceActorErrors }))
|
|
617
|
+
if (staged) {
|
|
618
|
+
const stagedPaths = stagedPathsForProject(project, { gitExecutable })
|
|
619
|
+
findings.push(...forbiddenPathFindings({ policy, stagedPaths }))
|
|
620
|
+
findings.push(...semanticDiffFindings({ policy, project, gitExecutable }))
|
|
621
|
+
findings.push(...stagedContentFindings({ policy, project, gitExecutable }))
|
|
578
622
|
}
|
|
579
623
|
const errors = findings.filter((item) => item.severity === 'error')
|
|
580
624
|
const warnings = findings.filter((item) => item.severity !== 'error')
|
|
@@ -616,12 +660,12 @@ export function installBoundaryHooks({ project, force = false } = {}) {
|
|
|
616
660
|
const skipped = []
|
|
617
661
|
for (const repo of managedRepos(project)) {
|
|
618
662
|
if (!repo.path || !fs.existsSync(path.join(repo.path, '.git'))) continue
|
|
619
|
-
const hooksDir =
|
|
663
|
+
const hooksDir = gitHooksDir(repo.path)
|
|
620
664
|
fs.mkdirSync(hooksDir, { recursive: true })
|
|
621
665
|
for (const hookName of ['pre-commit', 'pre-push']) {
|
|
622
666
|
const hookPath = path.join(hooksDir, hookName)
|
|
623
667
|
const existing = fs.existsSync(hookPath) ? fs.readFileSync(hookPath, 'utf8') : ''
|
|
624
|
-
const script =
|
|
668
|
+
const script = boundaryHookScript(project.configPath, hookName, repo.path)
|
|
625
669
|
if (existing && !existing.includes('MNSTRY_ATELIER_BOUNDARY_GUARD') && !force) {
|
|
626
670
|
const sidecar = `${hookPath}.mnstry-atelier-boundary`
|
|
627
671
|
fs.writeFileSync(sidecar, script)
|
|
@@ -637,8 +681,32 @@ export function installBoundaryHooks({ project, force = false } = {}) {
|
|
|
637
681
|
return { installed, skipped }
|
|
638
682
|
}
|
|
639
683
|
|
|
640
|
-
function
|
|
641
|
-
const
|
|
684
|
+
function gitHooksDir(repoPath) {
|
|
685
|
+
const result = spawnSync('git', ['-C', repoPath, 'rev-parse', '--git-path', 'hooks'], { encoding: 'utf8' })
|
|
686
|
+
if (result.status !== 0 || !result.stdout.trim()) throw new Error(`cannot resolve Git hooks directory for ${repoPath}`)
|
|
687
|
+
const value = result.stdout.trim()
|
|
688
|
+
return path.isAbsolute(value) ? value : path.resolve(repoPath, value)
|
|
689
|
+
}
|
|
690
|
+
|
|
691
|
+
function shellSingleQuote(value) {
|
|
692
|
+
const text = String(value ?? '')
|
|
693
|
+
if (!text || /[\0\r\n]/.test(text)) throw new Error('project config path contains unsupported control characters')
|
|
694
|
+
return `'${text.replaceAll("'", `'"'"'`)}'`
|
|
695
|
+
}
|
|
696
|
+
|
|
697
|
+
export function boundaryHookScript(projectConfigPath, hookName, repoPath) {
|
|
698
|
+
const relativeConfigPath = projectConfigPath && repoPath ? path.relative(repoPath, projectConfigPath) : null
|
|
699
|
+
const configLivesInRepo = relativeConfigPath && relativeConfigPath !== '..' && !relativeConfigPath.startsWith(`..${path.sep}`) && !path.isAbsolute(relativeConfigPath)
|
|
700
|
+
const portableRelativeConfig = configLivesInRepo ? relativeConfigPath.replaceAll(path.sep, '/') : null
|
|
701
|
+
const configSetup = configLivesInRepo
|
|
702
|
+
? `ATELIER_HOOK_REPO_ROOT="$(git rev-parse --show-toplevel)"
|
|
703
|
+
ATELIER_HOOK_PROJECT_CONFIG="$ATELIER_HOOK_REPO_ROOT"/${shellSingleQuote(portableRelativeConfig)}`
|
|
704
|
+
: ''
|
|
705
|
+
const config = configLivesInRepo
|
|
706
|
+
? '--project-config="$ATELIER_HOOK_PROJECT_CONFIG"'
|
|
707
|
+
: projectConfigPath
|
|
708
|
+
? `--project-config=${shellSingleQuote(projectConfigPath)}`
|
|
709
|
+
: ''
|
|
642
710
|
// pre-push reads the ref updates git writes to stdin and judges only that range;
|
|
643
711
|
// pre-commit judges the staged diff. Neither scans the whole tree — that view is
|
|
644
712
|
// `atelier boundary audit`, which reports without blocking.
|
|
@@ -646,6 +714,7 @@ function hookScript(projectConfigPath, hookName) {
|
|
|
646
714
|
return `#!/usr/bin/env bash
|
|
647
715
|
# MNSTRY_ATELIER_BOUNDARY_GUARD ${hookName}
|
|
648
716
|
set -euo pipefail
|
|
717
|
+
${configSetup}
|
|
649
718
|
if command -v atelier >/dev/null 2>&1; then
|
|
650
719
|
exec atelier ${invocation}
|
|
651
720
|
elif [ -x "./node_modules/.bin/atelier" ]; then
|
|
@@ -663,7 +732,7 @@ fi
|
|
|
663
732
|
`
|
|
664
733
|
}
|
|
665
734
|
|
|
666
|
-
export function runBoundaryPushCheckCommand(argv = process.argv.slice(2), { stdin =
|
|
735
|
+
export function runBoundaryPushCheckCommand(argv = process.argv.slice(2), { stdin = readBoundaryPushInput() } = {}) {
|
|
667
736
|
const args = parseArgs(argv)
|
|
668
737
|
const project = commandProject({ argv })
|
|
669
738
|
const loaded = loadBoundaryPolicy(project)
|
|
@@ -671,12 +740,23 @@ export function runBoundaryPushCheckCommand(argv = process.argv.slice(2), { stdi
|
|
|
671
740
|
console.error(loaded.errors.join('\n'))
|
|
672
741
|
process.exit(1)
|
|
673
742
|
}
|
|
674
|
-
const
|
|
675
|
-
|
|
743
|
+
const input = stdin && typeof stdin === 'object' && Object.hasOwn(stdin, 'ok')
|
|
744
|
+
? stdin
|
|
745
|
+
: { ok: true, text: String(stdin ?? '') }
|
|
746
|
+
if (!input.ok) {
|
|
747
|
+
console.error('[boundary:push-check] could not read pre-push ref updates')
|
|
748
|
+
process.exit(1)
|
|
749
|
+
}
|
|
750
|
+
const parsed = parsePushRefInput(input.text)
|
|
751
|
+
if (!parsed.ok) {
|
|
752
|
+
console.error(`[boundary:push-check] invalid pre-push input: ${parsed.issues.join('; ')}`)
|
|
753
|
+
process.exit(1)
|
|
754
|
+
}
|
|
755
|
+
if (parsed.kind === 'empty') {
|
|
676
756
|
console.log('[boundary:push-check] no ref updates on stdin · nothing to judge')
|
|
677
757
|
process.exit(0)
|
|
678
758
|
}
|
|
679
|
-
const report = checkPushContent({ project, policy: loaded.policy, updates, cwd: process.cwd() })
|
|
759
|
+
const report = checkPushContent({ project, policy: loaded.policy, updates: parsed.updates, cwd: process.cwd() })
|
|
680
760
|
if (args.json) {
|
|
681
761
|
console.log(JSON.stringify(report, null, 2))
|
|
682
762
|
} else {
|
|
@@ -691,6 +771,10 @@ export function runBoundaryPushCheckCommand(argv = process.argv.slice(2), { stdi
|
|
|
691
771
|
process.exit(report.ok ? 0 : 1)
|
|
692
772
|
}
|
|
693
773
|
|
|
774
|
+
export function resolveBoundaryAuditSource(args = {}) {
|
|
775
|
+
return args.head ? 'head' : firstString(args.source) || 'working-tree'
|
|
776
|
+
}
|
|
777
|
+
|
|
694
778
|
export function runBoundaryAuditCommand(argv = process.argv.slice(2)) {
|
|
695
779
|
const args = parseArgs(argv)
|
|
696
780
|
const project = commandProject({ argv })
|
|
@@ -699,23 +783,29 @@ export function runBoundaryAuditCommand(argv = process.argv.slice(2)) {
|
|
|
699
783
|
console.error(loaded.errors.join('\n'))
|
|
700
784
|
process.exit(1)
|
|
701
785
|
}
|
|
702
|
-
const
|
|
786
|
+
const source = resolveBoundaryAuditSource(args)
|
|
787
|
+
if (!['working-tree', 'head'].includes(source)) {
|
|
788
|
+
console.error('boundary audit --source must be working-tree or head')
|
|
789
|
+
process.exit(1)
|
|
790
|
+
}
|
|
791
|
+
const report = auditContentRules({ project, policy: loaded.policy, source })
|
|
703
792
|
if (args.json) {
|
|
704
793
|
console.log(JSON.stringify(report, null, 2))
|
|
705
794
|
} else {
|
|
706
|
-
console.log(`[boundary:audit] ${report.findings.length} content-rule matches
|
|
795
|
+
console.log(`[boundary:audit] ${report.findings.length} content-rule matches · ${report.diagnostics.length} incomplete reads · ${report.accepted.length} declared exceptions · source ${report.source}`)
|
|
707
796
|
for (const item of report.findings.slice(0, 100)) console.log(`${item.severity} ${item.rule}: ${item.message}`)
|
|
797
|
+
for (const item of report.diagnostics.slice(0, 100)) console.log(`${item.severity} ${item.code}: ${item.message}`)
|
|
708
798
|
for (const item of report.accepted) console.log(`accepted ${item.rule} in ${item.repo} (${item.paths.join(', ')}): ${item.reason}`)
|
|
709
799
|
}
|
|
710
800
|
// Reporting only. A pre-existing accepted usage must never block unrelated work.
|
|
711
801
|
process.exit(0)
|
|
712
802
|
}
|
|
713
803
|
|
|
714
|
-
function
|
|
804
|
+
export function readBoundaryPushInput(reader = () => fs.readFileSync(0, 'utf8')) {
|
|
715
805
|
try {
|
|
716
|
-
return
|
|
806
|
+
return { ok: true, text: reader() }
|
|
717
807
|
} catch {
|
|
718
|
-
return ''
|
|
808
|
+
return { ok: false, text: '', error: 'stdin-read-failed' }
|
|
719
809
|
}
|
|
720
810
|
}
|
|
721
811
|
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
import path from 'node:path'
|
|
4
|
+
import { pathToFileURL } from 'node:url'
|
|
5
|
+
|
|
6
|
+
const [, , requestedScript, ...args] = process.argv
|
|
7
|
+
|
|
8
|
+
function renderError(error) {
|
|
9
|
+
const debug = process.env.ATELIER_DEBUG === '1'
|
|
10
|
+
if (debug) {
|
|
11
|
+
console.error(error?.stack || String(error))
|
|
12
|
+
return Number.isInteger(error?.exitCode) ? error.exitCode : 1
|
|
13
|
+
}
|
|
14
|
+
if (typeof error?.code === 'string' && error.code) {
|
|
15
|
+
console.error(`[${error.code}] ${error.message}`)
|
|
16
|
+
if (error.hint) console.error(`Next: ${error.hint}`)
|
|
17
|
+
return Number.isInteger(error.exitCode) ? error.exitCode : 2
|
|
18
|
+
}
|
|
19
|
+
console.error('[internal-error] command failed without a safe diagnostic')
|
|
20
|
+
console.error('Next: rerun with ATELIER_DEBUG=1 to inspect the stack locally.')
|
|
21
|
+
return 1
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
if (!requestedScript) {
|
|
25
|
+
console.error('[command-missing] no command module was selected')
|
|
26
|
+
process.exit(2)
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const scriptPath = path.resolve(requestedScript)
|
|
30
|
+
process.argv = [process.execPath, scriptPath, ...args]
|
|
31
|
+
|
|
32
|
+
try {
|
|
33
|
+
await import(pathToFileURL(scriptPath).href)
|
|
34
|
+
} catch (error) {
|
|
35
|
+
process.exitCode = renderError(error)
|
|
36
|
+
}
|
package/src/cli/run.mjs
CHANGED
|
@@ -51,11 +51,14 @@ export const commandMap = new Map([
|
|
|
51
51
|
['extension-pack:list', ['src/commands/extension-pack.mjs', 'list']],
|
|
52
52
|
['distribution', ['src/commands/distribution.mjs']],
|
|
53
53
|
['distribution:check', ['src/commands/distribution.mjs', 'check']],
|
|
54
|
+
['disclosure', ['src/commands/disclosure.mjs']],
|
|
55
|
+
['disclosure:check', ['src/commands/disclosure.mjs', 'check']],
|
|
54
56
|
['attestation', ['src/commands/attestation.mjs']],
|
|
55
57
|
['feedback', ['src/commands/feedback.mjs']],
|
|
56
58
|
['feedback:check', ['src/commands/feedback.mjs', 'check']],
|
|
57
59
|
['announcements', ['src/commands/announcements.mjs']],
|
|
58
60
|
['announcements:list', ['src/commands/announcements.mjs', 'list']],
|
|
61
|
+
['sync', ['src/commands/sync.mjs']],
|
|
59
62
|
])
|
|
60
63
|
|
|
61
64
|
function isDefaultBrand(brand) {
|
|
@@ -113,6 +116,12 @@ Core commands:
|
|
|
113
116
|
support bundle --dry-run Preview a no-send support bundle.
|
|
114
117
|
feedback --message TEXT Write a local, never-sent feedback report.
|
|
115
118
|
announcements list List and verify MNSTRY announcements.
|
|
119
|
+
sync enroll Explicitly enroll one repository for supervision.
|
|
120
|
+
sync status Fully observe the enrolled repository.
|
|
121
|
+
sync reconcile Fetch and perform only proven fast-forwards.
|
|
122
|
+
sync plan Prepare one bounded, reviewable commit plan.
|
|
123
|
+
sync commit Execute an exactly confirmed commit plan.
|
|
124
|
+
sync run --once Run one full-state supervisor cycle.
|
|
116
125
|
egress check Check extracted Atelier paths for forbidden egress.
|
|
117
126
|
boundary check Enforce private/shared repo placement rules.
|
|
118
127
|
boundary audit Report content-rule matches tree-wide without blocking.
|
|
@@ -125,6 +134,7 @@ Core commands:
|
|
|
125
134
|
extension-pack validate Validate declared extension packs.
|
|
126
135
|
extension-pack list List declared extension packs.
|
|
127
136
|
distribution check Check a distribution for MNSTRY attribution.
|
|
137
|
+
disclosure check Scan tracked or staged content for disclosure risks.
|
|
128
138
|
|
|
129
139
|
Attestation commands:
|
|
130
140
|
attestation hash FILE Print the canonical payload hash of a payload.
|
|
@@ -132,8 +142,10 @@ Attestation commands:
|
|
|
132
142
|
attestation verify FILE Verify an attestation against a public key file.
|
|
133
143
|
attestation keygen --key-id ID Generate a signing key pair.
|
|
134
144
|
|
|
135
|
-
|
|
136
|
-
|
|
145
|
+
Commands whose usage names --project accept --project=PATH or --project PATH.
|
|
146
|
+
The project resolver also accepts --project-config=PATH and
|
|
147
|
+
MNSTRY_ATELIER_PROJECT_CONFIG=PATH; each command's own help is authoritative.
|
|
148
|
+
Machine-local repo paths belong in
|
|
137
149
|
.atelier-local/, atelier.local.json, or atelier.workspace.local.json.`
|
|
138
150
|
}
|
|
139
151
|
|
|
@@ -152,9 +164,9 @@ Ensures ignored local Atelier state exists, verifies ignore coverage, and record
|
|
|
152
164
|
doctor: `Usage: ${c} doctor [--project ./atelier.project.json] [--fix] [--dry-run]
|
|
153
165
|
|
|
154
166
|
Reports project config, local overlay, and repo boundary readiness. --fix only repairs ignored local state.`,
|
|
155
|
-
boundary: `Usage: ${c} boundary check|push-check|audit|install-hooks [--project ./atelier.project.json] [--staged]
|
|
167
|
+
boundary: `Usage: ${c} boundary check|push-check|audit|install-hooks [--project ./atelier.project.json] [--staged] [--source=working-tree|head]
|
|
156
168
|
|
|
157
|
-
check --staged judges the staged diff. push-check reads pre-push ref updates on stdin and judges only the pushed range. audit scans the
|
|
169
|
+
check --staged judges the staged diff. push-check reads pre-push ref updates on stdin and judges only the pushed range. audit scans the working tree by default and reports without blocking; --source=head selects the committed snapshot.`,
|
|
158
170
|
graph: `Usage: ${c} graph [--check] [--project ./atelier.project.json]
|
|
159
171
|
|
|
160
172
|
Builds or checks the project knowledge graph from tracked sources plus ignored local path bindings.`,
|
|
@@ -175,21 +187,35 @@ Loads every extension pack declared under ext["mnstry.atelier"].extensionPacks i
|
|
|
175
187
|
distribution: `Usage: ${c} distribution check [--target DIR] [--pack DIR]
|
|
176
188
|
|
|
177
189
|
Checks a distribution package for the required MNSTRY attribution markers. Blocking: the distribution README.md byte check, and a CLI probe that EXECUTES the target's declared bin with --version (spawned with the current Node, cwd set to the target — only run this against distributions you trust) and requires the attribution in its output; a target that looks like a distribution but declares no probe-able bin, or ships a malformed package.json, is also blocking. The extension-pack manifest attribution key is advisory and reported only. The normative wording lives in TRADEMARKS.md under "Required attribution"; see also docs/attestation.md and docs/distributions.md.`,
|
|
190
|
+
disclosure: `Usage: ${c} disclosure check [--root DIR] [--staged] [--denylist FILE | --structural-only] [--fail-on-binary] [--untrusted]
|
|
191
|
+
|
|
192
|
+
Scans Git-tracked files, or staged index blobs with --staged, without following symlinks. A private denylist is required by default and must be supplied through ATELIER_DENYLIST_JSON, --denylist, or ignored .atelier-local/disclosure-denylist.json. --structural-only is the explicit no-denylist lane. --untrusted suppresses finding details.`,
|
|
178
193
|
attestation: `Usage:
|
|
179
194
|
${c} attestation hash <payload.json>
|
|
180
195
|
${c} attestation sign <attestation.json> [--key FILE] [--out FILE]
|
|
181
196
|
${c} attestation verify <attestation.json> --public-key FILE [--payload FILE] [--json]
|
|
182
197
|
${c} attestation keygen --key-id ID [--algorithm ed25519|es256] [--out FILE]
|
|
183
198
|
|
|
184
|
-
Records and checks admission decisions. hash prints the canonical payload hash (RFC 8785 JCS, SHA-256). sign reads the local signing key file. verify reads a public key file and exits 1 when it judges the attestation invalid. keygen writes the signing key file mode 0600, refuses to overwrite, and prints only the public key document.`,
|
|
199
|
+
Records and checks admission decisions. hash prints the canonical payload hash (RFC 8785 JCS, SHA-256). sign reads the local signing key file. verify reads a public key file and exits 1 when it judges the attestation invalid. keygen writes the signing key file mode 0600 on POSIX, refuses to overwrite, and prints only the public key document.`,
|
|
185
200
|
feedback: `Usage:
|
|
186
201
|
${c} feedback create --message TEXT | --message-file PATH [--context FILE] [--include-gates]
|
|
187
202
|
${c} feedback check FILE
|
|
188
203
|
|
|
189
|
-
Assembles a local feedback report under ignored .atelier-local/feedback/ (mode 0600), scanned with the support-bundle banned key and value patterns before writing; any match refuses the write naming pattern label and location only. Files given to --message-file and --context must be valid UTF-8 text of at most 262144 bytes. The scan is a backstop, not clearance: read the whole report before sharing it. The kit has no send path — sharing the file is always the user's own explicit act.`,
|
|
204
|
+
Assembles a local feedback report under ignored .atelier-local/feedback/ (mode 0600 on POSIX), scanned with the support-bundle banned key and value patterns before writing; any match refuses the write naming pattern label and location only. Files given to --message-file and --context must be valid UTF-8 text of at most 262144 bytes. The scan is a backstop, not clearance: read the whole report before sharing it. The kit has no send path — sharing the file is always the user's own explicit act.`,
|
|
190
205
|
announcements: `Usage: ${c} announcements list [--dir DIR] [--public-key FILE] | verify <file> [--public-key FILE] [--json] | show <file> [--public-key FILE]
|
|
191
206
|
|
|
192
207
|
MNSTRY announcements are a pull-only channel: signed JSON documents under announcements/ in the repository. The trust anchor is always the committed MNSTRY key, or one you pass explicitly with --public-key; --dir changes only where documents are read from and never which key verifies them. Every run names the key and keyId it used. The kit never fetches anything — receiving announcements is the git pull you chose to run, and show refuses to print a body whose signature does not verify.`,
|
|
208
|
+
sync: `Usage:
|
|
209
|
+
${c} sync enroll --repo DIR [--project atelier.project.json] [--git ABSOLUTE_PATH]
|
|
210
|
+
${c} sync status --repo DIR
|
|
211
|
+
${c} sync reconcile --repo DIR [--retries 3]
|
|
212
|
+
${c} sync run --repo DIR [--once] [--interval 30]
|
|
213
|
+
${c} sync plan --repo DIR --path FILE [--path FILE] --message TEXT [--publish]
|
|
214
|
+
${c} sync commit --repo DIR --operation ID --confirm ID
|
|
215
|
+
${c} sync pause|freeze|resume --repo DIR
|
|
216
|
+
${c} sync trace --repo DIR
|
|
217
|
+
|
|
218
|
+
Enrolls exactly one repository and keeps Git plus readable files authoritative. Reconciliation observes the complete repository every cycle and performs only fast-forward updates. Commit creation is a two-phase user-confirmed operation: plan shows one bounded change set, and commit refuses unless the repository is unchanged and --confirm exactly repeats the operation id. No semantic conflict resolution, force operation, browser apply endpoint, telemetry, or desktop shell is present.`,
|
|
193
219
|
}
|
|
194
220
|
return help[command] || `Usage: ${c} ${command} [args]\n\nRun ${c} --help for the command list.`
|
|
195
221
|
}
|
|
@@ -219,6 +245,7 @@ function normalizeArgs(argv) {
|
|
|
219
245
|
if (args[0] === 'extension-pack' && args[1] === 'validate') args.splice(0, 2, 'extension-pack:validate')
|
|
220
246
|
if (args[0] === 'extension-pack' && args[1] === 'list') args.splice(0, 2, 'extension-pack:list')
|
|
221
247
|
if (args[0] === 'distribution' && args[1] === 'check') args.splice(0, 2, 'distribution:check')
|
|
248
|
+
if (args[0] === 'disclosure' && args[1] === 'check') args.splice(0, 2, 'disclosure:check')
|
|
222
249
|
if (args[0] === 'support' && args[1] === 'bundle') args.splice(0, 2, 'support:bundle')
|
|
223
250
|
if (args[0] === 'support:bundle' && args[1] === '--dry-run') args.splice(1, 1)
|
|
224
251
|
return { help: false, args }
|
|
@@ -261,12 +288,13 @@ export async function runCli({
|
|
|
261
288
|
|
|
262
289
|
const [script, ...prefixArgs] = target
|
|
263
290
|
const scriptPath = path.join(packageRoot, script)
|
|
291
|
+
const executorPath = path.join(packageRoot, 'src', 'cli', 'execute-command.mjs')
|
|
264
292
|
if (!fs.existsSync(scriptPath)) {
|
|
265
293
|
stderr(`${brand.displayName} command is not available in this package install: ${command}`)
|
|
266
294
|
return 1
|
|
267
295
|
}
|
|
268
296
|
|
|
269
|
-
const result = spawnSync(process.execPath, [scriptPath, ...prefixArgs, ...rest], {
|
|
297
|
+
const result = spawnSync(process.execPath, [executorPath, scriptPath, ...prefixArgs, ...rest], {
|
|
270
298
|
cwd,
|
|
271
299
|
stdio: 'inherit',
|
|
272
300
|
env: {
|