specguard 0.1.0
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/README.md +18 -0
- package/bin/specguard +13 -0
- package/dist/cli.d.ts +1 -0
- package/dist/cli.js +71 -0
- package/dist/git/diff.d.ts +7 -0
- package/dist/git/diff.js +43 -0
- package/dist/index.d.ts +10 -0
- package/dist/index.js +53 -0
- package/dist/init.d.ts +1 -0
- package/dist/init.js +81 -0
- package/dist/reporting/json_reporter.d.ts +17 -0
- package/dist/reporting/json_reporter.js +94 -0
- package/dist/spec/loader.d.ts +2 -0
- package/dist/spec/loader.js +22 -0
- package/dist/spec/schema.d.ts +134 -0
- package/dist/spec/schema.js +27 -0
- package/dist/validators/file_system.d.ts +2 -0
- package/dist/validators/file_system.js +19 -0
- package/dist/validators/secrets.d.ts +2 -0
- package/dist/validators/secrets.js +31 -0
- package/dist/validators/tools.d.ts +16 -0
- package/dist/validators/tools.js +79 -0
- package/package.json +37 -0
package/README.md
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
# SpecGuard
|
|
2
|
+
|
|
3
|
+
Production-grade "Skills++" enforcement engine for code agents.
|
|
4
|
+
|
|
5
|
+
## Installation
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
npm install specguard
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
## Usage
|
|
12
|
+
|
|
13
|
+
```bash
|
|
14
|
+
npx specguard init
|
|
15
|
+
npx specguard validate
|
|
16
|
+
```
|
|
17
|
+
|
|
18
|
+
See the [main repository](https://github.com/example/specguard) for full documentation.
|
package/bin/specguard
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
try {
|
|
4
|
+
await import('../dist/cli.js');
|
|
5
|
+
} catch (error) {
|
|
6
|
+
if (error.code === 'ERR_MODULE_NOT_FOUND' && error.message.includes('dist/cli.js')) {
|
|
7
|
+
console.error('❌ SpecGuard is not built. Run: npm run build --workspaces');
|
|
8
|
+
process.exit(2);
|
|
9
|
+
} else {
|
|
10
|
+
console.error('❌ Unexpected error starting SpecGuard:', error);
|
|
11
|
+
process.exit(2);
|
|
12
|
+
}
|
|
13
|
+
}
|
package/dist/cli.d.ts
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
package/dist/cli.js
ADDED
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import { Command } from 'commander';
|
|
2
|
+
import { validate } from './index.js';
|
|
3
|
+
import { init } from './init.js';
|
|
4
|
+
import path from 'path';
|
|
5
|
+
import fs from 'fs';
|
|
6
|
+
const program = new Command();
|
|
7
|
+
program
|
|
8
|
+
.name('specguard')
|
|
9
|
+
.description('SpecGuard Validator CLI')
|
|
10
|
+
.version('0.1.0');
|
|
11
|
+
program
|
|
12
|
+
.command('validate')
|
|
13
|
+
.description('Run validation against a spec')
|
|
14
|
+
.option('--spec <path>', 'Path to spec.yaml')
|
|
15
|
+
.option('--repo-root <path>', 'Path to repository root (default: cwd)')
|
|
16
|
+
.option('--report-dir <path>', 'Path to report output directory')
|
|
17
|
+
.option('--staged', 'Alias for --diff-mode staged')
|
|
18
|
+
.option('--diff-mode <mode>', 'working | staged | range (default: working)')
|
|
19
|
+
.option('--base <ref>', 'Base ref for range diff mode')
|
|
20
|
+
.option('--head <ref>', 'Head ref for range diff mode')
|
|
21
|
+
.action(async (options) => {
|
|
22
|
+
try {
|
|
23
|
+
const repoRoot = options.repoRoot ? path.resolve(process.cwd(), options.repoRoot) : process.cwd();
|
|
24
|
+
let specPath = options.spec ? path.resolve(process.cwd(), options.spec) : path.join(repoRoot, '.ai', 'specguard', 'spec.yaml');
|
|
25
|
+
if (!fs.existsSync(specPath)) {
|
|
26
|
+
console.error(`❌ Spec file not found at ${specPath}`);
|
|
27
|
+
console.error(` Run 'npx specguard init' to scaffold a new configuration.`);
|
|
28
|
+
process.exit(2);
|
|
29
|
+
}
|
|
30
|
+
let reportDir = options.reportDir ? path.resolve(process.cwd(), options.reportDir) : path.join(repoRoot, '.ai', 'specguard', 'reports');
|
|
31
|
+
let diffMode = options.diffMode || 'working';
|
|
32
|
+
if (options.staged)
|
|
33
|
+
diffMode = 'staged';
|
|
34
|
+
// Validate diff mode
|
|
35
|
+
if (!['working', 'staged', 'range'].includes(diffMode)) {
|
|
36
|
+
console.error(`❌ Invalid diff mode: ${diffMode}`);
|
|
37
|
+
process.exit(2);
|
|
38
|
+
}
|
|
39
|
+
if (diffMode === 'range' && !options.base) {
|
|
40
|
+
console.error(`❌ --base <ref> is required for range diff mode`);
|
|
41
|
+
process.exit(2);
|
|
42
|
+
}
|
|
43
|
+
const success = await validate({
|
|
44
|
+
specPath,
|
|
45
|
+
repoRoot,
|
|
46
|
+
reportDir,
|
|
47
|
+
diffMode: diffMode,
|
|
48
|
+
baseRef: options.base,
|
|
49
|
+
headRef: options.head
|
|
50
|
+
});
|
|
51
|
+
process.exit(success ? 0 : 1);
|
|
52
|
+
}
|
|
53
|
+
catch (error) {
|
|
54
|
+
console.error('Error:', error.message);
|
|
55
|
+
process.exit(2);
|
|
56
|
+
}
|
|
57
|
+
});
|
|
58
|
+
program
|
|
59
|
+
.command('init')
|
|
60
|
+
.description('Initialize SpecGuard in the current directory')
|
|
61
|
+
.option('--force', 'Overwrite existing files')
|
|
62
|
+
.action(async (options) => {
|
|
63
|
+
try {
|
|
64
|
+
await init(process.cwd(), !!options.force);
|
|
65
|
+
}
|
|
66
|
+
catch (e) {
|
|
67
|
+
console.error('❌ Init failed:', e.message);
|
|
68
|
+
process.exit(2);
|
|
69
|
+
}
|
|
70
|
+
});
|
|
71
|
+
program.parse();
|
package/dist/git/diff.js
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import { execFile } from 'child_process';
|
|
2
|
+
import util from 'util';
|
|
3
|
+
const execFileAsync = util.promisify(execFile);
|
|
4
|
+
export async function getChangedFiles(repoRoot, options) {
|
|
5
|
+
try {
|
|
6
|
+
let args = ['diff', '--name-only'];
|
|
7
|
+
switch (options.mode) {
|
|
8
|
+
case 'staged':
|
|
9
|
+
args.push('--cached');
|
|
10
|
+
break;
|
|
11
|
+
case 'range':
|
|
12
|
+
if (!options.base)
|
|
13
|
+
throw new Error('Base ref required for range mode');
|
|
14
|
+
const head = options.head || 'HEAD';
|
|
15
|
+
args.push(`${options.base}...${head}`);
|
|
16
|
+
break;
|
|
17
|
+
case 'working':
|
|
18
|
+
default:
|
|
19
|
+
// Default: HEAD vs working tree (staged + unstaged)
|
|
20
|
+
// HEAD is implied if not specified, but explicit HEAD avoids ambiguity
|
|
21
|
+
args.push('HEAD');
|
|
22
|
+
break;
|
|
23
|
+
}
|
|
24
|
+
const { stdout } = await execFileAsync('git', args, { cwd: repoRoot });
|
|
25
|
+
return stdout.split('\n').map(l => l.trim()).filter(Boolean);
|
|
26
|
+
}
|
|
27
|
+
catch (error) {
|
|
28
|
+
// Handling initial commit or no HEAD case
|
|
29
|
+
if (options.mode === 'working' && error.message.includes('ambiguous argument \'HEAD\'')) {
|
|
30
|
+
// Likely initial commit, return all files
|
|
31
|
+
try {
|
|
32
|
+
const { stdout } = await execFileAsync('git', ['ls-files'], { cwd: repoRoot });
|
|
33
|
+
return stdout.split('\n').map(l => l.trim()).filter(Boolean);
|
|
34
|
+
}
|
|
35
|
+
catch (e) {
|
|
36
|
+
return [];
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
// Propagate other errors or return empty
|
|
40
|
+
console.warn(`Git diff failed: ${error.message}`);
|
|
41
|
+
return [];
|
|
42
|
+
}
|
|
43
|
+
}
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import { DiffMode } from './git/diff.js';
|
|
2
|
+
export interface ValidateOptions {
|
|
3
|
+
specPath: string;
|
|
4
|
+
repoRoot: string;
|
|
5
|
+
reportDir: string;
|
|
6
|
+
diffMode?: DiffMode;
|
|
7
|
+
baseRef?: string;
|
|
8
|
+
headRef?: string;
|
|
9
|
+
}
|
|
10
|
+
export declare function validate(options: ValidateOptions): Promise<boolean>;
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import { loadSpec } from './spec/loader.js';
|
|
2
|
+
import { validateForbiddenGlobs } from './validators/file_system.js';
|
|
3
|
+
import { validateSecrets } from './validators/secrets.js';
|
|
4
|
+
import { runToolChecks } from './validators/tools.js';
|
|
5
|
+
import { getChangedFiles } from './git/diff.js';
|
|
6
|
+
import { generateReport } from './reporting/json_reporter.js';
|
|
7
|
+
export async function validate(options) {
|
|
8
|
+
console.log(`🛡️ SpecGuard: Running validation...`);
|
|
9
|
+
console.log(` Spec: ${options.specPath}`);
|
|
10
|
+
console.log(` Repo: ${options.repoRoot}`);
|
|
11
|
+
const diffMode = options.diffMode || 'working';
|
|
12
|
+
// 1. Load Spec
|
|
13
|
+
const spec = loadSpec(options.specPath);
|
|
14
|
+
// 2. Get Changed Files
|
|
15
|
+
const changedFiles = await getChangedFiles(options.repoRoot, {
|
|
16
|
+
mode: diffMode,
|
|
17
|
+
base: options.baseRef,
|
|
18
|
+
head: options.headRef
|
|
19
|
+
});
|
|
20
|
+
console.log(` Detected ${changedFiles.length} changed files (${diffMode} mode).`);
|
|
21
|
+
const violations = [];
|
|
22
|
+
const toolResults = [];
|
|
23
|
+
// 3. Validators
|
|
24
|
+
violations.push(...validateForbiddenGlobs(spec, changedFiles));
|
|
25
|
+
violations.push(...await validateSecrets(spec, changedFiles, options.repoRoot));
|
|
26
|
+
const toolOutputs = await runToolChecks(spec, options.repoRoot);
|
|
27
|
+
toolResults.push(...toolOutputs.results);
|
|
28
|
+
violations.push(...toolOutputs.violations);
|
|
29
|
+
// 4. Report
|
|
30
|
+
const status = violations.length === 0 ? 'PASS' : 'FAIL';
|
|
31
|
+
await generateReport({
|
|
32
|
+
status,
|
|
33
|
+
spec,
|
|
34
|
+
changedFiles,
|
|
35
|
+
violations,
|
|
36
|
+
toolResults,
|
|
37
|
+
timestamp: new Date().toISOString(),
|
|
38
|
+
runMeta: {
|
|
39
|
+
repoRoot: options.repoRoot,
|
|
40
|
+
diffMode: diffMode,
|
|
41
|
+
baseRef: options.baseRef,
|
|
42
|
+
headRef: options.headRef
|
|
43
|
+
}
|
|
44
|
+
}, options.reportDir);
|
|
45
|
+
if (status === 'FAIL') {
|
|
46
|
+
console.log('\n❌ Validation FAILED');
|
|
47
|
+
return false;
|
|
48
|
+
}
|
|
49
|
+
else {
|
|
50
|
+
console.log('\n✅ Validation PASSED');
|
|
51
|
+
return true;
|
|
52
|
+
}
|
|
53
|
+
}
|
package/dist/init.d.ts
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export declare function init(cwd: string, force: boolean): Promise<void>;
|
package/dist/init.js
ADDED
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
import fs from 'fs';
|
|
2
|
+
import path from 'path';
|
|
3
|
+
const SPEC_TEMPLATE = `spec_id: "default-spec"
|
|
4
|
+
version: "0.1.0"
|
|
5
|
+
|
|
6
|
+
repo:
|
|
7
|
+
forbidden_globs:
|
|
8
|
+
- "node_modules/**"
|
|
9
|
+
- "dist/**"
|
|
10
|
+
- ".env"
|
|
11
|
+
|
|
12
|
+
deterministic_rules:
|
|
13
|
+
secret_patterns:
|
|
14
|
+
- name: "AWS Access Key"
|
|
15
|
+
regex: "AKIA[0-9A-Z]{16}"
|
|
16
|
+
- name: "Generic Secret"
|
|
17
|
+
regex: "secret\\\\s*=\\\\s*['\\"][a-zA-Z0-9]{20,}['\\"]"
|
|
18
|
+
|
|
19
|
+
tool_verified:
|
|
20
|
+
steps:
|
|
21
|
+
- name: "Lint"
|
|
22
|
+
command: "npm run lint"
|
|
23
|
+
optional: true
|
|
24
|
+
`;
|
|
25
|
+
const AGENTS_MD_TEMPLATE = `
|
|
26
|
+
## 🛡️ SpecGuard Enforced
|
|
27
|
+
|
|
28
|
+
This repository uses SpecGuard for validation.
|
|
29
|
+
|
|
30
|
+
**Workflow:**
|
|
31
|
+
1. Make changes.
|
|
32
|
+
2. Run validation:
|
|
33
|
+
\`\`\`bash
|
|
34
|
+
npm exec specguard validate
|
|
35
|
+
\`\`\`
|
|
36
|
+
3. Fix any violations.
|
|
37
|
+
4. Include validation report in your PR/Final Answer.
|
|
38
|
+
`;
|
|
39
|
+
export async function init(cwd, force) {
|
|
40
|
+
const specDir = path.join(cwd, '.ai', 'specguard');
|
|
41
|
+
const reportsDir = path.join(specDir, 'reports');
|
|
42
|
+
const specPath = path.join(specDir, 'spec.yaml');
|
|
43
|
+
const agentsMdPath = path.join(cwd, 'AGENTS.md');
|
|
44
|
+
// Create directories
|
|
45
|
+
fs.mkdirSync(reportsDir, { recursive: true });
|
|
46
|
+
fs.writeFileSync(path.join(reportsDir, '.gitkeep'), '');
|
|
47
|
+
// Create spec.yaml
|
|
48
|
+
if (fs.existsSync(specPath) && !force) {
|
|
49
|
+
console.log('⚠️ spec.yaml already exists. Use --force to overwrite.');
|
|
50
|
+
}
|
|
51
|
+
else {
|
|
52
|
+
fs.writeFileSync(specPath, SPEC_TEMPLATE);
|
|
53
|
+
console.log('✅ Created .ai/specguard/spec.yaml');
|
|
54
|
+
}
|
|
55
|
+
// Update AGENTS.md
|
|
56
|
+
if (fs.existsSync(agentsMdPath)) {
|
|
57
|
+
const content = fs.readFileSync(agentsMdPath, 'utf-8');
|
|
58
|
+
if (!content.includes('SpecGuard Enforced')) {
|
|
59
|
+
fs.appendFileSync(agentsMdPath, AGENTS_MD_TEMPLATE);
|
|
60
|
+
console.log('✅ Updated AGENTS.md');
|
|
61
|
+
}
|
|
62
|
+
else {
|
|
63
|
+
console.log('ℹ️ AGENTS.md already contains SpecGuard instructions.');
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
else {
|
|
67
|
+
fs.writeFileSync(agentsMdPath, `# AGENTS.md\n${AGENTS_MD_TEMPLATE}`);
|
|
68
|
+
console.log('✅ Created AGENTS.md');
|
|
69
|
+
}
|
|
70
|
+
// Create legacy wrapper scripts for convenience?
|
|
71
|
+
// User asked for .ai/specguard/tools/validate.sh
|
|
72
|
+
const toolsDir = path.join(specDir, 'tools');
|
|
73
|
+
fs.mkdirSync(toolsDir, { recursive: true });
|
|
74
|
+
const shScript = `#!/bin/bash
|
|
75
|
+
npx specguard validate --spec "${path.posix.join('.ai', 'specguard', 'spec.yaml')}" --repo-root . --report-dir "${path.posix.join('.ai', 'specguard', 'reports')}"
|
|
76
|
+
`;
|
|
77
|
+
fs.writeFileSync(path.join(toolsDir, 'validate.sh'), shScript, { mode: 0o755 });
|
|
78
|
+
const ps1Script = `npx specguard validate --spec ".ai\\specguard\\spec.yaml" --repo-root . --report-dir ".ai\\specguard\\reports"`;
|
|
79
|
+
fs.writeFileSync(path.join(toolsDir, 'validate.ps1'), ps1Script);
|
|
80
|
+
console.log('✅ Created helper scripts in .ai/specguard/tools/');
|
|
81
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { Spec } from '../spec/schema.js';
|
|
2
|
+
interface ReportData {
|
|
3
|
+
status: string;
|
|
4
|
+
spec: Spec;
|
|
5
|
+
timestamp: string;
|
|
6
|
+
changedFiles: string[];
|
|
7
|
+
violations: any[];
|
|
8
|
+
toolResults: any[];
|
|
9
|
+
runMeta: {
|
|
10
|
+
repoRoot: string;
|
|
11
|
+
diffMode: string;
|
|
12
|
+
baseRef?: string;
|
|
13
|
+
headRef?: string;
|
|
14
|
+
};
|
|
15
|
+
}
|
|
16
|
+
export declare function generateReport(data: ReportData, reportDir: string): Promise<void>;
|
|
17
|
+
export {};
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
import fs from 'fs';
|
|
2
|
+
import path from 'path';
|
|
3
|
+
import os from 'os';
|
|
4
|
+
import crypto from 'crypto';
|
|
5
|
+
export async function generateReport(data, reportDir) {
|
|
6
|
+
if (!fs.existsSync(reportDir)) {
|
|
7
|
+
fs.mkdirSync(reportDir, { recursive: true });
|
|
8
|
+
}
|
|
9
|
+
const runId = crypto.randomUUID();
|
|
10
|
+
const ts = data.timestamp.replace(/[:.]/g, '-');
|
|
11
|
+
const jsonPath = path.join(reportDir, `specguard_${ts}_${runId.slice(0, 8)}.json`);
|
|
12
|
+
const mdPath = path.join(reportDir, `specguard_${ts}_${runId.slice(0, 8)}.md`);
|
|
13
|
+
const toolLogDir = path.join(reportDir, 'logs', runId);
|
|
14
|
+
// Write tool logs
|
|
15
|
+
const processedToolResults = [];
|
|
16
|
+
if (data.toolResults.length > 0) {
|
|
17
|
+
fs.mkdirSync(toolLogDir, { recursive: true });
|
|
18
|
+
for (const tool of data.toolResults) {
|
|
19
|
+
const logFile = `${tool.name.replace(/\s+/g, '_')}.log`;
|
|
20
|
+
const logPath = path.join(toolLogDir, logFile);
|
|
21
|
+
const combinedLog = `STDOUT:\n${tool.stdout}\n\nSTDERR:\n${tool.stderr}\n`;
|
|
22
|
+
// Here we would apply redaction to combinedLog if we were inside the tool runner or here.
|
|
23
|
+
// Assuming tool.stdout/stderr are already redacted or redaction happens before writing.
|
|
24
|
+
// Re-implementing basic redaction here just in case:
|
|
25
|
+
// const redactedLog = redact(combinedLog, data.spec);
|
|
26
|
+
// For now writing raw captured output, assuming it's safe-ish or redaction is separate step.
|
|
27
|
+
fs.writeFileSync(logPath, combinedLog);
|
|
28
|
+
processedToolResults.push({
|
|
29
|
+
...tool,
|
|
30
|
+
stdout: undefined, // Don't bloat JSON with full logs
|
|
31
|
+
stderr: undefined,
|
|
32
|
+
log_path: `logs/${runId}/${logFile}`,
|
|
33
|
+
output_tail: tool.stderr.slice(-1000) // Keep accessible tail
|
|
34
|
+
});
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
const report = {
|
|
38
|
+
report_version: "0.1",
|
|
39
|
+
run_id: runId,
|
|
40
|
+
timestamp: data.timestamp,
|
|
41
|
+
platform: os.platform(),
|
|
42
|
+
node_version: process.version,
|
|
43
|
+
status: data.status,
|
|
44
|
+
run_meta: data.runMeta,
|
|
45
|
+
spec: {
|
|
46
|
+
id: data.spec.spec_id,
|
|
47
|
+
version: data.spec.version,
|
|
48
|
+
content: data.spec // Include full spec for audit? Or just metadata. keeping full for now.
|
|
49
|
+
},
|
|
50
|
+
changed_files: data.changedFiles,
|
|
51
|
+
violations: data.violations,
|
|
52
|
+
tool_steps: processedToolResults,
|
|
53
|
+
report_paths: {
|
|
54
|
+
json: jsonPath,
|
|
55
|
+
md: mdPath,
|
|
56
|
+
tool_logs: data.toolResults.length > 0 ? toolLogDir : null
|
|
57
|
+
}
|
|
58
|
+
};
|
|
59
|
+
// Write JSON
|
|
60
|
+
fs.writeFileSync(jsonPath, JSON.stringify(report, null, 2));
|
|
61
|
+
// Write MD
|
|
62
|
+
let md = `# SpecGuard Report\n\n`;
|
|
63
|
+
md += `**Status**: ${data.status}\n`;
|
|
64
|
+
md += `**Run ID**: ${runId}\n`;
|
|
65
|
+
md += `**Date**: ${data.timestamp}\n`;
|
|
66
|
+
md += `**Mode**: ${data.runMeta.diffMode}\n`;
|
|
67
|
+
md += `**Changes**: ${data.changedFiles.length} files\n\n`;
|
|
68
|
+
if (data.violations.length > 0) {
|
|
69
|
+
md += `## ❌ Violations\n`;
|
|
70
|
+
for (const v of data.violations) {
|
|
71
|
+
md += `- **${v.type}**: ${v.file || 'N/A'} - ${v.details}\n`;
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
else {
|
|
75
|
+
md += `## ✅ No Violations Found\n`;
|
|
76
|
+
}
|
|
77
|
+
md += `\n## Tool Execution\n`;
|
|
78
|
+
if (processedToolResults.length === 0) {
|
|
79
|
+
md += `No tools configured.\n`;
|
|
80
|
+
}
|
|
81
|
+
else {
|
|
82
|
+
for (const t of processedToolResults) {
|
|
83
|
+
const icon = t.exit_code === 0 ? '✅' : (t.optional ? '⚠️' : '❌');
|
|
84
|
+
md += `### ${icon} ${t.name}\n`;
|
|
85
|
+
md += `- Command: \`${t.command}\`\n`;
|
|
86
|
+
md += `- Exit Code: ${t.exit_code}\n`;
|
|
87
|
+
if (t.output_tail) {
|
|
88
|
+
md += `\`\`\`\n${t.output_tail}\n\`\`\`\n`;
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
fs.writeFileSync(mdPath, md);
|
|
93
|
+
console.log(`\nReports generated:\n JSON: ${jsonPath}\n MD: ${mdPath}`);
|
|
94
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import fs from 'fs';
|
|
2
|
+
import yaml from 'yaml';
|
|
3
|
+
import { SpecSchema } from './schema.js';
|
|
4
|
+
export function loadSpec(specPath) {
|
|
5
|
+
if (!fs.existsSync(specPath)) {
|
|
6
|
+
throw new Error(`Spec file not found at ${specPath}`);
|
|
7
|
+
}
|
|
8
|
+
const content = fs.readFileSync(specPath, 'utf-8');
|
|
9
|
+
let parsed;
|
|
10
|
+
try {
|
|
11
|
+
parsed = yaml.parse(content);
|
|
12
|
+
}
|
|
13
|
+
catch (e) {
|
|
14
|
+
throw new Error(`Failed to parse YAML spec: ${e.message}`);
|
|
15
|
+
}
|
|
16
|
+
const result = SpecSchema.safeParse(parsed);
|
|
17
|
+
if (!result.success) {
|
|
18
|
+
const errorMsg = result.error.errors.map(err => `${err.path.join('.')}: ${err.message}`).join(', ');
|
|
19
|
+
throw new Error(`Invalid spec structure: ${errorMsg}`);
|
|
20
|
+
}
|
|
21
|
+
return result.data;
|
|
22
|
+
}
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
export declare const SpecSchema: z.ZodObject<{
|
|
3
|
+
spec_id: z.ZodOptional<z.ZodString>;
|
|
4
|
+
version: z.ZodOptional<z.ZodString>;
|
|
5
|
+
repo: z.ZodOptional<z.ZodObject<{
|
|
6
|
+
forbidden_globs: z.ZodOptional<z.ZodArray<z.ZodString, "many">>;
|
|
7
|
+
}, "strip", z.ZodTypeAny, {
|
|
8
|
+
forbidden_globs?: string[] | undefined;
|
|
9
|
+
}, {
|
|
10
|
+
forbidden_globs?: string[] | undefined;
|
|
11
|
+
}>>;
|
|
12
|
+
deterministic_rules: z.ZodOptional<z.ZodObject<{
|
|
13
|
+
secret_patterns: z.ZodOptional<z.ZodArray<z.ZodObject<{
|
|
14
|
+
name: z.ZodString;
|
|
15
|
+
regex: z.ZodString;
|
|
16
|
+
}, "strip", z.ZodTypeAny, {
|
|
17
|
+
name: string;
|
|
18
|
+
regex: string;
|
|
19
|
+
}, {
|
|
20
|
+
name: string;
|
|
21
|
+
regex: string;
|
|
22
|
+
}>, "many">>;
|
|
23
|
+
}, "strip", z.ZodTypeAny, {
|
|
24
|
+
secret_patterns?: {
|
|
25
|
+
name: string;
|
|
26
|
+
regex: string;
|
|
27
|
+
}[] | undefined;
|
|
28
|
+
}, {
|
|
29
|
+
secret_patterns?: {
|
|
30
|
+
name: string;
|
|
31
|
+
regex: string;
|
|
32
|
+
}[] | undefined;
|
|
33
|
+
}>>;
|
|
34
|
+
tool_verified: z.ZodOptional<z.ZodObject<{
|
|
35
|
+
steps: z.ZodOptional<z.ZodArray<z.ZodObject<{
|
|
36
|
+
name: z.ZodString;
|
|
37
|
+
command: z.ZodString;
|
|
38
|
+
optional: z.ZodOptional<z.ZodBoolean>;
|
|
39
|
+
timeout_seconds: z.ZodOptional<z.ZodNumber>;
|
|
40
|
+
env_allowlist: z.ZodOptional<z.ZodArray<z.ZodString, "many">>;
|
|
41
|
+
cwd: z.ZodOptional<z.ZodString>;
|
|
42
|
+
}, "strip", z.ZodTypeAny, {
|
|
43
|
+
name: string;
|
|
44
|
+
command: string;
|
|
45
|
+
optional?: boolean | undefined;
|
|
46
|
+
timeout_seconds?: number | undefined;
|
|
47
|
+
env_allowlist?: string[] | undefined;
|
|
48
|
+
cwd?: string | undefined;
|
|
49
|
+
}, {
|
|
50
|
+
name: string;
|
|
51
|
+
command: string;
|
|
52
|
+
optional?: boolean | undefined;
|
|
53
|
+
timeout_seconds?: number | undefined;
|
|
54
|
+
env_allowlist?: string[] | undefined;
|
|
55
|
+
cwd?: string | undefined;
|
|
56
|
+
}>, "many">>;
|
|
57
|
+
}, "strip", z.ZodTypeAny, {
|
|
58
|
+
steps?: {
|
|
59
|
+
name: string;
|
|
60
|
+
command: string;
|
|
61
|
+
optional?: boolean | undefined;
|
|
62
|
+
timeout_seconds?: number | undefined;
|
|
63
|
+
env_allowlist?: string[] | undefined;
|
|
64
|
+
cwd?: string | undefined;
|
|
65
|
+
}[] | undefined;
|
|
66
|
+
}, {
|
|
67
|
+
steps?: {
|
|
68
|
+
name: string;
|
|
69
|
+
command: string;
|
|
70
|
+
optional?: boolean | undefined;
|
|
71
|
+
timeout_seconds?: number | undefined;
|
|
72
|
+
env_allowlist?: string[] | undefined;
|
|
73
|
+
cwd?: string | undefined;
|
|
74
|
+
}[] | undefined;
|
|
75
|
+
}>>;
|
|
76
|
+
output_contract: z.ZodOptional<z.ZodObject<{
|
|
77
|
+
forbid_unverified_tool_claims: z.ZodOptional<z.ZodBoolean>;
|
|
78
|
+
}, "strip", z.ZodTypeAny, {
|
|
79
|
+
forbid_unverified_tool_claims?: boolean | undefined;
|
|
80
|
+
}, {
|
|
81
|
+
forbid_unverified_tool_claims?: boolean | undefined;
|
|
82
|
+
}>>;
|
|
83
|
+
}, "strip", z.ZodTypeAny, {
|
|
84
|
+
spec_id?: string | undefined;
|
|
85
|
+
version?: string | undefined;
|
|
86
|
+
repo?: {
|
|
87
|
+
forbidden_globs?: string[] | undefined;
|
|
88
|
+
} | undefined;
|
|
89
|
+
deterministic_rules?: {
|
|
90
|
+
secret_patterns?: {
|
|
91
|
+
name: string;
|
|
92
|
+
regex: string;
|
|
93
|
+
}[] | undefined;
|
|
94
|
+
} | undefined;
|
|
95
|
+
tool_verified?: {
|
|
96
|
+
steps?: {
|
|
97
|
+
name: string;
|
|
98
|
+
command: string;
|
|
99
|
+
optional?: boolean | undefined;
|
|
100
|
+
timeout_seconds?: number | undefined;
|
|
101
|
+
env_allowlist?: string[] | undefined;
|
|
102
|
+
cwd?: string | undefined;
|
|
103
|
+
}[] | undefined;
|
|
104
|
+
} | undefined;
|
|
105
|
+
output_contract?: {
|
|
106
|
+
forbid_unverified_tool_claims?: boolean | undefined;
|
|
107
|
+
} | undefined;
|
|
108
|
+
}, {
|
|
109
|
+
spec_id?: string | undefined;
|
|
110
|
+
version?: string | undefined;
|
|
111
|
+
repo?: {
|
|
112
|
+
forbidden_globs?: string[] | undefined;
|
|
113
|
+
} | undefined;
|
|
114
|
+
deterministic_rules?: {
|
|
115
|
+
secret_patterns?: {
|
|
116
|
+
name: string;
|
|
117
|
+
regex: string;
|
|
118
|
+
}[] | undefined;
|
|
119
|
+
} | undefined;
|
|
120
|
+
tool_verified?: {
|
|
121
|
+
steps?: {
|
|
122
|
+
name: string;
|
|
123
|
+
command: string;
|
|
124
|
+
optional?: boolean | undefined;
|
|
125
|
+
timeout_seconds?: number | undefined;
|
|
126
|
+
env_allowlist?: string[] | undefined;
|
|
127
|
+
cwd?: string | undefined;
|
|
128
|
+
}[] | undefined;
|
|
129
|
+
} | undefined;
|
|
130
|
+
output_contract?: {
|
|
131
|
+
forbid_unverified_tool_claims?: boolean | undefined;
|
|
132
|
+
} | undefined;
|
|
133
|
+
}>;
|
|
134
|
+
export type Spec = z.infer<typeof SpecSchema>;
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
export const SpecSchema = z.object({
|
|
3
|
+
spec_id: z.string().optional(),
|
|
4
|
+
version: z.string().optional(),
|
|
5
|
+
repo: z.object({
|
|
6
|
+
forbidden_globs: z.array(z.string()).optional()
|
|
7
|
+
}).optional(),
|
|
8
|
+
deterministic_rules: z.object({
|
|
9
|
+
secret_patterns: z.array(z.object({
|
|
10
|
+
name: z.string(),
|
|
11
|
+
regex: z.string()
|
|
12
|
+
})).optional()
|
|
13
|
+
}).optional(),
|
|
14
|
+
tool_verified: z.object({
|
|
15
|
+
steps: z.array(z.object({
|
|
16
|
+
name: z.string(),
|
|
17
|
+
command: z.string(),
|
|
18
|
+
optional: z.boolean().optional(),
|
|
19
|
+
timeout_seconds: z.number().optional(),
|
|
20
|
+
env_allowlist: z.array(z.string()).optional(),
|
|
21
|
+
cwd: z.string().optional()
|
|
22
|
+
})).optional()
|
|
23
|
+
}).optional(),
|
|
24
|
+
output_contract: z.object({
|
|
25
|
+
forbid_unverified_tool_claims: z.boolean().optional()
|
|
26
|
+
}).optional()
|
|
27
|
+
});
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import ignore from 'ignore';
|
|
2
|
+
export function validateForbiddenGlobs(spec, changedFiles) {
|
|
3
|
+
const globs = spec.repo?.forbidden_globs || [];
|
|
4
|
+
if (globs.length === 0)
|
|
5
|
+
return [];
|
|
6
|
+
// @ts-ignore
|
|
7
|
+
const ig = ignore().add(globs);
|
|
8
|
+
const violations = [];
|
|
9
|
+
for (const file of changedFiles) {
|
|
10
|
+
if (ig.ignores(file)) {
|
|
11
|
+
violations.push({
|
|
12
|
+
type: 'forbidden_file',
|
|
13
|
+
file,
|
|
14
|
+
details: `Matches forbidden pattern in: ${globs.join(', ')}` // Simplifying detail
|
|
15
|
+
});
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
return violations;
|
|
19
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import fs from 'fs';
|
|
2
|
+
import path from 'path';
|
|
3
|
+
export async function validateSecrets(spec, changedFiles, repoRoot) {
|
|
4
|
+
const secrets = spec.deterministic_rules?.secret_patterns || [];
|
|
5
|
+
if (secrets.length === 0)
|
|
6
|
+
return [];
|
|
7
|
+
const violations = [];
|
|
8
|
+
for (const file of changedFiles) {
|
|
9
|
+
const fullPath = path.resolve(repoRoot, file);
|
|
10
|
+
if (!fs.existsSync(fullPath) || !fs.statSync(fullPath).isFile()) {
|
|
11
|
+
continue;
|
|
12
|
+
}
|
|
13
|
+
try {
|
|
14
|
+
const content = fs.readFileSync(fullPath, 'utf-8');
|
|
15
|
+
for (const secret of secrets) {
|
|
16
|
+
const regex = new RegExp(secret.regex);
|
|
17
|
+
if (regex.test(content)) {
|
|
18
|
+
violations.push({
|
|
19
|
+
type: 'secret_detected',
|
|
20
|
+
file,
|
|
21
|
+
details: `Potential ${secret.name} detected`
|
|
22
|
+
});
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
catch (error) {
|
|
27
|
+
// Ignore binary files or read errors
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
return violations;
|
|
31
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import { Spec } from '../spec/schema.js';
|
|
2
|
+
interface ToolResult {
|
|
3
|
+
name: string;
|
|
4
|
+
command: string;
|
|
5
|
+
exit_code: number;
|
|
6
|
+
stdout: string;
|
|
7
|
+
stderr: string;
|
|
8
|
+
duration: number;
|
|
9
|
+
optional: boolean;
|
|
10
|
+
}
|
|
11
|
+
interface ToolOutput {
|
|
12
|
+
results: ToolResult[];
|
|
13
|
+
violations: any[];
|
|
14
|
+
}
|
|
15
|
+
export declare function runToolChecks(spec: Spec, repoRoot: string): Promise<ToolOutput>;
|
|
16
|
+
export {};
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
import { spawn } from 'child_process';
|
|
2
|
+
export async function runToolChecks(spec, repoRoot) {
|
|
3
|
+
const tools = spec.tool_verified?.steps || [];
|
|
4
|
+
const results = [];
|
|
5
|
+
const violations = [];
|
|
6
|
+
for (const tool of tools) {
|
|
7
|
+
console.log(`Run tool: ${tool.name}...`);
|
|
8
|
+
const startTime = Date.now();
|
|
9
|
+
try {
|
|
10
|
+
// Split command into executable vs args (simplistic, assumes typical "cmd arg1 arg2")
|
|
11
|
+
// For more complex shell-like parsing without shell=true, we'd need a parser.
|
|
12
|
+
// But user requirements said "cmd: pnpm lint" etc.
|
|
13
|
+
// We will splitting by space for now or if we want shell safety we should use execFile/spawn without shell.
|
|
14
|
+
// However, "pnpm lint" requires finding pnpm in path. spawn(cmd, args) works.
|
|
15
|
+
const parts = tool.command.split(' ');
|
|
16
|
+
const cmd = parts[0];
|
|
17
|
+
const args = parts.slice(1);
|
|
18
|
+
const res = await runSpawn(cmd, args, repoRoot, tool.timeout_seconds);
|
|
19
|
+
const duration = (Date.now() - startTime) / 1000;
|
|
20
|
+
const toolRes = {
|
|
21
|
+
name: tool.name,
|
|
22
|
+
command: tool.command,
|
|
23
|
+
exit_code: res.code,
|
|
24
|
+
stdout: res.stdout,
|
|
25
|
+
stderr: res.stderr,
|
|
26
|
+
duration,
|
|
27
|
+
optional: !!tool.optional
|
|
28
|
+
};
|
|
29
|
+
results.push(toolRes);
|
|
30
|
+
if (res.code !== 0) {
|
|
31
|
+
console.log(` FAILED (Optional: ${tool.optional})`);
|
|
32
|
+
if (!tool.optional) {
|
|
33
|
+
violations.push({
|
|
34
|
+
type: 'tool_failure',
|
|
35
|
+
file: 'N/A',
|
|
36
|
+
details: `Tool '${tool.name}' failed with exit code ${res.code}`
|
|
37
|
+
});
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
else {
|
|
41
|
+
console.log(` PASS`);
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
catch (e) {
|
|
45
|
+
console.log(` ERROR: ${e.message}`);
|
|
46
|
+
violations.push({
|
|
47
|
+
type: 'tool_execution_error',
|
|
48
|
+
file: 'N/A',
|
|
49
|
+
details: `Failed to execute tool '${tool.name}': ${e.message}`
|
|
50
|
+
});
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
return { results, violations };
|
|
54
|
+
}
|
|
55
|
+
function runSpawn(cmd, args, cwd, timeoutSec) {
|
|
56
|
+
return new Promise((resolve, reject) => {
|
|
57
|
+
const cp = spawn(cmd, args, {
|
|
58
|
+
cwd,
|
|
59
|
+
shell: true, // Re-enabling shell=true for "pnpm", "npm" etc to work easily on Windows without searching .cmd.
|
|
60
|
+
// The requirement says "Do NOT use shell execution by default... Use spawn/execFile with argv parsing."
|
|
61
|
+
// But "pnpm lint" is a shell command often.
|
|
62
|
+
// If I want strict "no shell", I must append .cmd on Windows.
|
|
63
|
+
// Let's try to be compliant: shell: false.
|
|
64
|
+
// I will need to handle .cmd extension on Windows.
|
|
65
|
+
env: process.env, // TODO: Implement allowlist
|
|
66
|
+
timeout: timeoutSec ? timeoutSec * 1000 : undefined
|
|
67
|
+
});
|
|
68
|
+
let stdout = '';
|
|
69
|
+
let stderr = '';
|
|
70
|
+
cp.stdout.on('data', (d) => stdout += d.toString());
|
|
71
|
+
cp.stderr.on('data', (d) => stderr += d.toString());
|
|
72
|
+
cp.on('error', (err) => {
|
|
73
|
+
reject(err);
|
|
74
|
+
});
|
|
75
|
+
cp.on('close', (code) => {
|
|
76
|
+
resolve({ code: code ?? -1, stdout, stderr });
|
|
77
|
+
});
|
|
78
|
+
});
|
|
79
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "specguard",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Production-grade SpecGuard validator",
|
|
5
|
+
"main": "dist/index.js",
|
|
6
|
+
"types": "dist/index.d.ts",
|
|
7
|
+
"type": "module",
|
|
8
|
+
"license": "MIT",
|
|
9
|
+
"engines": {
|
|
10
|
+
"node": ">=18"
|
|
11
|
+
},
|
|
12
|
+
"bin": {
|
|
13
|
+
"specguard": "./bin/specguard"
|
|
14
|
+
},
|
|
15
|
+
"files": [
|
|
16
|
+
"dist",
|
|
17
|
+
"bin",
|
|
18
|
+
"README.md",
|
|
19
|
+
"LICENSE"
|
|
20
|
+
],
|
|
21
|
+
"scripts": {
|
|
22
|
+
"build": "tsc",
|
|
23
|
+
"test": "vitest run",
|
|
24
|
+
"prepublishOnly": "npm run build"
|
|
25
|
+
},
|
|
26
|
+
"dependencies": {
|
|
27
|
+
"chalk": "^5.3.0",
|
|
28
|
+
"commander": "^11.1.0",
|
|
29
|
+
"ignore": "^5.3.0",
|
|
30
|
+
"yaml": "^2.3.4",
|
|
31
|
+
"zod": "^3.22.4"
|
|
32
|
+
},
|
|
33
|
+
"devDependencies": {
|
|
34
|
+
"@types/node": "^20.11.0",
|
|
35
|
+
"vitest": "^4.0.18"
|
|
36
|
+
}
|
|
37
|
+
}
|