@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.
- package/CHANGELOG.md +62 -0
- package/README.md +38 -12
- package/contracts/public-api-baseline.json +57 -0
- package/docs/assurance-controls.md +39 -0
- package/docs/atelier-runtime.md +15 -0
- package/docs/blocks/claims.md +15 -9
- 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 +75 -10
- package/docs/repo-boundary-guard.md +12 -2
- package/docs/upgrade.md +25 -2
- 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 +12 -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 +278 -20
- package/src/boundary/policy.mjs +150 -60
- package/src/cli/execute-command.mjs +36 -0
- package/src/cli/run.mjs +17 -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/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 +12 -0
- package/src/project/config.mjs +66 -7
- 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/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
|
@@ -1,12 +1,18 @@
|
|
|
1
1
|
import crypto from 'node:crypto'
|
|
2
2
|
import fs from 'node:fs'
|
|
3
3
|
import path from 'node:path'
|
|
4
|
+
import { createCollaborationEventLedger } from './event-ledger.mjs'
|
|
5
|
+
import {
|
|
6
|
+
atomicReplacePrivateText,
|
|
7
|
+
ensureContainedPrivateDirectory,
|
|
8
|
+
readRegularTextNoFollow,
|
|
9
|
+
} from '../project/private-state.mjs'
|
|
4
10
|
|
|
5
11
|
export const ATELIER_PROPOSAL_SCHEMA = 'atelier-proposal@v1'
|
|
6
12
|
export const ATELIER_PROPOSALS_SCHEMA = 'atelier-proposals@v1'
|
|
7
13
|
export const PROPOSAL_REVIEW_STATUSES = new Set(['reviewed', 'accepted', 'rejected', 'superseded'])
|
|
8
|
-
|
|
9
|
-
const
|
|
14
|
+
export const COPY_ONLY_PROPOSAL_CAPABILITY = 'proposal.copy-only'
|
|
15
|
+
const PROPOSAL_ID_PATTERN = /^proposal-[a-z0-9]+(?:-[a-z0-9]+)*$/
|
|
10
16
|
|
|
11
17
|
function nowIso() {
|
|
12
18
|
return new Date().toISOString()
|
|
@@ -20,6 +26,10 @@ function stableCompare(left, right) {
|
|
|
20
26
|
return String(left).localeCompare(String(right), 'en')
|
|
21
27
|
}
|
|
22
28
|
|
|
29
|
+
function isRecord(value) {
|
|
30
|
+
return value !== null && typeof value === 'object' && !Array.isArray(value)
|
|
31
|
+
}
|
|
32
|
+
|
|
23
33
|
function safeJsonText(value, max = 50000) {
|
|
24
34
|
if (value == null) return ''
|
|
25
35
|
if (typeof value === 'string') return value.slice(0, max)
|
|
@@ -30,24 +40,20 @@ function safeJsonText(value, max = 50000) {
|
|
|
30
40
|
}
|
|
31
41
|
}
|
|
32
42
|
|
|
33
|
-
function
|
|
34
|
-
|
|
35
|
-
try {
|
|
36
|
-
fs.chmodSync(dir, 0o700)
|
|
37
|
-
} catch {
|
|
38
|
-
// Best effort on filesystems that do not support chmod.
|
|
39
|
-
}
|
|
43
|
+
function secureWriteJson(file, payload) {
|
|
44
|
+
atomicReplacePrivateText(file, `${JSON.stringify(payload, null, 2)}\n`)
|
|
40
45
|
}
|
|
41
46
|
|
|
42
|
-
function
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
+
function readRegularJson(file) {
|
|
48
|
+
return JSON.parse(readRegularTextNoFollow(file))
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function writeSnapshotProjectionWith(writer, file, payload) {
|
|
47
52
|
try {
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
53
|
+
writer(file, payload)
|
|
54
|
+
return []
|
|
55
|
+
} catch (error) {
|
|
56
|
+
return [{ code: 'proposal-snapshot-write-failed', message: `compatibility snapshot was not updated: ${error.message}` }]
|
|
51
57
|
}
|
|
52
58
|
}
|
|
53
59
|
|
|
@@ -61,15 +67,25 @@ function proposalId(seed = crypto.randomBytes(16).toString('hex')) {
|
|
|
61
67
|
return `proposal-${crypto.createHash('sha256').update(seed).digest('hex').slice(0, 32)}`
|
|
62
68
|
}
|
|
63
69
|
|
|
64
|
-
export function
|
|
65
|
-
const
|
|
66
|
-
|
|
70
|
+
export function validateCopyOnlyProposalAuthority(input = {}) {
|
|
71
|
+
const authority = input.authority && typeof input.authority === 'object' ? input.authority : {}
|
|
72
|
+
const capability = input.capability ?? authority.capability
|
|
73
|
+
const directWrite = input.directWrite ?? authority.directWrite
|
|
74
|
+
const applyEndpoint = input.applyEndpoint ?? authority.applyEndpoint
|
|
75
|
+
const issues = []
|
|
76
|
+
if (capability != null && capability !== COPY_ONLY_PROPOSAL_CAPABILITY) {
|
|
77
|
+
issues.push(`capability must be ${COPY_ONLY_PROPOSAL_CAPABILITY}`)
|
|
78
|
+
}
|
|
79
|
+
if (directWrite != null && directWrite !== false) issues.push('direct-write capability must be false')
|
|
80
|
+
if (applyEndpoint != null) issues.push('applyEndpoint must be null')
|
|
81
|
+
return issues.length ? { ok: false, status: 409, issues } : { ok: true, status: 200 }
|
|
67
82
|
}
|
|
68
83
|
|
|
69
84
|
export function copyOnlyActionSummary(action) {
|
|
70
85
|
return {
|
|
71
86
|
action: String(action || ''),
|
|
72
|
-
|
|
87
|
+
capability: COPY_ONLY_PROPOSAL_CAPABILITY,
|
|
88
|
+
copyOnly: true,
|
|
73
89
|
directWrite: false,
|
|
74
90
|
applyEndpoint: null,
|
|
75
91
|
}
|
|
@@ -107,15 +123,24 @@ export function acceptedProposalCopy(record) {
|
|
|
107
123
|
|
|
108
124
|
export function createProposalStore({
|
|
109
125
|
workspaceRoot = process.cwd(),
|
|
110
|
-
proposalsDir = path.join(workspaceRoot, '.atelier-proposals'),
|
|
126
|
+
proposalsDir: requestedProposalsDir = path.join(workspaceRoot, '.atelier-proposals'),
|
|
111
127
|
workspaceId = null,
|
|
128
|
+
snapshotWriter = secureWriteJson,
|
|
112
129
|
} = {}) {
|
|
113
130
|
const workspaceRootReal = fs.realpathSync(workspaceRoot)
|
|
114
|
-
|
|
131
|
+
const proposalsDir = ensureContainedPrivateDirectory({
|
|
132
|
+
workspaceRoot,
|
|
133
|
+
directory: requestedProposalsDir,
|
|
134
|
+
label: 'proposal state directory',
|
|
135
|
+
})
|
|
136
|
+
const eventLedger = createCollaborationEventLedger({
|
|
137
|
+
workspaceRoot,
|
|
138
|
+
ledgerPath: path.join(proposalsDir, 'events.ndjson'),
|
|
139
|
+
})
|
|
115
140
|
|
|
116
141
|
function proposalPath(id) {
|
|
117
|
-
const clean = String(id || '')
|
|
118
|
-
if (!clean) throw new Error('proposal id is
|
|
142
|
+
const clean = String(id || '')
|
|
143
|
+
if (clean.length > 200 || !PROPOSAL_ID_PATTERN.test(clean)) throw new Error('proposal id is invalid')
|
|
119
144
|
const file = path.join(proposalsDir, `${clean}.json`)
|
|
120
145
|
const candidateDir = fs.realpathSync(proposalsDir)
|
|
121
146
|
if (!pathContainedBy(workspaceRootReal, candidateDir)) {
|
|
@@ -124,47 +149,189 @@ export function createProposalStore({
|
|
|
124
149
|
return file
|
|
125
150
|
}
|
|
126
151
|
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
152
|
+
function reduceProposal(state, event) {
|
|
153
|
+
if (event.type === 'proposal-created' || event.type === 'proposal-imported') {
|
|
154
|
+
return event.payload.record
|
|
155
|
+
}
|
|
156
|
+
if (event.type === 'proposal-reviewed' && state) {
|
|
157
|
+
if (event.payload.record) return event.payload.record
|
|
158
|
+
const next = {
|
|
159
|
+
...state,
|
|
160
|
+
proposal: {
|
|
161
|
+
...state.proposal,
|
|
162
|
+
status: event.payload.status,
|
|
163
|
+
updatedAt: event.at,
|
|
164
|
+
review: event.payload.review,
|
|
165
|
+
eventVersion: event.version,
|
|
166
|
+
},
|
|
167
|
+
}
|
|
168
|
+
if (event.payload.copyable) next.copyable = event.payload.copyable
|
|
169
|
+
else delete next.copyable
|
|
170
|
+
return next
|
|
171
|
+
}
|
|
172
|
+
return state
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
function reduceProposalEvents(id, events) {
|
|
176
|
+
if (events.length === 0) {
|
|
177
|
+
return { ok: false, status: 404, error: 'proposal not found', record: null }
|
|
178
|
+
}
|
|
179
|
+
let state = null
|
|
180
|
+
let previousVersion = 0
|
|
181
|
+
for (const event of events) {
|
|
182
|
+
if (state === null) {
|
|
183
|
+
if (event.version !== 1 || !['proposal-created', 'proposal-imported'].includes(event.type)) {
|
|
184
|
+
return { ok: false, status: 422, error: 'proposal ledger does not begin with a canonical record', record: null }
|
|
185
|
+
}
|
|
186
|
+
const record = event.payload?.record
|
|
187
|
+
if (
|
|
188
|
+
!isRecord(record) || record.schema !== ATELIER_PROPOSAL_SCHEMA ||
|
|
189
|
+
!isRecord(record.proposal) || record.proposal.id !== id ||
|
|
190
|
+
!['proposed', ...PROPOSAL_REVIEW_STATUSES].includes(record.proposal.status)
|
|
191
|
+
) {
|
|
192
|
+
return { ok: false, status: 422, error: 'proposal ledger record is invalid', record: null }
|
|
193
|
+
}
|
|
194
|
+
} else {
|
|
195
|
+
const checkpoint = event.payload?.record
|
|
196
|
+
const checkpointValid = (
|
|
197
|
+
isRecord(checkpoint) && checkpoint.schema === ATELIER_PROPOSAL_SCHEMA &&
|
|
198
|
+
isRecord(checkpoint.proposal) && checkpoint.proposal.id === id &&
|
|
199
|
+
checkpoint.proposal.status === event.payload?.status &&
|
|
200
|
+
checkpoint.proposal.eventVersion === event.version
|
|
201
|
+
)
|
|
202
|
+
const hasCompactionGap = event.version > previousVersion + 1
|
|
203
|
+
if (
|
|
204
|
+
event.type !== 'proposal-reviewed' || !PROPOSAL_REVIEW_STATUSES.has(event.payload?.status) ||
|
|
205
|
+
!isRecord(event.payload?.review) ||
|
|
206
|
+
(checkpoint !== undefined && !checkpointValid) ||
|
|
207
|
+
(hasCompactionGap && !checkpointValid) ||
|
|
208
|
+
(!hasCompactionGap && !canTransitionProposal(state.proposal.status, event.payload.status))
|
|
209
|
+
) {
|
|
210
|
+
return { ok: false, status: 422, error: 'proposal ledger review sequence is invalid', record: null }
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
state = reduceProposal(state, event)
|
|
214
|
+
previousVersion = event.version
|
|
215
|
+
}
|
|
216
|
+
return { ok: true, status: 200, record: state }
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
function materializedProposal(id) {
|
|
220
|
+
const result = eventLedger.eventsFor(id)
|
|
221
|
+
if (!result.ok) return { ...result, record: null }
|
|
222
|
+
return { ...result, ...reduceProposalEvents(id, result.events) }
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
function readCompatibilitySnapshot(id) {
|
|
131
226
|
let file
|
|
132
227
|
try {
|
|
133
228
|
file = proposalPath(id)
|
|
134
229
|
} catch {
|
|
135
|
-
return null
|
|
230
|
+
return { ok: false, status: 404, error: 'proposal not found', record: null }
|
|
136
231
|
}
|
|
137
|
-
if (!fs.existsSync(file)) return null
|
|
232
|
+
if (!fs.existsSync(file)) return { ok: false, status: 404, error: 'proposal not found', record: null }
|
|
138
233
|
try {
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
234
|
+
const resolved = fs.realpathSync(file)
|
|
235
|
+
if (!pathContainedBy(fs.realpathSync(proposalsDir), resolved)) {
|
|
236
|
+
throw new Error('proposal snapshot escapes workspace')
|
|
237
|
+
}
|
|
238
|
+
const record = readRegularJson(file)
|
|
239
|
+
if (record?.proposal?.id !== id) throw new Error('proposal snapshot id does not match its filename')
|
|
240
|
+
return { ok: true, status: 200, record, source: 'compatibility-snapshot' }
|
|
241
|
+
} catch (error) {
|
|
242
|
+
return { ok: false, status: 422, error: `proposal snapshot cannot be read: ${error.message}`, record: null }
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
// Read is a lookup, not an assertion: an unusable id and an unreadable file
|
|
247
|
+
// are both "no such proposal", never a throw. New records materialize from
|
|
248
|
+
// the append-only ledger. Per-proposal JSON remains a compatibility snapshot.
|
|
249
|
+
function readProposal(id) {
|
|
250
|
+
if (String(id || '').length > 200 || !PROPOSAL_ID_PATTERN.test(String(id || ''))) {
|
|
251
|
+
return { ok: false, status: 404, error: 'proposal not found', record: null }
|
|
142
252
|
}
|
|
253
|
+
const materialized = materializedProposal(id)
|
|
254
|
+
if (materialized.ok) {
|
|
255
|
+
if (materialized.record?.proposal?.id !== id) {
|
|
256
|
+
return { ok: false, status: 422, error: 'proposal ledger identity mismatch', record: null }
|
|
257
|
+
}
|
|
258
|
+
return materialized
|
|
259
|
+
}
|
|
260
|
+
if (materialized.status !== 404) return materialized
|
|
261
|
+
return readCompatibilitySnapshot(id)
|
|
143
262
|
}
|
|
144
263
|
|
|
145
264
|
function listProposals() {
|
|
146
|
-
if (!fs.existsSync(proposalsDir)) return []
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
265
|
+
if (!fs.existsSync(proposalsDir)) return { ok: true, status: 200, proposals: [] }
|
|
266
|
+
const ledger = eventLedger.readAll()
|
|
267
|
+
if (!ledger.ok) return { ...ledger, proposals: [] }
|
|
268
|
+
const ledgerEvents = new Map()
|
|
269
|
+
for (const event of ledger.events) {
|
|
270
|
+
if (!PROPOSAL_ID_PATTERN.test(event.aggregateId)) {
|
|
271
|
+
return { ok: false, status: 422, error: 'proposal ledger contains an invalid identity', proposals: [] }
|
|
272
|
+
}
|
|
273
|
+
const events = ledgerEvents.get(event.aggregateId) ?? []
|
|
274
|
+
events.push(event)
|
|
275
|
+
ledgerEvents.set(event.aggregateId, events)
|
|
276
|
+
}
|
|
277
|
+
const ledgerRecords = new Map()
|
|
278
|
+
for (const [id, events] of ledgerEvents) {
|
|
279
|
+
const reduced = reduceProposalEvents(id, events)
|
|
280
|
+
if (!reduced.ok) return { ...reduced, proposals: [] }
|
|
281
|
+
ledgerRecords.set(id, reduced.record)
|
|
282
|
+
}
|
|
283
|
+
const ledgerIds = [...ledgerRecords.keys()]
|
|
284
|
+
const snapshotEntries = fs.readdirSync(proposalsDir, { withFileTypes: true })
|
|
285
|
+
.filter((entry) => entry.name.endsWith('.json'))
|
|
286
|
+
const unsafeSnapshot = snapshotEntries.find((entry) => !entry.isFile())
|
|
287
|
+
if (unsafeSnapshot) {
|
|
288
|
+
return { ok: false, status: 422, error: 'proposal snapshot cannot be read: state leaf is not a regular file', proposals: [] }
|
|
289
|
+
}
|
|
290
|
+
const snapshotIds = snapshotEntries.map((entry) => entry.name.slice(0, -'.json'.length))
|
|
291
|
+
const readIds = [...new Set([...ledgerIds, ...snapshotIds])].sort(stableCompare)
|
|
292
|
+
const reads = readIds.map((id) => {
|
|
293
|
+
const record = ledgerRecords.get(id)
|
|
294
|
+
if (record) return { ok: true, status: 200, record, source: 'event-ledger' }
|
|
295
|
+
return readCompatibilitySnapshot(id)
|
|
296
|
+
})
|
|
297
|
+
const failed = reads.find((result) => !result.ok)
|
|
298
|
+
if (failed) return { ...failed, proposals: [] }
|
|
299
|
+
if (reads.some((result, index) => result.record?.proposal?.id !== readIds[index])) {
|
|
300
|
+
return { ok: false, status: 422, error: 'proposal identity does not match its state key', proposals: [] }
|
|
301
|
+
}
|
|
302
|
+
const proposals = reads
|
|
303
|
+
.map((result) => result.record)
|
|
158
304
|
.sort((left, right) => stableCompare(right.proposal?.updatedAt || '', left.proposal?.updatedAt || ''))
|
|
305
|
+
return { ok: true, status: 200, proposals, diagnostics: ledger.diagnostics, stats: ledger.stats }
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
function ensureLedgerSeed(id, record) {
|
|
309
|
+
const existing = eventLedger.eventsFor(id)
|
|
310
|
+
if (!existing.ok) return existing
|
|
311
|
+
if (existing.events.length > 0) return { ok: true, version: existing.currentVersion }
|
|
312
|
+
const seeded = eventLedger.append({
|
|
313
|
+
aggregateId: id,
|
|
314
|
+
expectedVersion: 0,
|
|
315
|
+
type: 'proposal-imported',
|
|
316
|
+
actor: 'atelier compatibility importer',
|
|
317
|
+
at: record.proposal?.createdAt || nowIso(),
|
|
318
|
+
payload: { record },
|
|
319
|
+
})
|
|
320
|
+
if (!seeded.ok) return seeded
|
|
321
|
+
return { ok: true, version: seeded.event.version }
|
|
159
322
|
}
|
|
160
323
|
|
|
161
324
|
function createProposal(body = {}) {
|
|
162
325
|
const action = cleanIdentity(body.action || body.proposal?.action || 'copy.repoPath', 120)
|
|
163
|
-
|
|
326
|
+
const authority = validateCopyOnlyProposalAuthority({
|
|
327
|
+
...(body.proposal && typeof body.proposal === 'object' ? body.proposal : {}),
|
|
328
|
+
...body,
|
|
329
|
+
})
|
|
330
|
+
if (!authority.ok) {
|
|
164
331
|
return {
|
|
165
332
|
ok: false,
|
|
166
|
-
status:
|
|
167
|
-
error:
|
|
333
|
+
status: authority.status,
|
|
334
|
+
error: `proposal authority refused: ${authority.issues.join('; ')}`,
|
|
168
335
|
}
|
|
169
336
|
}
|
|
170
337
|
|
|
@@ -196,19 +363,30 @@ export function createProposalStore({
|
|
|
196
363
|
ignored: true,
|
|
197
364
|
},
|
|
198
365
|
authority: copyOnlyActionSummary(action),
|
|
366
|
+
eventVersion: 1,
|
|
199
367
|
},
|
|
200
368
|
diff: safeJsonText(body.diff || body.proposal?.diff || ''),
|
|
201
369
|
payload: body.proposal && typeof body.proposal === 'object' ? body.proposal : {},
|
|
202
370
|
}
|
|
203
|
-
|
|
204
|
-
|
|
371
|
+
const appended = eventLedger.append({
|
|
372
|
+
aggregateId: id,
|
|
373
|
+
expectedVersion: 0,
|
|
374
|
+
type: 'proposal-created',
|
|
375
|
+
actor: cleanIdentity(body.actor || body.proposal?.createdBy || 'local contributor', 160),
|
|
376
|
+
at: createdAt,
|
|
377
|
+
payload: { record },
|
|
378
|
+
})
|
|
379
|
+
if (!appended.ok) {
|
|
380
|
+
return { ok: false, status: appended.status, error: appended.error }
|
|
381
|
+
}
|
|
382
|
+
const diagnostics = writeSnapshotProjectionWith(snapshotWriter, proposalPath(id), record)
|
|
383
|
+
return { ok: true, status: 200, record, diagnostics }
|
|
205
384
|
}
|
|
206
385
|
|
|
207
386
|
function reviewProposal(id, body = {}) {
|
|
208
|
-
const
|
|
209
|
-
if (!
|
|
210
|
-
|
|
211
|
-
}
|
|
387
|
+
const read = readProposal(id)
|
|
388
|
+
if (!read.ok) return read
|
|
389
|
+
const record = read.record
|
|
212
390
|
if (body.proposalId && body.proposalId !== id) {
|
|
213
391
|
return { ok: false, status: 409, error: 'ambiguous proposal review refused' }
|
|
214
392
|
}
|
|
@@ -232,27 +410,49 @@ export function createProposalStore({
|
|
|
232
410
|
}
|
|
233
411
|
|
|
234
412
|
const updatedAt = nowIso()
|
|
235
|
-
|
|
413
|
+
const review = {
|
|
414
|
+
reviewer: cleanIdentity(body.reviewer || 'unknown reviewer', 160),
|
|
415
|
+
notes: cleanIdentity(body.notes, 2000),
|
|
416
|
+
reviewedAt: updatedAt,
|
|
417
|
+
}
|
|
418
|
+
const nextProposal = {
|
|
236
419
|
...record.proposal,
|
|
237
420
|
status: nextStatus,
|
|
238
421
|
updatedAt,
|
|
239
|
-
review
|
|
240
|
-
reviewer: cleanIdentity(body.reviewer || 'unknown reviewer', 160),
|
|
241
|
-
notes: cleanIdentity(body.notes, 2000),
|
|
242
|
-
reviewedAt: updatedAt,
|
|
243
|
-
},
|
|
422
|
+
review,
|
|
244
423
|
}
|
|
424
|
+
const nextRecord = { ...record, proposal: nextProposal }
|
|
245
425
|
if (nextStatus === 'accepted') {
|
|
246
|
-
|
|
426
|
+
nextRecord.copyable = acceptedProposalCopy(nextRecord)
|
|
247
427
|
} else {
|
|
248
|
-
delete
|
|
428
|
+
delete nextRecord.copyable
|
|
429
|
+
}
|
|
430
|
+
const seeded = ensureLedgerSeed(id, record)
|
|
431
|
+
if (!seeded.ok) return seeded
|
|
432
|
+
nextRecord.proposal.eventVersion = seeded.version + 1
|
|
433
|
+
const appended = eventLedger.append({
|
|
434
|
+
aggregateId: id,
|
|
435
|
+
expectedVersion: seeded.version,
|
|
436
|
+
type: 'proposal-reviewed',
|
|
437
|
+
actor: review.reviewer,
|
|
438
|
+
at: updatedAt,
|
|
439
|
+
payload: {
|
|
440
|
+
status: nextStatus,
|
|
441
|
+
review,
|
|
442
|
+
record: nextRecord,
|
|
443
|
+
...(nextRecord.copyable ? { copyable: nextRecord.copyable } : {}),
|
|
444
|
+
},
|
|
445
|
+
})
|
|
446
|
+
if (!appended.ok) {
|
|
447
|
+
return { ok: false, status: appended.status, error: appended.error }
|
|
249
448
|
}
|
|
250
|
-
|
|
251
|
-
return { ok: true, status: 200, record }
|
|
449
|
+
const diagnostics = writeSnapshotProjectionWith(snapshotWriter, proposalPath(id), nextRecord)
|
|
450
|
+
return { ok: true, status: 200, record: nextRecord, diagnostics }
|
|
252
451
|
}
|
|
253
452
|
|
|
254
453
|
return {
|
|
255
454
|
proposalsDir,
|
|
455
|
+
eventLedger,
|
|
256
456
|
proposalPath,
|
|
257
457
|
readProposal,
|
|
258
458
|
listProposals,
|
|
@@ -37,7 +37,7 @@ Subcommands:
|
|
|
37
37
|
|
|
38
38
|
keygen --key-id ID [--algorithm ed25519|es256] [--out FILE]
|
|
39
39
|
Generate a signing key pair. Writes the private key file (default
|
|
40
|
-
${LOCAL_KEY_FILE}, mode 0600, refuses to overwrite)
|
|
40
|
+
${LOCAL_KEY_FILE}, mode 0600 on POSIX, refuses to overwrite)
|
|
41
41
|
and prints only the public key document.
|
|
42
42
|
|
|
43
43
|
Exit codes: 0 success or valid, 1 verify judged the attestation invalid,
|
|
@@ -162,11 +162,24 @@ function runKeygen(argv) {
|
|
|
162
162
|
fail(`--algorithm must be ed25519 or es256, got ${algorithm}`)
|
|
163
163
|
}
|
|
164
164
|
const outPath = path.resolve(options.out ?? LOCAL_KEY_FILE)
|
|
165
|
+
// On Windows, O_EXCL alone can still follow a planted dangling symlink.
|
|
166
|
+
// lstat examines the directory entry itself, so refuse every existing entry
|
|
167
|
+
// (including a dangling link) before the exclusive create. The subsequent
|
|
168
|
+
// 'wx' retains the atomic no-overwrite guarantee for ordinary files created
|
|
169
|
+
// after this check.
|
|
170
|
+
try {
|
|
171
|
+
fs.lstatSync(outPath)
|
|
172
|
+
fail(`refusing to write signing key file: path already exists or is a symlink: ${outPath}`)
|
|
173
|
+
} catch (error) {
|
|
174
|
+
if (error?.code !== 'ENOENT') {
|
|
175
|
+
if (error?.code === undefined) throw error
|
|
176
|
+
fail(`cannot inspect signing key file path: ${outPath}`)
|
|
177
|
+
}
|
|
178
|
+
}
|
|
165
179
|
const { privateKeyDoc, publicKeyDoc } = generateKeyPair({ algorithm, keyId })
|
|
166
|
-
// Atomic create-exclusive ('wx' = O_CREAT|O_EXCL, mode 0600):
|
|
167
|
-
//
|
|
168
|
-
//
|
|
169
|
-
// there is no check-then-write race window. Error output stays path-only.
|
|
180
|
+
// Atomic create-exclusive ('wx' = O_CREAT|O_EXCL, mode 0600 on POSIX): an
|
|
181
|
+
// ordinary competing create cannot overwrite the destination. Error output
|
|
182
|
+
// stays path-only.
|
|
170
183
|
let fd = null
|
|
171
184
|
try {
|
|
172
185
|
fd = fs.openSync(outPath, 'wx', 0o600)
|
|
@@ -181,7 +194,8 @@ function runKeygen(argv) {
|
|
|
181
194
|
} finally {
|
|
182
195
|
fs.closeSync(fd)
|
|
183
196
|
}
|
|
184
|
-
|
|
197
|
+
const permissions = process.platform === 'win32' ? 'containing-directory Windows ACLs inherited' : 'mode 0600'
|
|
198
|
+
console.error(`signing key file written to ${outPath} (${permissions}). Keep it out of version control; ${LOCAL_KEY_FILE} is gitignored by default.`)
|
|
185
199
|
console.log(JSON.stringify(publicKeyDoc, null, 2))
|
|
186
200
|
}
|
|
187
201
|
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
import fs from 'node:fs'
|
|
2
|
+
import path from 'node:path'
|
|
3
|
+
import { pathToFileURL } from 'node:url'
|
|
4
|
+
import {
|
|
5
|
+
compileDisclosurePatterns,
|
|
6
|
+
isPathIgnored,
|
|
7
|
+
isPathTracked,
|
|
8
|
+
scanDisclosureContent,
|
|
9
|
+
} from '../disclosure/content-scan.mjs'
|
|
10
|
+
|
|
11
|
+
export function runDisclosureCommand(argv = process.argv.slice(2), { env = process.env, stdout = console.log, stderr = console.error } = {}) {
|
|
12
|
+
let root = process.cwd()
|
|
13
|
+
let staged = false
|
|
14
|
+
let structuralOnly = false
|
|
15
|
+
let untrusted = false
|
|
16
|
+
let failOnBinary = false
|
|
17
|
+
let denylistPath = null
|
|
18
|
+
|
|
19
|
+
try {
|
|
20
|
+
for (let index = 0; index < argv.length; index += 1) {
|
|
21
|
+
const arg = argv[index]
|
|
22
|
+
if (arg === 'check') continue
|
|
23
|
+
if (arg === '--root') {
|
|
24
|
+
root = requiredValue(argv, ++index, '--root')
|
|
25
|
+
} else if (arg === '--denylist') {
|
|
26
|
+
denylistPath = requiredValue(argv, ++index, '--denylist')
|
|
27
|
+
} else if (arg === '--staged') {
|
|
28
|
+
staged = true
|
|
29
|
+
} else if (arg === '--structural-only') {
|
|
30
|
+
structuralOnly = true
|
|
31
|
+
} else if (arg === '--untrusted') {
|
|
32
|
+
untrusted = true
|
|
33
|
+
} else if (arg === '--fail-on-binary') {
|
|
34
|
+
failOnBinary = true
|
|
35
|
+
} else {
|
|
36
|
+
return usageError(stderr, `unknown argument: ${arg}`)
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
} catch (error) {
|
|
40
|
+
return usageError(stderr, error.message)
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const resolvedRoot = path.resolve(root)
|
|
44
|
+
let denylistPatterns = []
|
|
45
|
+
if (!structuralOnly) {
|
|
46
|
+
const denylist = loadDenylist({ root: resolvedRoot, denylistPath, env, stderr })
|
|
47
|
+
if (!denylist.ok) return 2
|
|
48
|
+
try {
|
|
49
|
+
denylistPatterns = compileDisclosurePatterns(denylist.document.patterns)
|
|
50
|
+
} catch (error) {
|
|
51
|
+
stderr(`[disclosure:check] ${error.message}`)
|
|
52
|
+
return 2
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
let report
|
|
57
|
+
try {
|
|
58
|
+
report = scanDisclosureContent({
|
|
59
|
+
root: resolvedRoot,
|
|
60
|
+
staged,
|
|
61
|
+
denylistPatterns,
|
|
62
|
+
failOnBinary,
|
|
63
|
+
})
|
|
64
|
+
} catch (error) {
|
|
65
|
+
stderr(`[disclosure:check] ${error.message}`)
|
|
66
|
+
return 2
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
if (!report.ok) {
|
|
70
|
+
if (untrusted) {
|
|
71
|
+
stderr('[disclosure:check] findings present (details suppressed: untrusted tree)')
|
|
72
|
+
} else {
|
|
73
|
+
for (const finding of report.findings) {
|
|
74
|
+
const location = finding.line === null ? finding.path : `${finding.path}:${finding.line}`
|
|
75
|
+
stderr(`[disclosure:check] ${finding.label}: ${location}`)
|
|
76
|
+
}
|
|
77
|
+
stderr(`[disclosure:check] ${report.findings.length} finding(s)`)
|
|
78
|
+
}
|
|
79
|
+
return 1
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
const binaryNote = report.skippedBinary.length > 0
|
|
83
|
+
? `; ${report.skippedBinary.length} binary file(s) skipped${failOnBinary ? '' : ' (use --fail-on-binary for public text-only surfaces)'}`
|
|
84
|
+
: ''
|
|
85
|
+
stdout(`[disclosure:check] clean: ${report.scannedFiles} ${staged ? 'staged' : 'tracked'} text file(s)${binaryNote}`)
|
|
86
|
+
return 0
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function loadDenylist({ root, denylistPath, env, stderr }) {
|
|
90
|
+
if (env.ATELIER_DENYLIST_JSON) {
|
|
91
|
+
try {
|
|
92
|
+
return { ok: true, document: JSON.parse(env.ATELIER_DENYLIST_JSON) }
|
|
93
|
+
} catch {
|
|
94
|
+
stderr('[disclosure:check] ATELIER_DENYLIST_JSON is not valid JSON')
|
|
95
|
+
return { ok: false }
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
const candidate = path.resolve(root, denylistPath ?? '.atelier-local/disclosure-denylist.json')
|
|
100
|
+
if (!fs.existsSync(candidate)) {
|
|
101
|
+
stderr('[disclosure:check] private denylist unavailable; provide ATELIER_DENYLIST_JSON, --denylist FILE, or pass --structural-only explicitly')
|
|
102
|
+
return { ok: false }
|
|
103
|
+
}
|
|
104
|
+
if (isPathTracked(root, candidate)) {
|
|
105
|
+
stderr('[disclosure:check] refusing a Git-tracked private denylist')
|
|
106
|
+
return { ok: false }
|
|
107
|
+
}
|
|
108
|
+
if (path.resolve(candidate).startsWith(`${path.resolve(root)}${path.sep}`) && !isPathIgnored(root, candidate)) {
|
|
109
|
+
stderr('[disclosure:check] a repository-local private denylist must be covered by .gitignore')
|
|
110
|
+
return { ok: false }
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
try {
|
|
114
|
+
return { ok: true, document: JSON.parse(fs.readFileSync(candidate, 'utf8')) }
|
|
115
|
+
} catch {
|
|
116
|
+
stderr('[disclosure:check] private denylist is not valid JSON')
|
|
117
|
+
return { ok: false }
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
function requiredValue(argv, index, flag) {
|
|
122
|
+
if (!argv[index]) throw new Error(`${flag} requires a value`)
|
|
123
|
+
return argv[index]
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
function usageError(stderr, message) {
|
|
127
|
+
stderr(`[disclosure:check] ${message}`)
|
|
128
|
+
return 2
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
|
|
132
|
+
process.exitCode = runDisclosureCommand()
|
|
133
|
+
}
|
|
@@ -22,7 +22,8 @@ const logError = (message) => console.error(`${PREFIX} ${message}`)
|
|
|
22
22
|
|
|
23
23
|
// Paths in output stay relative to the checked distribution so logs never
|
|
24
24
|
// leak machine-local absolute paths.
|
|
25
|
-
const
|
|
25
|
+
const portablePath = (file) => file.split(path.sep).join('/')
|
|
26
|
+
const displayPath = (target, file) => portablePath(path.relative(target, file) || path.basename(file))
|
|
26
27
|
|
|
27
28
|
function checkReadmeAttribution(target) {
|
|
28
29
|
const readmePath = path.join(target, 'README.md')
|
|
@@ -86,6 +86,7 @@ function declaredEntries(project) {
|
|
|
86
86
|
}
|
|
87
87
|
|
|
88
88
|
const LOCK_MISMATCH_PATTERN = /^lock (version|digest) mismatch/
|
|
89
|
+
const portablePath = (file) => file.split(path.sep).join('/')
|
|
89
90
|
|
|
90
91
|
// One row per declared entry, in declaration order, joining the loader report
|
|
91
92
|
// back onto the declaration. Loader messages without a usable packId (project-
|
|
@@ -107,7 +108,7 @@ function buildRows(project, result) {
|
|
|
107
108
|
// (same rationale as distribution.mjs) so neither text nor --json output
|
|
108
109
|
// ever leaks machine-local absolute paths.
|
|
109
110
|
const resolvedPath = record?.path ?? (declaredPath ? path.resolve(project.configDir, declaredPath) : null)
|
|
110
|
-
const displayPath = resolvedPath ? path.relative(project.configDir, resolvedPath) || '.' : null
|
|
111
|
+
const displayPath = resolvedPath ? portablePath(path.relative(project.configDir, resolvedPath) || '.') : null
|
|
111
112
|
return {
|
|
112
113
|
id: label,
|
|
113
114
|
status,
|
package/src/commands/init.mjs
CHANGED
|
@@ -3,9 +3,10 @@ import { spawnSync } from 'node:child_process'
|
|
|
3
3
|
import fs from 'node:fs'
|
|
4
4
|
import path from 'node:path'
|
|
5
5
|
import { parseArgs, resolveProjectConfig, writeJson } from '../project/config.mjs'
|
|
6
|
+
import { packageRootFrom } from '../project/package-root.mjs'
|
|
6
7
|
import { writeAtelierLock } from '../upgrade/upgrade.mjs'
|
|
7
8
|
|
|
8
|
-
const packageRoot =
|
|
9
|
+
const packageRoot = packageRootFrom(import.meta.url)
|
|
9
10
|
|
|
10
11
|
function slug(value) {
|
|
11
12
|
return String(value || '')
|