@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
@@ -0,0 +1,110 @@
1
+ import fs from 'node:fs'
2
+ import path from 'node:path'
3
+
4
+ function escapes(root, candidate) {
5
+ const relative = path.relative(root, candidate)
6
+ return relative.startsWith('..') || path.isAbsolute(relative)
7
+ }
8
+
9
+ function lstatIfPresent(file) {
10
+ try {
11
+ return fs.lstatSync(file)
12
+ } catch (error) {
13
+ if (error?.code === 'ENOENT') return null
14
+ throw error
15
+ }
16
+ }
17
+
18
+ export function ensureContainedPrivateDirectory({ workspaceRoot, directory, label = 'private state directory' }) {
19
+ const lexicalRoot = path.resolve(workspaceRoot)
20
+ const realRoot = fs.realpathSync(lexicalRoot)
21
+ const requested = path.resolve(directory)
22
+ let relative = path.relative(lexicalRoot, requested)
23
+ if (escapes(lexicalRoot, requested)) relative = path.relative(realRoot, requested)
24
+ if (relative === '' || escapes(realRoot, path.join(realRoot, relative))) {
25
+ if (relative === '') return realRoot
26
+ throw new Error(`${label} escapes workspace`)
27
+ }
28
+
29
+ let current = realRoot
30
+ for (const segment of relative.split(path.sep)) {
31
+ current = path.join(current, segment)
32
+ let stat = lstatIfPresent(current)
33
+ if (!stat) {
34
+ fs.mkdirSync(current, { mode: 0o700 })
35
+ stat = fs.lstatSync(current)
36
+ }
37
+ if (stat.isSymbolicLink() || !stat.isDirectory()) {
38
+ throw new Error(`${label} contains a redirected or non-directory component`)
39
+ }
40
+ }
41
+ const resolved = fs.realpathSync(current)
42
+ if (escapes(realRoot, resolved)) throw new Error(`${label} escapes workspace`)
43
+ try {
44
+ fs.chmodSync(resolved, 0o700)
45
+ } catch {
46
+ // Best effort on filesystems that do not support chmod.
47
+ }
48
+ return resolved
49
+ }
50
+
51
+ export function openRegularFileNoFollow(file, flags = fs.constants.O_RDONLY, mode) {
52
+ const before = lstatIfPresent(file)
53
+ if (before && (before.isSymbolicLink() || !before.isFile())) {
54
+ throw new Error('state leaf is not a regular file')
55
+ }
56
+ const descriptor = fs.openSync(file, flags | (fs.constants.O_NOFOLLOW ?? 0), mode)
57
+ try {
58
+ const opened = fs.fstatSync(descriptor)
59
+ if (!opened.isFile()) throw new Error('state leaf is not a regular file')
60
+ // Windows does not expose O_NOFOLLOW. Refuse a pre-existing redirected
61
+ // leaf before open and bind the opened descriptor back to that same file
62
+ // identity where the filesystem supplies stable device/inode values.
63
+ if (before && before.ino !== 0 && (before.dev !== opened.dev || before.ino !== opened.ino)) {
64
+ throw new Error('state leaf changed while opening')
65
+ }
66
+ return descriptor
67
+ } catch (error) {
68
+ fs.closeSync(descriptor)
69
+ throw error
70
+ }
71
+ }
72
+
73
+ export function readRegularTextNoFollow(file) {
74
+ const descriptor = openRegularFileNoFollow(file)
75
+ try {
76
+ return fs.readFileSync(descriptor, 'utf8')
77
+ } finally {
78
+ fs.closeSync(descriptor)
79
+ }
80
+ }
81
+
82
+ export function atomicReplacePrivateText(file, text, mode = 0o600) {
83
+ const existing = lstatIfPresent(file)
84
+ if (existing && (existing.isSymbolicLink() || !existing.isFile())) {
85
+ throw new Error('state leaf is not a regular file')
86
+ }
87
+ const tmp = `${file}.${process.pid}.${Date.now()}.tmp`
88
+ let descriptor
89
+ try {
90
+ descriptor = openRegularFileNoFollow(
91
+ tmp,
92
+ fs.constants.O_WRONLY | fs.constants.O_CREAT | fs.constants.O_EXCL,
93
+ mode,
94
+ )
95
+ fs.writeFileSync(descriptor, text)
96
+ fs.fsyncSync(descriptor)
97
+ fs.fchmodSync(descriptor, mode)
98
+ fs.closeSync(descriptor)
99
+ descriptor = null
100
+ fs.renameSync(tmp, file)
101
+ } catch (error) {
102
+ if (descriptor != null) fs.closeSync(descriptor)
103
+ try {
104
+ fs.unlinkSync(tmp)
105
+ } catch {
106
+ // The temporary file may not have been created.
107
+ }
108
+ throw error
109
+ }
110
+ }
@@ -0,0 +1,189 @@
1
+ import { spawnSync } from 'node:child_process'
2
+ import fs from 'node:fs'
3
+ import path from 'node:path'
4
+
5
+ const DEFAULT_TIMEOUT_MS = 30_000
6
+ const DEFAULT_MAX_BUFFER = 16 * 1024 * 1024
7
+
8
+ function executableNames(platform = process.platform, env = process.env) {
9
+ if (platform !== 'win32') return ['git']
10
+ const extensions = String(env.PATHEXT || '.EXE;.CMD;.BAT;.COM')
11
+ .split(';')
12
+ .map((value) => value.trim().toLowerCase())
13
+ .filter(Boolean)
14
+ return ['git.exe', ...extensions.map((extension) => `git${extension}`).filter((name) => name !== 'git.exe')]
15
+ }
16
+
17
+ function isExecutable(file, platform = process.platform) {
18
+ try {
19
+ fs.accessSync(file, platform === 'win32' ? fs.constants.F_OK : fs.constants.X_OK)
20
+ return fs.statSync(file).isFile()
21
+ } catch {
22
+ return false
23
+ }
24
+ }
25
+
26
+ function canonicalExecutable(file) {
27
+ try {
28
+ return fs.realpathSync.native(file)
29
+ } catch {
30
+ return fs.realpathSync(file)
31
+ }
32
+ }
33
+
34
+ /**
35
+ * Resolve one exact Git executable without invoking a shell. The selected path
36
+ * is carried through every later operation so PATH changes cannot swap engines
37
+ * underneath a running supervisor.
38
+ */
39
+ export function resolveGitExecutable({ env = process.env, platform = process.platform } = {}) {
40
+ const configured = String(env.ATELIER_GIT_PATH || '').trim()
41
+ if (configured) {
42
+ if (!path.isAbsolute(configured)) throw new Error('ATELIER_GIT_PATH must be an absolute path')
43
+ if (!isExecutable(configured, platform)) throw new Error(`configured Git executable is unavailable: ${configured}`)
44
+ return canonicalExecutable(configured)
45
+ }
46
+
47
+ const searchPath = String(env.PATH || '')
48
+ const delimiter = platform === 'win32' ? ';' : path.delimiter
49
+ const names = executableNames(platform, env)
50
+ for (const directory of searchPath.split(delimiter).filter(Boolean)) {
51
+ const cleanDir = directory.replace(/^"|"$/g, '')
52
+ for (const name of names) {
53
+ const candidate = path.join(cleanDir, name)
54
+ if (isExecutable(candidate, platform)) return canonicalExecutable(candidate)
55
+ }
56
+ }
57
+ throw new Error('compatible system Git was not found on PATH')
58
+ }
59
+
60
+ export function sanitizedGitEnvironment(env = process.env, allowPrompt = false) {
61
+ const next = { ...env }
62
+ for (const key of Object.keys(next)) {
63
+ if (key.toUpperCase().startsWith('GIT_')) delete next[key]
64
+ }
65
+ if (!allowPrompt) next.GIT_TERMINAL_PROMPT = '0'
66
+ next.GIT_OPTIONAL_LOCKS = next.GIT_OPTIONAL_LOCKS || '1'
67
+ next.GIT_NO_REPLACE_OBJECTS = '1'
68
+ return next
69
+ }
70
+
71
+ export function redactGitDiagnostic(value) {
72
+ return String(value || '')
73
+ .replace(/https?:\/\/[^\s]+/gi, (url) => sanitizeRemoteUrl(url))
74
+ .replace(/\b((?:password|passwd|token|secret|authorization|proxy-authorization|extraheader)\s*[=:]\s*)[^\s]+/gi, '$1[redacted]')
75
+ .replace(/\b(Bearer|Basic)\s+[A-Za-z0-9._~+/=-]+/gi, '$1 [redacted]')
76
+ }
77
+
78
+ export class GitCommandError extends Error {
79
+ constructor(message, result) {
80
+ super(message)
81
+ this.name = 'GitCommandError'
82
+ this.result = result
83
+ }
84
+ }
85
+
86
+ export function runGit(gitExecutable, repoRoot, args, {
87
+ allowFailure = false,
88
+ allowPrompt = false,
89
+ env = process.env,
90
+ input = undefined,
91
+ timeout = DEFAULT_TIMEOUT_MS,
92
+ } = {}) {
93
+ if (!path.isAbsolute(gitExecutable)) throw new Error('Git executable must be an absolute path')
94
+ if (!Array.isArray(args) || args.some((value) => typeof value !== 'string')) {
95
+ throw new Error('Git arguments must be a string array')
96
+ }
97
+ const argv = repoRoot ? ['-C', repoRoot, ...args] : args
98
+ const child = spawnSync(gitExecutable, argv, {
99
+ encoding: 'utf8',
100
+ env: sanitizedGitEnvironment(env, allowPrompt),
101
+ input,
102
+ maxBuffer: DEFAULT_MAX_BUFFER,
103
+ shell: false,
104
+ timeout,
105
+ windowsHide: true,
106
+ })
107
+ const result = {
108
+ ok: !child.error && child.status === 0,
109
+ status: child.status,
110
+ signal: child.signal ?? null,
111
+ stdout: child.stdout || '',
112
+ stderr: redactGitDiagnostic(child.stderr || ''),
113
+ error: child.error?.message ? redactGitDiagnostic(child.error.message) : null,
114
+ executable: gitExecutable,
115
+ args: argv,
116
+ }
117
+ if (!result.ok && !allowFailure) {
118
+ const detail = result.error || result.stderr.trim() || `exit ${String(result.status)}`
119
+ throw new GitCommandError(`Git ${args[0] || 'command'} failed: ${detail}`, result)
120
+ }
121
+ return result
122
+ }
123
+
124
+ export function gitText(gitExecutable, repoRoot, args, options = {}) {
125
+ return runGit(gitExecutable, repoRoot, args, options).stdout.trim()
126
+ }
127
+
128
+ export function parseGitVersion(text) {
129
+ const match = String(text || '').match(/git version (\d+)\.(\d+)\.(\d+)/i)
130
+ if (!match) return null
131
+ return { major: Number(match[1]), minor: Number(match[2]), patch: Number(match[3]), text: match[0] }
132
+ }
133
+
134
+ export function isSupportedGitVersion(version) {
135
+ return Boolean(version && (version.major > 2 || (version.major === 2 && version.minor >= 40)))
136
+ }
137
+
138
+ export function inspectGitEngine(gitExecutable, { env = process.env } = {}) {
139
+ const result = runGit(gitExecutable, null, ['--version'], { env })
140
+ const version = parseGitVersion(result.stdout)
141
+ if (!version) throw new Error(`unrecognized Git version output from ${gitExecutable}`)
142
+ return {
143
+ executable: gitExecutable,
144
+ version: `${version.major}.${version.minor}.${version.patch}`,
145
+ supported: isSupportedGitVersion(version),
146
+ minimum: '2.40.0',
147
+ }
148
+ }
149
+
150
+ export function classifyRemoteAuthentication(url) {
151
+ const value = String(url || '').trim()
152
+ if (!value) return 'none'
153
+ if (/^(?:ssh:\/\/|[^/@\s]+@[^:/\s]+:)/i.test(value)) return 'ssh'
154
+ if (/^https?:\/\//i.test(value)) return 'https'
155
+ if (/^(?:file:\/\/|\.{0,2}[\\/]|[a-z]:[\\/]|[\\/])/i.test(value)) return 'local'
156
+ return 'unknown'
157
+ }
158
+
159
+ /**
160
+ * Remote URLs are evidence, not authentication material. Git may carry HTTP
161
+ * credentials in user-info or query fragments, so local observations retain
162
+ * only the provider/repository address needed for diagnosis.
163
+ */
164
+ export function sanitizeRemoteUrl(url) {
165
+ const value = String(url || '').trim()
166
+ const scpLike = value.match(/^[^/@\s]+@([^:/\s]+:.+)$/)
167
+ if (scpLike) return scpLike[1]
168
+ if (!/^[a-z][a-z0-9+.-]*:\/\//i.test(value)) return value
169
+ try {
170
+ const parsed = new URL(value)
171
+ parsed.username = ''
172
+ Reflect.set(parsed, 'password', '')
173
+ parsed.search = ''
174
+ parsed.hash = ''
175
+ return parsed.toString()
176
+ } catch {
177
+ return value.replace(/^((?:https?|ssh):\/\/)(?:[^/@:]+:)?[^/@]+@/i, '$1')
178
+ }
179
+ }
180
+
181
+ export function parseNullConfig(text) {
182
+ const entries = []
183
+ for (const record of String(text || '').split('\0').filter(Boolean)) {
184
+ const newline = record.indexOf('\n')
185
+ if (newline === -1) entries.push({ key: record, value: '' })
186
+ else entries.push({ key: record.slice(0, newline), value: record.slice(newline + 1) })
187
+ }
188
+ return entries
189
+ }