Skip to content

Commit e8c8bf9

Browse files
Add init, guard, runtime, advisory intelligence, and contribution prompting (#2)
* Add init, guard, runtime commands and enhance verify with trust queries - opena2a init: Security posture assessment with trust score, credential scan, hygiene checks, and prioritized next steps (protect flow) - opena2a guard: ConfigGuard MVP with sign/verify/status subcommands for config file integrity via SHA-256 hash pinning - opena2a runtime: ARP wrapper with start/status/tail/init subcommands for agent runtime protection - verify: Enhanced with trust profile and oracle verdict queries from registry, showing trust score, verdict, and dependency risks - Extract credential patterns to shared module for init/protect reuse - Remove guard/runtime from adapter registry (now direct commands) - 22 new tests (init: 8, guard: 8, runtime: 6) * Add advisory intelligence feed and scan report submission - Advisory check: init command fetches advisories from registry and warns about flagged tools in the project's dependencies - Advisory cache: 5-minute local cache to avoid repeated fetches - Package detection: Scans package.json, go.mod, requirements.txt for dependency names to match against advisories - Scan report submission: Utility for sharing detailed scan findings with registry when contribute mode is enabled (opt-in) - 7 new tests for advisory matching, caching, and error handling * Fix shared package test script to pass with no test files * Fix intent-map regex to allow modifier words between possessive and target * Add delayed contribution prompting and GitHub star CTA - Track scan count in user config, only prompt to share reports after 3+ scans - Add shouldPromptContribute/dismissContributePrompt to shared config - Add open source link to init report footer and HTML security report - 5 new telemetry config tests
1 parent ecc0eaa commit e8c8bf9

22 files changed

Lines changed: 2703 additions & 190 deletions

packages/cli/__tests__/adapters/routing.test.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -53,8 +53,9 @@ describe('input classifier', () => {
5353

5454
describe('adapter registry', () => {
5555
it('has all expected adapters', () => {
56-
const expected = ['scan', 'secrets', 'runtime', 'benchmark', 'registry',
57-
'research', 'hunt', 'train', 'crypto', 'identity', 'guard', 'broker', 'dlp'];
56+
// guard and runtime are now handled directly (not adapter-based)
57+
const expected = ['scan', 'secrets', 'benchmark', 'registry',
58+
'research', 'hunt', 'train', 'crypto', 'identity', 'broker', 'dlp'];
5859
for (const name of expected) {
5960
expect(ADAPTER_REGISTRY[name]).toBeDefined();
6061
}
Lines changed: 184 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,184 @@
1+
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
2+
import * as fs from 'node:fs';
3+
import * as path from 'node:path';
4+
import * as os from 'node:os';
5+
import { guard } from '../../src/commands/guard.js';
6+
7+
function captureStdout(fn: () => Promise<number>): Promise<{ exitCode: number; output: string }> {
8+
const chunks: string[] = [];
9+
const origWrite = process.stdout.write;
10+
process.stdout.write = ((chunk: any) => {
11+
chunks.push(String(chunk));
12+
return true;
13+
}) as any;
14+
15+
return fn().then(exitCode => {
16+
process.stdout.write = origWrite;
17+
return { exitCode, output: chunks.join('') };
18+
}).catch(err => {
19+
process.stdout.write = origWrite;
20+
throw err;
21+
});
22+
}
23+
24+
describe('guard', () => {
25+
let tempDir: string;
26+
27+
beforeEach(() => {
28+
tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'opena2a-guard-test-'));
29+
});
30+
31+
afterEach(() => {
32+
fs.rmSync(tempDir, { recursive: true, force: true });
33+
});
34+
35+
it('sign creates signature store', async () => {
36+
fs.writeFileSync(path.join(tempDir, 'package.json'), '{"name":"test"}');
37+
fs.writeFileSync(path.join(tempDir, 'tsconfig.json'), '{"compilerOptions":{}}');
38+
39+
const { exitCode, output } = await captureStdout(() => guard({
40+
subcommand: 'sign',
41+
targetDir: tempDir,
42+
format: 'json',
43+
}));
44+
45+
expect(exitCode).toBe(0);
46+
const result = JSON.parse(output);
47+
expect(result.signed).toBe(2);
48+
49+
// Verify store file exists
50+
const storePath = path.join(tempDir, '.opena2a/guard/signatures.json');
51+
expect(fs.existsSync(storePath)).toBe(true);
52+
const store = JSON.parse(fs.readFileSync(storePath, 'utf-8'));
53+
expect(store.version).toBe(1);
54+
expect(store.signatures).toHaveLength(2);
55+
});
56+
57+
it('verify detects tampering', async () => {
58+
fs.writeFileSync(path.join(tempDir, 'package.json'), '{"name":"original"}');
59+
60+
// Sign
61+
await guard({ subcommand: 'sign', targetDir: tempDir, format: 'json' });
62+
63+
// Tamper with the file
64+
fs.writeFileSync(path.join(tempDir, 'package.json'), '{"name":"tampered"}');
65+
66+
// Verify
67+
const { exitCode, output } = await captureStdout(() => guard({
68+
subcommand: 'verify',
69+
targetDir: tempDir,
70+
format: 'json',
71+
}));
72+
73+
expect(exitCode).toBe(1);
74+
const report = JSON.parse(output);
75+
expect(report.tampered).toBe(1);
76+
const tamperedFile = report.results.find((r: any) => r.status === 'tampered');
77+
expect(tamperedFile).toBeDefined();
78+
expect(tamperedFile.filePath).toBe('package.json');
79+
});
80+
81+
it('verify passes for clean files', async () => {
82+
fs.writeFileSync(path.join(tempDir, 'package.json'), '{"name":"clean"}');
83+
84+
// Sign
85+
await guard({ subcommand: 'sign', targetDir: tempDir, format: 'json' });
86+
87+
// Verify without modification
88+
const { exitCode, output } = await captureStdout(() => guard({
89+
subcommand: 'verify',
90+
targetDir: tempDir,
91+
format: 'json',
92+
}));
93+
94+
expect(exitCode).toBe(0);
95+
const report = JSON.parse(output);
96+
expect(report.passed).toBe(1);
97+
expect(report.tampered).toBe(0);
98+
});
99+
100+
it('status shows correct counts', async () => {
101+
fs.writeFileSync(path.join(tempDir, 'package.json'), '{"name":"test"}');
102+
fs.writeFileSync(path.join(tempDir, 'tsconfig.json'), '{}');
103+
fs.writeFileSync(path.join(tempDir, 'Dockerfile'), 'FROM node');
104+
105+
// Sign only package.json
106+
await guard({ subcommand: 'sign', targetDir: tempDir, files: ['package.json'], format: 'json' });
107+
108+
const { exitCode, output } = await captureStdout(() => guard({
109+
subcommand: 'status',
110+
targetDir: tempDir,
111+
format: 'json',
112+
}));
113+
114+
expect(exitCode).toBe(0);
115+
const status = JSON.parse(output);
116+
expect(status.signed).toBe(1);
117+
expect(status.unsigned).toBe(2); // tsconfig.json and Dockerfile
118+
expect(status.tampered).toBe(0);
119+
});
120+
121+
it('sign with custom --files only signs specified files', async () => {
122+
fs.writeFileSync(path.join(tempDir, 'package.json'), '{}');
123+
fs.writeFileSync(path.join(tempDir, 'tsconfig.json'), '{}');
124+
125+
const { exitCode, output } = await captureStdout(() => guard({
126+
subcommand: 'sign',
127+
targetDir: tempDir,
128+
files: ['tsconfig.json'],
129+
format: 'json',
130+
}));
131+
132+
expect(exitCode).toBe(0);
133+
const result = JSON.parse(output);
134+
expect(result.signed).toBe(1);
135+
expect(result.files).toContain('tsconfig.json');
136+
expect(result.files).not.toContain('package.json');
137+
});
138+
139+
it('reports unsigned config files', async () => {
140+
fs.writeFileSync(path.join(tempDir, 'package.json'), '{}');
141+
// Create an empty store (no signatures)
142+
const storeDir = path.join(tempDir, '.opena2a/guard');
143+
fs.mkdirSync(storeDir, { recursive: true });
144+
fs.writeFileSync(path.join(storeDir, 'signatures.json'), JSON.stringify({
145+
version: 1, signatures: [], updatedAt: new Date().toISOString(),
146+
}));
147+
148+
const { output } = await captureStdout(() => guard({
149+
subcommand: 'verify',
150+
targetDir: tempDir,
151+
format: 'json',
152+
}));
153+
154+
const report = JSON.parse(output);
155+
expect(report.unsigned).toBeGreaterThan(0);
156+
});
157+
158+
it('returns 1 when no store exists for verify', async () => {
159+
const { exitCode } = await captureStdout(() => guard({
160+
subcommand: 'verify',
161+
targetDir: tempDir,
162+
format: 'json',
163+
}));
164+
165+
expect(exitCode).toBe(1);
166+
});
167+
168+
it('handles missing signed files', async () => {
169+
fs.writeFileSync(path.join(tempDir, 'package.json'), '{}');
170+
await guard({ subcommand: 'sign', targetDir: tempDir, format: 'json' });
171+
172+
// Delete the signed file
173+
fs.unlinkSync(path.join(tempDir, 'package.json'));
174+
175+
const { exitCode, output } = await captureStdout(() => guard({
176+
subcommand: 'verify',
177+
targetDir: tempDir,
178+
format: 'json',
179+
}));
180+
181+
const report = JSON.parse(output);
182+
expect(report.missing).toBe(1);
183+
});
184+
});
Lines changed: 170 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,170 @@
1+
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
2+
import * as fs from 'node:fs';
3+
import * as path from 'node:path';
4+
import * as os from 'node:os';
5+
import { init } from '../../src/commands/init.js';
6+
7+
function captureStdout(fn: () => Promise<number>): Promise<{ exitCode: number; output: string }> {
8+
const chunks: string[] = [];
9+
const origWrite = process.stdout.write;
10+
process.stdout.write = ((chunk: any) => {
11+
chunks.push(String(chunk));
12+
return true;
13+
}) as any;
14+
15+
return fn().then(exitCode => {
16+
process.stdout.write = origWrite;
17+
return { exitCode, output: chunks.join('') };
18+
}).catch(err => {
19+
process.stdout.write = origWrite;
20+
throw err;
21+
});
22+
}
23+
24+
describe('init', () => {
25+
let tempDir: string;
26+
27+
beforeEach(() => {
28+
tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'opena2a-init-test-'));
29+
});
30+
31+
afterEach(() => {
32+
fs.rmSync(tempDir, { recursive: true, force: true });
33+
});
34+
35+
it('returns 0 for a clean project', async () => {
36+
// Create a clean Node project
37+
fs.writeFileSync(path.join(tempDir, 'package.json'), JSON.stringify({ name: 'test-project', version: '1.0.0' }));
38+
fs.writeFileSync(path.join(tempDir, '.gitignore'), '.env\nnode_modules\n');
39+
fs.writeFileSync(path.join(tempDir, 'package-lock.json'), '{}');
40+
fs.mkdirSync(path.join(tempDir, '.git'));
41+
42+
const { exitCode, output } = await captureStdout(() => init({
43+
targetDir: tempDir,
44+
format: 'json',
45+
}));
46+
47+
expect(exitCode).toBe(0);
48+
const report = JSON.parse(output);
49+
expect(report.projectType).toContain('Node.js');
50+
expect(report.credentialFindings).toBe(0);
51+
expect(report.trustScore).toBeGreaterThanOrEqual(90);
52+
expect(report.grade).toBe('A');
53+
});
54+
55+
it('detects hardcoded credentials', async () => {
56+
fs.writeFileSync(path.join(tempDir, 'package.json'), JSON.stringify({ name: 'test-project' }));
57+
fs.writeFileSync(path.join(tempDir, '.gitignore'), 'node_modules\n');
58+
// Simulate a file with a hardcoded key
59+
const fakeKey = 'sk-ant-api03-' + 'A'.repeat(85);
60+
fs.writeFileSync(path.join(tempDir, 'config.ts'), `const key = "${fakeKey}";`);
61+
62+
const { exitCode, output } = await captureStdout(() => init({
63+
targetDir: tempDir,
64+
format: 'json',
65+
}));
66+
67+
expect(exitCode).toBe(0);
68+
const report = JSON.parse(output);
69+
expect(report.credentialFindings).toBeGreaterThan(0);
70+
expect(report.trustScore).toBeLessThan(90);
71+
});
72+
73+
it('detects MCP config', async () => {
74+
fs.writeFileSync(path.join(tempDir, 'package.json'), JSON.stringify({ name: 'mcp-server' }));
75+
fs.writeFileSync(path.join(tempDir, '.gitignore'), '.env\n');
76+
fs.writeFileSync(path.join(tempDir, 'mcp.json'), '{}');
77+
78+
const { exitCode, output } = await captureStdout(() => init({
79+
targetDir: tempDir,
80+
format: 'json',
81+
}));
82+
83+
expect(exitCode).toBe(0);
84+
const report = JSON.parse(output);
85+
expect(report.projectType).toContain('MCP');
86+
});
87+
88+
it('warns about missing .gitignore', async () => {
89+
fs.writeFileSync(path.join(tempDir, 'package.json'), JSON.stringify({ name: 'test' }));
90+
91+
const { exitCode, output } = await captureStdout(() => init({
92+
targetDir: tempDir,
93+
format: 'json',
94+
}));
95+
96+
expect(exitCode).toBe(0);
97+
const report = JSON.parse(output);
98+
const gitignoreCheck = report.hygieneChecks.find((c: any) => c.label === '.gitignore');
99+
expect(gitignoreCheck.status).toBe('warn');
100+
expect(report.trustScore).toBeLessThan(90);
101+
});
102+
103+
it('generates JSON output with all expected fields', async () => {
104+
fs.writeFileSync(path.join(tempDir, 'package.json'), JSON.stringify({ name: 'test', version: '2.0.0' }));
105+
fs.writeFileSync(path.join(tempDir, '.gitignore'), '.env\n');
106+
107+
const { output } = await captureStdout(() => init({
108+
targetDir: tempDir,
109+
format: 'json',
110+
}));
111+
112+
const report = JSON.parse(output);
113+
expect(report).toHaveProperty('projectName');
114+
expect(report).toHaveProperty('projectType');
115+
expect(report).toHaveProperty('directory');
116+
expect(report).toHaveProperty('credentialFindings');
117+
expect(report).toHaveProperty('hygieneChecks');
118+
expect(report).toHaveProperty('trustScore');
119+
expect(report).toHaveProperty('grade');
120+
expect(report).toHaveProperty('nextSteps');
121+
});
122+
123+
it('includes protect in next steps when credentials found', async () => {
124+
fs.writeFileSync(path.join(tempDir, 'package.json'), JSON.stringify({ name: 'test' }));
125+
fs.writeFileSync(path.join(tempDir, '.gitignore'), '.env\n');
126+
const fakeKey = 'sk-ant-api03-' + 'B'.repeat(85);
127+
fs.writeFileSync(path.join(tempDir, 'app.js'), `const k = "${fakeKey}";`);
128+
129+
const { output } = await captureStdout(() => init({
130+
targetDir: tempDir,
131+
format: 'json',
132+
}));
133+
134+
const report = JSON.parse(output);
135+
const protectStep = report.nextSteps.find((s: any) => s.command === 'opena2a protect');
136+
expect(protectStep).toBeDefined();
137+
expect(protectStep.severity).toBe('critical');
138+
});
139+
140+
it('calculates correct grade boundaries', async () => {
141+
// Clean project should get A
142+
fs.writeFileSync(path.join(tempDir, 'package.json'), JSON.stringify({ name: 'clean' }));
143+
fs.writeFileSync(path.join(tempDir, '.gitignore'), '.env\nnode_modules\n');
144+
fs.writeFileSync(path.join(tempDir, 'package-lock.json'), '{}');
145+
146+
const { output } = await captureStdout(() => init({
147+
targetDir: tempDir,
148+
format: 'json',
149+
}));
150+
151+
const report = JSON.parse(output);
152+
expect(report.grade).toMatch(/^[A-F]$/);
153+
expect(report.trustScore).toBeGreaterThanOrEqual(0);
154+
expect(report.trustScore).toBeLessThanOrEqual(100);
155+
});
156+
157+
it('returns 1 for nonexistent directory', async () => {
158+
const stderrChunks: string[] = [];
159+
const origStderr = process.stderr.write;
160+
process.stderr.write = ((chunk: any) => {
161+
stderrChunks.push(String(chunk));
162+
return true;
163+
}) as any;
164+
165+
const exitCode = await init({ targetDir: '/nonexistent/path/xyz' });
166+
process.stderr.write = origStderr;
167+
168+
expect(exitCode).toBe(1);
169+
});
170+
});

0 commit comments

Comments
 (0)