@mnstry/atelier 0.2.0-alpha.1

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 (260) hide show
  1. package/CHANGELOG.md +383 -0
  2. package/LICENSE +202 -0
  3. package/NOTICE +5 -0
  4. package/README.md +262 -0
  5. package/SECURITY.md +93 -0
  6. package/TRADEMARKS.md +88 -0
  7. package/announcements/2026-08-06-announcements-channel.v1.json +12 -0
  8. package/announcements/keys/mnstry-announcements.public.v1.json +9 -0
  9. package/bin/atelier.mjs +2 -0
  10. package/bin/mnstry-atelier.mjs +3 -0
  11. package/contracts/analysis-adapter.v1.schema.json +248 -0
  12. package/contracts/atelier-action-intent.v1.schema.json +143 -0
  13. package/contracts/atelier-analysis-adapter.v1.schema.json +360 -0
  14. package/contracts/atelier-attestation.v1.schema.json +239 -0
  15. package/contracts/atelier-boundary-policy.v1.schema.json +339 -0
  16. package/contracts/atelier-claim.v1.schema.json +97 -0
  17. package/contracts/atelier-export.v1.schema.json +1015 -0
  18. package/contracts/atelier-extension-pack.v1.schema.json +290 -0
  19. package/contracts/atelier-kit-manifest.v1.schema.json +273 -0
  20. package/contracts/atelier-lock.v1.schema.json +364 -0
  21. package/contracts/atelier-migration.v1.schema.json +162 -0
  22. package/contracts/atelier-project-config.v1.schema.json +352 -0
  23. package/contracts/atelier-readiness-protocol.v1.schema.json +517 -0
  24. package/contracts/atelier-readiness-run.v1.schema.json +229 -0
  25. package/contracts/atelier-readiness.v1.schema.json +457 -0
  26. package/contracts/compat-baseline.json +10 -0
  27. package/contracts/git-promote-event.v1.schema.json +145 -0
  28. package/contracts/knowledge-source-sidecar.v1.schema.json +157 -0
  29. package/contracts/mnstry-atelier-semantic-profile.v1.json +254 -0
  30. package/docs/announcements.md +101 -0
  31. package/docs/atelier-runtime.md +34 -0
  32. package/docs/attestation.md +201 -0
  33. package/docs/continuity.md +55 -0
  34. package/docs/contract-stability.md +111 -0
  35. package/docs/distributions.md +220 -0
  36. package/docs/install.md +140 -0
  37. package/docs/knowledge-graph.md +140 -0
  38. package/docs/ontology.md +206 -0
  39. package/docs/release-engineering.md +151 -0
  40. package/docs/repo-boundary-guard.md +199 -0
  41. package/docs/tenant-readiness.md +64 -0
  42. package/docs/upgrade.md +124 -0
  43. package/fixtures/analysis/invalid/native-graph.v1.json +4 -0
  44. package/fixtures/analysis/invalid/promoted-claim.v1.json +17 -0
  45. package/fixtures/analysis/invalid/runtime-import.v1.json +20 -0
  46. package/fixtures/analysis/valid/output.claims.v1.json +17 -0
  47. package/fixtures/analysis-adapter/invalid/manifest-hidden-provider.v1.json +24 -0
  48. package/fixtures/analysis-adapter/invalid/output-frontmatter-mutation.v1.json +21 -0
  49. package/fixtures/analysis-adapter/invalid/output-native-graph.v1.json +14 -0
  50. package/fixtures/analysis-adapter/manifest.disabled.v1.json +22 -0
  51. package/fixtures/analysis-adapter/manifest.enabled-local.v1.json +23 -0
  52. package/fixtures/analysis-adapter/output.claims.v1.json +21 -0
  53. package/fixtures/atelier-action-intent/invalid/runtime-mutation-authority.v1.json +16 -0
  54. package/fixtures/atelier-action-intent/invalid/unknown-top-level-field.v1.json +17 -0
  55. package/fixtures/atelier-action-intent/invalid/writes-beyond-clipboard.v1.json +16 -0
  56. package/fixtures/atelier-action-intent/valid/copy-agent-prompt.v1.json +16 -0
  57. package/fixtures/atelier-action-intent/valid/copy-repo-path.v1.json +22 -0
  58. package/fixtures/atelier-analysis-adapter/invalid/hidden-model-provider.v1.json +24 -0
  59. package/fixtures/atelier-analysis-adapter/invalid/inert-with-execute-command.v1.json +24 -0
  60. package/fixtures/atelier-analysis-adapter/invalid/proposals-with-runtime-import.v1.json +30 -0
  61. package/fixtures/atelier-analysis-adapter/valid/manifest-inert.v1.json +24 -0
  62. package/fixtures/atelier-analysis-adapter/valid/proposals-claim-only.v1.json +27 -0
  63. package/fixtures/atelier-attestation/invalid/bad-digest.v1.json +23 -0
  64. package/fixtures/atelier-attestation/invalid/conformance-scope.v1.json +23 -0
  65. package/fixtures/atelier-attestation/invalid/missing-signature.v1.json +22 -0
  66. package/fixtures/atelier-attestation/invalid/unknown-root-field.v1.json +24 -0
  67. package/fixtures/atelier-attestation/keys/roundtrip-issuer.public.v1.json +9 -0
  68. package/fixtures/atelier-attestation/valid/rejected-with-reasons.v1.json +49 -0
  69. package/fixtures/atelier-attestation/valid/signed-admitted.v1.json +35 -0
  70. package/fixtures/atelier-attestation/valid/signed-roundtrip.v1.json +29 -0
  71. package/fixtures/atelier-attestation/valid/unsigned-advisory.v1.json +23 -0
  72. package/fixtures/atelier-claim/invalid/unknown-provider.v1.json +15 -0
  73. package/fixtures/atelier-claim/invalid/unknown-top-level-field.v1.json +16 -0
  74. package/fixtures/atelier-claim/invalid/wrong-schema-const.v1.json +15 -0
  75. package/fixtures/atelier-claim/valid/adapter-style-proposed-relation.v1.json +19 -0
  76. package/fixtures/atelier-claim/valid/codex-proposed-relation.v1.json +16 -0
  77. package/fixtures/atelier-claim/valid/manual-proposed-relation.v1.json +16 -0
  78. package/fixtures/atelier-claim/valid/readiness-proposed-relation.v1.json +18 -0
  79. package/fixtures/atelier-export/invalid/dirty-unforced-empty-taint.v1.json +19 -0
  80. package/fixtures/atelier-export/invalid/object-local-audience-visibility.v1.json +11 -0
  81. package/fixtures/atelier-export/invalid/open-object-class-owner.v1.json +23 -0
  82. package/fixtures/atelier-export/invalid/pricing-legacy-amount.v1.json +23 -0
  83. package/fixtures/atelier-export/invalid/public-disguised-internal-source-ref.v1.json +11 -0
  84. package/fixtures/atelier-export/invalid/public-nested-private-source-ref.v1.json +17 -0
  85. package/fixtures/atelier-export/invalid/public-resolved-private-source-ref.v1.json +13 -0
  86. package/fixtures/atelier-export/invalid/public-resolved-sensitive-source-ref.v1.json +13 -0
  87. package/fixtures/atelier-export/invalid/public-source-ref-missing-provenance.v1.json +20 -0
  88. package/fixtures/atelier-export/invalid/public-source-team-kg.json +12 -0
  89. package/fixtures/atelier-export/invalid/source-node-legacy-visibility.v1.json +21 -0
  90. package/fixtures/atelier-export/invalid/source-node-unknown-field.v1.json +18 -0
  91. package/fixtures/atelier-export/invalid/unresolved-public-source-ref.v1.json +11 -0
  92. package/fixtures/atelier-export/sample-studio-offer.v1.json +397 -0
  93. package/fixtures/atelier-extension-pack/invalid/bundled-slug-collision.v1.json +22 -0
  94. package/fixtures/atelier-extension-pack/invalid/escapes-root.v1.json +22 -0
  95. package/fixtures/atelier-extension-pack/invalid/posture-tampered.v1.json +22 -0
  96. package/fixtures/atelier-extension-pack/invalid/protocols/autonomous-authority.v1.json +118 -0
  97. package/fixtures/atelier-extension-pack/invalid/protocols/offer-map.v1.json +109 -0
  98. package/fixtures/atelier-extension-pack/invalid/redefines-runtime-term.v1.json +19 -0
  99. package/fixtures/atelier-extension-pack/invalid/wrong-namespace.v1.json +19 -0
  100. package/fixtures/atelier-extension-pack/valid/protocols/contract-gate.v1.json +109 -0
  101. package/fixtures/atelier-extension-pack/valid/sample-pack.v1.json +48 -0
  102. package/fixtures/atelier-kit-manifest/invalid/runtime-copy-without-canonical-role.v1.json +53 -0
  103. package/fixtures/atelier-kit-manifest/invalid/telemetry-send-path.v1.json +21 -0
  104. package/fixtures/atelier-kit-manifest/valid/mnstry-atelier.valid.v1.json +76 -0
  105. package/fixtures/atelier-lock/invalid/absolute-contract-path.v1.json +40 -0
  106. package/fixtures/atelier-lock/invalid/runtime-mutation-enabled.v1.json +41 -0
  107. package/fixtures/atelier-lock/invalid/unknown-top-level-field.v1.json +42 -0
  108. package/fixtures/atelier-lock/valid/extension-pack.v1.json +55 -0
  109. package/fixtures/atelier-lock/valid/minimal.v1.json +44 -0
  110. package/fixtures/atelier-migration/invalid/live-apply-mode.v1.json +21 -0
  111. package/fixtures/atelier-migration/invalid/missing-review-evidence.v1.json +20 -0
  112. package/fixtures/atelier-migration/invalid/runtime-mutation-enabled.v1.json +21 -0
  113. package/fixtures/atelier-migration/valid/minimal.v1.json +32 -0
  114. package/fixtures/atelier-project-config/invalid/absolute-repo-path.v1.json +11 -0
  115. package/fixtures/atelier-project-config/invalid/ext-not-object.v1.json +12 -0
  116. package/fixtures/atelier-project-config/invalid/external-kind-read-boundary.v1.json +12 -0
  117. package/fixtures/atelier-project-config/invalid/missing-repos-and-alignment.v1.json +7 -0
  118. package/fixtures/atelier-project-config/invalid/unknown-top-level-field.v1.json +14 -0
  119. package/fixtures/atelier-project-config/valid/alignment-scaffold.v1.json +15 -0
  120. package/fixtures/atelier-project-config/valid/ext-extension-packs.v1.json +36 -0
  121. package/fixtures/atelier-project-config/valid/repo-workspace.v1.json +23 -0
  122. package/fixtures/atelier-project-config/validator-only/ext-member-not-object.v1.json +14 -0
  123. package/fixtures/atelier-readiness/invalid/dry-run-only-false.v1.json +87 -0
  124. package/fixtures/atelier-readiness/invalid/support-bundle-send-path.v1.json +87 -0
  125. package/fixtures/atelier-readiness/invalid/unknown-top-level-field.v1.json +88 -0
  126. package/fixtures/atelier-readiness/valid/ready-workspace.v1.json +84 -0
  127. package/fixtures/boundary-policy/invalid/content-rule-exception-blanket-path.v1.json +67 -0
  128. package/fixtures/boundary-policy/invalid/invalid-actor-private-repo-mapping.v1.json +20 -0
  129. package/fixtures/boundary-policy/invalid/legacy-warning-missing-warning.v1.json +20 -0
  130. package/fixtures/boundary-policy/invalid/missing-repo-coverage.v1.json +19 -0
  131. package/fixtures/boundary-policy/invalid/unknown-key.v1.json +12 -0
  132. package/fixtures/boundary-policy/valid/content-rule-exception.v1.json +67 -0
  133. package/fixtures/boundary-policy/valid/legacy-warning.v1.json +33 -0
  134. package/fixtures/boundary-policy/valid/strict-private-shared.v1.json +40 -0
  135. package/fixtures/git-promote/invalid/provision-named-revocable.v1.json +29 -0
  136. package/fixtures/git-promote/private-to-team.v1.json +29 -0
  137. package/fixtures/knowledge-source-sidecar/invalid/legacy-visibility-field.v1.json +17 -0
  138. package/fixtures/knowledge-source-sidecar/invalid/missing-audience.v1.json +15 -0
  139. package/fixtures/knowledge-source-sidecar/invalid/unknown-relation-kind.v1.json +18 -0
  140. package/fixtures/knowledge-source-sidecar/valid/html-source.v1.json +18 -0
  141. package/fixtures/knowledge-source-sidecar/valid/pdf-evidence.v1.json +18 -0
  142. package/fixtures/projects/neutral-project/atelier.project.json +15 -0
  143. package/fixtures/projects/neutral-project/ops/config/repo-access.v1.json +12 -0
  144. package/fixtures/projects/neutral-project/project-knowledge.graph.json +69 -0
  145. package/fixtures/projects/sample-workspace/atelier.project.json +23 -0
  146. package/fixtures/projects/sample-workspace/boundary-policy.v1.json +59 -0
  147. package/fixtures/projects/sample-workspace/content/README.md +16 -0
  148. package/fixtures/projects/sample-workspace/content/source.html +5 -0
  149. package/fixtures/projects/sample-workspace/content/source.html.kg.json +15 -0
  150. package/fixtures/projects/sample-workspace/repo-access.v1.json +9 -0
  151. package/fixtures/projects/source-formats-workspace/atelier.project.json +23 -0
  152. package/fixtures/projects/source-formats-workspace/content/README.md +17 -0
  153. package/fixtures/projects/source-formats-workspace/content/data.json +7 -0
  154. package/fixtures/projects/source-formats-workspace/content/data.json.kg.json +15 -0
  155. package/fixtures/projects/source-formats-workspace/content/logo.png.kg.json +15 -0
  156. package/fixtures/projects/source-formats-workspace/content/metrics.csv +3 -0
  157. package/fixtures/projects/source-formats-workspace/content/metrics.csv.kg.json +15 -0
  158. package/fixtures/projects/source-formats-workspace/content/pipeline.yaml +4 -0
  159. package/fixtures/projects/source-formats-workspace/content/pipeline.yaml.kg.json +15 -0
  160. package/fixtures/projects/source-formats-workspace/repo-access.v1.json +9 -0
  161. package/fixtures/readiness-protocols/extension-pack/invalid/protocol-missing-path.v1.json +20 -0
  162. package/fixtures/readiness-protocols/extension-pack/valid/with-protocol.v1.json +24 -0
  163. package/fixtures/readiness-protocols/protocol/invalid/missing-check-target.v1.json +103 -0
  164. package/fixtures/readiness-protocols/protocol/invalid/runtime-mutation-enabled.v1.json +107 -0
  165. package/fixtures/readiness-protocols/protocol/valid/minimal.v1.json +100 -0
  166. package/fixtures/readiness-protocols/run/invalid/missing-evidence.v1.json +37 -0
  167. package/fixtures/readiness-protocols/run/invalid/pass-with-blocker.v1.json +22 -0
  168. package/fixtures/readiness-protocols/run/invalid/runtime-mutation-enabled.v1.json +22 -0
  169. package/fixtures/readiness-protocols/run/valid/pass.v1.json +44 -0
  170. package/fixtures/support/invalid/leaky-support.v1.json +13 -0
  171. package/fixtures/support/valid/minimal-safe.v1.json +20 -0
  172. package/package.json +98 -0
  173. package/skills/claude/atelier-open/SKILL.md +68 -0
  174. package/skills/claude/mnstry-readiness/SKILL.md +41 -0
  175. package/skills/codex/atelier-open/SKILL.md +68 -0
  176. package/skills/codex/mnstry-readiness/SKILL.md +41 -0
  177. package/src/analysis/adapter.mjs +20 -0
  178. package/src/analysis/analysis-claim-contract.mjs +335 -0
  179. package/src/attestation/jcs.mjs +64 -0
  180. package/src/attestation/sign.mjs +305 -0
  181. package/src/boundary/content-rules.mjs +262 -0
  182. package/src/boundary/policy.mjs +806 -0
  183. package/src/check-atelier-export-contract.mjs +116 -0
  184. package/src/cli/run.mjs +283 -0
  185. package/src/collaboration/proposals.mjs +262 -0
  186. package/src/commands/analysis.mjs +8 -0
  187. package/src/commands/announcements.mjs +308 -0
  188. package/src/commands/attestation.mjs +198 -0
  189. package/src/commands/boundary.mjs +25 -0
  190. package/src/commands/config.mjs +4 -0
  191. package/src/commands/context.mjs +3 -0
  192. package/src/commands/distribution.mjs +224 -0
  193. package/src/commands/egress.mjs +11 -0
  194. package/src/commands/extension-pack.mjs +209 -0
  195. package/src/commands/feedback.mjs +3 -0
  196. package/src/commands/graph.mjs +3 -0
  197. package/src/commands/init.mjs +130 -0
  198. package/src/commands/lock.mjs +3 -0
  199. package/src/commands/project.mjs +3 -0
  200. package/src/commands/promote.mjs +4 -0
  201. package/src/commands/readiness.mjs +3 -0
  202. package/src/commands/server.mjs +6 -0
  203. package/src/commands/setup.mjs +250 -0
  204. package/src/commands/support.mjs +3 -0
  205. package/src/commands/upgrade.mjs +3 -0
  206. package/src/contracts/corpus.mjs +171 -0
  207. package/src/egress/check.mjs +45 -0
  208. package/src/egress/forbidden-egress.mjs +323 -0
  209. package/src/export/atelier-export-contract.mjs +722 -0
  210. package/src/extension-packs/loader.mjs +491 -0
  211. package/src/graph/graph.mjs +370 -0
  212. package/src/graph/knowledge-graph.mjs +887 -0
  213. package/src/harness/context-client.mjs +144 -0
  214. package/src/harness/context.mjs +27 -0
  215. package/src/index.mjs +88 -0
  216. package/src/project/config.mjs +557 -0
  217. package/src/project/file-class.mjs +113 -0
  218. package/src/project/git-ignore.mjs +31 -0
  219. package/src/project/path-match.mjs +30 -0
  220. package/src/project/repo-identity.mjs +176 -0
  221. package/src/projection/alignment-projection.mjs +169 -0
  222. package/src/projection/policy.mjs +90 -0
  223. package/src/projection/project.mjs +152 -0
  224. package/src/readiness/readiness.mjs +312 -0
  225. package/src/readiness-protocols/bundled-pack.mjs +766 -0
  226. package/src/readiness-protocols/runtime.mjs +419 -0
  227. package/src/server/local-sidecar.mjs +640 -0
  228. package/src/server/security.mjs +229 -0
  229. package/src/server/server.mjs +42 -0
  230. package/src/support/feedback-report.mjs +414 -0
  231. package/src/support/support-bundle.mjs +226 -0
  232. package/src/ui/html-primitives.mjs +104 -0
  233. package/src/upgrade/upgrade.mjs +701 -0
  234. package/src/validate-atelier-export-dry-run.mjs +75 -0
  235. package/templates/atelier.project.example.json +23 -0
  236. package/templates/distribution-workspace/.claude/launch.json +12 -0
  237. package/templates/distribution-workspace/README.md +86 -0
  238. package/templates/distribution-workspace/atelier.project.json +36 -0
  239. package/templates/distribution-workspace/boundary-policy.v1.json +61 -0
  240. package/templates/distribution-workspace/gitignore +29 -0
  241. package/templates/distribution-workspace/governance/repo-boundary-ledger.md +5 -0
  242. package/templates/distribution-workspace/project/README.md +19 -0
  243. package/templates/distribution-workspace/repo-access.v1.json +9 -0
  244. package/templates/launch.json +12 -0
  245. package/templates/private-domain-workspace/.claude/launch.json +12 -0
  246. package/templates/private-domain-workspace/README.md +50 -0
  247. package/templates/private-domain-workspace/atelier.project.json +28 -0
  248. package/templates/private-domain-workspace/boundary-policy.v1.json +54 -0
  249. package/templates/private-domain-workspace/domain/README.md +19 -0
  250. package/templates/private-domain-workspace/gitignore +29 -0
  251. package/templates/private-domain-workspace/governance/repo-boundary-ledger.md +4 -0
  252. package/templates/private-domain-workspace/repo-access.v1.json +9 -0
  253. package/templates/shared-project-workspace/.claude/launch.json +12 -0
  254. package/templates/shared-project-workspace/README.md +51 -0
  255. package/templates/shared-project-workspace/atelier.project.json +28 -0
  256. package/templates/shared-project-workspace/boundary-policy.v1.json +61 -0
  257. package/templates/shared-project-workspace/gitignore +29 -0
  258. package/templates/shared-project-workspace/governance/repo-boundary-ledger.md +5 -0
  259. package/templates/shared-project-workspace/project/README.md +19 -0
  260. package/templates/shared-project-workspace/repo-access.v1.json +9 -0
@@ -0,0 +1,229 @@
1
+ import fs from 'node:fs'
2
+ import path from 'node:path'
3
+
4
+ export const ATELIER_HTML_CSP = [
5
+ "default-src 'self'",
6
+ "base-uri 'self'",
7
+ "connect-src 'self'",
8
+ "form-action 'self'",
9
+ "frame-ancestors 'none'",
10
+ "img-src 'self' data:",
11
+ "object-src 'none'",
12
+ "script-src 'self' 'unsafe-inline'",
13
+ "style-src 'self' 'unsafe-inline'",
14
+ "font-src 'self' data:",
15
+ ].join('; ')
16
+
17
+ export const DENIED_STATIC_STATE_FILES = new Set([
18
+ '.atelier-nonce',
19
+ '.atelier-current',
20
+ 'atelier.local.json',
21
+ 'atelier.workspace.local.json',
22
+ '.atelier-presence.json',
23
+ '.atelier-capability-grants.json',
24
+ '.atelier-events.jsonl',
25
+ ])
26
+
27
+ export const DENIED_STATIC_STATE_DIRS = new Set([
28
+ '.atelier-local',
29
+ '.mnstry-local',
30
+ '.atelier-proposals',
31
+ ])
32
+
33
+ export const LOOPBACK_HOSTS = new Set([
34
+ 'localhost',
35
+ '127.0.0.1',
36
+ '::1',
37
+ '[::1]',
38
+ ])
39
+
40
+ export function parseHostHeader(value) {
41
+ const raw = String(value || '').trim().toLowerCase()
42
+ if (!raw) return ''
43
+ if (raw.startsWith('[')) return raw.slice(0, raw.indexOf(']') + 1)
44
+ return raw.split(':')[0]
45
+ }
46
+
47
+ export function isLoopbackHost(value) {
48
+ const host = parseHostHeader(value)
49
+ return LOOPBACK_HOSTS.has(host) || /^127(?:\.\d{1,3}){3}$/.test(host)
50
+ }
51
+
52
+ export function requestHeader(headers, name) {
53
+ if (!headers) return ''
54
+ if (typeof headers.get === 'function') return headers.get(name) || ''
55
+ const lower = name.toLowerCase()
56
+ for (const [key, value] of Object.entries(headers)) {
57
+ if (key.toLowerCase() === lower) return Array.isArray(value) ? value.join(', ') : String(value || '')
58
+ }
59
+ return ''
60
+ }
61
+
62
+ export function expectedOriginForRequest(headers, fallbackPort = null) {
63
+ const host = requestHeader(headers, 'host')
64
+ if (!host) return fallbackPort ? `http://127.0.0.1:${fallbackPort}` : ''
65
+ return `http://${host}`
66
+ }
67
+
68
+ export function sameOrigin(origin, expectedOrigin) {
69
+ if (!origin) return true
70
+ if (!expectedOrigin) return false
71
+ try {
72
+ return new URL(origin).origin === new URL(expectedOrigin).origin
73
+ } catch {
74
+ return false
75
+ }
76
+ }
77
+
78
+ export function trustedFetchSite(headers) {
79
+ const value = requestHeader(headers, 'sec-fetch-site').toLowerCase()
80
+ return value === '' || value === 'none' || value === 'same-origin' || value === 'same-site'
81
+ }
82
+
83
+ export function trustedHost(headers) {
84
+ return isLoopbackHost(requestHeader(headers, 'host'))
85
+ }
86
+
87
+ export function trustedReadRequest(headers, { expectedOrigin = expectedOriginForRequest(headers) } = {}) {
88
+ return trustedHost(headers) && sameOrigin(requestHeader(headers, 'origin'), expectedOrigin)
89
+ }
90
+
91
+ export function trustedMutationRequest(headers, { expectedOrigin = expectedOriginForRequest(headers) } = {}) {
92
+ return trustedReadRequest(headers, { expectedOrigin }) && trustedFetchSite(headers)
93
+ }
94
+
95
+ export function pathContainedBy(root, candidate) {
96
+ const resolvedRoot = path.resolve(root)
97
+ const resolvedCandidate = path.resolve(candidate)
98
+ return resolvedCandidate === resolvedRoot || resolvedCandidate.startsWith(`${resolvedRoot}${path.sep}`)
99
+ }
100
+
101
+ export function realpathMaybe(abs) {
102
+ try {
103
+ return fs.realpathSync(abs)
104
+ } catch {
105
+ return null
106
+ }
107
+ }
108
+
109
+ export function realpathContainedBy(rootReal, abs) {
110
+ const candidateReal = realpathMaybe(abs)
111
+ if (!candidateReal) return false
112
+ return pathContainedBy(rootReal, candidateReal)
113
+ }
114
+
115
+ export function deniedStaticStatePath(rel) {
116
+ const clean = String(rel || '')
117
+ .replaceAll('\\', '/')
118
+ .replace(/^\/+/, '')
119
+ if (!clean || clean.includes('\0')) return true
120
+ if (DENIED_STATIC_STATE_FILES.has(clean)) return true
121
+ const firstSegment = clean.split('/')[0]
122
+ return DENIED_STATIC_STATE_DIRS.has(firstSegment)
123
+ }
124
+
125
+ export function normalizeWorkspacePath(value, { defaultFile = 'index.html' } = {}) {
126
+ const raw = String(value || '').trim()
127
+ const withoutQuery = raw.split(/[?#]/)[0]
128
+ let decoded = ''
129
+ try {
130
+ decoded = decodeURIComponent(withoutQuery || defaultFile)
131
+ } catch {
132
+ return { ok: false, error: 'path is not valid URI encoding' }
133
+ }
134
+ const normalized = decoded.replaceAll('\\', '/').replace(/^\/+/, '') || defaultFile
135
+ if (normalized.includes('\0')) {
136
+ return { ok: false, error: 'path contains NUL byte' }
137
+ }
138
+ if (path.isAbsolute(normalized) || normalized.split('/').includes('..')) {
139
+ return { ok: false, error: 'path escapes workspace' }
140
+ }
141
+ return { ok: true, path: normalized }
142
+ }
143
+
144
+ export function resolveWorkspacePath({
145
+ workspaceRoot,
146
+ workspaceRootReal = fs.realpathSync(workspaceRoot),
147
+ rel,
148
+ requireFile = false,
149
+ requireHtml = false,
150
+ denyState = true,
151
+ } = {}) {
152
+ const normalized = normalizeWorkspacePath(rel)
153
+ if (!normalized.ok) return normalized
154
+ if (denyState && deniedStaticStatePath(normalized.path)) {
155
+ return { ok: false, status: 403, error: 'local Atelier state is not static content', path: normalized.path }
156
+ }
157
+
158
+ const abs = path.join(workspaceRoot, normalized.path)
159
+ if (!pathContainedBy(workspaceRoot, abs)) {
160
+ return { ok: false, status: 403, error: 'path escapes workspace', path: normalized.path }
161
+ }
162
+
163
+ let stat = null
164
+ try {
165
+ stat = fs.statSync(abs)
166
+ } catch {
167
+ return { ok: false, status: 404, error: 'workspace file not found', path: normalized.path }
168
+ }
169
+
170
+ const real = fs.realpathSync(abs)
171
+ if (!pathContainedBy(workspaceRootReal, real)) {
172
+ return { ok: false, status: 403, error: 'realpath escapes workspace', path: normalized.path }
173
+ }
174
+
175
+ const realRel = path.relative(workspaceRootReal, real).replaceAll('\\', '/')
176
+ if (denyState && deniedStaticStatePath(realRel)) {
177
+ return { ok: false, status: 403, error: 'local Atelier state is not static content', path: normalized.path }
178
+ }
179
+
180
+ if (requireFile && !stat.isFile()) {
181
+ return { ok: false, status: 404, error: 'workspace file not found', path: normalized.path }
182
+ }
183
+ if (requireHtml && (!stat.isFile() || path.extname(real).toLowerCase() !== '.html')) {
184
+ return { ok: false, status: 404, error: 'workspace html file not found', path: normalized.path }
185
+ }
186
+
187
+ return {
188
+ ok: true,
189
+ path: normalized.path,
190
+ abs,
191
+ real,
192
+ stat,
193
+ workspaceContained: true,
194
+ }
195
+ }
196
+
197
+ export function staticHeaders(abs, stat) {
198
+ const ext = path.extname(abs).toLowerCase()
199
+ const contentType = {
200
+ '.html': 'text/html; charset=utf-8',
201
+ '.js': 'text/javascript; charset=utf-8',
202
+ '.mjs': 'text/javascript; charset=utf-8',
203
+ '.css': 'text/css; charset=utf-8',
204
+ '.json': 'application/json; charset=utf-8',
205
+ '.svg': 'image/svg+xml',
206
+ '.png': 'image/png',
207
+ '.jpg': 'image/jpeg',
208
+ '.jpeg': 'image/jpeg',
209
+ '.webp': 'image/webp',
210
+ }[ext] || 'application/octet-stream'
211
+
212
+ return {
213
+ 'Content-Type': contentType,
214
+ 'Content-Length': String(stat.size),
215
+ 'Cache-Control': 'no-store',
216
+ 'X-Content-Type-Options': 'nosniff',
217
+ }
218
+ }
219
+
220
+ export function htmlDocumentHeaders(byteLength) {
221
+ return {
222
+ 'Content-Type': 'text/html; charset=utf-8',
223
+ 'Content-Length': String(byteLength),
224
+ 'Cache-Control': 'no-store',
225
+ 'Content-Security-Policy': ATELIER_HTML_CSP,
226
+ 'Referrer-Policy': 'no-referrer',
227
+ 'X-Content-Type-Options': 'nosniff',
228
+ }
229
+ }
@@ -0,0 +1,42 @@
1
+ import { resolveProjectConfig } from '../project/config.mjs'
2
+ import { createAtelierSidecarServer } from './local-sidecar.mjs'
3
+
4
+ function parseServerArgs(argv = []) {
5
+ const args = { projectArgs: [] }
6
+ for (const arg of argv) {
7
+ if (arg === '--smoke') args.smoke = true
8
+ else if (arg.startsWith('--port=')) args.port = arg.slice('--port='.length)
9
+ else args.projectArgs.push(arg)
10
+ }
11
+ return args
12
+ }
13
+
14
+ export async function runServerCommand(argv = process.argv.slice(2)) {
15
+ const args = parseServerArgs(argv)
16
+ const project = resolveProjectConfig({ argv: args.projectArgs })
17
+ const sidecar = createAtelierSidecarServer({
18
+ workspaceRoot: project.outputRoot,
19
+ stateDir: project.outputRoot,
20
+ // argv > PORT env > default. Supervisors that assign a free port (preview
21
+ // panes, dev harnesses) pass it via PORT, and an Atelier that ignores that
22
+ // fights whatever already holds the canonical port instead of coexisting.
23
+ port: args.smoke ? 0 : Number(args.port || process.env.PORT || 8137),
24
+ })
25
+
26
+ if (args.smoke) {
27
+ const address = await sidecar.listen()
28
+ const base = `http://127.0.0.1:${address.port}`
29
+ // @atelier-egress-local-computed
30
+ const health = await fetch(`${base}/api/health`).then((res) => res.json())
31
+ if (!health.ok) throw new Error('health check failed')
32
+ // @atelier-egress-local-computed
33
+ const page = await fetch(`${base}/index.html`).then((res) => res.text())
34
+ if (!page.includes('<meta name="mnstry:atelier"')) throw new Error('projection smoke failed')
35
+ await sidecar.close()
36
+ console.log('[atelier:browser:smoke] local projection and health endpoint passed')
37
+ return
38
+ }
39
+
40
+ const address = await sidecar.listen()
41
+ console.log(`MNSTRY Atelier listening on http://127.0.0.1:${address.port}/`)
42
+ }
@@ -0,0 +1,414 @@
1
+ #!/usr/bin/env node
2
+ // atelier feedback create|check
3
+ //
4
+ // Assembles a LOCAL feedback report on the support-bundle chassis. The kit
5
+ // has no send path — that absence is the consent design: the report is
6
+ // written to ignored local state, the user inspects it, and transmitting it
7
+ // anywhere is always the user's own explicit act.
8
+ //
9
+ // The payload is the user's own words plus a deliberately tiny environment
10
+ // block (package name and version, Node.js version, and — only when
11
+ // --include-gates is passed — local gate names with pass or fail status).
12
+ // Before anything is written the whole report is walked with the banned key
13
+ // and value patterns imported from the support-bundle chassis; any match
14
+ // refuses the write. Log-safety contract: refusals name the pattern label
15
+ // and the report location only, never the matched text.
16
+ //
17
+ // That scan is a backstop, not clearance to share: it matches known patterns
18
+ // only, so the success path says so plainly and asks the user to read the
19
+ // file. File inputs are bounded before they are embedded — at most
20
+ // MAX_INPUT_FILE_BYTES, valid UTF-8 only — so an accidental `--context` of an
21
+ // archive, dump, or key file is refused rather than copied into the report.
22
+ //
23
+ // Exit codes: 0 report written (or check clean), 1 the scan matched
24
+ // (nothing written) or a checked report failed the scan, 2 usage or input
25
+ // error (including an oversized or non-UTF-8 input file).
26
+
27
+ import crypto from 'node:crypto'
28
+ import { spawnSync } from 'node:child_process'
29
+ import fs from 'node:fs'
30
+ import path from 'node:path'
31
+ import { fileURLToPath } from 'node:url'
32
+ import { ensureLocalState, LOCAL_STATE_DIR } from '../project/config.mjs'
33
+ import { BANNED_KEY_PATTERNS, BANNED_VALUE_PATTERNS } from './support-bundle.mjs'
34
+
35
+ export const FEEDBACK_REPORT_SCHEMA = 'mnstry.atelier-feedback@v1'
36
+
37
+ // An attached file is an excerpt a human will read, not an archive. The cap
38
+ // bounds what can be embedded and what the scan has to walk, and it makes the
39
+ // accidental `--context` of a database dump, core file, or whole log a refusal
40
+ // instead of a silent copy into the report.
41
+ export const MAX_INPUT_FILE_BYTES = 256 * 1024
42
+
43
+ // Gate names use dashes rather than the npm-script colon form on purpose:
44
+ // a colon-joined identifier is exactly the shape the chassis kg-node-id
45
+ // banned-value pattern refuses, and the report must pass its own scan.
46
+ export const LOCAL_GATES = [
47
+ { name: 'contract', entry: 'src/check-atelier-export-contract.mjs', args: [] },
48
+ { name: 'egress-check', entry: 'bin/atelier.mjs', args: ['egress', 'check'] },
49
+ { name: 'repo-check', entry: 'scripts/check-repo-disclosure.mjs', args: [] },
50
+ { name: 'migrations-check', entry: 'scripts/check-breaking-migrations.mjs', args: [] },
51
+ { name: 'contract-compat', entry: 'scripts/check-contract-compat.mjs', args: [] },
52
+ ]
53
+
54
+ const USAGE = `Usage: atelier feedback <subcommand>
55
+
56
+ Subcommands:
57
+ create --message TEXT | --message-file PATH [--context FILE] [--include-gates]
58
+ Assemble a local feedback report from your own words and write it to
59
+ ${LOCAL_STATE_DIR}/feedback/<hash>.json (mode 0600). This is the
60
+ default subcommand. The report is first walked with the support-bundle
61
+ banned key and value patterns; if anything matches, nothing is written
62
+ and the refusal names the pattern label and location only.
63
+
64
+ --message TEXT the feedback message itself
65
+ --message-file PATH read the feedback message from a file
66
+ --context FILE attach one user-chosen file (scanned like the rest)
67
+ --include-gates record local gate names with pass or fail status
68
+
69
+ Files read by --message-file and --context must be valid UTF-8 text of
70
+ at most ${MAX_INPUT_FILE_BYTES} bytes; anything larger or binary is
71
+ refused rather than embedded.
72
+
73
+ check FILE
74
+ Re-run the banned key and value scan over an existing feedback report.
75
+
76
+ Beyond your own words the report records only the package name and version
77
+ and the Node.js version, plus local gate names with pass or fail status when
78
+ --include-gates is passed. Nothing is ever sent: this kit has no send path,
79
+ so sharing the file is always your own explicit act.
80
+
81
+ The scan is a backstop against an obvious mistake, not a guarantee: it matches
82
+ known patterns only. Read the report yourself before you share it anywhere.
83
+
84
+ Exit codes: 0 report written or check clean, 1 the scan matched (nothing
85
+ written), 2 usage or input error.`
86
+
87
+ class UsageError extends Error {}
88
+
89
+ export function resolvePackageRoot() {
90
+ return process.env.MNSTRY_ATELIER_PACKAGE_ROOT || fileURLToPath(new URL('../..', import.meta.url))
91
+ }
92
+
93
+ // Same stable-serialization shape as the chassis hashPayload (which is not
94
+ // exported); sorted keys keep the report hash independent of assembly order.
95
+ function stable(value) {
96
+ if (Array.isArray(value)) return value.map(stable)
97
+ if (value && typeof value === 'object') {
98
+ return Object.fromEntries(Object.keys(value).sort().map((key) => [key, stable(value[key])]))
99
+ }
100
+ return value
101
+ }
102
+
103
+ export function feedbackReportHash(payload) {
104
+ return crypto.createHash('sha256').update(JSON.stringify(stable(payload))).digest('hex')
105
+ }
106
+
107
+ // Same walk shape as the chassis validator; the pattern lists themselves are
108
+ // imported from the chassis, never copied. Findings carry the pattern label
109
+ // and dotted location only — never the matched text.
110
+ export function scanFeedbackReport(payload) {
111
+ const findings = []
112
+ // A JSON document can hide a key from every text pattern with \u escapes
113
+ // while JSON.parse still yields it intact. Any attachment that parses as
114
+ // JSON is therefore scanned twice: as the text it is, and as the structure
115
+ // it decodes to. The release audit already works this way.
116
+ const decoded = []
117
+ const decodeIfJson = (value, parts) => {
118
+ if (typeof value !== 'string' || value.length > MAX_INPUT_FILE_BYTES) return
119
+ const trimmed = value.trim()
120
+ if (!trimmed.startsWith('{') && !trimmed.startsWith('[')) return
121
+ try {
122
+ decoded.push([JSON.parse(trimmed), [...parts, '<decoded>']])
123
+ } catch {
124
+ // Not JSON after all; the text scan is the whole check.
125
+ }
126
+ }
127
+ const visit = (value, parts) => {
128
+ const key = parts.at(-1) || ''
129
+ const location = parts.join('.') || '<root>'
130
+ if (key && BANNED_KEY_PATTERNS.some((pattern) => pattern.test(key))) {
131
+ findings.push(`banned key at ${location}`)
132
+ }
133
+ if (typeof value === 'string') {
134
+ for (const { type, re } of BANNED_VALUE_PATTERNS) {
135
+ if (re.test(value)) findings.push(`banned value (${type}) at ${location}`)
136
+ }
137
+ // The value patterns match JSON syntax, which a decoded structure no
138
+ // longer carries: a member named d holding a scalar-length base64url
139
+ // run is key material whatever escaping produced it.
140
+ if (key === 'd' && /^[A-Za-z0-9_-]{40,}$/.test(value)) {
141
+ findings.push(`banned value (jwk-private-key) at ${location}`)
142
+ }
143
+ decodeIfJson(value, parts)
144
+ }
145
+ if (key === 'privateKeyJwk' && value && typeof value === 'object') {
146
+ findings.push(`banned value (jwk-private-key-document) at ${location}`)
147
+ }
148
+ if (Array.isArray(value)) {
149
+ value.forEach((item, index) => visit(item, [...parts, String(index)]))
150
+ } else if (value && typeof value === 'object') {
151
+ for (const [childKey, item] of Object.entries(value)) visit(item, [...parts, childKey])
152
+ }
153
+ }
154
+ visit(payload, [])
155
+ // Decoded structures are appended as they are discovered, so a JSON
156
+ // attachment nested inside another one is still reached.
157
+ for (let i = 0; i < decoded.length; i += 1) {
158
+ const [value, parts] = decoded[i]
159
+ visit(value, parts)
160
+ }
161
+ return findings
162
+ }
163
+
164
+ function runLocalGate(gate, packageRoot) {
165
+ const entry = path.join(packageRoot, gate.entry)
166
+ if (!fs.existsSync(entry)) return { name: gate.name, status: 'unavailable' }
167
+ // Gate output is discarded on purpose: the report carries names and pass
168
+ // or fail status only, never gate output.
169
+ const result = spawnSync(process.execPath, [entry, ...gate.args], {
170
+ cwd: packageRoot,
171
+ stdio: 'ignore',
172
+ env: { ...process.env, MNSTRY_ATELIER_PACKAGE_ROOT: packageRoot },
173
+ })
174
+ return { name: gate.name, status: result.status === 0 ? 'pass' : 'fail' }
175
+ }
176
+
177
+ export function collectEnvironment({ includeGates = false, packageRoot = resolvePackageRoot() } = {}) {
178
+ const pkg = JSON.parse(fs.readFileSync(path.join(packageRoot, 'package.json'), 'utf8'))
179
+ const environment = {
180
+ packageName: String(pkg.name || 'unknown'),
181
+ packageVersion: String(pkg.version || 'unknown'),
182
+ nodeVersion: process.version,
183
+ }
184
+ if (includeGates) {
185
+ environment.gates = LOCAL_GATES.map((gate) => runLocalGate(gate, packageRoot))
186
+ }
187
+ return environment
188
+ }
189
+
190
+ export function buildFeedbackReport({
191
+ message,
192
+ context = null,
193
+ environment = null,
194
+ // Deterministic on purpose, matching the chassis: wall-clock timestamps
195
+ // are machine metadata the report does not need to carry.
196
+ createdAt = new Date(0).toISOString(),
197
+ } = {}) {
198
+ const payload = { schema: FEEDBACK_REPORT_SCHEMA, createdAt, message }
199
+ if (context) payload.context = context
200
+ if (environment) payload.environment = environment
201
+ return payload
202
+ }
203
+
204
+ export function writeFeedbackReport(payload, { baseDir = process.cwd() } = {}) {
205
+ const relative = path.join(LOCAL_STATE_DIR, 'feedback', `${feedbackReportHash(payload)}.json`)
206
+ const file = path.join(baseDir, relative)
207
+ // "Local state" is only local if the directory it lands in is ignored, and
208
+ // feedback writes wherever the user happens to stand rather than in a
209
+ // configured workspace. Same git check-ignore test the project chassis runs
210
+ // (ensureLocalState), warned about here because this caller has no config to
211
+ // have run it. Warnings name the directory only, never the machine path.
212
+ const localState = ensureLocalState({ configDir: baseDir })
213
+ for (const warning of localState.warnings) console.error(`[atelier-feedback] warning: ${warning}`)
214
+ if (!localState.ignored) {
215
+ console.error(
216
+ '[atelier-feedback] warning: the report is landing inside a checkout that would track it, so a later commit could publish your own words and any attached context',
217
+ )
218
+ }
219
+ fs.mkdirSync(path.dirname(file), { recursive: true })
220
+ fs.writeFileSync(file, `${JSON.stringify(payload, null, 2)}\n`, { mode: 0o600 })
221
+ // The mode option only applies on creation; re-writing the same report
222
+ // must still leave the file user read/write only.
223
+ fs.chmodSync(file, 0o600)
224
+ return relative
225
+ }
226
+
227
+ // Reads user-chosen input as text, with two refusals rather than a best-effort
228
+ // embed: anything over the cap, and anything that is not valid UTF-8. Binary
229
+ // input has no business in a report a human is expected to read before sharing,
230
+ // and a lossy decode would put replacement characters (and whatever survived
231
+ // the decode) into the payload while the scan patterns, all text-shaped, walk
232
+ // straight past the bytes that matter.
233
+ function readTextFile(label, file) {
234
+ // Character devices and FIFOs never return from readFileSync, so the
235
+ // post-read cap below can never fire for them: refuse anything that is not
236
+ // a regular file, and refuse an oversized one before reading it. The
237
+ // post-read check stays — it is what makes the size bound race-free.
238
+ let stat
239
+ try {
240
+ stat = fs.statSync(file)
241
+ } catch {
242
+ throw new UsageError(`could not read ${label}: ${file}`)
243
+ }
244
+ if (!stat.isFile()) {
245
+ throw new UsageError(`${label} is not a regular file, so nothing was embedded: ${file}`)
246
+ }
247
+ if (stat.size > MAX_INPUT_FILE_BYTES) {
248
+ throw new UsageError(
249
+ `${label} is ${stat.size} bytes and the limit is ${MAX_INPUT_FILE_BYTES} bytes (${MAX_INPUT_FILE_BYTES / 1024} KiB); nothing was embedded. Attach a trimmed excerpt instead: ${file}`,
250
+ )
251
+ }
252
+ let buffer
253
+ try {
254
+ buffer = fs.readFileSync(file)
255
+ } catch {
256
+ // The path is echoed exactly as the user typed it — their own input,
257
+ // never a resolved machine-local path.
258
+ throw new UsageError(`could not read ${label}: ${file}`)
259
+ }
260
+ if (buffer.byteLength > MAX_INPUT_FILE_BYTES) {
261
+ throw new UsageError(
262
+ `${label} is ${buffer.byteLength} bytes and the limit is ${MAX_INPUT_FILE_BYTES} bytes (${MAX_INPUT_FILE_BYTES / 1024} KiB); nothing was embedded. Attach a trimmed excerpt instead: ${file}`,
263
+ )
264
+ }
265
+ if (buffer.includes(0)) {
266
+ throw new UsageError(`${label} contains NUL bytes, so it is not UTF-8 text; nothing was embedded: ${file}`)
267
+ }
268
+ try {
269
+ return new TextDecoder('utf-8', { fatal: true }).decode(buffer)
270
+ } catch {
271
+ throw new UsageError(`${label} is not valid UTF-8 text; nothing was embedded: ${file}`)
272
+ }
273
+ }
274
+
275
+ const CREATE_OPTION_SPEC = { message: 'value', 'message-file': 'value', context: 'value', 'include-gates': 'flag' }
276
+ const CREATE_OPTION_KEYS = { message: 'message', 'message-file': 'messageFile', context: 'context' }
277
+
278
+ function parseCreateOptions(argv) {
279
+ const options = { message: null, messageFile: null, context: null, includeGates: false, help: false }
280
+ for (let i = 0; i < argv.length; i += 1) {
281
+ const arg = argv[i]
282
+ if (arg === '--help' || arg === '-h') {
283
+ options.help = true
284
+ return options
285
+ }
286
+ if (!arg.startsWith('--')) throw new UsageError(`unexpected argument: ${arg}`)
287
+ const eq = arg.indexOf('=')
288
+ const name = eq === -1 ? arg.slice(2) : arg.slice(2, eq)
289
+ const kind = CREATE_OPTION_SPEC[name]
290
+ if (!kind) throw new UsageError(`unknown option --${name}`)
291
+ if (kind === 'flag') {
292
+ if (eq !== -1) throw new UsageError(`--${name} takes no value`)
293
+ options.includeGates = true
294
+ continue
295
+ }
296
+ let value
297
+ if (eq !== -1) {
298
+ value = arg.slice(eq + 1)
299
+ } else {
300
+ i += 1
301
+ value = argv[i]
302
+ if (value === undefined) throw new UsageError(`--${name} requires a value`)
303
+ }
304
+ options[CREATE_OPTION_KEYS[name]] = value
305
+ }
306
+ return options
307
+ }
308
+
309
+ function shareHint(packageRoot = resolvePackageRoot()) {
310
+ try {
311
+ const pkg = JSON.parse(fs.readFileSync(path.join(packageRoot, 'package.json'), 'utf8'))
312
+ const bugs = typeof pkg.bugs === 'string' ? pkg.bugs : pkg.bugs?.url
313
+ if (bugs) return ` (the package.json bugs field points at ${bugs})`
314
+ } catch {
315
+ // Fall through to the generic hint.
316
+ }
317
+ return " (see the bugs field in this package's package.json)"
318
+ }
319
+
320
+ function runCreate(argv) {
321
+ const options = parseCreateOptions(argv)
322
+ if (options.help) {
323
+ console.log(USAGE)
324
+ return 0
325
+ }
326
+ if ((options.message === null) === (options.messageFile === null)) {
327
+ throw new UsageError('create requires exactly one of --message or --message-file')
328
+ }
329
+ const message = options.message ?? readTextFile('--message-file', options.messageFile)
330
+ if (!message.trim()) throw new UsageError('the feedback message must not be empty')
331
+ // --message arrives through argv rather than a file, so the file reader's
332
+ // bound never saw it; the same payload reaches the same scan either way.
333
+ if (Buffer.byteLength(message, 'utf8') > MAX_INPUT_FILE_BYTES) {
334
+ throw new UsageError(
335
+ `--message is longer than the ${MAX_INPUT_FILE_BYTES} byte limit (${MAX_INPUT_FILE_BYTES / 1024} KiB); nothing was written. Use --message-file with a trimmed excerpt instead.`,
336
+ )
337
+ }
338
+ const context = options.context === null
339
+ ? null
340
+ : { name: path.basename(options.context), text: readTextFile('--context', options.context) }
341
+ const environment = collectEnvironment({ includeGates: options.includeGates })
342
+ const payload = buildFeedbackReport({ message, context, environment })
343
+ const findings = scanFeedbackReport(payload)
344
+ if (findings.length) {
345
+ console.error('[atelier-feedback] refusing to write: the report matched local disclosure patterns')
346
+ for (const finding of findings) console.error(`[atelier-feedback] ${finding}`)
347
+ console.error('[atelier-feedback] nothing was written; edit the flagged part and retry')
348
+ return 1
349
+ }
350
+ const relative = writeFeedbackReport(payload)
351
+ console.log(`wrote ${relative}`)
352
+ console.log('This report stayed on this machine. Nothing was sent anywhere; this kit has no send path.')
353
+ // Deliberately not a clearance: a clean scan means no known pattern matched,
354
+ // which is a backstop against an obvious mistake and nothing more. Whether
355
+ // the contents are safe to publish is a judgement only the person who wrote
356
+ // them can make, on the actual file.
357
+ console.log('The scan that just passed is a backstop, not a guarantee: it matches known patterns only, so a clean run does not mean the file is safe to publish.')
358
+ console.log(`Read the whole file yourself before you share it. Attaching it to the project issue tracker publishes it${shareHint()}.`)
359
+ return 0
360
+ }
361
+
362
+ function runCheck(argv) {
363
+ if (argv[0] === '--help' || argv[0] === '-h') {
364
+ console.log(USAGE)
365
+ return 0
366
+ }
367
+ const file = argv[0]
368
+ if (!file || file.startsWith('--')) throw new UsageError('check requires a report file path')
369
+ if (argv.length > 1) throw new UsageError(`unexpected argument: ${argv[1]}`)
370
+ let payload
371
+ try {
372
+ payload = JSON.parse(fs.readFileSync(file, 'utf8'))
373
+ } catch {
374
+ throw new UsageError(`could not read a JSON report at ${file}`)
375
+ }
376
+ if (!payload || typeof payload !== 'object' || Array.isArray(payload) || payload.schema !== FEEDBACK_REPORT_SCHEMA) {
377
+ throw new UsageError(`not a feedback report (expected schema ${FEEDBACK_REPORT_SCHEMA})`)
378
+ }
379
+ const findings = scanFeedbackReport(payload)
380
+ if (findings.length) {
381
+ for (const finding of findings) console.error(`[atelier-feedback] ${finding}`)
382
+ return 1
383
+ }
384
+ console.log(`${file} ok: no banned key or value patterns matched`)
385
+ return 0
386
+ }
387
+
388
+ export function runFeedbackCommand(argv = process.argv.slice(2)) {
389
+ try {
390
+ const first = argv[0]
391
+ if (first === '--help' || first === '-h' || first === 'help') {
392
+ console.log(USAGE)
393
+ return 0
394
+ }
395
+ if (first === 'create') return runCreate(argv.slice(1))
396
+ if (first === 'check') return runCheck(argv.slice(1))
397
+ if (first === undefined || first.startsWith('--')) return runCreate(argv)
398
+ throw new UsageError(`unknown feedback subcommand: ${first}`)
399
+ } catch (error) {
400
+ if (error instanceof UsageError) {
401
+ console.error(`[atelier-feedback] ${error.message}`)
402
+ console.error('')
403
+ console.error(USAGE)
404
+ return 2
405
+ }
406
+ console.error(`[atelier-feedback] ${error instanceof Error ? error.message : String(error)}`)
407
+ return 2
408
+ }
409
+ }
410
+
411
+ const currentFile = fileURLToPath(import.meta.url)
412
+ if (process.argv[1] && path.resolve(process.argv[1]) === currentFile) {
413
+ process.exitCode = runFeedbackCommand()
414
+ }