@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.
Files changed (64) hide show
  1. package/CHANGELOG.md +77 -0
  2. package/README.md +61 -18
  3. package/contracts/atelier-repository-observation.v1.schema.json +163 -0
  4. package/contracts/public-api-baseline.json +57 -0
  5. package/docs/assurance-controls.md +41 -0
  6. package/docs/atelier-runtime.md +28 -2
  7. package/docs/atelier-sync.md +171 -0
  8. package/docs/blocks/claims.md +23 -12
  9. package/docs/blocks/will-not-do.md +9 -3
  10. package/docs/design.md +12 -6
  11. package/docs/install.md +26 -4
  12. package/docs/knowledge-graph.md +8 -4
  13. package/docs/local-services.md +101 -0
  14. package/docs/release-engineering.md +86 -12
  15. package/docs/repo-boundary-guard.md +12 -2
  16. package/docs/upgrade.md +40 -2
  17. package/fixtures/atelier-repository-observation/invalid/complete-with-blocker.v1.json +18 -0
  18. package/fixtures/atelier-repository-observation/valid/complete-local.v1.json +48 -0
  19. package/fixtures/projects/sample-workspace/content/source.html.kg.json +4 -1
  20. package/fixtures/projects/source-formats-workspace/content/data.json.kg.json +4 -1
  21. package/fixtures/projects/source-formats-workspace/content/logo.png.kg.json +4 -1
  22. package/fixtures/projects/source-formats-workspace/content/metrics.csv.kg.json +4 -1
  23. package/fixtures/projects/source-formats-workspace/content/pipeline.yaml.kg.json +4 -1
  24. package/package.json +16 -5
  25. package/skills/claude/atelier-local-service/SKILL.md +47 -0
  26. package/skills/claude/atelier-public-boundary/SKILL.md +31 -0
  27. package/skills/codex/atelier-local-service/SKILL.md +47 -0
  28. package/skills/codex/atelier-public-boundary/SKILL.md +31 -0
  29. package/src/boundary/content-rules.mjs +283 -20
  30. package/src/boundary/policy.mjs +162 -72
  31. package/src/cli/execute-command.mjs +36 -0
  32. package/src/cli/run.mjs +35 -7
  33. package/src/collaboration/event-ledger.mjs +365 -0
  34. package/src/collaboration/index.mjs +17 -0
  35. package/src/collaboration/proposals.mjs +265 -65
  36. package/src/commands/attestation.mjs +20 -6
  37. package/src/commands/disclosure.mjs +133 -0
  38. package/src/commands/distribution.mjs +2 -1
  39. package/src/commands/extension-pack.mjs +2 -1
  40. package/src/commands/init.mjs +2 -1
  41. package/src/commands/server.mjs +1 -4
  42. package/src/commands/sync.mjs +100 -0
  43. package/src/contracts/corpus.mjs +6 -0
  44. package/src/disclosure/content-scan.mjs +193 -0
  45. package/src/egress/check.mjs +7 -38
  46. package/src/egress/forbidden-egress.mjs +32 -18
  47. package/src/graph/graph.mjs +112 -314
  48. package/src/graph/knowledge-graph.mjs +94 -18
  49. package/src/harness/context-client.mjs +9 -1
  50. package/src/index.mjs +41 -0
  51. package/src/project/config.mjs +89 -28
  52. package/src/project/file-class.mjs +14 -0
  53. package/src/project/package-root.mjs +10 -0
  54. package/src/project/path-match.mjs +38 -15
  55. package/src/project/private-state.mjs +110 -0
  56. package/src/runtime/git-adapter.mjs +189 -0
  57. package/src/runtime/local-state.mjs +439 -0
  58. package/src/runtime/repository-observation.mjs +491 -0
  59. package/src/runtime/supervisor.mjs +788 -0
  60. package/src/server/local-sidecar.mjs +81 -59
  61. package/src/server/security.mjs +89 -4
  62. package/src/server/server.mjs +3 -2
  63. package/src/support/feedback-report.mjs +4 -3
  64. package/src/upgrade/upgrade.mjs +2 -1
@@ -1,11 +1,18 @@
1
1
  import { spawnSync } from 'node:child_process'
2
+ import fs from 'node:fs'
3
+ import path from 'node:path'
2
4
  import { matchesPathPattern, normalizeRelPath } from '../project/path-match.mjs'
5
+ import { sanitizedGitEnvironment } from '../runtime/git-adapter.mjs'
3
6
 
4
7
  export const EMPTY_TREE = '4b825dc642cb6eb9a060e54bf8d69288fbee4904'
5
8
  export const ZERO_SHA_RE = /^0{40,}$/
6
9
 
7
10
  export const RULE_KINDS = Object.freeze(['content', 'path'])
8
11
  export const RULE_SEVERITIES = Object.freeze(['error', 'warning'])
12
+ export const DIFF_RESULT_MAX_BYTES = 16 * 1024 * 1024
13
+ export const CHECK_AGGREGATE_MAX_BYTES = 64 * 1024 * 1024
14
+ export const BINARY_FILE_MAX_BYTES = 8 * 1024 * 1024
15
+ export const BINARY_AGGREGATE_MAX_BYTES = 32 * 1024 * 1024
9
16
 
10
17
  // A blanket exception is not an exception, it is switching the rule off. The
11
18
  // contract requires a rule, a repo, real paths, and a reason a reviewer can act on.
@@ -33,7 +40,8 @@ export const DEFAULT_CONTENT_RULES = Object.freeze(
33
40
  id: 'private-financial-filename',
34
41
  kind: 'path',
35
42
  severity: 'error',
36
- pattern: '(\\.env($|\\.)|invoice|payroll|(^|[-_/])salar(y|ies)|bank[-_]?statement|tax[-_]?return|credential|(^|[-_/])password([-_.]|$)|(^|[-_/])secret([-_.]|$)|id_rsa)',
43
+ pattern:
44
+ '((^|/)\\.env(?:$|[.-])|invoice|payroll|(^|[-_/])salar(y|ies)|bank[-_]?statement|tax[-_]?return|credential|(^|[-_/])password([-_.]|$)|(^|[-_/])secret([-_.]|$)|id_rsa)',
37
45
  description: 'Private or financial material does not belong in a source repo.',
38
46
  },
39
47
  ].map(Object.freeze),
@@ -45,6 +53,7 @@ const trimmed = (value) => (typeof value === 'string' ? value.trim() : '')
45
53
  export function validateContentRules(rules, { label = 'contentRules' } = {}) {
46
54
  const errors = []
47
55
  if (!Array.isArray(rules)) return [`${label} must be an array`]
56
+ if (rules.length === 0) errors.push(`${label} must contain at least one rule; omit the field to use the defaults`)
48
57
  const seen = new Set()
49
58
  for (const [index, rule] of rules.entries()) {
50
59
  const at = `${label}[${index}]`
@@ -112,7 +121,8 @@ export function validateContentRuleExceptions(exceptions, rules = DEFAULT_CONTEN
112
121
 
113
122
  export function resolveContentRules(policy) {
114
123
  const declared = Array.isArray(policy?.contentRules) ? policy.contentRules : null
115
- return declared ?? DEFAULT_CONTENT_RULES
124
+ if (!declared?.length || validateContentRules(declared).length > 0) return DEFAULT_CONTENT_RULES
125
+ return declared
116
126
  }
117
127
 
118
128
  export function findException({ exceptions = [], rule, repo, path: filePath }) {
@@ -209,36 +219,237 @@ export function parseAddedContent(diff) {
209
219
 
210
220
  /** Ref updates arrive on the pre-push hook's stdin as `<localRef> <localSha> <remoteRef> <remoteSha>`. */
211
221
  export function parsePushRefUpdates(text) {
222
+ return parsePushRefInput(text).updates
223
+ }
224
+
225
+ export function parsePushRefInput(text) {
212
226
  const updates = []
213
- for (const raw of String(text ?? '').split('\n')) {
227
+ const issues = []
228
+ const input = String(text ?? '')
229
+ if (!input.trim()) return { ok: true, kind: 'empty', updates, issues }
230
+ for (const [index, raw] of input.split(/\r?\n/).entries()) {
231
+ if (!raw.trim()) continue
214
232
  const parts = raw.trim().split(/\s+/).filter(Boolean)
215
- if (parts.length < 4) continue
233
+ if (parts.length !== 4) {
234
+ issues.push(`line ${index + 1}: expected four pre-push fields`)
235
+ continue
236
+ }
216
237
  const [localRef, localSha, remoteRef, remoteSha] = parts
238
+ if (!/^[0-9a-f]{40}(?:[0-9a-f]{24})?$/i.test(localSha) || !/^[0-9a-f]{40}(?:[0-9a-f]{24})?$/i.test(remoteSha)) {
239
+ issues.push(`line ${index + 1}: ref update contains an invalid object id`)
240
+ continue
241
+ }
242
+ if (!localRef || !remoteRef) {
243
+ issues.push(`line ${index + 1}: ref names are required`)
244
+ continue
245
+ }
217
246
  if (ZERO_SHA_RE.test(localSha)) continue // branch deletion pushes no content
218
247
  updates.push({ localRef, localSha, remoteRef, remoteSha })
219
248
  }
220
- return updates
249
+ return issues.length > 0
250
+ ? { ok: false, kind: 'invalid', updates: [], issues }
251
+ : { ok: true, kind: updates.length > 0 ? 'valid' : 'empty', updates, issues: [] }
221
252
  }
222
253
 
223
- function gitOutput(repoRoot, args) {
224
- const result = spawnSync('git', ['-C', repoRoot, ...args], { encoding: 'utf8', maxBuffer: 64 * 1024 * 1024 })
225
- return result.status === 0 ? result.stdout : null
254
+ export function gitOutputResult(repoRoot, args, {
255
+ encoding = 'utf8',
256
+ gitExecutable = 'git',
257
+ maxBuffer = DIFF_RESULT_MAX_BYTES,
258
+ runner = spawnSync,
259
+ } = {}) {
260
+ let result
261
+ try {
262
+ result = runner(gitExecutable, ['-C', repoRoot, ...args], { encoding, maxBuffer, env: sanitizedGitEnvironment() })
263
+ } catch (error) {
264
+ return { ok: false, code: 'git-command-failed', error: error instanceof Error ? error.message : String(error), stdout: null }
265
+ }
266
+ if (result?.error?.code === 'ENOBUFS') {
267
+ return { ok: false, code: 'git-output-limit-exceeded', error: `Git ${args[0] ?? 'command'} exceeded the ${maxBuffer}-byte evidence limit`, stdout: null }
268
+ }
269
+ if (result?.status !== 0) {
270
+ return { ok: false, code: 'git-command-failed', error: `Git ${args[0] ?? 'command'} failed with status ${result?.status ?? 'unknown'}`, stdout: null }
271
+ }
272
+ const stdout = result.stdout ?? (encoding === 'buffer' ? Buffer.alloc(0) : '')
273
+ const bytes = Buffer.isBuffer(stdout) ? stdout.length : Buffer.byteLength(stdout)
274
+ if (bytes > maxBuffer) {
275
+ return { ok: false, code: 'git-output-limit-exceeded', error: `Git ${args[0] ?? 'command'} exceeded the ${maxBuffer}-byte evidence limit`, stdout: null }
276
+ }
277
+ return { ok: true, stdout, bytes }
226
278
  }
227
279
 
228
280
  export function pushRangeBase(repoRoot, remoteSha) {
229
281
  // A new branch has no remote counterpart, so everything it carries is new:
230
282
  // diff against the empty tree rather than silently scanning nothing.
231
283
  if (!remoteSha || ZERO_SHA_RE.test(remoteSha)) return EMPTY_TREE
232
- return gitOutput(repoRoot, ['rev-parse', '--verify', '--quiet', `${remoteSha}^{commit}`]) ? remoteSha : EMPTY_TREE
284
+ const result = spawnSync('git', ['-C', repoRoot, 'rev-parse', '--verify', '--quiet', `${remoteSha}^{commit}`], {
285
+ encoding: 'utf8',
286
+ maxBuffer: 1024 * 1024,
287
+ })
288
+ return result.status === 0 ? remoteSha : EMPTY_TREE
233
289
  }
234
290
 
235
- export function diffForPushRange(repoRoot, { localSha, remoteSha }) {
291
+ export function diffForPushRange(repoRoot, { localSha, remoteSha }, options = {}) {
236
292
  const base = pushRangeBase(repoRoot, remoteSha)
237
- return gitOutput(repoRoot, ['diff', '--unified=0', base, localSha]) ?? ''
293
+ const result = gitOutputResult(repoRoot, ['diff', '--unified=0', base, localSha], options)
294
+ return result.ok ? { ...result, base, diff: result.stdout } : { ...result, base, diff: null }
295
+ }
296
+
297
+ export function stagedDiff(repoRoot, options = {}) {
298
+ const result = gitOutputResult(repoRoot, ['diff', '--cached', '--unified=0'], options)
299
+ return result.ok ? { ...result, diff: result.stdout } : { ...result, diff: null }
300
+ }
301
+
302
+ function incompleteDiagnostic({ repo, path: filePath = null, message, details = {} }) {
303
+ return {
304
+ severity: 'error',
305
+ code: 'content-scan-incomplete',
306
+ repo,
307
+ path: filePath,
308
+ line: null,
309
+ message: `${repo ?? 'repository'}${filePath ? `/${filePath}` : ''}: ${message}`,
310
+ details,
311
+ }
238
312
  }
239
313
 
240
- export function stagedDiff(repoRoot) {
241
- return gitOutput(repoRoot, ['diff', '--cached', '--unified=0']) ?? ''
314
+ function binaryPathsFromDiff(diff) {
315
+ const paths = new Set()
316
+ let current = null
317
+ for (const line of String(diff ?? '').split('\n')) {
318
+ if (line.startsWith('diff --git ')) {
319
+ const header = line.match(/^diff --git a\/(.*) b\/(.*)$/)
320
+ current = header ? normalizeRelPath(header[2]) : null
321
+ continue
322
+ }
323
+ if (current && (line.startsWith('Binary files ') || line === 'GIT binary patch')) paths.add(current)
324
+ }
325
+ return [...paths]
326
+ }
327
+
328
+ function isBinaryBuffer(buffer) {
329
+ if (buffer.subarray(0, Math.min(buffer.length, 8192)).includes(0)) return true
330
+ try {
331
+ new TextDecoder('utf-8', { fatal: true }).decode(buffer)
332
+ return false
333
+ } catch {
334
+ return true
335
+ }
336
+ }
337
+
338
+ function scanBinaryBuffer({ buffer, filePath, rules, exceptions, repo }) {
339
+ if (!isBinaryBuffer(buffer)) return []
340
+ const text = buffer.toString('latin1')
341
+ const findings = []
342
+ for (const rule of rules) {
343
+ if ((rule.kind ?? 'content') !== 'content' || !rulePathMatches(rule, filePath)) continue
344
+ if (!new RegExp(rule.pattern).test(text)) continue
345
+ if (findException({ exceptions, rule: rule.id, repo, path: filePath })) continue
346
+ findings.push({
347
+ severity: rule.severity ?? 'error',
348
+ code: 'content-rule-violation',
349
+ rule: rule.id,
350
+ repo,
351
+ path: filePath,
352
+ line: null,
353
+ binary: true,
354
+ message: `${repo ?? ''}/${filePath}: ${rule.description ?? rule.id} (binary content)`.replace(/^\//, ''),
355
+ })
356
+ }
357
+ return findings
358
+ }
359
+
360
+ function readBinaryChanges({ repoRoot, revision, diff, rules, exceptions, repo, gitExecutable, gitRunner }) {
361
+ const findings = []
362
+ const diagnostics = []
363
+ let totalBytes = 0
364
+ for (const filePath of binaryPathsFromDiff(diff)) {
365
+ const spec = revision === ':' ? `:${filePath}` : `${revision}:${filePath}`
366
+ const result = gitOutputResult(repoRoot, ['show', spec], {
367
+ encoding: 'buffer',
368
+ gitExecutable,
369
+ maxBuffer: BINARY_FILE_MAX_BYTES + 1,
370
+ runner: gitRunner,
371
+ })
372
+ if (!result.ok) {
373
+ diagnostics.push(incompleteDiagnostic({
374
+ repo,
375
+ path: filePath,
376
+ message: `binary content could not be read within the ${BINARY_FILE_MAX_BYTES}-byte per-file limit`,
377
+ details: { reason: result.code, limitBytes: BINARY_FILE_MAX_BYTES },
378
+ }))
379
+ continue
380
+ }
381
+ if (result.bytes > BINARY_FILE_MAX_BYTES) {
382
+ diagnostics.push(incompleteDiagnostic({
383
+ repo,
384
+ path: filePath,
385
+ message: `binary content exceeds the ${BINARY_FILE_MAX_BYTES}-byte per-file limit`,
386
+ details: { limitBytes: BINARY_FILE_MAX_BYTES, observedBytes: result.bytes },
387
+ }))
388
+ continue
389
+ }
390
+ totalBytes += result.bytes
391
+ if (totalBytes > BINARY_AGGREGATE_MAX_BYTES) {
392
+ diagnostics.push(incompleteDiagnostic({
393
+ repo,
394
+ path: filePath,
395
+ message: `binary content exceeds the ${BINARY_AGGREGATE_MAX_BYTES}-byte aggregate limit`,
396
+ details: { limitBytes: BINARY_AGGREGATE_MAX_BYTES },
397
+ }))
398
+ break
399
+ }
400
+ findings.push(...scanBinaryBuffer({ buffer: result.stdout, filePath, rules, exceptions, repo }))
401
+ }
402
+ return { findings, diagnostics, bytes: totalBytes }
403
+ }
404
+
405
+ export function scanStagedRepository({
406
+ repoRoot,
407
+ rules = DEFAULT_CONTENT_RULES,
408
+ exceptions = [],
409
+ repo = null,
410
+ gitExecutable = 'git',
411
+ gitRunner = spawnSync,
412
+ } = {}) {
413
+ const acquired = stagedDiff(repoRoot, { gitExecutable, runner: gitRunner })
414
+ if (!acquired.ok) {
415
+ return {
416
+ findings: [],
417
+ diagnostics: [incompleteDiagnostic({ repo, message: 'staged evidence could not be acquired', details: { reason: acquired.code } })],
418
+ bytes: 0,
419
+ }
420
+ }
421
+ const files = parseAddedContent(acquired.diff)
422
+ const binary = readBinaryChanges({ repoRoot, revision: ':', diff: acquired.diff, rules, exceptions, repo, gitExecutable, gitRunner })
423
+ return {
424
+ findings: [...scanAddedContent({ files, rules, exceptions, repo }), ...binary.findings],
425
+ diagnostics: binary.diagnostics,
426
+ bytes: acquired.bytes + binary.bytes,
427
+ }
428
+ }
429
+
430
+ export function scanPushUpdate({
431
+ repoRoot,
432
+ update,
433
+ rules = DEFAULT_CONTENT_RULES,
434
+ exceptions = [],
435
+ repo = null,
436
+ gitRunner = spawnSync,
437
+ } = {}) {
438
+ const acquired = diffForPushRange(repoRoot, update, { runner: gitRunner })
439
+ if (!acquired.ok) {
440
+ return {
441
+ findings: [],
442
+ diagnostics: [incompleteDiagnostic({ repo, message: 'push evidence could not be acquired', details: { reason: acquired.code } })],
443
+ bytes: 0,
444
+ }
445
+ }
446
+ const files = parseAddedContent(acquired.diff)
447
+ const binary = readBinaryChanges({ repoRoot, revision: update.localSha, diff: acquired.diff, rules, exceptions, repo })
448
+ return {
449
+ findings: [...scanAddedContent({ files, rules, exceptions, repo }), ...binary.findings],
450
+ diagnostics: binary.diagnostics,
451
+ bytes: acquired.bytes + binary.bytes,
452
+ }
242
453
  }
243
454
 
244
455
  /**
@@ -246,17 +457,69 @@ export function stagedDiff(repoRoot) {
246
457
  * accepted usage, so a repo can see what it has taken on without those findings
247
458
  * stopping unrelated work from being pushed.
248
459
  */
249
- export function scanTree(repoRoot, { rules = DEFAULT_CONTENT_RULES, exceptions = [], repo = null } = {}) {
250
- const listed = gitOutput(repoRoot, ['ls-files', '-z']) ?? ''
460
+ export function scanTree(repoRoot, {
461
+ rules = DEFAULT_CONTENT_RULES,
462
+ exceptions = [],
463
+ repo = null,
464
+ source = 'working-tree',
465
+ } = {}) {
466
+ if (!['working-tree', 'head'].includes(source)) {
467
+ return { findings: [], diagnostics: [incompleteDiagnostic({ repo, message: `unknown audit source ${source}` })], source }
468
+ }
469
+ const listed = source === 'head'
470
+ ? gitOutputResult(repoRoot, ['ls-tree', '-r', '--name-only', '-z', 'HEAD'])
471
+ : gitOutputResult(repoRoot, ['ls-files', '-co', '--exclude-standard', '-z'])
472
+ if (!listed.ok) {
473
+ return { findings: [], diagnostics: [incompleteDiagnostic({ repo, message: `${source} audit paths could not be acquired`, details: { reason: listed.code } })], source }
474
+ }
251
475
  const files = []
252
- for (const rel of listed.split('\0').filter(Boolean)) {
253
- const blob = gitOutput(repoRoot, ['show', `HEAD:${rel}`])
254
- if (blob == null) continue
476
+ const findings = []
477
+ const diagnostics = []
478
+ let totalBytes = 0
479
+ for (const rel of listed.stdout.split('\0').filter(Boolean)) {
480
+ let blob
481
+ if (source === 'head') {
482
+ const result = gitOutputResult(repoRoot, ['show', `HEAD:${rel}`], { encoding: 'buffer', maxBuffer: BINARY_FILE_MAX_BYTES + 1 })
483
+ if (!result.ok) {
484
+ diagnostics.push(incompleteDiagnostic({ repo, path: rel, message: 'HEAD blob could not be read', details: { reason: result.code } }))
485
+ continue
486
+ }
487
+ blob = result.stdout
488
+ } else {
489
+ const abs = pathForAudit(repoRoot, rel)
490
+ try {
491
+ const stat = fs.lstatSync(abs)
492
+ if (!stat.isFile() && !stat.isSymbolicLink()) continue
493
+ blob = stat.isSymbolicLink() ? Buffer.from(fs.readlinkSync(abs)) : fs.readFileSync(abs)
494
+ } catch {
495
+ diagnostics.push(incompleteDiagnostic({ repo, path: rel, message: 'working-tree file could not be read' }))
496
+ continue
497
+ }
498
+ }
499
+ if (blob.length > BINARY_FILE_MAX_BYTES) {
500
+ diagnostics.push(incompleteDiagnostic({ repo, path: rel, message: `audit content exceeds the ${BINARY_FILE_MAX_BYTES}-byte per-file limit` }))
501
+ continue
502
+ }
503
+ totalBytes += blob.length
504
+ if (totalBytes > BINARY_AGGREGATE_MAX_BYTES) {
505
+ diagnostics.push(incompleteDiagnostic({ repo, path: rel, message: `audit content exceeds the ${BINARY_AGGREGATE_MAX_BYTES}-byte aggregate limit` }))
506
+ break
507
+ }
508
+ if (isBinaryBuffer(blob)) {
509
+ findings.push(...scanBinaryBuffer({ buffer: blob, filePath: rel, rules, exceptions, repo }))
510
+ continue
511
+ }
512
+ const text = blob.toString('utf8')
255
513
  files.push({
256
514
  path: rel,
257
515
  added: true,
258
- addedLines: blob.split('\n').map((text, index) => ({ number: index + 1, text })),
516
+ addedLines: text.split('\n').map((line, index) => ({ number: index + 1, text: line })),
259
517
  })
260
518
  }
261
- return scanAddedContent({ files, rules, exceptions, repo })
519
+ findings.push(...scanAddedContent({ files, rules, exceptions, repo }))
520
+ return { findings, diagnostics, source, bytes: totalBytes }
521
+ }
522
+
523
+ function pathForAudit(repoRoot, rel) {
524
+ return path.join(repoRoot, ...normalizeRelPath(rel).split('/'))
262
525
  }