@mnstry/atelier 0.2.0-alpha.4 → 0.2.0-alpha.5

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