@elmoxbt/agentskillguard 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/src/cli.ts ADDED
@@ -0,0 +1,91 @@
1
+ #!/usr/bin/env node
2
+ import { Command } from 'commander';
3
+ import * as fs from 'fs';
4
+ import * as path from 'path';
5
+ import chalk from 'chalk';
6
+ import { scanTarget } from './scanner';
7
+ import { loadPolicy, DEFAULT_POLICY_YAML } from './policy/loader';
8
+ import { printReport } from './report/formatter';
9
+ import { saveScan, listScans } from './db/store';
10
+
11
+ const program = new Command();
12
+
13
+ program
14
+ .name('skillguard')
15
+ .description('AgentSkillGuard — static security scanner for AI agent tools that touch a Solana wallet.')
16
+ .version('0.1.0');
17
+
18
+ program
19
+ .command('scan <target>')
20
+ .description('Scan a tool directory or file for dangerous capabilities')
21
+ .option('-p, --policy <file>', 'Path to a YAML capability manifest to enforce')
22
+ .option('--json', 'Print machine-readable JSON instead of the formatted report')
23
+ .option('--no-save', 'Do not persist this scan to the local history database')
24
+ .action((target: string, opts: { policy?: string; json?: boolean; save: boolean }) => {
25
+ const resolvedTarget = path.resolve(target);
26
+ if (!fs.existsSync(resolvedTarget)) {
27
+ console.error(chalk.red(`Target not found: ${resolvedTarget}`));
28
+ process.exitCode = 1;
29
+ return;
30
+ }
31
+
32
+ const policy = opts.policy ? loadPolicy(path.resolve(opts.policy)) : null;
33
+ const result = scanTarget(resolvedTarget, policy);
34
+
35
+ if (opts.save) {
36
+ try {
37
+ saveScan(result);
38
+ } catch (err) {
39
+ console.error(chalk.yellow(`Warning: could not save scan history (${(err as Error).message})`));
40
+ }
41
+ }
42
+
43
+ if (opts.json) {
44
+ console.log(JSON.stringify(result, null, 2));
45
+ } else {
46
+ printReport(result);
47
+ }
48
+
49
+ if (result.verdict === 'BLOCK') {
50
+ process.exitCode = 2;
51
+ } else if (result.verdict === 'WARN') {
52
+ process.exitCode = 1;
53
+ }
54
+ });
55
+
56
+ program
57
+ .command('init-policy')
58
+ .description('Write a starter deny-by-default policy manifest')
59
+ .option('-o, --out <file>', 'Output path', 'skillguard.policy.yaml')
60
+ .action((opts: { out: string }) => {
61
+ const outPath = path.resolve(opts.out);
62
+ if (fs.existsSync(outPath)) {
63
+ console.error(chalk.red(`Refusing to overwrite existing file: ${outPath}`));
64
+ process.exitCode = 1;
65
+ return;
66
+ }
67
+ fs.writeFileSync(outPath, DEFAULT_POLICY_YAML, 'utf-8');
68
+ console.log(chalk.green(`Wrote starter policy to ${outPath}`));
69
+ });
70
+
71
+ program
72
+ .command('history')
73
+ .description('Show recent scans from local history')
74
+ .option('-n, --limit <n>', 'Number of rows to show', '20')
75
+ .action((opts: { limit: string }) => {
76
+ const rows = listScans(parseInt(opts.limit, 10));
77
+ if (rows.length === 0) {
78
+ console.log(chalk.dim('No scans recorded yet. Run `skillguard scan <target>` first.'));
79
+ return;
80
+ }
81
+ for (const r of rows) {
82
+ const label = String(r.verdict).padEnd(6);
83
+ const colored = r.verdict === 'BLOCK' ? chalk.red(label) : r.verdict === 'WARN' ? chalk.yellow(label) : chalk.green(label);
84
+ console.log(
85
+ `#${r.id} ${r.scanned_at} ${colored} ${r.tool_name ?? '(unnamed)'} ${r.target} ` +
86
+ chalk.dim(`(${r.findings_count} findings, ${r.violations_count} violations)`)
87
+ );
88
+ }
89
+ });
90
+
91
+ program.parse(process.argv);
@@ -0,0 +1,61 @@
1
+ import Database from 'better-sqlite3';
2
+ import * as fs from 'fs';
3
+ import * as os from 'os';
4
+ import * as path from 'path';
5
+ import { ScanResult } from '../types';
6
+
7
+ const DB_DIR = path.join(os.homedir(), '.agentskillguard');
8
+ const DB_PATH = path.join(DB_DIR, 'scans.db');
9
+
10
+ function getDb(): Database.Database {
11
+ if (!fs.existsSync(DB_DIR)) fs.mkdirSync(DB_DIR, { recursive: true });
12
+ const db = new Database(DB_PATH);
13
+ db.exec(`
14
+ CREATE TABLE IF NOT EXISTS scans (
15
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
16
+ target TEXT NOT NULL,
17
+ tool_name TEXT,
18
+ verdict TEXT NOT NULL,
19
+ verdict_reason TEXT,
20
+ findings_count INTEGER,
21
+ violations_count INTEGER,
22
+ result_json TEXT NOT NULL,
23
+ scanned_at TEXT NOT NULL
24
+ );
25
+ `);
26
+ return db;
27
+ }
28
+
29
+ export function saveScan(result: ScanResult): number {
30
+ const db = getDb();
31
+ try {
32
+ const stmt = db.prepare(`
33
+ INSERT INTO scans (target, tool_name, verdict, verdict_reason, findings_count, violations_count, result_json, scanned_at)
34
+ VALUES (@target, @tool_name, @verdict, @verdict_reason, @findings_count, @violations_count, @result_json, @scanned_at)
35
+ `);
36
+ const info = stmt.run({
37
+ target: result.target,
38
+ tool_name: result.metadata?.name ?? null,
39
+ verdict: result.verdict,
40
+ verdict_reason: result.verdictReason,
41
+ findings_count: result.findings.length,
42
+ violations_count: result.violations.length,
43
+ result_json: JSON.stringify(result),
44
+ scanned_at: result.scannedAt,
45
+ });
46
+ return info.lastInsertRowid as number;
47
+ } finally {
48
+ db.close();
49
+ }
50
+ }
51
+
52
+ export function listScans(limit = 20): any[] {
53
+ const db = getDb();
54
+ try {
55
+ return db
56
+ .prepare(`SELECT id, target, tool_name, verdict, findings_count, violations_count, scanned_at FROM scans ORDER BY id DESC LIMIT ?`)
57
+ .all(limit);
58
+ } finally {
59
+ db.close();
60
+ }
61
+ }
@@ -0,0 +1,86 @@
1
+ import * as fs from 'fs';
2
+ import { Finding, Violation } from '../types';
3
+ import { Policy } from './schema';
4
+ import { extractDomains, extractLiteralUrls } from '../scanner/staticAnalyzer';
5
+
6
+ /**
7
+ * Capabilities that are never acceptable for an agent tool, regardless of
8
+ * what a policy manifest grants. A policy can only narrow permissions
9
+ * below this floor — it can never widen past it.
10
+ */
11
+ const ALWAYS_BLOCKED_CAPABILITIES = new Set([
12
+ 'wallet.private_key_access',
13
+ 'system.shell_exec',
14
+ 'code.dynamic_load',
15
+ 'code.obfuscation',
16
+ 'solana.unlimited_approval',
17
+ ]);
18
+
19
+ export function evaluatePolicy(findings: Finding[], policy: Policy | null, filesScanned: string[]): Violation[] {
20
+ const violations: Violation[] = [];
21
+ const capabilitiesSeen = new Set(findings.map((f) => f.capability));
22
+
23
+ // 1. Hard floor — applies even with no policy supplied at all.
24
+ for (const cap of capabilitiesSeen) {
25
+ if (ALWAYS_BLOCKED_CAPABILITIES.has(cap)) {
26
+ const f = findings.find((x) => x.capability === cap)!;
27
+ violations.push({
28
+ capability: cap,
29
+ severity: 'CRITICAL',
30
+ message: `${cap} is never permitted, regardless of policy (${f.description})`,
31
+ });
32
+ }
33
+ }
34
+
35
+ if (!policy) {
36
+ return violations;
37
+ }
38
+
39
+ // 2. Wallet signing must be explicitly granted.
40
+ if (capabilitiesSeen.has('wallet.signing') && !policy.permissions.wallet.signing) {
41
+ violations.push({
42
+ capability: 'wallet.signing',
43
+ severity: 'CRITICAL',
44
+ message: 'Tool signs transactions but policy.permissions.wallet.signing is false.',
45
+ });
46
+ }
47
+
48
+ // 3. Dynamic URLs can never be verified against an allowlist.
49
+ if (capabilitiesSeen.has('network.dynamic_url')) {
50
+ violations.push({
51
+ capability: 'network.dynamic_url',
52
+ severity: 'HIGH',
53
+ message: 'Tool builds request URLs dynamically — the target domain cannot be statically verified against the allowlist.',
54
+ });
55
+ }
56
+
57
+ // 4. Any literal domain contacted must be in the allowlist.
58
+ if (capabilitiesSeen.has('network.http')) {
59
+ const allowlist = new Set(policy.permissions.network.domains);
60
+ const seenDomains = new Set<string>();
61
+ for (const file of filesScanned) {
62
+ const content = fs.readFileSync(file, 'utf-8');
63
+ extractDomains(extractLiteralUrls(content)).forEach((d) => seenDomains.add(d));
64
+ }
65
+ for (const domain of seenDomains) {
66
+ if (!allowlist.has(domain)) {
67
+ violations.push({
68
+ capability: 'network.http',
69
+ severity: 'HIGH',
70
+ message: `Tool contacts domain "${domain}", which is not in the policy's network.domains allowlist.`,
71
+ });
72
+ }
73
+ }
74
+ }
75
+
76
+ // 5. Transaction manipulation requires at least one granted Solana program.
77
+ if (capabilitiesSeen.has('solana.tx_modification') && policy.permissions.solana.programs.length === 0) {
78
+ violations.push({
79
+ capability: 'solana.tx_modification',
80
+ severity: 'HIGH',
81
+ message: 'Tool modifies transaction instructions but the policy grants no Solana program permissions.',
82
+ });
83
+ }
84
+
85
+ return violations;
86
+ }
@@ -0,0 +1,31 @@
1
+ import * as fs from 'fs';
2
+ import * as yaml from 'js-yaml';
3
+ import { Policy, PolicySchema } from './schema';
4
+
5
+ export function loadPolicy(policyPath: string): Policy {
6
+ const raw = fs.readFileSync(policyPath, 'utf-8');
7
+ const parsed = yaml.load(raw);
8
+ const result = PolicySchema.safeParse(parsed);
9
+ if (!result.success) {
10
+ const issues = result.error.issues.map((i) => `${i.path.join('.')}: ${i.message}`).join('; ');
11
+ throw new Error(`Invalid policy manifest (${policyPath}): ${issues}`);
12
+ }
13
+ return result.data;
14
+ }
15
+
16
+ export const DEFAULT_POLICY_YAML = `name: default-deny
17
+ description: "Starter policy — deny everything until explicitly granted."
18
+
19
+ permissions:
20
+ solana:
21
+ programs: []
22
+ max_sol: 0
23
+ tokens: []
24
+
25
+ network:
26
+ domains: []
27
+
28
+ wallet:
29
+ signing: false
30
+ max_transactions_per_hour: 0
31
+ `;
@@ -0,0 +1,38 @@
1
+ import { z } from 'zod';
2
+
3
+ /**
4
+ * The Solana-specific capability manifest schema.
5
+ *
6
+ * This is intentionally narrow for the MVP: it describes the *ceiling* of
7
+ * what a tool is allowed to do. The static scanner's findings are checked
8
+ * against it in src/policy/enforcer.ts. Fields like max_sol and
9
+ * max_transactions_per_hour are captured here and are enforced at *runtime*
10
+ * by a wrapping guard (see README "Roadmap") — they cannot be verified by
11
+ * static analysis alone.
12
+ */
13
+ export const PolicySchema = z.object({
14
+ name: z.string(),
15
+ description: z.string().optional(),
16
+ permissions: z.object({
17
+ solana: z
18
+ .object({
19
+ programs: z.array(z.string()).default([]),
20
+ max_sol: z.number().default(0),
21
+ tokens: z.array(z.string()).default([]),
22
+ })
23
+ .default({ programs: [], max_sol: 0, tokens: [] }),
24
+ network: z
25
+ .object({
26
+ domains: z.array(z.string()).default([]),
27
+ })
28
+ .default({ domains: [] }),
29
+ wallet: z
30
+ .object({
31
+ signing: z.boolean().default(false),
32
+ max_transactions_per_hour: z.number().default(0),
33
+ })
34
+ .default({ signing: false, max_transactions_per_hour: 0 }),
35
+ }),
36
+ });
37
+
38
+ export type Policy = z.infer<typeof PolicySchema>;
@@ -0,0 +1,63 @@
1
+ import chalk from 'chalk';
2
+ import { ScanResult, Finding } from '../types';
3
+
4
+ const SEVERITY_COLOR: Record<string, (s: string) => string> = {
5
+ CRITICAL: chalk.bgRed.white.bold,
6
+ HIGH: chalk.red.bold,
7
+ MEDIUM: chalk.yellow,
8
+ LOW: chalk.gray,
9
+ };
10
+
11
+ function verdictBadge(verdict: string): string {
12
+ const label = ` ${verdict} `;
13
+ if (verdict === 'BLOCK') return chalk.bgRed.white.bold(label);
14
+ if (verdict === 'WARN') return chalk.bgYellow.black.bold(label);
15
+ return chalk.bgGreen.black.bold(label);
16
+ }
17
+
18
+ export function printReport(result: ScanResult): void {
19
+ console.log('');
20
+ console.log(chalk.bold('AGENTSKILLGUARD'));
21
+ console.log(chalk.dim(`target: ${result.target}`));
22
+ if (result.metadata?.name) {
23
+ console.log(chalk.dim(`tool: ${result.metadata.name}${result.metadata.version ? '@' + result.metadata.version : ''}`));
24
+ }
25
+ console.log(chalk.dim(`files scanned: ${result.filesScanned}`));
26
+ console.log('');
27
+
28
+ if (result.findings.length === 0) {
29
+ console.log(chalk.green('No suspicious capabilities detected.'));
30
+ } else {
31
+ console.log(chalk.bold('Capabilities detected:'));
32
+ const byCapability = new Map<string, Finding[]>();
33
+ for (const f of result.findings) {
34
+ if (!byCapability.has(f.capability)) byCapability.set(f.capability, []);
35
+ byCapability.get(f.capability)!.push(f);
36
+ }
37
+ for (const [capability, findings] of byCapability) {
38
+ const sev = findings[0].severity;
39
+ const color = SEVERITY_COLOR[sev] ?? ((s: string) => s);
40
+ console.log(` [x] ${color(sev.padEnd(8))} ${capability} — ${findings[0].description}`);
41
+ for (const f of findings.slice(0, 3)) {
42
+ console.log(chalk.dim(` ${f.file}:${f.line} ${f.snippet}`));
43
+ }
44
+ if (findings.length > 3) {
45
+ console.log(chalk.dim(` …and ${findings.length - 3} more occurrence(s)`));
46
+ }
47
+ }
48
+ }
49
+
50
+ console.log('');
51
+ if (result.violations.length > 0) {
52
+ console.log(chalk.bold('Policy violations:'));
53
+ for (const v of result.violations) {
54
+ const color = SEVERITY_COLOR[v.severity] ?? ((s: string) => s);
55
+ console.log(` [!] ${color(v.severity.padEnd(8))} ${v.message}`);
56
+ }
57
+ console.log('');
58
+ }
59
+
60
+ console.log(`Recommended action: ${verdictBadge(result.verdict)}`);
61
+ console.log(chalk.dim(result.verdictReason));
62
+ console.log('');
63
+ }
@@ -0,0 +1,35 @@
1
+ import { Violation } from '../types';
2
+
3
+ /**
4
+ * Verdict is driven entirely by *violations* (findings checked against the
5
+ * policy + hard floor), not raw finding counts. A tool can have plenty of
6
+ * informational MEDIUM findings (e.g. "reads process.env") and still ALLOW,
7
+ * as long as nothing crosses a permission boundary.
8
+ */
9
+ export function computeVerdict(violations: Violation[]): { verdict: 'ALLOW' | 'WARN' | 'BLOCK'; reason: string } {
10
+ const critical = violations.filter((v) => v.severity === 'CRITICAL');
11
+ const high = violations.filter((v) => v.severity === 'HIGH');
12
+
13
+ if (critical.length > 0) {
14
+ return {
15
+ verdict: 'BLOCK',
16
+ reason: `${critical.length} critical violation(s): ${critical.map((v) => v.capability).join(', ')}.`,
17
+ };
18
+ }
19
+
20
+ if (high.length > 0) {
21
+ return {
22
+ verdict: 'WARN',
23
+ reason: `${high.length} high-severity issue(s) require manual review: ${high.map((v) => v.capability).join(', ')}.`,
24
+ };
25
+ }
26
+
27
+ if (violations.length > 0) {
28
+ return {
29
+ verdict: 'WARN',
30
+ reason: `${violations.length} lower-severity policy issue(s) found. Review recommended before granting broader access.`,
31
+ };
32
+ }
33
+
34
+ return { verdict: 'ALLOW', reason: 'No policy violations detected. Review any informational findings below before deploying.' };
35
+ }
@@ -0,0 +1,110 @@
1
+ import { Severity } from '../types';
2
+
3
+ export interface Rule {
4
+ id: string;
5
+ capability: string;
6
+ severity: Severity;
7
+ description: string;
8
+ pattern: RegExp;
9
+ recommendation: string;
10
+ }
11
+
12
+ /**
13
+ * Static detection rules.
14
+ *
15
+ * Each rule is a single-line regex heuristic (MVP scope — see README for the
16
+ * tree-sitter/AST upgrade path). A match means "this line is CAPABLE of the
17
+ * behavior", not "this line is definitely malicious" — that judgment is made
18
+ * by the policy enforcer + verdict engine, which weighs findings against the
19
+ * capability manifest.
20
+ */
21
+ export const RULES: Rule[] = [
22
+ {
23
+ id: 'wallet-signing-access',
24
+ capability: 'wallet.signing',
25
+ severity: 'CRITICAL',
26
+ description: 'Tool can invoke wallet transaction/message signing.',
27
+ pattern: /\b(signTransaction|signAllTransactions|signMessage|Keypair\.fromSecretKey)\b/,
28
+ recommendation: 'Only allow if the policy explicitly grants wallet.signing and the code path is audited.',
29
+ },
30
+ {
31
+ id: 'private-key-access',
32
+ capability: 'wallet.private_key_access',
33
+ severity: 'CRITICAL',
34
+ description: 'Tool references private key / secret / mnemonic material directly.',
35
+ pattern: /(PRIVATE_KEY|SECRET_KEY|secretKey\s*[:=]|mnemonic|seedPhrase|seed_phrase)/i,
36
+ recommendation: 'Tools should never need raw key material. Treat as an automatic block.',
37
+ },
38
+ {
39
+ id: 'network-call',
40
+ capability: 'network.http',
41
+ severity: 'MEDIUM',
42
+ description: 'Tool performs outbound HTTP(S) requests.',
43
+ pattern: /\b(fetch|axios\.(get|post|put|delete|patch|request)|http\.request|https\.request|XMLHttpRequest)\s*\(/,
44
+ recommendation: 'Cross-check target domains against the network allowlist in the policy manifest.',
45
+ },
46
+ {
47
+ id: 'dynamic-url-construction',
48
+ capability: 'network.dynamic_url',
49
+ severity: 'HIGH',
50
+ description: 'Request URL is built from a variable/expression rather than a static string literal.',
51
+ pattern: /\b(fetch|axios\.\w+)\s*\(\s*[^'"`\s)][^)]*\)/,
52
+ recommendation: 'Dynamic URLs cannot be statically allowlisted — treat as arbitrary network access.',
53
+ },
54
+ {
55
+ id: 'tx-destination-modification',
56
+ capability: 'solana.tx_modification',
57
+ severity: 'HIGH',
58
+ description: 'Tool manipulates transaction instructions or recipient/destination fields.',
59
+ pattern: /\b(transaction|tx)\.(instructions|add)\s*\(|\b(recipient|destination|toPubkey)\s*=/,
60
+ recommendation: 'Manually review: confirm destination addresses are user-supplied, not tool-controlled.',
61
+ },
62
+ {
63
+ id: 'shell-exec',
64
+ capability: 'system.shell_exec',
65
+ severity: 'CRITICAL',
66
+ description: 'Tool can execute OS shell commands via child_process.',
67
+ pattern: /require\(\s*['"]child_process['"]\s*\)|\b(execSync|spawnSync|exec|spawn|fork)\s*\(/,
68
+ recommendation: 'No legitimate Solana/agent tool needs shell access. Treat as an automatic block.',
69
+ },
70
+ {
71
+ id: 'dynamic-code-load',
72
+ capability: 'code.dynamic_load',
73
+ severity: 'CRITICAL',
74
+ description: 'Tool loads or executes code dynamically at runtime (eval, new Function, dynamic require, vm module).',
75
+ pattern: /\beval\s*\(|new\s+Function\s*\(|vm\.runInNewContext|vm\.runInThisContext|require\(\s*[a-zA-Z_$][\w$]*\s*\)/,
76
+ recommendation: 'Dynamic code loading defeats static review entirely. Treat as an automatic block.',
77
+ },
78
+ {
79
+ id: 'unlimited-token-approval',
80
+ capability: 'solana.unlimited_approval',
81
+ severity: 'HIGH',
82
+ description: 'Tool requests unlimited or maximum token approvals/authority delegation.',
83
+ pattern: /approve\([^)]*(MAX_UINT256|0xffffffff|Infinity)|setAuthority\([^)]*null/i,
84
+ recommendation: 'Unlimited approvals should never be requested by an automated agent tool.',
85
+ },
86
+ {
87
+ id: 'env-read',
88
+ capability: 'system.env_read',
89
+ severity: 'MEDIUM',
90
+ description: 'Tool reads process environment variables.',
91
+ pattern: /\bprocess\.env\b/,
92
+ recommendation: 'Confirm env vars read are not secrets being staged for exfiltration over a network call.',
93
+ },
94
+ {
95
+ id: 'obfuscated-execution',
96
+ capability: 'code.obfuscation',
97
+ severity: 'CRITICAL',
98
+ description: 'Tool decodes an obfuscated (base64/hex) payload and executes it.',
99
+ pattern: /(atob\(|Buffer\.from\([^)]*base64[^)]*\))[^;]*\b(eval|Function)\b/i,
100
+ recommendation: 'Obfuscated execution is a strong malware indicator. Treat as an automatic block.',
101
+ },
102
+ {
103
+ id: 'filesystem-write',
104
+ capability: 'system.filesystem_write',
105
+ severity: 'MEDIUM',
106
+ description: 'Tool writes to or deletes files on disk.',
107
+ pattern: /\bfs\.(writeFile(Sync)?|appendFile(Sync)?|unlink(Sync)?|rm(Sync)?|rmdir(Sync)?)\s*\(/,
108
+ recommendation: 'Confirm writes are scoped to an expected working directory, not arbitrary paths.',
109
+ },
110
+ ];
@@ -0,0 +1,33 @@
1
+ import { walk } from '../utils/fileWalker';
2
+ import { analyzeFile } from './staticAnalyzer';
3
+ import { readMetadata } from './mcpMetadata';
4
+ import { evaluatePolicy } from '../policy/enforcer';
5
+ import { computeVerdict } from '../report/verdict';
6
+ import { Policy } from '../policy/schema';
7
+ import { Finding, ScanResult } from '../types';
8
+
9
+ export function scanTarget(target: string, policy: Policy | null): ScanResult {
10
+ const files = walk(target);
11
+ const metadata = readMetadata(target);
12
+
13
+ const findings: Finding[] = [];
14
+ for (const file of files) {
15
+ findings.push(...analyzeFile(file));
16
+ }
17
+
18
+ const violations = evaluatePolicy(findings, policy, files);
19
+ const { verdict, reason } = computeVerdict(violations);
20
+ const capabilities = Array.from(new Set(findings.map((f) => f.capability))).sort();
21
+
22
+ return {
23
+ target,
24
+ scannedAt: new Date().toISOString(),
25
+ filesScanned: files.length,
26
+ metadata,
27
+ findings,
28
+ capabilities,
29
+ violations,
30
+ verdict,
31
+ verdictReason: reason,
32
+ };
33
+ }
@@ -0,0 +1,56 @@
1
+ import * as fs from 'fs';
2
+ import * as path from 'path';
3
+ import { ToolMetadata } from '../types';
4
+
5
+ /**
6
+ * Best-effort read of a tool's declared identity: package.json plus an
7
+ * optional MCP manifest (mcp.json / mcp.manifest.json) if present. This is
8
+ * informational only — it is never trusted as a source of truth for
9
+ * capabilities, since a manifest can claim anything. Capabilities always
10
+ * come from the static analyzer, not from what the tool says about itself.
11
+ */
12
+ export function readMetadata(root: string): ToolMetadata | null {
13
+ const stat = fs.existsSync(root) ? fs.statSync(root) : null;
14
+ const dir = stat && stat.isDirectory() ? root : path.dirname(root);
15
+
16
+ const pkgPath = path.join(dir, 'package.json');
17
+ const mcpPath1 = path.join(dir, 'mcp.json');
18
+ const mcpPath2 = path.join(dir, 'mcp.manifest.json');
19
+
20
+ let name: string | undefined;
21
+ let version: string | undefined;
22
+ let description: string | undefined;
23
+ let declaredTools: string[] | undefined;
24
+ let found = false;
25
+
26
+ if (fs.existsSync(pkgPath)) {
27
+ try {
28
+ const pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf-8'));
29
+ name = pkg.name;
30
+ version = pkg.version;
31
+ description = pkg.description;
32
+ found = true;
33
+ } catch {
34
+ // malformed package.json — ignore, not fatal to the scan
35
+ }
36
+ }
37
+
38
+ const mcpFile = fs.existsSync(mcpPath1) ? mcpPath1 : fs.existsSync(mcpPath2) ? mcpPath2 : null;
39
+ if (mcpFile) {
40
+ try {
41
+ const mcp = JSON.parse(fs.readFileSync(mcpFile, 'utf-8'));
42
+ if (Array.isArray(mcp.tools)) {
43
+ declaredTools = mcp.tools.map((t: any) => (t && t.name ? String(t.name) : String(t)));
44
+ }
45
+ name = name ?? mcp.name;
46
+ description = description ?? mcp.description;
47
+ found = true;
48
+ } catch {
49
+ // malformed MCP manifest — ignore, not fatal to the scan
50
+ }
51
+ }
52
+
53
+ if (!found) return null;
54
+
55
+ return { name, version, description, declaredTools, source: dir };
56
+ }
@@ -0,0 +1,48 @@
1
+ import * as fs from 'fs';
2
+ import { RULES } from '../rules/rules';
3
+ import { Finding } from '../types';
4
+
5
+ /** Runs every rule against every line of a single file. */
6
+ export function analyzeFile(filePath: string): Finding[] {
7
+ const content = fs.readFileSync(filePath, 'utf-8');
8
+ const lines = content.split(/\r?\n/);
9
+ const findings: Finding[] = [];
10
+
11
+ lines.forEach((line, idx) => {
12
+ for (const rule of RULES) {
13
+ if (rule.pattern.test(line)) {
14
+ findings.push({
15
+ ruleId: rule.id,
16
+ capability: rule.capability,
17
+ severity: rule.severity,
18
+ description: rule.description,
19
+ file: filePath,
20
+ line: idx + 1,
21
+ snippet: line.trim().slice(0, 160),
22
+ });
23
+ }
24
+ }
25
+ });
26
+
27
+ return findings;
28
+ }
29
+
30
+ /** Pulls literal http(s) URLs out of a file's raw text. */
31
+ export function extractLiteralUrls(content: string): string[] {
32
+ const urlRegex = /https?:\/\/[^\s'"`)]+/g;
33
+ return Array.from(content.matchAll(urlRegex)).map((m) => m[0]);
34
+ }
35
+
36
+ /** Converts literal URLs into bare hostnames for allowlist comparison. */
37
+ export function extractDomains(urls: string[]): string[] {
38
+ const domains = new Set<string>();
39
+ for (const url of urls) {
40
+ try {
41
+ domains.add(new URL(url).hostname);
42
+ } catch {
43
+ // malformed / template-interpolated URL — ignore, it will already
44
+ // have been flagged as a dynamic-url-construction finding instead.
45
+ }
46
+ }
47
+ return Array.from(domains);
48
+ }