@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.
- package/CHANGELOG.md +383 -0
- package/LICENSE +202 -0
- package/NOTICE +5 -0
- package/README.md +262 -0
- package/SECURITY.md +93 -0
- package/TRADEMARKS.md +88 -0
- package/announcements/2026-08-06-announcements-channel.v1.json +12 -0
- package/announcements/keys/mnstry-announcements.public.v1.json +9 -0
- package/bin/atelier.mjs +2 -0
- package/bin/mnstry-atelier.mjs +3 -0
- package/contracts/analysis-adapter.v1.schema.json +248 -0
- package/contracts/atelier-action-intent.v1.schema.json +143 -0
- package/contracts/atelier-analysis-adapter.v1.schema.json +360 -0
- package/contracts/atelier-attestation.v1.schema.json +239 -0
- package/contracts/atelier-boundary-policy.v1.schema.json +339 -0
- package/contracts/atelier-claim.v1.schema.json +97 -0
- package/contracts/atelier-export.v1.schema.json +1015 -0
- package/contracts/atelier-extension-pack.v1.schema.json +290 -0
- package/contracts/atelier-kit-manifest.v1.schema.json +273 -0
- package/contracts/atelier-lock.v1.schema.json +364 -0
- package/contracts/atelier-migration.v1.schema.json +162 -0
- package/contracts/atelier-project-config.v1.schema.json +352 -0
- package/contracts/atelier-readiness-protocol.v1.schema.json +517 -0
- package/contracts/atelier-readiness-run.v1.schema.json +229 -0
- package/contracts/atelier-readiness.v1.schema.json +457 -0
- package/contracts/compat-baseline.json +10 -0
- package/contracts/git-promote-event.v1.schema.json +145 -0
- package/contracts/knowledge-source-sidecar.v1.schema.json +157 -0
- package/contracts/mnstry-atelier-semantic-profile.v1.json +254 -0
- package/docs/announcements.md +101 -0
- package/docs/atelier-runtime.md +34 -0
- package/docs/attestation.md +201 -0
- package/docs/continuity.md +55 -0
- package/docs/contract-stability.md +111 -0
- package/docs/distributions.md +220 -0
- package/docs/install.md +140 -0
- package/docs/knowledge-graph.md +140 -0
- package/docs/ontology.md +206 -0
- package/docs/release-engineering.md +151 -0
- package/docs/repo-boundary-guard.md +199 -0
- package/docs/tenant-readiness.md +64 -0
- package/docs/upgrade.md +124 -0
- package/fixtures/analysis/invalid/native-graph.v1.json +4 -0
- package/fixtures/analysis/invalid/promoted-claim.v1.json +17 -0
- package/fixtures/analysis/invalid/runtime-import.v1.json +20 -0
- package/fixtures/analysis/valid/output.claims.v1.json +17 -0
- package/fixtures/analysis-adapter/invalid/manifest-hidden-provider.v1.json +24 -0
- package/fixtures/analysis-adapter/invalid/output-frontmatter-mutation.v1.json +21 -0
- package/fixtures/analysis-adapter/invalid/output-native-graph.v1.json +14 -0
- package/fixtures/analysis-adapter/manifest.disabled.v1.json +22 -0
- package/fixtures/analysis-adapter/manifest.enabled-local.v1.json +23 -0
- package/fixtures/analysis-adapter/output.claims.v1.json +21 -0
- package/fixtures/atelier-action-intent/invalid/runtime-mutation-authority.v1.json +16 -0
- package/fixtures/atelier-action-intent/invalid/unknown-top-level-field.v1.json +17 -0
- package/fixtures/atelier-action-intent/invalid/writes-beyond-clipboard.v1.json +16 -0
- package/fixtures/atelier-action-intent/valid/copy-agent-prompt.v1.json +16 -0
- package/fixtures/atelier-action-intent/valid/copy-repo-path.v1.json +22 -0
- package/fixtures/atelier-analysis-adapter/invalid/hidden-model-provider.v1.json +24 -0
- package/fixtures/atelier-analysis-adapter/invalid/inert-with-execute-command.v1.json +24 -0
- package/fixtures/atelier-analysis-adapter/invalid/proposals-with-runtime-import.v1.json +30 -0
- package/fixtures/atelier-analysis-adapter/valid/manifest-inert.v1.json +24 -0
- package/fixtures/atelier-analysis-adapter/valid/proposals-claim-only.v1.json +27 -0
- package/fixtures/atelier-attestation/invalid/bad-digest.v1.json +23 -0
- package/fixtures/atelier-attestation/invalid/conformance-scope.v1.json +23 -0
- package/fixtures/atelier-attestation/invalid/missing-signature.v1.json +22 -0
- package/fixtures/atelier-attestation/invalid/unknown-root-field.v1.json +24 -0
- package/fixtures/atelier-attestation/keys/roundtrip-issuer.public.v1.json +9 -0
- package/fixtures/atelier-attestation/valid/rejected-with-reasons.v1.json +49 -0
- package/fixtures/atelier-attestation/valid/signed-admitted.v1.json +35 -0
- package/fixtures/atelier-attestation/valid/signed-roundtrip.v1.json +29 -0
- package/fixtures/atelier-attestation/valid/unsigned-advisory.v1.json +23 -0
- package/fixtures/atelier-claim/invalid/unknown-provider.v1.json +15 -0
- package/fixtures/atelier-claim/invalid/unknown-top-level-field.v1.json +16 -0
- package/fixtures/atelier-claim/invalid/wrong-schema-const.v1.json +15 -0
- package/fixtures/atelier-claim/valid/adapter-style-proposed-relation.v1.json +19 -0
- package/fixtures/atelier-claim/valid/codex-proposed-relation.v1.json +16 -0
- package/fixtures/atelier-claim/valid/manual-proposed-relation.v1.json +16 -0
- package/fixtures/atelier-claim/valid/readiness-proposed-relation.v1.json +18 -0
- package/fixtures/atelier-export/invalid/dirty-unforced-empty-taint.v1.json +19 -0
- package/fixtures/atelier-export/invalid/object-local-audience-visibility.v1.json +11 -0
- package/fixtures/atelier-export/invalid/open-object-class-owner.v1.json +23 -0
- package/fixtures/atelier-export/invalid/pricing-legacy-amount.v1.json +23 -0
- package/fixtures/atelier-export/invalid/public-disguised-internal-source-ref.v1.json +11 -0
- package/fixtures/atelier-export/invalid/public-nested-private-source-ref.v1.json +17 -0
- package/fixtures/atelier-export/invalid/public-resolved-private-source-ref.v1.json +13 -0
- package/fixtures/atelier-export/invalid/public-resolved-sensitive-source-ref.v1.json +13 -0
- package/fixtures/atelier-export/invalid/public-source-ref-missing-provenance.v1.json +20 -0
- package/fixtures/atelier-export/invalid/public-source-team-kg.json +12 -0
- package/fixtures/atelier-export/invalid/source-node-legacy-visibility.v1.json +21 -0
- package/fixtures/atelier-export/invalid/source-node-unknown-field.v1.json +18 -0
- package/fixtures/atelier-export/invalid/unresolved-public-source-ref.v1.json +11 -0
- package/fixtures/atelier-export/sample-studio-offer.v1.json +397 -0
- package/fixtures/atelier-extension-pack/invalid/bundled-slug-collision.v1.json +22 -0
- package/fixtures/atelier-extension-pack/invalid/escapes-root.v1.json +22 -0
- package/fixtures/atelier-extension-pack/invalid/posture-tampered.v1.json +22 -0
- package/fixtures/atelier-extension-pack/invalid/protocols/autonomous-authority.v1.json +118 -0
- package/fixtures/atelier-extension-pack/invalid/protocols/offer-map.v1.json +109 -0
- package/fixtures/atelier-extension-pack/invalid/redefines-runtime-term.v1.json +19 -0
- package/fixtures/atelier-extension-pack/invalid/wrong-namespace.v1.json +19 -0
- package/fixtures/atelier-extension-pack/valid/protocols/contract-gate.v1.json +109 -0
- package/fixtures/atelier-extension-pack/valid/sample-pack.v1.json +48 -0
- package/fixtures/atelier-kit-manifest/invalid/runtime-copy-without-canonical-role.v1.json +53 -0
- package/fixtures/atelier-kit-manifest/invalid/telemetry-send-path.v1.json +21 -0
- package/fixtures/atelier-kit-manifest/valid/mnstry-atelier.valid.v1.json +76 -0
- package/fixtures/atelier-lock/invalid/absolute-contract-path.v1.json +40 -0
- package/fixtures/atelier-lock/invalid/runtime-mutation-enabled.v1.json +41 -0
- package/fixtures/atelier-lock/invalid/unknown-top-level-field.v1.json +42 -0
- package/fixtures/atelier-lock/valid/extension-pack.v1.json +55 -0
- package/fixtures/atelier-lock/valid/minimal.v1.json +44 -0
- package/fixtures/atelier-migration/invalid/live-apply-mode.v1.json +21 -0
- package/fixtures/atelier-migration/invalid/missing-review-evidence.v1.json +20 -0
- package/fixtures/atelier-migration/invalid/runtime-mutation-enabled.v1.json +21 -0
- package/fixtures/atelier-migration/valid/minimal.v1.json +32 -0
- package/fixtures/atelier-project-config/invalid/absolute-repo-path.v1.json +11 -0
- package/fixtures/atelier-project-config/invalid/ext-not-object.v1.json +12 -0
- package/fixtures/atelier-project-config/invalid/external-kind-read-boundary.v1.json +12 -0
- package/fixtures/atelier-project-config/invalid/missing-repos-and-alignment.v1.json +7 -0
- package/fixtures/atelier-project-config/invalid/unknown-top-level-field.v1.json +14 -0
- package/fixtures/atelier-project-config/valid/alignment-scaffold.v1.json +15 -0
- package/fixtures/atelier-project-config/valid/ext-extension-packs.v1.json +36 -0
- package/fixtures/atelier-project-config/valid/repo-workspace.v1.json +23 -0
- package/fixtures/atelier-project-config/validator-only/ext-member-not-object.v1.json +14 -0
- package/fixtures/atelier-readiness/invalid/dry-run-only-false.v1.json +87 -0
- package/fixtures/atelier-readiness/invalid/support-bundle-send-path.v1.json +87 -0
- package/fixtures/atelier-readiness/invalid/unknown-top-level-field.v1.json +88 -0
- package/fixtures/atelier-readiness/valid/ready-workspace.v1.json +84 -0
- package/fixtures/boundary-policy/invalid/content-rule-exception-blanket-path.v1.json +67 -0
- package/fixtures/boundary-policy/invalid/invalid-actor-private-repo-mapping.v1.json +20 -0
- package/fixtures/boundary-policy/invalid/legacy-warning-missing-warning.v1.json +20 -0
- package/fixtures/boundary-policy/invalid/missing-repo-coverage.v1.json +19 -0
- package/fixtures/boundary-policy/invalid/unknown-key.v1.json +12 -0
- package/fixtures/boundary-policy/valid/content-rule-exception.v1.json +67 -0
- package/fixtures/boundary-policy/valid/legacy-warning.v1.json +33 -0
- package/fixtures/boundary-policy/valid/strict-private-shared.v1.json +40 -0
- package/fixtures/git-promote/invalid/provision-named-revocable.v1.json +29 -0
- package/fixtures/git-promote/private-to-team.v1.json +29 -0
- package/fixtures/knowledge-source-sidecar/invalid/legacy-visibility-field.v1.json +17 -0
- package/fixtures/knowledge-source-sidecar/invalid/missing-audience.v1.json +15 -0
- package/fixtures/knowledge-source-sidecar/invalid/unknown-relation-kind.v1.json +18 -0
- package/fixtures/knowledge-source-sidecar/valid/html-source.v1.json +18 -0
- package/fixtures/knowledge-source-sidecar/valid/pdf-evidence.v1.json +18 -0
- package/fixtures/projects/neutral-project/atelier.project.json +15 -0
- package/fixtures/projects/neutral-project/ops/config/repo-access.v1.json +12 -0
- package/fixtures/projects/neutral-project/project-knowledge.graph.json +69 -0
- package/fixtures/projects/sample-workspace/atelier.project.json +23 -0
- package/fixtures/projects/sample-workspace/boundary-policy.v1.json +59 -0
- package/fixtures/projects/sample-workspace/content/README.md +16 -0
- package/fixtures/projects/sample-workspace/content/source.html +5 -0
- package/fixtures/projects/sample-workspace/content/source.html.kg.json +15 -0
- package/fixtures/projects/sample-workspace/repo-access.v1.json +9 -0
- package/fixtures/projects/source-formats-workspace/atelier.project.json +23 -0
- package/fixtures/projects/source-formats-workspace/content/README.md +17 -0
- package/fixtures/projects/source-formats-workspace/content/data.json +7 -0
- package/fixtures/projects/source-formats-workspace/content/data.json.kg.json +15 -0
- package/fixtures/projects/source-formats-workspace/content/logo.png.kg.json +15 -0
- package/fixtures/projects/source-formats-workspace/content/metrics.csv +3 -0
- package/fixtures/projects/source-formats-workspace/content/metrics.csv.kg.json +15 -0
- package/fixtures/projects/source-formats-workspace/content/pipeline.yaml +4 -0
- package/fixtures/projects/source-formats-workspace/content/pipeline.yaml.kg.json +15 -0
- package/fixtures/projects/source-formats-workspace/repo-access.v1.json +9 -0
- package/fixtures/readiness-protocols/extension-pack/invalid/protocol-missing-path.v1.json +20 -0
- package/fixtures/readiness-protocols/extension-pack/valid/with-protocol.v1.json +24 -0
- package/fixtures/readiness-protocols/protocol/invalid/missing-check-target.v1.json +103 -0
- package/fixtures/readiness-protocols/protocol/invalid/runtime-mutation-enabled.v1.json +107 -0
- package/fixtures/readiness-protocols/protocol/valid/minimal.v1.json +100 -0
- package/fixtures/readiness-protocols/run/invalid/missing-evidence.v1.json +37 -0
- package/fixtures/readiness-protocols/run/invalid/pass-with-blocker.v1.json +22 -0
- package/fixtures/readiness-protocols/run/invalid/runtime-mutation-enabled.v1.json +22 -0
- package/fixtures/readiness-protocols/run/valid/pass.v1.json +44 -0
- package/fixtures/support/invalid/leaky-support.v1.json +13 -0
- package/fixtures/support/valid/minimal-safe.v1.json +20 -0
- package/package.json +98 -0
- package/skills/claude/atelier-open/SKILL.md +68 -0
- package/skills/claude/mnstry-readiness/SKILL.md +41 -0
- package/skills/codex/atelier-open/SKILL.md +68 -0
- package/skills/codex/mnstry-readiness/SKILL.md +41 -0
- package/src/analysis/adapter.mjs +20 -0
- package/src/analysis/analysis-claim-contract.mjs +335 -0
- package/src/attestation/jcs.mjs +64 -0
- package/src/attestation/sign.mjs +305 -0
- package/src/boundary/content-rules.mjs +262 -0
- package/src/boundary/policy.mjs +806 -0
- package/src/check-atelier-export-contract.mjs +116 -0
- package/src/cli/run.mjs +283 -0
- package/src/collaboration/proposals.mjs +262 -0
- package/src/commands/analysis.mjs +8 -0
- package/src/commands/announcements.mjs +308 -0
- package/src/commands/attestation.mjs +198 -0
- package/src/commands/boundary.mjs +25 -0
- package/src/commands/config.mjs +4 -0
- package/src/commands/context.mjs +3 -0
- package/src/commands/distribution.mjs +224 -0
- package/src/commands/egress.mjs +11 -0
- package/src/commands/extension-pack.mjs +209 -0
- package/src/commands/feedback.mjs +3 -0
- package/src/commands/graph.mjs +3 -0
- package/src/commands/init.mjs +130 -0
- package/src/commands/lock.mjs +3 -0
- package/src/commands/project.mjs +3 -0
- package/src/commands/promote.mjs +4 -0
- package/src/commands/readiness.mjs +3 -0
- package/src/commands/server.mjs +6 -0
- package/src/commands/setup.mjs +250 -0
- package/src/commands/support.mjs +3 -0
- package/src/commands/upgrade.mjs +3 -0
- package/src/contracts/corpus.mjs +171 -0
- package/src/egress/check.mjs +45 -0
- package/src/egress/forbidden-egress.mjs +323 -0
- package/src/export/atelier-export-contract.mjs +722 -0
- package/src/extension-packs/loader.mjs +491 -0
- package/src/graph/graph.mjs +370 -0
- package/src/graph/knowledge-graph.mjs +887 -0
- package/src/harness/context-client.mjs +144 -0
- package/src/harness/context.mjs +27 -0
- package/src/index.mjs +88 -0
- package/src/project/config.mjs +557 -0
- package/src/project/file-class.mjs +113 -0
- package/src/project/git-ignore.mjs +31 -0
- package/src/project/path-match.mjs +30 -0
- package/src/project/repo-identity.mjs +176 -0
- package/src/projection/alignment-projection.mjs +169 -0
- package/src/projection/policy.mjs +90 -0
- package/src/projection/project.mjs +152 -0
- package/src/readiness/readiness.mjs +312 -0
- package/src/readiness-protocols/bundled-pack.mjs +766 -0
- package/src/readiness-protocols/runtime.mjs +419 -0
- package/src/server/local-sidecar.mjs +640 -0
- package/src/server/security.mjs +229 -0
- package/src/server/server.mjs +42 -0
- package/src/support/feedback-report.mjs +414 -0
- package/src/support/support-bundle.mjs +226 -0
- package/src/ui/html-primitives.mjs +104 -0
- package/src/upgrade/upgrade.mjs +701 -0
- package/src/validate-atelier-export-dry-run.mjs +75 -0
- package/templates/atelier.project.example.json +23 -0
- package/templates/distribution-workspace/.claude/launch.json +12 -0
- package/templates/distribution-workspace/README.md +86 -0
- package/templates/distribution-workspace/atelier.project.json +36 -0
- package/templates/distribution-workspace/boundary-policy.v1.json +61 -0
- package/templates/distribution-workspace/gitignore +29 -0
- package/templates/distribution-workspace/governance/repo-boundary-ledger.md +5 -0
- package/templates/distribution-workspace/project/README.md +19 -0
- package/templates/distribution-workspace/repo-access.v1.json +9 -0
- package/templates/launch.json +12 -0
- package/templates/private-domain-workspace/.claude/launch.json +12 -0
- package/templates/private-domain-workspace/README.md +50 -0
- package/templates/private-domain-workspace/atelier.project.json +28 -0
- package/templates/private-domain-workspace/boundary-policy.v1.json +54 -0
- package/templates/private-domain-workspace/domain/README.md +19 -0
- package/templates/private-domain-workspace/gitignore +29 -0
- package/templates/private-domain-workspace/governance/repo-boundary-ledger.md +4 -0
- package/templates/private-domain-workspace/repo-access.v1.json +9 -0
- package/templates/shared-project-workspace/.claude/launch.json +12 -0
- package/templates/shared-project-workspace/README.md +51 -0
- package/templates/shared-project-workspace/atelier.project.json +28 -0
- package/templates/shared-project-workspace/boundary-policy.v1.json +61 -0
- package/templates/shared-project-workspace/gitignore +29 -0
- package/templates/shared-project-workspace/governance/repo-boundary-ledger.md +5 -0
- package/templates/shared-project-workspace/project/README.md +19 -0
- 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
|
+
}
|