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 +21 -0
- package/README.md +24 -4
- package/dist/init.js +65 -18
- package/dist/reporting/json_reporter.js +54 -10
- package/dist/spec/schema.d.ts +21 -0
- package/dist/spec/schema.js +4 -1
- package/dist/utils/cmd_parser.d.ts +8 -0
- package/dist/utils/cmd_parser.js +56 -0
- package/dist/utils/redaction.d.ts +2 -0
- package/dist/utils/redaction.js +17 -0
- package/dist/validators/tools.d.ts +6 -4
- package/dist/validators/tools.js +173 -29
- package/package.json +2 -2
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
|
-
##
|
|
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
|
-
|
|
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
|
|
29
|
+
This repository uses **SpecGuard** to ensure code quality and security.
|
|
30
|
+
As an AI agent, you **MUST** follow this workflow:
|
|
29
31
|
|
|
30
|
-
**
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
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.
|
|
46
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
71
|
-
|
|
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.
|
|
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
|
|
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
|
|
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
|
-
|
|
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 += `**
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 +=
|
|
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
|
}
|
package/dist/spec/schema.d.ts
CHANGED
|
@@ -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?: {
|
package/dist/spec/schema.js
CHANGED
|
@@ -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
|
-
|
|
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,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,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
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
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 {
|
package/dist/validators/tools.js
CHANGED
|
@@ -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
|
-
|
|
11
|
-
//
|
|
12
|
-
|
|
13
|
-
//
|
|
14
|
-
|
|
15
|
-
const
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
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
|
|
25
|
-
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 (
|
|
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
|
|
60
|
-
|
|
61
|
-
|
|
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
|
-
|
|
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.
|
|
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
|
+
}
|