specguard 0.1.1 → 0.1.3

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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 SpecGuard Contributors
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -2,17 +2,37 @@
2
2
 
3
3
  Production-grade "Skills++" enforcement engine for code agents.
4
4
 
5
+ ## Features
6
+
7
+ - **🛡️ Secure Tool Execution**: Tools run without a shell by default.
8
+ - **🔍 Secret Redaction**: Deterministic masking of secrets in logs and reports.
9
+ - **📄 Agent-Friendly Reporting**: Built-in "Agent Summary" for repair loops.
10
+ - **⏭️ Smart Skipping**: Automatic detection of missing optional scripts.
11
+
12
+ ## Documentation
13
+
14
+ For full guides and reference, see the [Documentation Index](docs/README.md).
15
+
16
+ - [🚀 Quickstart](docs/quickstart.md)
17
+ - [🤖 Codex & Agents](docs/codex-and-agents.md)
18
+ - [🛠️ Spec Reference](docs/spec-reference.md)
19
+ - [📊 Reports](docs/reports.md)
20
+ - [🔄 CI Integration](docs/ci-integration.md)
21
+ - [🔐 Security](docs/security.md)
22
+
5
23
  ## Installation
6
24
 
7
25
  ```bash
8
26
  npm install specguard
9
27
  ```
10
28
 
11
- ## Usage
29
+ ## Quick Commands
12
30
 
13
31
  ```bash
14
- npx specguard init
15
- npx specguard validate
32
+ npx specguard init # Setup SpecGuard in your repo
33
+ npx specguard validate # Run validation
16
34
  ```
17
35
 
18
- See the [main repository](https://github.com/example/specguard) for full documentation.
36
+ ## License
37
+
38
+ MIT
package/dist/init.js CHANGED
@@ -21,29 +21,45 @@ tool_verified:
21
21
  - name: "Lint"
22
22
  command: "npm run lint"
23
23
  optional: true
24
+ skip_if_missing: true
24
25
  `;
25
26
  const AGENTS_MD_TEMPLATE = `
26
27
  ## 🛡️ SpecGuard Enforced
27
28
 
28
- This repository uses SpecGuard for validation.
29
+ This repository uses **SpecGuard** to ensure code quality and security.
30
+ As an AI agent, you **MUST** follow this workflow:
29
31
 
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.
32
+ 1. **Edit**: Make your code changes.
33
+ 2. **Validate**: Run the validation script to check for issues.
34
+ \`\`\`bash
35
+ npx specguard validate --staged
36
+ \`\`\`
37
+ 3. **Repair**: If validation fails, fix the errors and re-run.
38
+ 4. **Report**: Include the validation summary in your final response.
39
+
40
+ **Artifacts Location:**
41
+ - Spec: \`.ai/specguard/spec.yaml\`
42
+ - Reports: \`.ai/specguard/reports/\`
43
+
44
+ **Safety Rules:**
45
+ - 🚫 NO secrets in code.
46
+ - 🚫 NO shell execution in tool steps (unless explicitly allowed).
47
+ - ✅ ALWAYS verify your changes.
38
48
  `;
39
49
  export async function init(cwd, force) {
40
50
  const specDir = path.join(cwd, '.ai', 'specguard');
41
51
  const reportsDir = path.join(specDir, 'reports');
42
52
  const specPath = path.join(specDir, 'spec.yaml');
43
53
  const agentsMdPath = path.join(cwd, 'AGENTS.md');
54
+ const gitignorePath = path.join(cwd, '.gitignore');
44
55
  // Create directories
45
- fs.mkdirSync(reportsDir, { recursive: true });
46
- fs.writeFileSync(path.join(reportsDir, '.gitkeep'), '');
56
+ if (!fs.existsSync(reportsDir)) {
57
+ fs.mkdirSync(reportsDir, { recursive: true });
58
+ }
59
+ const gitkeepPath = path.join(reportsDir, '.gitkeep');
60
+ if (!fs.existsSync(gitkeepPath)) {
61
+ fs.writeFileSync(gitkeepPath, '');
62
+ }
47
63
  // Create spec.yaml
48
64
  if (fs.existsSync(specPath) && !force) {
49
65
  console.log('⚠️ spec.yaml already exists. Use --force to overwrite.');
@@ -56,7 +72,9 @@ export async function init(cwd, force) {
56
72
  if (fs.existsSync(agentsMdPath)) {
57
73
  const content = fs.readFileSync(agentsMdPath, 'utf-8');
58
74
  if (!content.includes('SpecGuard Enforced')) {
59
- fs.appendFileSync(agentsMdPath, AGENTS_MD_TEMPLATE);
75
+ // Append clearly with a separator
76
+ const appendContent = `\n\n---\n${AGENTS_MD_TEMPLATE}`;
77
+ fs.appendFileSync(agentsMdPath, appendContent);
60
78
  console.log('✅ Updated AGENTS.md');
61
79
  }
62
80
  else {
@@ -67,15 +85,44 @@ export async function init(cwd, force) {
67
85
  fs.writeFileSync(agentsMdPath, `# AGENTS.md\n${AGENTS_MD_TEMPLATE}`);
68
86
  console.log('✅ Created AGENTS.md');
69
87
  }
70
- // Create legacy wrapper scripts for convenience?
71
- // User asked for .ai/specguard/tools/validate.sh
88
+ // Update .gitignore
89
+ let gitignoreContent = '';
90
+ if (fs.existsSync(gitignorePath)) {
91
+ gitignoreContent = fs.readFileSync(gitignorePath, 'utf-8');
92
+ }
93
+ const ignores = [
94
+ '.ai/specguard/reports/**',
95
+ '!.ai/specguard/reports/.gitkeep'
96
+ ];
97
+ let addedIgnore = false;
98
+ // Ensure we end with newline before appending if file not empty
99
+ if (gitignoreContent && !gitignoreContent.endsWith('\n')) {
100
+ gitignoreContent += '\n';
101
+ }
102
+ for (const ign of ignores) {
103
+ if (!gitignoreContent.includes(ign)) {
104
+ gitignoreContent += `${ign}\n`;
105
+ addedIgnore = true;
106
+ }
107
+ }
108
+ if (addedIgnore) {
109
+ fs.writeFileSync(gitignorePath, gitignoreContent);
110
+ console.log('✅ Updated .gitignore');
111
+ }
112
+ else {
113
+ console.log('ℹ️ .gitignore already configured.');
114
+ }
115
+ // Create helper scripts
72
116
  const toolsDir = path.join(specDir, 'tools');
73
- fs.mkdirSync(toolsDir, { recursive: true });
117
+ if (!fs.existsSync(toolsDir)) {
118
+ fs.mkdirSync(toolsDir, { recursive: true });
119
+ }
120
+ // Using npx specguard@latest as requested
74
121
  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')}"
122
+ npx specguard@latest validate
76
123
  `;
77
124
  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"`;
125
+ const ps1Script = `npx specguard@latest validate`;
79
126
  fs.writeFileSync(path.join(toolsDir, 'validate.ps1'), ps1Script);
80
- console.log('✅ Created helper scripts in .ai/specguard/tools/');
127
+ console.log('✅ Created validation helper scripts in .ai/specguard/tools/');
81
128
  }
@@ -60,32 +60,76 @@ export async function generateReport(data, reportDir) {
60
60
  fs.writeFileSync(jsonPath, JSON.stringify(report, null, 2));
61
61
  // Write MD
62
62
  let md = `# SpecGuard Report\n\n`;
63
- md += `**Status**: ${data.status}\n`;
63
+ // Agent Summary
64
+ md += `## 🤖 Agent Summary\n\n`;
65
+ const isPass = data.status === 'PASS';
66
+ md += `**Result**: ${isPass ? '✅ PASS' : '❌ FAIL'}\n`;
67
+ md += `**Changes**: ${data.changedFiles.length} files\n`;
68
+ if (data.violations.length > 0) {
69
+ md += `**Top Violations**:\n`;
70
+ // Top 5
71
+ for (const v of data.violations.slice(0, 5)) {
72
+ md += `- ${v.type}: ${v.details.slice(0, 100)}${v.details.length > 100 ? '...' : ''}\n`;
73
+ }
74
+ if (data.violations.length > 5) {
75
+ md += `- ... and ${data.violations.length - 5} more\n`;
76
+ }
77
+ }
78
+ else {
79
+ md += `**Violations**: None\n`;
80
+ }
81
+ if (processedToolResults.length > 0) {
82
+ md += `\n**Tools**:\n`;
83
+ md += `| Tool | Status | Optional | Exit |\n`;
84
+ md += `| --- | --- | --- | --- |\n`;
85
+ for (const t of processedToolResults) {
86
+ md += `| ${t.name} | ${t.status} | ${t.optional} | ${t.exit_code ?? '-'} |\n`;
87
+ }
88
+ }
89
+ md += `\n**Next Action**: `;
90
+ if (isPass) {
91
+ md += `Proceed with changes.\n`;
92
+ }
93
+ else {
94
+ md += `Fix violations and re-run: \`npx specguard validate\`\n`;
95
+ }
96
+ md += `\n---\n\n`; // Separator
97
+ // Detailed Report
98
+ md += `## Run Details\n`;
64
99
  md += `**Run ID**: ${runId}\n`;
65
100
  md += `**Date**: ${data.timestamp}\n`;
66
101
  md += `**Mode**: ${data.runMeta.diffMode}\n`;
67
- md += `**Changes**: ${data.changedFiles.length} files\n\n`;
102
+ md += `**Platform**: ${os.platform()} / Node ${process.version}\n\n`;
68
103
  if (data.violations.length > 0) {
69
- md += `## ❌ Violations\n`;
104
+ md += `## ❌ Violations Full List\n`;
70
105
  for (const v of data.violations) {
71
106
  md += `- **${v.type}**: ${v.file || 'N/A'} - ${v.details}\n`;
72
107
  }
73
108
  }
74
- else {
75
- md += `## ✅ No Violations Found\n`;
76
- }
77
- md += `\n## Tool Execution\n`;
109
+ md += `\n## Tool Execution Details\n`;
78
110
  if (processedToolResults.length === 0) {
79
111
  md += `No tools configured.\n`;
80
112
  }
81
113
  else {
82
114
  for (const t of processedToolResults) {
83
- const icon = t.exit_code === 0 ? '✅' : (t.optional ? '⚠️' : '❌');
115
+ let icon = '✅';
116
+ if (t.status === 'FAILED')
117
+ icon = t.optional ? '⚠️' : '❌';
118
+ if (t.status === 'SKIPPED')
119
+ icon = '⏭️';
84
120
  md += `### ${icon} ${t.name}\n`;
121
+ md += `- Status: **${t.status}**\n`;
85
122
  md += `- Command: \`${t.command}\`\n`;
86
- md += `- Exit Code: ${t.exit_code}\n`;
123
+ if (t.reason)
124
+ md += `- Reason: ${t.reason}\n`;
125
+ if (t.exit_code !== null)
126
+ md += `- Exit Code: ${t.exit_code}\n`;
127
+ md += `- Duration: ${t.duration_ms}ms\n`;
87
128
  if (t.output_tail) {
88
- md += `\`\`\`\n${t.output_tail}\n\`\`\`\n`;
129
+ md += `Output Tail:\n\`\`\`\n${t.output_tail}\n\`\`\`\n`;
130
+ }
131
+ if (t.log_path) {
132
+ md += `Full Log: [${path.basename(t.log_path)}](${t.log_path})\n`;
89
133
  }
90
134
  }
91
135
  }
@@ -38,21 +38,30 @@ export declare const SpecSchema: z.ZodObject<{
38
38
  optional: z.ZodOptional<z.ZodBoolean>;
39
39
  timeout_seconds: z.ZodOptional<z.ZodNumber>;
40
40
  env_allowlist: z.ZodOptional<z.ZodArray<z.ZodString, "many">>;
41
+ env: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodString>>;
41
42
  cwd: z.ZodOptional<z.ZodString>;
43
+ skip_if_missing: z.ZodOptional<z.ZodBoolean>;
44
+ allow_shell: z.ZodOptional<z.ZodBoolean>;
42
45
  }, "strip", z.ZodTypeAny, {
43
46
  name: string;
44
47
  command: string;
45
48
  optional?: boolean | undefined;
46
49
  timeout_seconds?: number | undefined;
47
50
  env_allowlist?: string[] | undefined;
51
+ env?: Record<string, string> | undefined;
48
52
  cwd?: string | undefined;
53
+ skip_if_missing?: boolean | undefined;
54
+ allow_shell?: boolean | undefined;
49
55
  }, {
50
56
  name: string;
51
57
  command: string;
52
58
  optional?: boolean | undefined;
53
59
  timeout_seconds?: number | undefined;
54
60
  env_allowlist?: string[] | undefined;
61
+ env?: Record<string, string> | undefined;
55
62
  cwd?: string | undefined;
63
+ skip_if_missing?: boolean | undefined;
64
+ allow_shell?: boolean | undefined;
56
65
  }>, "many">>;
57
66
  }, "strip", z.ZodTypeAny, {
58
67
  steps?: {
@@ -61,7 +70,10 @@ export declare const SpecSchema: z.ZodObject<{
61
70
  optional?: boolean | undefined;
62
71
  timeout_seconds?: number | undefined;
63
72
  env_allowlist?: string[] | undefined;
73
+ env?: Record<string, string> | undefined;
64
74
  cwd?: string | undefined;
75
+ skip_if_missing?: boolean | undefined;
76
+ allow_shell?: boolean | undefined;
65
77
  }[] | undefined;
66
78
  }, {
67
79
  steps?: {
@@ -70,7 +82,10 @@ export declare const SpecSchema: z.ZodObject<{
70
82
  optional?: boolean | undefined;
71
83
  timeout_seconds?: number | undefined;
72
84
  env_allowlist?: string[] | undefined;
85
+ env?: Record<string, string> | undefined;
73
86
  cwd?: string | undefined;
87
+ skip_if_missing?: boolean | undefined;
88
+ allow_shell?: boolean | undefined;
74
89
  }[] | undefined;
75
90
  }>>;
76
91
  output_contract: z.ZodOptional<z.ZodObject<{
@@ -99,7 +114,10 @@ export declare const SpecSchema: z.ZodObject<{
99
114
  optional?: boolean | undefined;
100
115
  timeout_seconds?: number | undefined;
101
116
  env_allowlist?: string[] | undefined;
117
+ env?: Record<string, string> | undefined;
102
118
  cwd?: string | undefined;
119
+ skip_if_missing?: boolean | undefined;
120
+ allow_shell?: boolean | undefined;
103
121
  }[] | undefined;
104
122
  } | undefined;
105
123
  output_contract?: {
@@ -124,7 +142,10 @@ export declare const SpecSchema: z.ZodObject<{
124
142
  optional?: boolean | undefined;
125
143
  timeout_seconds?: number | undefined;
126
144
  env_allowlist?: string[] | undefined;
145
+ env?: Record<string, string> | undefined;
127
146
  cwd?: string | undefined;
147
+ skip_if_missing?: boolean | undefined;
148
+ allow_shell?: boolean | undefined;
128
149
  }[] | undefined;
129
150
  } | undefined;
130
151
  output_contract?: {
@@ -18,7 +18,10 @@ export const SpecSchema = z.object({
18
18
  optional: z.boolean().optional(),
19
19
  timeout_seconds: z.number().optional(),
20
20
  env_allowlist: z.array(z.string()).optional(),
21
- cwd: z.string().optional()
21
+ env: z.record(z.string()).optional(),
22
+ cwd: z.string().optional(),
23
+ skip_if_missing: z.boolean().optional(),
24
+ allow_shell: z.boolean().optional()
22
25
  })).optional()
23
26
  }).optional(),
24
27
  output_contract: z.object({
@@ -0,0 +1,8 @@
1
+ /**
2
+ * Simple command parser that respects quoted arguments.
3
+ * Does NOT support shell operators (|, &&, >, etc).
4
+ */
5
+ export declare function parseCommand(command: string): {
6
+ cmd: string;
7
+ args: string[];
8
+ };
@@ -0,0 +1,56 @@
1
+ /**
2
+ * Simple command parser that respects quoted arguments.
3
+ * Does NOT support shell operators (|, &&, >, etc).
4
+ */
5
+ export function parseCommand(command) {
6
+ const args = [];
7
+ let current = '';
8
+ let quoteChar = null;
9
+ let escaped = false;
10
+ for (let i = 0; i < command.length; i++) {
11
+ const char = command[i];
12
+ if (escaped) {
13
+ current += char;
14
+ escaped = false;
15
+ continue;
16
+ }
17
+ if (char === '\\') {
18
+ escaped = true;
19
+ continue;
20
+ }
21
+ if (quoteChar) {
22
+ if (char === quoteChar) {
23
+ quoteChar = null;
24
+ }
25
+ else {
26
+ current += char;
27
+ }
28
+ }
29
+ else {
30
+ if (char === '"' || char === "'") {
31
+ quoteChar = char;
32
+ }
33
+ else if (/\s/.test(char)) {
34
+ if (current.length > 0) {
35
+ args.push(current);
36
+ current = '';
37
+ }
38
+ }
39
+ else {
40
+ current += char;
41
+ }
42
+ }
43
+ }
44
+ if (current.length > 0) {
45
+ args.push(current);
46
+ }
47
+ // Requirement: Ensure no empty string arguments
48
+ const filteredArgs = args.filter(arg => arg.length > 0);
49
+ if (filteredArgs.length === 0) {
50
+ throw new Error(`Invalid tool command: "${command}"`);
51
+ }
52
+ return {
53
+ cmd: filteredArgs[0],
54
+ args: filteredArgs.slice(1)
55
+ };
56
+ }
@@ -0,0 +1,2 @@
1
+ import { Spec } from '../spec/schema.js';
2
+ export declare function redact(text: string, spec: Spec): string;
@@ -0,0 +1,17 @@
1
+ export function redact(text, spec) {
2
+ if (!text || !spec.deterministic_rules?.secret_patterns) {
3
+ return text;
4
+ }
5
+ let redacted = text;
6
+ for (const pattern of spec.deterministic_rules.secret_patterns) {
7
+ try {
8
+ const regex = new RegExp(pattern.regex, 'g');
9
+ redacted = redacted.replace(regex, '***REDACTED***');
10
+ }
11
+ catch (e) {
12
+ // Ignore invalid regex in spec to prevent crashes
13
+ console.error(`Invalid regex for secret pattern '${pattern.name}':`, e);
14
+ }
15
+ }
16
+ return redacted;
17
+ }
@@ -2,10 +2,12 @@ import { Spec } from '../spec/schema.js';
2
2
  interface ToolResult {
3
3
  name: string;
4
4
  command: string;
5
- exit_code: number;
6
- stdout: string;
7
- stderr: string;
8
- duration: number;
5
+ status: 'RAN' | 'SKIPPED' | 'FAILED';
6
+ exit_code: number | null;
7
+ stdout?: string;
8
+ stderr?: string;
9
+ reason?: string;
10
+ duration_ms: number;
9
11
  optional: boolean;
10
12
  }
11
13
  interface ToolOutput {
@@ -1,4 +1,11 @@
1
1
  import { spawn } from 'child_process';
2
+ import path from 'path';
3
+ import fs from 'fs';
4
+ import { parseCommand } from '../utils/cmd_parser.js';
5
+ import { redact } from '../utils/redaction.js';
6
+ const DEFAULT_ENV_ALLOWLIST = [
7
+ "PATH", "HOME", "USERPROFILE", "TEMP", "TMP", "NODE_ENV", "CI", "npm_config_user_agent"
8
+ ];
2
9
  export async function runToolChecks(spec, repoRoot) {
3
10
  const tools = spec.tool_verified?.steps || [];
4
11
  const results = [];
@@ -6,34 +13,159 @@ export async function runToolChecks(spec, repoRoot) {
6
13
  for (const tool of tools) {
7
14
  console.log(`Run tool: ${tool.name}...`);
8
15
  const startTime = Date.now();
16
+ // Determine script existence for npm/pnpm/yarn commands
17
+ // If script is missing and skip_if_missing is true (default for optional), SKIP.
18
+ const firstWord = tool.command.split(/\s+/)[0];
19
+ const isNpmRun = firstWord === 'npm' || firstWord === 'pnpm' || firstWord === 'yarn';
20
+ let shouldSkip = false;
21
+ let skipReason = '';
22
+ if (isNpmRun) {
23
+ // Check process.cwd() or tool.cwd? Default checks repoRoot unless cwd specified
24
+ const toolCwd = tool.cwd ? path.resolve(repoRoot, tool.cwd) : repoRoot;
25
+ const pkgJsonPath = path.join(toolCwd, 'package.json');
26
+ // Extract script name. e.g. "npm run lint" -> "lint"
27
+ // "pnpm lint" -> "lint"; "yarn lint" -> "lint"
28
+ const args = tool.command.split(/\s+/);
29
+ let scriptName = '';
30
+ if (firstWord === 'npm' && args[1] === 'run' && args[2])
31
+ scriptName = args[2];
32
+ else if (firstWord === 'pnpm' && args[1] === 'run' && args[2])
33
+ scriptName = args[2];
34
+ else if (firstWord === 'pnpm' && args[1])
35
+ scriptName = args[1]; // pnpm lint
36
+ else if (firstWord === 'yarn' && args[1])
37
+ scriptName = args[1];
38
+ if (scriptName) {
39
+ if (!fs.existsSync(pkgJsonPath)) {
40
+ // No package.json
41
+ if (tool.skip_if_missing !== false) {
42
+ shouldSkip = true;
43
+ skipReason = `No package.json found in ${toolCwd}`;
44
+ }
45
+ }
46
+ else {
47
+ try {
48
+ const pkg = JSON.parse(fs.readFileSync(pkgJsonPath, 'utf-8'));
49
+ if (!pkg.scripts || !pkg.scripts[scriptName]) {
50
+ if (tool.skip_if_missing !== false) {
51
+ shouldSkip = true;
52
+ skipReason = `Script '${scriptName}' missing in package.json`;
53
+ }
54
+ else {
55
+ // If skip_if_missing = false, we proceed and let logic fail or we fail early
56
+ // But npm run will fail anyway if script missing.
57
+ // Requirement says: "If skip_if_missing=false => mark FAILED with clear message."
58
+ // But let's let it run? No, npm output might not be clear.
59
+ // Let's rely on runSpawn failing ideally, but user asked for detection.
60
+ // Let's let it run if not skipping? No, prompt "mark SKIPPED (not FAILED)" implies logic here.
61
+ }
62
+ }
63
+ }
64
+ catch (e) {
65
+ // Broken package.json, treat same as missing
66
+ if (tool.skip_if_missing !== false) {
67
+ shouldSkip = true;
68
+ skipReason = 'Failed to parse package.json';
69
+ }
70
+ }
71
+ }
72
+ }
73
+ }
74
+ // Manual skip override? (Not in spec, but helpful logic)
75
+ // Check "Skip if missing" default logic: default TRUE for optional, FALSE for required
76
+ if (tool.skip_if_missing === undefined) {
77
+ // If optional=true, default skip=true. If optional=false, default skip=false.
78
+ // Actually, strict logic requested: "default: true for optional steps; false for required steps"
79
+ // Implemented above implicitly? No.
80
+ // Wait, above logic only sets shouldSkip if explicit check fails.
81
+ }
82
+ if (shouldSkip) {
83
+ console.log(` SKIPPED: ${skipReason}`);
84
+ results.push({
85
+ name: tool.name,
86
+ command: tool.command,
87
+ status: 'SKIPPED',
88
+ exit_code: null,
89
+ reason: skipReason,
90
+ duration_ms: Date.now() - startTime,
91
+ optional: !!tool.optional
92
+ });
93
+ continue;
94
+ }
9
95
  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;
96
+ const cwd = tool.cwd ? path.resolve(repoRoot, tool.cwd) : repoRoot;
97
+ // Env construction
98
+ const env = {};
99
+ // Allowlist
100
+ const allowList = tool.env_allowlist || DEFAULT_ENV_ALLOWLIST;
101
+ for (const key of allowList) {
102
+ if (process.env[key] !== undefined) {
103
+ env[key] = process.env[key];
104
+ }
105
+ }
106
+ // Overrides
107
+ if (tool.env) {
108
+ Object.assign(env, tool.env);
109
+ }
110
+ // Command Parsing
111
+ let cmdStr = tool.command;
112
+ let finalCmd;
113
+ let finalArgs;
114
+ let shell = false;
115
+ if (tool.allow_shell) {
116
+ shell = true;
117
+ finalCmd = cmdStr;
118
+ finalArgs = []; // Spawn with shell enabled takes command string as first arg usually, or requires shell syntax
119
+ // spawn(command, args, { shell: true }) -> command is string.
120
+ // But wait, spawn behavior with shell: true:
121
+ // If args provided, they are passed to shell.
122
+ // Usually we just pass the whole command string if shell=true.
123
+ }
124
+ else {
125
+ const parsed = parseCommand(cmdStr);
126
+ finalCmd = parsed.cmd;
127
+ finalArgs = parsed.args;
128
+ // Compatibility hack for Windows npm/pnpm without shell
129
+ if (process.platform === 'win32') {
130
+ if (['npm', 'pnpm', 'yarn'].includes(finalCmd)) {
131
+ finalCmd = `${finalCmd}.cmd`;
132
+ }
133
+ }
134
+ }
135
+ // B) Tool runner safety
136
+ // 4) Assertions and filtering
137
+ if (!finalCmd) {
138
+ throw new Error('Command cannot be empty');
139
+ }
140
+ if (!Array.isArray(finalArgs)) {
141
+ throw new Error('Arguments must be an array');
142
+ }
143
+ finalArgs = finalArgs.filter(Boolean);
144
+ const res = await runSpawn(finalCmd, finalArgs, cwd, env, tool.timeout_seconds, shell);
145
+ const duration = Date.now() - startTime;
146
+ // Redact output
147
+ const stdout = redact(res.stdout, spec);
148
+ const stderr = redact(res.stderr, spec);
149
+ const status = res.code === 0 ? 'RAN' : 'FAILED';
20
150
  const toolRes = {
21
151
  name: tool.name,
22
152
  command: tool.command,
153
+ status,
23
154
  exit_code: res.code,
24
- stdout: res.stdout,
25
- stderr: res.stderr,
26
- duration,
27
- optional: !!tool.optional
155
+ stdout,
156
+ stderr,
157
+ duration_ms: duration,
158
+ optional: !!tool.optional,
159
+ reason: res.reason
28
160
  };
29
161
  results.push(toolRes);
30
- if (res.code !== 0) {
31
- console.log(` FAILED (Optional: ${tool.optional})`);
162
+ if (status === 'FAILED') {
163
+ console.log(` FAILED (Optional: ${tool.optional}) - Code: ${res.code}`);
32
164
  if (!tool.optional) {
33
165
  violations.push({
34
166
  type: 'tool_failure',
35
167
  file: 'N/A',
36
- details: `Tool '${tool.name}' failed with exit code ${res.code}`
168
+ details: `Tool '${tool.name}' failed with exit code ${res.code}. Reason: ${res.reason || 'Process exited with error'}`
37
169
  });
38
170
  }
39
171
  }
@@ -48,32 +180,44 @@ export async function runToolChecks(spec, repoRoot) {
48
180
  file: 'N/A',
49
181
  details: `Failed to execute tool '${tool.name}': ${e.message}`
50
182
  });
183
+ results.push({
184
+ name: tool.name,
185
+ command: tool.command,
186
+ status: 'FAILED',
187
+ exit_code: 1, // Synthetic
188
+ reason: e.message,
189
+ duration_ms: Date.now() - startTime,
190
+ optional: !!tool.optional
191
+ });
51
192
  }
52
193
  }
53
194
  return { results, violations };
54
195
  }
55
- function runSpawn(cmd, args, cwd, timeoutSec) {
196
+ function runSpawn(cmd, args, cwd, env, timeoutSec, shell = false) {
56
197
  return new Promise((resolve, reject) => {
198
+ // If shell=true, 'cmd' is the full command string, args should be empty or handled carefully.
199
+ // Node spawn with shell: true treats 'cmd' as command line.
57
200
  const cp = spawn(cmd, args, {
58
201
  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
202
+ shell,
203
+ env,
204
+ timeout: timeoutSec ? (timeoutSec * 1000) : 300000 // Default 300s
67
205
  });
68
206
  let stdout = '';
69
207
  let stderr = '';
70
208
  cp.stdout.on('data', (d) => stdout += d.toString());
71
209
  cp.stderr.on('data', (d) => stderr += d.toString());
72
210
  cp.on('error', (err) => {
73
- reject(err);
211
+ // reject(err); // Don't reject, just return error code so we can log it
212
+ resolve({ code: 127, stdout, stderr, reason: `Spawn error: ${err.message}` });
74
213
  });
75
- cp.on('close', (code) => {
76
- resolve({ code: code ?? -1, stdout, stderr });
214
+ cp.on('close', (code, signal) => {
215
+ if (signal) {
216
+ resolve({ code: 128 + 15, stdout, stderr, reason: `Killed by signal ${signal} (Timeout?)` });
217
+ }
218
+ else {
219
+ resolve({ code: code ?? -1, stdout, stderr });
220
+ }
77
221
  });
78
222
  });
79
223
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "specguard",
3
- "version": "0.1.1",
3
+ "version": "0.1.3",
4
4
  "description": "Production-grade SpecGuard validator",
5
5
  "main": "dist/index.js",
6
6
  "types": "dist/index.d.ts",
@@ -34,4 +34,4 @@
34
34
  "@types/node": "^20.11.0",
35
35
  "vitest": "^4.0.18"
36
36
  }
37
- }
37
+ }