Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 2 additions & 0 deletions docs/cli-reference.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <worker|frontend|backend|fullstack|docs>` — 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
Expand Down
48 changes: 48 additions & 0 deletions packages/cli/src/__tests__/bootstrap.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {
Expand Down Expand Up @@ -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);
});
});
7 changes: 5 additions & 2 deletions packages/cli/src/commands/bootstrap.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down Expand Up @@ -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;

Expand Down Expand Up @@ -472,7 +473,8 @@ function runSetupPhase(
contexts: ReturnType<typeof loadPackageContexts>,
ciTarget: string | undefined,
packageManager: 'npm' | 'pnpm',
force: boolean
force: boolean,
securitySensitive: boolean
): { step: StepResult } {
const warnings: string[] = [];
const created: string[] = [];
Expand All @@ -489,6 +491,7 @@ function runSetupPhase(
react: detection.signals.hasReact,
vite: detection.signals.hasVite,
},
securitySensitive,
});

if (initResult.created) {
Expand Down
51 changes: 51 additions & 0 deletions packages/cli/src/commands/doctor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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 {
Expand Down
72 changes: 65 additions & 7 deletions packages/cli/src/commands/drift.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<number> {
const config = loadConfig(options.configPath);
Expand Down Expand Up @@ -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,
Expand All @@ -82,25 +90,28 @@ 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)}%)`);
console.log(` Scanned: ${report.scannedFiles} files against ${report.scannedPatterns} patterns`);
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}):`);
Expand Down Expand Up @@ -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<string, unknown>, 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[],
Expand Down
Loading
Loading