diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 94df503..17e1296 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -126,11 +126,17 @@ jobs: - name: Build run: pnpm run build - # Enforces unified workspace versioning — every packages/*/package.json must match the tag. - - name: Verify workspace versions match tag + # Enforces unified workspace versioning: every packages/*/package.json must match the tag. + # If Charter adopts independent package versions, replace this with per-package release metadata. + - name: Verify tag and workspace versions shell: bash run: | TAG="${{ github.event.inputs.tag || github.ref_name }}" + if [[ ! "${TAG}" =~ ^v[0-9]+\.[0-9]+\.[0-9]+$ ]]; then + echo "::error::Invalid tag format: ${TAG}. Expected v.." + exit 1 + fi + EXPECTED="${TAG#v}" FAIL=0 for p in packages/*/package.json; do @@ -143,7 +149,43 @@ jobs: done if [[ $FAIL -ne 0 ]]; then exit 1; fi + - name: Verify packed manifests + run: pnpm run publish:check + # Auth is OIDC via npm trusted publishers — no NPM_TOKEN needed. # See: https://docs.npmjs.com/trusted-publishers - name: Publish to npm - run: npm publish --workspaces --access public --provenance + shell: bash + run: | + TAG="${{ github.event.inputs.tag || github.ref_name }}" + VERSION="${TAG#v}" + mkdir -p release-tarballs + + pack_package() { + local package_dir="$1" + (cd "${package_dir}" && pnpm pack --pack-destination ../../release-tarballs) + } + + pack_package packages/types + pack_package packages/core + pack_package packages/adf + pack_package packages/git + pack_package packages/classify + pack_package packages/validate + pack_package packages/drift + pack_package packages/blast + pack_package packages/surface + pack_package packages/ci + pack_package packages/cli + + npm publish "release-tarballs/stackbilt-types-${VERSION}.tgz" --access public --provenance + npm publish "release-tarballs/stackbilt-core-${VERSION}.tgz" --access public --provenance + npm publish "release-tarballs/stackbilt-adf-${VERSION}.tgz" --access public --provenance + npm publish "release-tarballs/stackbilt-git-${VERSION}.tgz" --access public --provenance + npm publish "release-tarballs/stackbilt-classify-${VERSION}.tgz" --access public --provenance + npm publish "release-tarballs/stackbilt-validate-${VERSION}.tgz" --access public --provenance + npm publish "release-tarballs/stackbilt-drift-${VERSION}.tgz" --access public --provenance + npm publish "release-tarballs/stackbilt-blast-${VERSION}.tgz" --access public --provenance + npm publish "release-tarballs/stackbilt-surface-${VERSION}.tgz" --access public --provenance + npm publish "release-tarballs/stackbilt-ci-${VERSION}.tgz" --access public --provenance + npm publish "release-tarballs/stackbilt-cli-${VERSION}.tgz" --access public --provenance diff --git a/PUBLISHING.md b/PUBLISHING.md index 738d0d8..c2c19b1 100644 --- a/PUBLISHING.md +++ b/PUBLISHING.md @@ -41,6 +41,7 @@ pnpm run clean pnpm run typecheck pnpm run build pnpm run test +pnpm run publish:check ``` ## Phase 2: Version Bump @@ -62,23 +63,29 @@ done ## Phase 3: Artifact Validation (Required) -1. Dry-run packed contents per package: +1. Verify packed package manifests do not contain `workspace:` dependency specifiers: ```bash -pnpm --filter @stackbilt/types pack --dry-run -pnpm --filter @stackbilt/core pack --dry-run -pnpm --filter @stackbilt/adf pack --dry-run -pnpm --filter @stackbilt/git pack --dry-run -pnpm --filter @stackbilt/classify pack --dry-run -pnpm --filter @stackbilt/validate pack --dry-run -pnpm --filter @stackbilt/drift pack --dry-run -pnpm --filter @stackbilt/blast pack --dry-run -pnpm --filter @stackbilt/surface pack --dry-run -pnpm --filter @stackbilt/ci pack --dry-run -pnpm --filter @stackbilt/cli pack --dry-run +pnpm run publish:check ``` -2. Verify CLI behavior before publish: +2. Dry-run packed contents per package: + +```bash +(cd packages/types && pnpm pack --dry-run) +(cd packages/core && pnpm pack --dry-run) +(cd packages/adf && pnpm pack --dry-run) +(cd packages/git && pnpm pack --dry-run) +(cd packages/classify && pnpm pack --dry-run) +(cd packages/validate && pnpm pack --dry-run) +(cd packages/drift && pnpm pack --dry-run) +(cd packages/blast && pnpm pack --dry-run) +(cd packages/surface && pnpm pack --dry-run) +(cd packages/ci && pnpm pack --dry-run) +(cd packages/cli && pnpm pack --dry-run) +``` + +3. Verify CLI behavior before publish: ```bash node packages/cli/dist/bin.js --version diff --git a/README.md b/README.md index fd6893e..995671d 100644 --- a/README.md +++ b/README.md @@ -104,6 +104,7 @@ Claude Code can query `getProjectContext`, `getArchitecturalDecisions`, `getProj ```bash charter # Repo risk/value snapshot charter bootstrap --ci github # One-command onboarding +charter bootstrap --security-sensitive # SECURITY.md + hard security drift denies charter doctor # Environment/config health check charter validate # Commit governance (trailers) charter drift # Pattern drift scanning diff --git a/docs/cli-reference.md b/docs/cli-reference.md index affd963..0f96920 100644 --- a/docs/cli-reference.md +++ b/docs/cli-reference.md @@ -78,11 +78,13 @@ One-command repo onboarding. Orchestrates detect + setup + ADF init + install + ```bash npx charter bootstrap # interactive npx charter bootstrap --preset worker --ci github --yes # fully automated +npx charter bootstrap --preset worker --security-sensitive # security posture baseline npx charter bootstrap --skip-install --skip-doctor # minimal ``` - `--ci github` — generate GitHub Actions governance workflow - `--preset ` — stack preset +- `--security-sensitive` — generate `SECURITY.md`, seed hard-fail drift denies in `.charter/patterns/security-deny.json`, and warn in `doctor` when no `security*` or `l4*` test file exists - `--skip-install` — skip dependency installation phase - `--skip-doctor` — skip health check phase - `-y, --yes` — accept all prompts diff --git a/package.json b/package.json index 8c7ecd1..c93c1c0 100644 --- a/package.json +++ b/package.json @@ -21,6 +21,7 @@ "docs:oss:check": "node scripts/docs-sync.mjs --check --config .docsync.oss.json", "docs:oss:auto": "node scripts/docs-oss-auto-sync.mjs --config .docsync.oss.json", "docs:oss:auto:dry-run": "node scripts/docs-oss-auto-sync.mjs --config .docsync.oss.json --dry-run --no-push", + "publish:check": "node scripts/assert-packages-publishable.mjs", "verify:adf": "bash -lc \"node packages/cli/dist/bin.js doctor --adf-only --ci --format json && node packages/cli/dist/bin.js adf evidence --auto-measure --ci --format json\"", "charter:detect": "charter setup --detect-only --format json", "charter:setup": "charter setup --preset fullstack --ci github --yes", diff --git a/packages/ci/package.json b/packages/ci/package.json index 4c35dc2..5ef2be2 100644 --- a/packages/ci/package.json +++ b/packages/ci/package.json @@ -31,6 +31,9 @@ "dependencies": { "@stackbilt/types": "workspace:^" }, + "scripts": { + "prepublishOnly": "node ../../scripts/ensure-pnpm-publish.mjs" + }, "publishConfig": { "access": "public" }, diff --git a/packages/classify/package.json b/packages/classify/package.json index 2237235..b56115f 100644 --- a/packages/classify/package.json +++ b/packages/classify/package.json @@ -31,6 +31,9 @@ "dependencies": { "@stackbilt/types": "workspace:^" }, + "scripts": { + "prepublishOnly": "node ../../scripts/ensure-pnpm-publish.mjs" + }, "publishConfig": { "access": "public" }, diff --git a/packages/cli/package.json b/packages/cli/package.json index 61b2fa5..cc4a38b 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -36,6 +36,7 @@ "access": "public" }, "scripts": { + "prepublishOnly": "node ../../scripts/ensure-pnpm-publish.mjs", "build": "pnpm exec tsc -p tsconfig.json" }, "dependencies": { diff --git a/packages/cli/src/__tests__/bootstrap.test.ts b/packages/cli/src/__tests__/bootstrap.test.ts index 699127e..4775364 100644 --- a/packages/cli/src/__tests__/bootstrap.test.ts +++ b/packages/cli/src/__tests__/bootstrap.test.ts @@ -3,6 +3,8 @@ import * as os from 'node:os'; import * as path from 'node:path'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { bootstrapCommand } from '../commands/bootstrap'; +import { doctorCommand } from '../commands/doctor'; +import { driftCommand } from '../commands/drift'; import type { CLIOptions } from '../index'; const baseOptions: CLIOptions = { @@ -122,4 +124,50 @@ STATE: expect(manifest).toContain('agent.adf'); expect(manifest).toContain('persona.adf'); }); + + it('seeds security-sensitive bootstrap files and warns when security tests are absent', async () => { + const exitCode = await bootstrapCommand( + { ...baseOptions, yes: true }, + ['--yes', '--preset', 'worker', '--security-sensitive', '--skip-install', '--skip-doctor'], + ); + + expect(exitCode).toBe(0); + expect(fs.existsSync('SECURITY.md')).toBe(true); + expect(fs.existsSync(path.join('.charter', 'patterns', 'security-deny.json'))).toBe(true); + + logs = []; + await doctorCommand({ ...baseOptions, format: 'json' }, []); + const report = JSON.parse(logs[0]); + const securityCheck = report.checks.find((check: { name: string }) => check.name === 'security test coverage'); + expect(securityCheck.status).toBe('WARN'); + + fs.mkdirSync('tests', { recursive: true }); + fs.writeFileSync(path.join('tests', 'security-l4.test.ts'), 'export {};'); + + logs = []; + await doctorCommand({ ...baseOptions, format: 'json' }, []); + const updatedReport = JSON.parse(logs[0]); + const updatedSecurityCheck = updatedReport.checks.find((check: { name: string }) => check.name === 'security test coverage'); + expect(updatedSecurityCheck.status).toBe('PASS'); + }); + + it('treats security deny drift matches as CI policy violations', async () => { + await bootstrapCommand( + { ...baseOptions, yes: true }, + ['--yes', '--preset', 'worker', '--security-sensitive', '--skip-install', '--skip-doctor'], + ); + fs.mkdirSync('src', { recursive: true }); + fs.writeFileSync(path.join('src', 'verify.ts'), 'export function verify(computed: string, signature: string) { return computed === signature; }\n'); + + logs = []; + const exitCode = await driftCommand({ ...baseOptions, format: 'json', ciMode: true }, ['--path', '.']); + const report = JSON.parse(logs[0]); + + expect(exitCode).toBe(1); + expect(report.status).toBe('FAIL'); + expect(report.securityBlockers).toBeGreaterThan(0); + expect(report.violations.some((violation: { severity: string; patternName: string }) => + violation.severity === 'BLOCKER' && violation.patternName.includes('Timing-Sensitive Equality') + )).toBe(true); + }); }); diff --git a/packages/cli/src/commands/bootstrap.ts b/packages/cli/src/commands/bootstrap.ts index 6f4442e..2f5e26d 100644 --- a/packages/cli/src/commands/bootstrap.ts +++ b/packages/cli/src/commands/bootstrap.ts @@ -79,6 +79,7 @@ export async function bootstrapCommand(options: CLIOptions, args: string[]): Pro const skipInstall = args.includes('--skip-install'); const skipDoctor = args.includes('--skip-doctor'); const force = args.includes('--force'); + const securitySensitive = args.includes('--security-sensitive'); const nonInteractive = options.yes; const setupOverwrite = options.yes || force; @@ -125,7 +126,7 @@ export async function bootstrapCommand(options: CLIOptions, args: string[]): Pro // ======================================================================== // Phase 2: Setup // ======================================================================== - const setupResult = runSetupPhase(options, selectedPreset, detection, contexts, ciTarget, packageManager, setupOverwrite); + const setupResult = runSetupPhase(options, selectedPreset, detection, contexts, ciTarget, packageManager, setupOverwrite, securitySensitive); result.steps.push(setupResult.step); warnings += setupResult.step.warnings.length; @@ -472,7 +473,8 @@ function runSetupPhase( contexts: ReturnType, ciTarget: string | undefined, packageManager: 'npm' | 'pnpm', - force: boolean + force: boolean, + securitySensitive: boolean ): { step: StepResult } { const warnings: string[] = []; const created: string[] = []; @@ -489,6 +491,7 @@ function runSetupPhase( react: detection.signals.hasReact, vite: detection.signals.hasVite, }, + securitySensitive, }); if (initResult.created) { diff --git a/packages/cli/src/commands/doctor.ts b/packages/cli/src/commands/doctor.ts index c66002e..a7364a8 100644 --- a/packages/cli/src/commands/doctor.ts +++ b/packages/cli/src/commands/doctor.ts @@ -65,6 +65,18 @@ export async function doctorCommand(options: CLIOptions, args: string[] = []): P status: policyCount > 0 ? 'PASS' : 'WARN', details: policyCount > 0 ? `${policyCount} markdown policy file(s).` : 'No policy markdown files found.', }); + + const securityDenyPath = path.join(options.configPath, 'patterns', 'security-deny.json'); + if (fs.existsSync(securityDenyPath)) { + const securityTestFiles = findSecurityTestFiles('.'); + checks.push({ + name: 'security test coverage', + status: securityTestFiles.length > 0 ? 'PASS' : 'WARN', + details: securityTestFiles.length > 0 + ? `${securityTestFiles.length} security test file(s): ${securityTestFiles.slice(0, 5).join(', ')}` + : 'Security-sensitive repo has no **/security* or **/l4* test file. Add L4/security regression tests.', + }); + } } // ADF readiness checks @@ -343,6 +355,45 @@ export async function doctorCommand(options: CLIOptions, args: string[] = []): P return EXIT_CODE.SUCCESS; } +function findSecurityTestFiles(rootPath: string): string[] { + const matches: string[] = []; + const skipDirs = new Set(['.git', 'node_modules', 'dist', 'coverage', '.ai', '.charter']); + + function walk(dir: string): void { + let entries: fs.Dirent[]; + try { + entries = fs.readdirSync(dir, { withFileTypes: true }); + } catch { + return; + } + + for (const entry of entries) { + const fullPath = path.join(dir, entry.name); + const relPath = path.relative(rootPath, fullPath) || entry.name; + if (entry.isDirectory()) { + if (!skipDirs.has(entry.name)) { + walk(fullPath); + } + continue; + } + + if (entry.isFile() && /^(security|l4)/i.test(entry.name) && isTestLikePath(relPath)) { + matches.push(relPath); + } + } + } + + walk(rootPath); + return matches.sort(); +} + +function isTestLikePath(filePath: string): boolean { + const normalized = filePath.replace(/\\/g, '/').toLowerCase(); + return normalized.includes('/test/') + || normalized.includes('/tests/') + || /\.(test|spec)\.[cm]?[jt]sx?$/.test(normalized); +} + function validateJSONConfig(configFile: string): DoctorResult['checks'][number] { try { diff --git a/packages/cli/src/commands/drift.ts b/packages/cli/src/commands/drift.ts index 4982634..a4e78a0 100644 --- a/packages/cli/src/commands/drift.ts +++ b/packages/cli/src/commands/drift.ts @@ -12,7 +12,7 @@ import { EXIT_CODE } from '../index'; import { getFlag } from '../flags'; import { loadConfig, loadPatterns, getPatternCustomizationStatus } from '../config'; import { scanForDrift } from '@stackbilt/drift'; -import type { DriftReport } from '@stackbilt/types'; +import type { DriftReport, DriftViolation, Pattern } from '@stackbilt/types'; export async function driftCommand(options: CLIOptions, args: string[]): Promise { const config = loadConfig(options.configPath); @@ -67,11 +67,19 @@ export async function driftCommand(options: CLIOptions, args: string[]): Promise return options.ciMode ? EXIT_CODE.POLICY_VIOLATION : EXIT_CODE.SUCCESS; } - const report = scanForDrift(files, patterns); - const status: 'PASS' | 'FAIL' = report.score >= config.drift.minScore ? 'PASS' : 'FAIL'; + const securityPatterns = loadSecurityDenyPatterns(options.configPath); + const securityReport = securityPatterns.length > 0 ? scanForDrift(files, securityPatterns) : null; + const securityViolations = (securityReport?.violations || []).map((violation) => ({ + ...violation, + severity: 'BLOCKER' as const, + })); + const report = mergeReports(scanForDrift(files, patterns), securityViolations, securityPatterns.length); + const hasSecurityBlocker = securityViolations.length > 0; + const status: 'PASS' | 'FAIL' = report.score >= config.drift.minScore && !hasSecurityBlocker ? 'PASS' : 'FAIL'; const patternsCustomized = getPatternCustomizationStatus(options.configPath); const output = { status, + securityBlockers: securityViolations.length, minScore: config.drift.minScore, thresholdPercent: Math.round(config.drift.minScore * 100), configPath: options.configPath, @@ -82,18 +90,18 @@ export async function driftCommand(options: CLIOptions, args: string[]): Promise if (options.format === 'json') { console.log(JSON.stringify(output, null, 2)); } else { - printReport(report, config.drift.minScore, patternsCustomized); + printReport(report, config.drift.minScore, patternsCustomized, securityViolations.length); } - if (options.ciMode && report.score < config.drift.minScore) { + if (options.ciMode && (report.score < config.drift.minScore || hasSecurityBlocker)) { return EXIT_CODE.POLICY_VIOLATION; } return EXIT_CODE.SUCCESS; } -function printReport(report: DriftReport, minScore: number, patternsCustomized: boolean | null): void { - const icon = report.score >= minScore ? '[ok]' : '[fail]'; +function printReport(report: DriftReport, minScore: number, patternsCustomized: boolean | null, securityBlockers: number): void { + const icon = report.score >= minScore && securityBlockers === 0 ? '[ok]' : '[fail]'; const pct = Math.round(report.score * 100); console.log(`\n ${icon} Drift Score: ${pct}% (threshold: ${Math.round(minScore * 100)}%)`); @@ -101,6 +109,9 @@ function printReport(report: DriftReport, minScore: number, patternsCustomized: if (patternsCustomized !== null) { console.log(` Patterns customized: ${patternsCustomized ? 'yes' : 'no'}`); } + if (securityBlockers > 0) { + console.log(` Security blockers: ${securityBlockers}`); + } if (report.violations.length > 0) { console.log(`\n Violations (${report.violations.length}):`); @@ -128,6 +139,53 @@ function printReport(report: DriftReport, minScore: number, patternsCustomized: console.log(''); } +function loadSecurityDenyPatterns(configPath: string): Pattern[] { + const denyPath = path.join(configPath, 'patterns', 'security-deny.json'); + if (!fs.existsSync(denyPath)) { + return []; + } + + try { + const parsed = JSON.parse(fs.readFileSync(denyPath, 'utf-8')); + const rawPatterns = Array.isArray(parsed) + ? parsed + : Array.isArray(parsed?.patterns) + ? parsed.patterns + : []; + + return rawPatterns.map((item: Record, index: number) => ({ + id: String(item.id || `security-deny-${index}`), + name: String(item.name || `Security Deny ${index + 1}`), + category: String(item.category || 'SECURITY'), + blessedSolution: String(item.blessed_solution || item.blessedSolution || ''), + rationale: typeof item.rationale === 'string' ? item.rationale : null, + antiPatterns: typeof item.anti_patterns === 'string' + ? item.anti_patterns + : typeof item.antiPatterns === 'string' + ? item.antiPatterns + : null, + documentationUrl: null, + relatedLedgerId: null, + status: 'ACTIVE' as const, + createdAt: new Date().toISOString(), + projectId: null, + })); + } catch { + console.warn(`Warning: Failed to parse security deny pattern file: ${denyPath}`); + return []; + } +} + +function mergeReports(base: DriftReport, securityViolations: DriftViolation[], extraPatternCount: number): DriftReport { + const violations = [...base.violations, ...securityViolations]; + return { + ...base, + violations, + scannedPatterns: base.scannedPatterns + extraPatternCount, + score: Math.max(0, 1.0 - (violations.length * 0.1)), + }; +} + function collectFiles( rootPath: string, include: string[], diff --git a/packages/cli/src/commands/init.ts b/packages/cli/src/commands/init.ts index 075b29e..333213d 100644 --- a/packages/cli/src/commands/init.ts +++ b/packages/cli/src/commands/init.ts @@ -228,6 +228,73 @@ const GITIGNORE_CONTENT = `# Charter local state .cache/ `; +const SECURITY_TEMPLATE = `# Security Policy + +## Supported Versions + +| Version | Supported | +| ------- | --------- | +| Current release | Yes | + +## Reporting a Vulnerability + +Please report suspected vulnerabilities privately to the project maintainers. +Do not open a public issue for security-sensitive findings. + +Include: +- affected package or service +- reproduction steps or proof of concept +- expected impact +- any known mitigations + +Maintainers should acknowledge reports within 3 business days and provide a remediation plan or status update when triage is complete. +`; + +const SECURITY_DENY_PATTERNS = { + customized: false, + preset: 'security-sensitive', + generatedAt: '1970-01-01T00:00:00.000Z', + hardFail: true, + patterns: [ + { + id: 'security-deny-timing-compare', + name: 'Security Deny: Timing-Sensitive Equality', + category: 'SECURITY', + blessed_solution: 'Use constant-time comparison helpers for signatures, digests, and tokens.', + rationale: 'Plain equality on security digests can leak timing information.', + anti_patterns: 'Avoid `/===\\s*(signature|expected|digest|token)/i` and `/(signature|expected|digest|token)\\s*===/i`.', + status: 'ACTIVE', + }, + { + id: 'security-deny-optional-security-binding', + name: 'Security Deny: Optional Security Binding Access', + category: 'SECURITY', + blessed_solution: 'Fail closed when security-critical bindings are missing.', + rationale: 'Optional reads on auth/session/token bindings can silently bypass enforcement.', + anti_patterns: 'Avoid `/\\b(auth|session|token|secret|key)\\w*\\?\\.(get|put)\\s*\\(/i`.', + status: 'ACTIVE', + }, + { + id: 'security-deny-auth-todo', + name: 'Security Deny: TODO in Security Path', + category: 'SECURITY', + blessed_solution: 'Resolve security TODOs before shipping auth, session, or token paths.', + rationale: 'TODO markers in security-sensitive code tend to become persistent control gaps.', + anti_patterns: 'Avoid `/TODO.*\\b(auth|session|token|secret|hmac|signature)\\b/i` and `/\\b(auth|session|token|secret|hmac|signature)\\b.*TODO/i`.', + status: 'ACTIVE', + }, + { + id: 'security-deny-token-json-exposure', + name: 'Security Deny: Token JSON Exposure', + category: 'SECURITY', + blessed_solution: 'Return opaque success responses or scoped public metadata instead of raw access tokens.', + rationale: 'Raw token exposure in JSON responses increases credential leakage risk.', + anti_patterns: 'Avoid `/c\\.json\\s*\\(\\s*\\{\\s*access_token/i`.', + status: 'ACTIVE', + }, + ], +}; + interface InitResult { created: boolean; configPath: string; @@ -244,6 +311,7 @@ interface InitializeOptions { react?: boolean; vite?: boolean; }; + securitySensitive?: boolean; } export async function initCommand(options: CLIOptions, args: string[] = []): Promise { @@ -251,13 +319,14 @@ export async function initCommand(options: CLIOptions, args: string[] = []): Pro const guided = args.includes('--guided'); const presetFlag = getFlag(args, '--preset'); const preset = isValidPreset(presetFlag) ? presetFlag : undefined; + const securitySensitive = args.includes('--security-sensitive'); // --guided: interactive mode that asks questions before scaffolding if (guided) { return guidedInit(options, force); } - const result = initializeCharter(options.configPath, force, { preset }); + const result = initializeCharter(options.configPath, force, { preset, securitySensitive }); if (options.format === 'json') { console.log(JSON.stringify(result, null, 2)); @@ -319,6 +388,10 @@ export function initializeCharter(configDir: string, force: boolean, initOptions if (writeIfChanged(path.join(configDir, 'patterns', 'blessed-stack.json'), JSON.stringify(patterns, null, 2) + '\n')) writesPerformed++; if (writeIfChanged(path.join(configDir, 'policies', 'governance.md'), DEFAULT_POLICY_CONTENT)) writesPerformed++; if (writeIfChanged(path.join(configDir, '.gitignore'), GITIGNORE_CONTENT)) writesPerformed++; + if (initOptions.securitySensitive) { + if (writeIfChanged(path.join(configDir, 'patterns', 'security-deny.json'), JSON.stringify(SECURITY_DENY_PATTERNS, null, 2) + '\n')) writesPerformed++; + if (writeIfChanged('SECURITY.md', SECURITY_TEMPLATE)) writesPerformed++; + } return { created: !exists, @@ -328,6 +401,7 @@ export function initializeCharter(configDir: string, force: boolean, initOptions 'patterns/blessed-stack.json', 'policies/governance.md', '.gitignore', + ...(initOptions.securitySensitive ? ['patterns/security-deny.json', '../SECURITY.md'] : []), ], writesPerformed, }; diff --git a/packages/cli/src/config.ts b/packages/cli/src/config.ts index c251bec..7e156ae 100644 --- a/packages/cli/src/config.ts +++ b/packages/cli/src/config.ts @@ -194,7 +194,7 @@ export function loadPatterns(configPath: string): Pattern[] { } const patterns: Pattern[] = []; - const files = fs.readdirSync(patternsDir).filter(f => f.endsWith('.json')); + const files = fs.readdirSync(patternsDir).filter(f => f.endsWith('.json') && f !== 'security-deny.json'); for (const file of files) { try { diff --git a/packages/cli/src/index.ts b/packages/cli/src/index.ts index b9c0bb9..ed3d528 100644 --- a/packages/cli/src/index.ts +++ b/packages/cli/src/index.ts @@ -38,6 +38,7 @@ Usage: charter Show immediate governance value + risk snapshot charter bootstrap [--ci github] [--preset ] [--yes] [--force] [--skip-install] [--skip-doctor] One-command repo onboarding (detect + setup + ADF + install + doctor) + --security-sensitive adds SECURITY.md, hard security drift denies, and a security test check charter setup [--ci github] [--preset ] [--detect-only] [--no-dependency-sync] Bootstrap .charter/ and optional CI workflow charter init [--preset ] [--guided] diff --git a/packages/drift/package.json b/packages/drift/package.json index f5c8ef3..fcc0a72 100644 --- a/packages/drift/package.json +++ b/packages/drift/package.json @@ -31,6 +31,9 @@ "dependencies": { "@stackbilt/types": "workspace:^" }, + "scripts": { + "prepublishOnly": "node ../../scripts/ensure-pnpm-publish.mjs" + }, "publishConfig": { "access": "public" }, diff --git a/packages/git/package.json b/packages/git/package.json index 0754a70..d31bef2 100644 --- a/packages/git/package.json +++ b/packages/git/package.json @@ -31,6 +31,9 @@ "dependencies": { "@stackbilt/types": "workspace:^" }, + "scripts": { + "prepublishOnly": "node ../../scripts/ensure-pnpm-publish.mjs" + }, "publishConfig": { "access": "public" }, diff --git a/packages/validate/package.json b/packages/validate/package.json index dddf198..cbda9cd 100644 --- a/packages/validate/package.json +++ b/packages/validate/package.json @@ -31,6 +31,9 @@ "dependencies": { "@stackbilt/types": "workspace:^" }, + "scripts": { + "prepublishOnly": "node ../../scripts/ensure-pnpm-publish.mjs" + }, "publishConfig": { "access": "public" }, diff --git a/scripts/assert-packages-publishable.mjs b/scripts/assert-packages-publishable.mjs new file mode 100644 index 0000000..f3dad09 --- /dev/null +++ b/scripts/assert-packages-publishable.mjs @@ -0,0 +1,124 @@ +#!/usr/bin/env node +import { mkdtempSync, readFileSync, readdirSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { dirname, join, resolve } from "node:path"; +import { spawnSync } from "node:child_process"; +import { fileURLToPath } from "node:url"; + +const root = resolve(dirname(fileURLToPath(import.meta.url)), ".."); +const packageGlobs = [ + "packages/types", + "packages/core", + "packages/adf", + "packages/git", + "packages/classify", + "packages/validate", + "packages/drift", + "packages/blast", + "packages/surface", + "packages/ci", + "packages/cli", +]; +const dependencyFields = [ + "dependencies", + "optionalDependencies", + "peerDependencies", + "devDependencies", +]; + +const tempDir = mkdtempSync(join(tmpdir(), "charter-publish-check-")); +const failures = []; + +function run(command, args, options) { + const result = spawnSync(command, args, { + ...options, + encoding: "utf8", + stdio: ["ignore", "pipe", "pipe"], + }); + + if (result.status !== 0) { + throw new Error( + [ + `${command} ${args.join(" ")} failed in ${options.cwd}`, + result.stdout.trim(), + result.stderr.trim(), + ] + .filter(Boolean) + .join("\n"), + ); + } + + return result.stdout; +} + +function packedPackageJson(tarball) { + return JSON.parse(run("tar", ["-xOf", tarball, "package/package.json"], { cwd: root })); +} + +function workspaceDependencyEntries(manifest) { + const entries = []; + + for (const field of dependencyFields) { + const dependencies = manifest[field] ?? {}; + for (const [name, specifier] of Object.entries(dependencies)) { + if (typeof specifier === "string" && specifier.startsWith("workspace:")) { + entries.push(`${field}.${name}=${specifier}`); + } + } + } + + return entries; +} + +function tarballsIn(directory) { + return new Set(readdirSync(directory).filter((file) => file.endsWith(".tgz"))); +} + +function packedFilename(packageDir, output, beforePack) { + if (output.trim().length > 0) { + const packResult = JSON.parse(output); + if (typeof packResult.filename === "string") { + return packResult.filename; + } + } + + const createdTarballs = readdirSync(tempDir) + .filter((file) => file.endsWith(".tgz") && !beforePack.has(file)); + + if (createdTarballs.length !== 1) { + throw new Error( + `Expected one tarball from pnpm pack for ${packageDir}, found ${createdTarballs.length}.`, + ); + } + + return join(tempDir, createdTarballs[0]); +} + +try { + for (const packageDir of packageGlobs) { + const cwd = join(root, packageDir); + readFileSync(join(cwd, "package.json"), "utf8"); + const beforePack = tarballsIn(tempDir); + const output = run("pnpm", ["pack", "--json", "--pack-destination", tempDir], { cwd }); + const filename = packedFilename(packageDir, output, beforePack); + const packedManifest = packedPackageJson(filename); + const workspaceEntries = workspaceDependencyEntries(packedManifest); + + if (workspaceEntries.length > 0) { + failures.push(`${packedManifest.name}: ${workspaceEntries.join(", ")}`); + } + } +} finally { + rmSync(tempDir, { recursive: true, force: true }); +} + +if (failures.length > 0) { + console.error("Packed package manifests contain workspace protocol dependencies:"); + for (const failure of failures) { + console.error(`- ${failure}`); + } + console.error("Publish with pnpm from the workspace root so workspace:^ is rewritten."); + process.exit(1); +} + +console.log("All packed package manifests are publishable; no workspace: dependency specifiers found."); diff --git a/scripts/ensure-pnpm-publish.mjs b/scripts/ensure-pnpm-publish.mjs new file mode 100644 index 0000000..5da855d --- /dev/null +++ b/scripts/ensure-pnpm-publish.mjs @@ -0,0 +1,36 @@ +#!/usr/bin/env node +import { readFileSync } from "node:fs"; +import { join } from "node:path"; + +const packageJson = JSON.parse(readFileSync(join(process.cwd(), "package.json"), "utf8")); +const dependencyFields = [ + "dependencies", + "optionalDependencies", + "peerDependencies", + "devDependencies", +]; + +const usesWorkspaceProtocol = dependencyFields.some((field) => + Object.values(packageJson[field] ?? {}).some( + (specifier) => typeof specifier === "string" && specifier.startsWith("workspace:"), + ), +); + +if (!usesWorkspaceProtocol) { + process.exit(0); +} + +const userAgent = process.env.npm_config_user_agent ?? ""; +const execPath = process.env.npm_execpath ?? ""; +const invokedByPnpm = userAgent.includes("pnpm/") || execPath.includes("pnpm"); + +if (!invokedByPnpm) { + console.error( + [ + `${packageJson.name} uses workspace: dependency specifiers in source package.json.`, + "Direct npm publish can leak those specifiers into the public tarball.", + "Publish with pnpm from the workspace root and run `pnpm run publish:check` before publishing.", + ].join("\n"), + ); + process.exit(1); +}