@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
|
@@ -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
|
+
}
|