toolmoat 0.0.0-stage → 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.
Files changed (97) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +302 -2
  3. package/dist/cli.d.ts +2 -0
  4. package/dist/cli.js +80 -0
  5. package/dist/clients/configs.d.ts +124 -0
  6. package/dist/clients/configs.js +403 -0
  7. package/dist/commands/approve.d.ts +15 -0
  8. package/dist/commands/approve.js +128 -0
  9. package/dist/commands/diff.d.ts +6 -0
  10. package/dist/commands/diff.js +114 -0
  11. package/dist/commands/pins.d.ts +18 -0
  12. package/dist/commands/pins.js +88 -0
  13. package/dist/commands/proxy.d.ts +24 -0
  14. package/dist/commands/proxy.js +144 -0
  15. package/dist/commands/rules.d.ts +15 -0
  16. package/dist/commands/rules.js +173 -0
  17. package/dist/commands/scan.d.ts +57 -0
  18. package/dist/commands/scan.js +323 -0
  19. package/dist/commands/unwrap.d.ts +5 -0
  20. package/dist/commands/unwrap.js +69 -0
  21. package/dist/commands/wrap.d.ts +131 -0
  22. package/dist/commands/wrap.js +518 -0
  23. package/dist/index.d.ts +20 -0
  24. package/dist/index.js +13 -0
  25. package/dist/patterns/indicators.d.ts +41 -0
  26. package/dist/patterns/indicators.js +85 -0
  27. package/dist/patterns/secrets.d.ts +20 -0
  28. package/dist/patterns/secrets.js +70 -0
  29. package/dist/proxy/audit.d.ts +53 -0
  30. package/dist/proxy/audit.js +79 -0
  31. package/dist/proxy/canonical.d.ts +13 -0
  32. package/dist/proxy/canonical.js +34 -0
  33. package/dist/proxy/diff.d.ts +22 -0
  34. package/dist/proxy/diff.js +124 -0
  35. package/dist/proxy/fingerprint.d.ts +34 -0
  36. package/dist/proxy/fingerprint.js +140 -0
  37. package/dist/proxy/firewall.d.ts +132 -0
  38. package/dist/proxy/firewall.js +665 -0
  39. package/dist/proxy/framing.d.ts +45 -0
  40. package/dist/proxy/framing.js +86 -0
  41. package/dist/proxy/lockfile.d.ts +58 -0
  42. package/dist/proxy/lockfile.js +258 -0
  43. package/dist/proxy/names.d.ts +27 -0
  44. package/dist/proxy/names.js +175 -0
  45. package/dist/proxy/policy.d.ts +72 -0
  46. package/dist/proxy/policy.js +86 -0
  47. package/dist/proxy/proxy.d.ts +18 -0
  48. package/dist/proxy/proxy.js +237 -0
  49. package/dist/proxy/spawn.d.ts +51 -0
  50. package/dist/proxy/spawn.js +110 -0
  51. package/dist/report/index.d.ts +35 -0
  52. package/dist/report/index.js +25 -0
  53. package/dist/report/json.d.ts +22 -0
  54. package/dist/report/json.js +40 -0
  55. package/dist/report/markdown.d.ts +12 -0
  56. package/dist/report/markdown.js +87 -0
  57. package/dist/report/sarif.d.ts +89 -0
  58. package/dist/report/sarif.js +142 -0
  59. package/dist/report/text.d.ts +16 -0
  60. package/dist/report/text.js +206 -0
  61. package/dist/rules/code.d.ts +15 -0
  62. package/dist/rules/code.js +729 -0
  63. package/dist/rules/config.d.ts +3 -0
  64. package/dist/rules/config.js +2164 -0
  65. package/dist/rules/index.d.ts +3 -0
  66. package/dist/rules/index.js +8 -0
  67. package/dist/rules/secrets.d.ts +2 -0
  68. package/dist/rules/secrets.js +108 -0
  69. package/dist/rules/text.d.ts +2 -0
  70. package/dist/rules/text.js +1698 -0
  71. package/dist/scan/discover.d.ts +37 -0
  72. package/dist/scan/discover.js +525 -0
  73. package/dist/scan/engine.d.ts +24 -0
  74. package/dist/scan/engine.js +111 -0
  75. package/dist/scan/ignore.d.ts +28 -0
  76. package/dist/scan/ignore.js +46 -0
  77. package/dist/types.d.ts +98 -0
  78. package/dist/types.js +25 -0
  79. package/dist/util/colors.d.ts +12 -0
  80. package/dist/util/colors.js +24 -0
  81. package/dist/util/frontmatter.d.ts +20 -0
  82. package/dist/util/frontmatter.js +136 -0
  83. package/dist/util/glob.d.ts +7 -0
  84. package/dist/util/glob.js +44 -0
  85. package/dist/util/hash.d.ts +3 -0
  86. package/dist/util/hash.js +24 -0
  87. package/dist/util/jsonc.d.ts +14 -0
  88. package/dist/util/jsonc.js +113 -0
  89. package/dist/util/paths.d.ts +9 -0
  90. package/dist/util/paths.js +37 -0
  91. package/dist/util/text.d.ts +29 -0
  92. package/dist/util/text.js +96 -0
  93. package/dist/util/toml-lite.d.ts +40 -0
  94. package/dist/util/toml-lite.js +507 -0
  95. package/dist/version.d.ts +1 -0
  96. package/dist/version.js +1 -0
  97. package/package.json +57 -4
@@ -0,0 +1,40 @@
1
+ import { countBySeverity } from '../scan/engine.js';
2
+ import { VERSION } from '../version.js';
3
+ /** Machine-readable report with a stable key order and 2-space indentation. */
4
+ export function renderJson(result, options = {}) {
5
+ const report = {
6
+ tool: 'toolmoat',
7
+ version: options.version ?? VERSION,
8
+ summary: {
9
+ filesScanned: result.filesScanned,
10
+ targets: [...result.targets],
11
+ durationMs: Math.round(result.durationMs),
12
+ counts: countBySeverity(result.findings),
13
+ },
14
+ findings: result.findings.map(toJsonFinding),
15
+ skipped: result.skipped.map((skip) => ({ path: skip.path, reason: skip.reason })),
16
+ };
17
+ return `${JSON.stringify(report, null, 2)}\n`;
18
+ }
19
+ /** Copies a finding with its keys in a fixed order, leaving out absent optional fields. */
20
+ export function toJsonFinding(finding) {
21
+ const out = {
22
+ ruleId: finding.ruleId,
23
+ severity: finding.severity,
24
+ category: finding.category,
25
+ title: finding.title,
26
+ message: finding.message,
27
+ file: finding.file,
28
+ };
29
+ if (finding.line !== undefined)
30
+ out.line = finding.line;
31
+ if (finding.column !== undefined)
32
+ out.column = finding.column;
33
+ if (finding.snippet !== undefined)
34
+ out.snippet = finding.snippet;
35
+ if (finding.target !== undefined)
36
+ out.target = finding.target;
37
+ if (finding.remediation !== undefined)
38
+ out.remediation = finding.remediation;
39
+ return out;
40
+ }
@@ -0,0 +1,12 @@
1
+ import type { ScanResult } from '../types.js';
2
+ import type { ReportOptions } from './index.js';
3
+ /** GitHub caps step summaries at 1 MiB and PR comments at 64 KiB, so long reports are cut. */
4
+ export declare const MAX_MARKDOWN_ROWS = 200;
5
+ /** Report for GitHub step summaries and PR comments: severity counts, then one table row per finding. */
6
+ export declare function renderMarkdown(result: ScanResult, options?: ReportOptions): string;
7
+ /**
8
+ * Makes untrusted text safe inside a GitHub table cell: one line, no
9
+ * invisible characters, no raw HTML, and markdown punctuation (including
10
+ * pipes and backticks) escaped so it renders literally.
11
+ */
12
+ export declare function escapeCell(text: string, max?: number): string;
@@ -0,0 +1,87 @@
1
+ import { countBySeverity } from '../scan/engine.js';
2
+ import { SEVERITIES } from '../types.js';
3
+ import { printable, truncate } from '../util/text.js';
4
+ import { VERSION } from '../version.js';
5
+ /** GitHub caps step summaries at 1 MiB and PR comments at 64 KiB, so long reports are cut. */
6
+ export const MAX_MARKDOWN_ROWS = 200;
7
+ const MAX_CELL = 300;
8
+ const MAX_REMEDIATIONS = 50;
9
+ /** Report for GitHub step summaries and PR comments: severity counts, then one table row per finding. */
10
+ export function renderMarkdown(result, options = {}) {
11
+ const version = options.version ?? VERSION;
12
+ const findings = result.findings;
13
+ const out = ['## toolmoat scan results', ''];
14
+ out.push(`Scanned ${plural(result.filesScanned, 'file')} in ${plural(result.targets.length, 'target')} with toolmoat ${escapeCell(version)}.`, '');
15
+ if (findings.length === 0) {
16
+ out.push('**No issues found.**', '');
17
+ }
18
+ else {
19
+ const counts = countBySeverity(findings);
20
+ out.push('| Severity | Findings |', '| --- | ---: |');
21
+ for (const severity of SEVERITIES)
22
+ out.push(`| ${label(severity)} | ${counts[severity]} |`);
23
+ out.push('', '### Findings', '', '| Severity | Rule | Location | Message |', '| --- | --- | --- | --- |');
24
+ for (const finding of findings.slice(0, MAX_MARKDOWN_ROWS))
25
+ out.push(row(finding));
26
+ out.push('');
27
+ if (findings.length > MAX_MARKDOWN_ROWS) {
28
+ out.push(`Showing the first ${MAX_MARKDOWN_ROWS} of ${findings.length} findings. Run \`toolmoat scan\` locally or use \`--format sarif\` for the full list.`, '');
29
+ }
30
+ out.push(...remediations(findings));
31
+ }
32
+ const notes = footnotes(result, options);
33
+ if (notes.length > 0)
34
+ out.push(...notes, '');
35
+ return `${out.join('\n').trimEnd()}\n`;
36
+ }
37
+ function row(finding) {
38
+ const severity = finding.severity === 'critical' || finding.severity === 'high' ? `**${label(finding.severity)}**` : label(finding.severity);
39
+ const rule = `\`${finding.ruleId}\` ${escapeCell(finding.title)}`;
40
+ return `| ${severity} | ${rule} | ${escapeCell(locationOf(finding))} | ${escapeCell(finding.message)} |`;
41
+ }
42
+ function remediations(findings) {
43
+ const seen = new Set();
44
+ const items = [];
45
+ for (const finding of findings) {
46
+ if (!finding.remediation || seen.has(finding.ruleId))
47
+ continue;
48
+ seen.add(finding.ruleId);
49
+ if (items.length < MAX_REMEDIATIONS)
50
+ items.push(`- \`${finding.ruleId}\` ${escapeCell(finding.title)}: ${escapeCell(finding.remediation, 600)}`);
51
+ }
52
+ return items.length === 0 ? [] : ['### How to fix', '', ...items, ''];
53
+ }
54
+ function footnotes(result, options) {
55
+ const notes = [];
56
+ if (options.hidden)
57
+ notes.push(`${plural(options.hidden, 'finding')} below ${options.minSeverity ?? 'low'} not shown.`);
58
+ if (options.suppressed)
59
+ notes.push(`${plural(options.suppressed, 'finding')} suppressed by ${escapeCell(options.ignoreFile ?? 'the ignore file')}.`);
60
+ if (result.skipped.length > 0)
61
+ notes.push(`${plural(result.skipped.length, 'file')} skipped.`);
62
+ return notes.length === 0 ? [] : [notes.join(' ')];
63
+ }
64
+ function locationOf(finding) {
65
+ if (finding.line === undefined)
66
+ return finding.file;
67
+ return finding.column === undefined ? `${finding.file}:${finding.line}` : `${finding.file}:${finding.line}:${finding.column}`;
68
+ }
69
+ /**
70
+ * Makes untrusted text safe inside a GitHub table cell: one line, no
71
+ * invisible characters, no raw HTML, and markdown punctuation (including
72
+ * pipes and backticks) escaped so it renders literally.
73
+ */
74
+ export function escapeCell(text, max = MAX_CELL) {
75
+ const flat = truncate(printable(text.replace(/\r?\n/g, ' ')).trim(), max);
76
+ return flat
77
+ .replace(/&/g, '&amp;')
78
+ .replace(/</g, '&lt;')
79
+ .replace(/>/g, '&gt;')
80
+ .replace(/[\\`*_[\]|~]/g, (ch) => `\\${ch}`);
81
+ }
82
+ function label(severity) {
83
+ return severity.charAt(0).toUpperCase() + severity.slice(1);
84
+ }
85
+ function plural(n, noun) {
86
+ return `${n} ${noun}${n === 1 ? '' : 's'}`;
87
+ }
@@ -0,0 +1,89 @@
1
+ import type { Category, ScanResult, Severity } from '../types.js';
2
+ import type { ReportOptions } from './index.js';
3
+ export declare const SARIF_SCHEMA = "https://json.schemastore.org/sarif-2.1.0.json";
4
+ export declare const INFORMATION_URI = "https://github.com/Whitul/toolmoat";
5
+ type SarifLevel = 'error' | 'warning' | 'note';
6
+ export interface SarifArtifactLocation {
7
+ uri: string;
8
+ uriBaseId?: string;
9
+ }
10
+ export interface SarifReportingDescriptor {
11
+ id: string;
12
+ name: string;
13
+ shortDescription: {
14
+ text: string;
15
+ };
16
+ fullDescription: {
17
+ text: string;
18
+ };
19
+ help: {
20
+ text: string;
21
+ markdown: string;
22
+ };
23
+ defaultConfiguration: {
24
+ level: SarifLevel;
25
+ };
26
+ properties: {
27
+ tags: string[];
28
+ precision: 'high' | 'medium';
29
+ 'security-severity': string;
30
+ };
31
+ }
32
+ export interface SarifResult {
33
+ ruleId: string;
34
+ ruleIndex: number;
35
+ level: SarifLevel;
36
+ message: {
37
+ text: string;
38
+ };
39
+ locations: {
40
+ physicalLocation: {
41
+ artifactLocation: SarifArtifactLocation;
42
+ region: {
43
+ startLine: number;
44
+ startColumn?: number;
45
+ snippet?: {
46
+ text: string;
47
+ };
48
+ };
49
+ };
50
+ }[];
51
+ partialFingerprints: {
52
+ primaryLocationLineHash: string;
53
+ };
54
+ properties: {
55
+ severity: Severity;
56
+ category: Category;
57
+ target?: string;
58
+ };
59
+ }
60
+ export interface SarifLog {
61
+ $schema: string;
62
+ version: '2.1.0';
63
+ runs: {
64
+ tool: {
65
+ driver: {
66
+ name: string;
67
+ version: string;
68
+ semanticVersion: string;
69
+ informationUri: string;
70
+ rules: SarifReportingDescriptor[];
71
+ };
72
+ };
73
+ originalUriBaseIds: Record<string, {
74
+ uri: string;
75
+ }>;
76
+ columnKind: 'utf16CodeUnits';
77
+ results: SarifResult[];
78
+ }[];
79
+ }
80
+ /** SARIF 2.1.0 for GitHub code scanning and other SARIF viewers. */
81
+ export declare function renderSarif(result: ScanResult, options?: ReportOptions): string;
82
+ export declare function buildSarif(result: ScanResult, options?: ReportOptions): SarifLog;
83
+ /**
84
+ * Where a finding lives, as SARIF wants it: a forward-slash path relative to
85
+ * %SRCROOT% (the cwd) when the file is inside it, a file:/// URI for files
86
+ * elsewhere such as ~/.claude, and synthetic ids like mcp://server/tool as is.
87
+ */
88
+ export declare function sarifArtifactLocation(file: string, cwd: string, filePaths?: ReadonlyMap<string, string>): SarifArtifactLocation;
89
+ export {};
@@ -0,0 +1,142 @@
1
+ import path from 'node:path';
2
+ import { pathToFileURL } from 'node:url';
3
+ import { sha256 } from '../util/hash.js';
4
+ import { expandHome } from '../util/paths.js';
5
+ import { printable } from '../util/text.js';
6
+ import { VERSION } from '../version.js';
7
+ export const SARIF_SCHEMA = 'https://json.schemastore.org/sarif-2.1.0.json';
8
+ export const INFORMATION_URI = 'https://github.com/Whitul/toolmoat';
9
+ const SRCROOT = '%SRCROOT%';
10
+ const LEVEL = {
11
+ critical: 'error',
12
+ high: 'error',
13
+ medium: 'warning',
14
+ low: 'note',
15
+ info: 'note',
16
+ };
17
+ /** GitHub code scanning maps these to its critical/high/medium/low buckets. */
18
+ const SECURITY_SEVERITY = {
19
+ critical: '9.5',
20
+ high: '8.0',
21
+ medium: '5.5',
22
+ low: '3.0',
23
+ info: '0.0',
24
+ };
25
+ /** SARIF 2.1.0 for GitHub code scanning and other SARIF viewers. */
26
+ export function renderSarif(result, options = {}) {
27
+ return `${JSON.stringify(buildSarif(result, options), null, 2)}\n`;
28
+ }
29
+ export function buildSarif(result, options = {}) {
30
+ const version = options.version ?? VERSION;
31
+ const cwd = path.resolve(options.cwd ?? process.cwd());
32
+ const descriptors = [];
33
+ const indexById = new Map();
34
+ const addDescriptor = (descriptor) => {
35
+ const existing = indexById.get(descriptor.id);
36
+ if (existing !== undefined)
37
+ return existing;
38
+ indexById.set(descriptor.id, descriptors.length);
39
+ descriptors.push(descriptor);
40
+ return descriptors.length - 1;
41
+ };
42
+ for (const rule of result.rules)
43
+ addDescriptor(ruleDescriptor(rule));
44
+ const occurrences = new Map();
45
+ const results = result.findings.map((finding) => {
46
+ const ruleIndex = indexById.get(finding.ruleId) ?? addDescriptor(findingDescriptor(finding));
47
+ const hash = sha256(`${finding.ruleId}\n${finding.file}\n${finding.snippet ?? ''}`).slice('sha256:'.length);
48
+ const occurrence = (occurrences.get(hash) ?? 0) + 1;
49
+ occurrences.set(hash, occurrence);
50
+ const region = { startLine: Math.max(1, finding.line ?? 1) };
51
+ if (finding.line !== undefined && finding.column !== undefined)
52
+ region.startColumn = Math.max(1, finding.column);
53
+ if (finding.snippet)
54
+ region.snippet = { text: finding.snippet };
55
+ const properties = { severity: finding.severity, category: finding.category };
56
+ if (finding.target !== undefined)
57
+ properties.target = finding.target;
58
+ return {
59
+ ruleId: finding.ruleId,
60
+ ruleIndex,
61
+ level: LEVEL[finding.severity],
62
+ message: { text: printable(finding.message) },
63
+ locations: [{ physicalLocation: { artifactLocation: sarifArtifactLocation(finding.file, cwd, options.filePaths), region } }],
64
+ partialFingerprints: { primaryLocationLineHash: `${hash.slice(0, 32)}:${occurrence}` },
65
+ properties,
66
+ };
67
+ });
68
+ return {
69
+ $schema: SARIF_SCHEMA,
70
+ version: '2.1.0',
71
+ runs: [
72
+ {
73
+ tool: { driver: { name: 'toolmoat', version, semanticVersion: version, informationUri: INFORMATION_URI, rules: descriptors } },
74
+ originalUriBaseIds: { [SRCROOT]: { uri: directoryUri(cwd) } },
75
+ columnKind: 'utf16CodeUnits',
76
+ results,
77
+ },
78
+ ],
79
+ };
80
+ }
81
+ /**
82
+ * Where a finding lives, as SARIF wants it: a forward-slash path relative to
83
+ * %SRCROOT% (the cwd) when the file is inside it, a file:/// URI for files
84
+ * elsewhere such as ~/.claude, and synthetic ids like mcp://server/tool as is.
85
+ */
86
+ export function sarifArtifactLocation(file, cwd, filePaths) {
87
+ if (file.includes('://'))
88
+ return { uri: file };
89
+ const absolute = filePaths?.get(file) ?? absoluteFromDisplay(file);
90
+ if (absolute === undefined)
91
+ return { uri: encodePath(file), uriBaseId: SRCROOT };
92
+ const relative = path.relative(cwd, absolute);
93
+ if (relative && relative !== '..' && !relative.startsWith(`..${path.sep}`) && !path.isAbsolute(relative)) {
94
+ return { uri: encodePath(relative.split(path.sep).join('/')), uriBaseId: SRCROOT };
95
+ }
96
+ return { uri: pathToFileURL(absolute).href };
97
+ }
98
+ function absoluteFromDisplay(file) {
99
+ if (file === '~' || file.startsWith('~/') || file.startsWith('~\\'))
100
+ return expandHome(file);
101
+ return path.isAbsolute(file) ? file : undefined;
102
+ }
103
+ function encodePath(relative) {
104
+ return relative.replace(/\\/g, '/').split('/').map(encodeURIComponent).join('/');
105
+ }
106
+ function directoryUri(dir) {
107
+ const href = pathToFileURL(dir).href;
108
+ return href.endsWith('/') ? href : `${href}/`;
109
+ }
110
+ function ruleDescriptor(rule) {
111
+ return descriptor(rule.id, rule.title, rule.description, rule.severity, rule.category, rule.remediation, rule.kinds);
112
+ }
113
+ /** Fallback for a finding whose rule is not in the active rule list, so ruleIndex always resolves. */
114
+ function findingDescriptor(finding) {
115
+ return descriptor(finding.ruleId, finding.title, finding.title, finding.severity, finding.category, finding.remediation, []);
116
+ }
117
+ function descriptor(id, title, description, severity, category, remediation, kinds) {
118
+ const facts = `Severity: ${severity}. Category: ${category}.${kinds.length > 0 ? ` Applies to: ${kinds.join(', ')}.` : ''}`;
119
+ const text = [description, remediation ? `How to fix: ${remediation}` : '', facts].filter(Boolean).join('\n\n');
120
+ const markdown = [`**${title}**`, description, remediation ? `**How to fix:** ${remediation}` : '', facts].filter(Boolean).join('\n\n');
121
+ return {
122
+ id,
123
+ name: pascalCase(title) || id.replace(/-/g, ''),
124
+ shortDescription: { text: title },
125
+ fullDescription: { text: description },
126
+ help: { text, markdown },
127
+ defaultConfiguration: { level: LEVEL[severity] },
128
+ properties: {
129
+ tags: ['security', category],
130
+ precision: severity === 'critical' || severity === 'high' ? 'high' : 'medium',
131
+ 'security-severity': SECURITY_SEVERITY[severity],
132
+ },
133
+ };
134
+ }
135
+ /** "Download piped into a shell" -> "DownloadPipedIntoAShell". */
136
+ function pascalCase(title) {
137
+ return title
138
+ .split(/[^A-Za-z0-9]+/)
139
+ .filter(Boolean)
140
+ .map((word) => word.charAt(0).toUpperCase() + word.slice(1))
141
+ .join('');
142
+ }
@@ -0,0 +1,16 @@
1
+ import type { ScanResult } from '../types.js';
2
+ import type { ReportOptions } from './index.js';
3
+ /**
4
+ * Renders findings for a terminal or a CI log: grouped by target, then file,
5
+ * with remediation once per rule at the end. Output is ASCII apart from the
6
+ * ellipsis in truncated snippets, and every attacker-influenced string goes
7
+ * through printable() so a scanned file cannot inject terminal escapes.
8
+ */
9
+ export declare function renderText(result: ScanResult, options?: ReportOptions): string;
10
+ /**
11
+ * Short form of a path for headers. Display paths (relative, "~/..." or
12
+ * synthetic ids) pass through; absolute paths become "." or a path relative
13
+ * to cwd when inside it, "~/..." inside the home directory, otherwise stay
14
+ * absolute. Always with forward slashes.
15
+ */
16
+ export declare function shortPath(p: string, cwd?: string): string;
@@ -0,0 +1,206 @@
1
+ import path from 'node:path';
2
+ import { countBySeverity } from '../scan/engine.js';
3
+ import { SEVERITIES } from '../types.js';
4
+ import { palette } from '../util/colors.js';
5
+ import { homeDir } from '../util/paths.js';
6
+ import { printable } from '../util/text.js';
7
+ import { VERSION } from '../version.js';
8
+ const BADGE_WIDTH = 'CRITICAL'.length;
9
+ const WRAP_WIDTH = 100;
10
+ const MAX_HEADER_ROOTS = 4;
11
+ /**
12
+ * Renders findings for a terminal or a CI log: grouped by target, then file,
13
+ * with remediation once per rule at the end. Output is ASCII apart from the
14
+ * ellipsis in truncated snippets, and every attacker-influenced string goes
15
+ * through printable() so a scanned file cannot inject terminal escapes.
16
+ */
17
+ export function renderText(result, options = {}) {
18
+ const c = palette(process.stdout, options.color ?? false);
19
+ const cwd = options.cwd ?? process.cwd();
20
+ const lines = [];
21
+ if (!options.quiet) {
22
+ lines.push(header(result, options, cwd));
23
+ if (result.filesScanned === 0)
24
+ lines.push('No agent skills, plugins, hooks or MCP configs were found.');
25
+ if (options.ignoreFile)
26
+ lines.push(c.gray(`Ignore file: ${printable(options.ignoreFile)}`));
27
+ lines.push('');
28
+ }
29
+ for (const group of groupByTarget(result.findings)) {
30
+ lines.push(`${c.bold(group.label)} ${c.gray(`(${plural(group.findings.length, 'finding')})`)}`);
31
+ for (const finding of group.findings)
32
+ lines.push(...findingBlock(finding, c), '');
33
+ }
34
+ if (!options.quiet) {
35
+ lines.push(...remediationSection(result.findings, c));
36
+ lines.push(...footer(result, options, c));
37
+ }
38
+ while (lines.length > 0 && lines[lines.length - 1] === '')
39
+ lines.pop();
40
+ return lines.length === 0 ? '' : `${lines.join('\n')}\n`;
41
+ }
42
+ function header(result, options, cwd) {
43
+ const version = options.version ?? VERSION;
44
+ let text = `toolmoat ${version} scanned ${plural(result.filesScanned, 'file')} in ${plural(result.targets.length, 'target')}`;
45
+ const roots = (options.roots ?? []).map((root) => printable(shortPath(root, cwd)));
46
+ if (roots.length > 0) {
47
+ const shown = options.verbose || roots.length <= MAX_HEADER_ROOTS ? roots : [...roots.slice(0, MAX_HEADER_ROOTS - 1), `+${roots.length - MAX_HEADER_ROOTS + 1} more`];
48
+ text += ` (${shown.join(', ')})`;
49
+ }
50
+ return `${text} in ${formatDuration(result.durationMs)}`;
51
+ }
52
+ function findingBlock(finding, c) {
53
+ const lines = [
54
+ ` ${badge(finding.severity, c)} ${c.cyan(finding.ruleId)} ${c.bold(printable(finding.title))}`,
55
+ ` ${location(finding)} ${printable(finding.message)}`,
56
+ ];
57
+ if (finding.snippet)
58
+ lines.push(` ${c.gray(`> ${printable(finding.snippet)}`)}`);
59
+ return lines;
60
+ }
61
+ function remediationSection(findings, c) {
62
+ const seen = new Set();
63
+ const lines = [];
64
+ for (const finding of findings) {
65
+ if (!finding.remediation || seen.has(finding.ruleId))
66
+ continue;
67
+ seen.add(finding.ruleId);
68
+ lines.push(` ${c.cyan(finding.ruleId)} ${c.bold(printable(finding.title))}`);
69
+ lines.push(...wrapText(finding.remediation, WRAP_WIDTH, ' '));
70
+ }
71
+ return lines.length === 0 ? [] : [c.bold('How to fix'), ...lines, ''];
72
+ }
73
+ function footer(result, options, c) {
74
+ const lines = [];
75
+ const total = result.findings.length;
76
+ if (total === 0) {
77
+ lines.push(c.green('No issues found.'));
78
+ }
79
+ else {
80
+ const counts = countBySeverity(result.findings);
81
+ const parts = SEVERITIES.filter((s) => counts[s] > 0).map((s) => colorFor(s, c)(`${counts[s]} ${s}`));
82
+ lines.push(`${c.bold(plural(total, 'finding'))}: ${parts.join(', ')}`);
83
+ }
84
+ if (options.hidden) {
85
+ const below = options.minSeverity ?? 'low';
86
+ lines.push(c.gray(`${plural(options.hidden, 'finding')} below ${below} not shown (use --min-severity info to include them).`));
87
+ }
88
+ if (options.suppressed) {
89
+ lines.push(c.gray(`${plural(options.suppressed, 'finding')} suppressed by ${printable(options.ignoreFile ?? 'the ignore file')}.`));
90
+ }
91
+ if (result.skipped.length > 0) {
92
+ if (options.verbose) {
93
+ lines.push(`Skipped ${plural(result.skipped.length, 'file')}:`);
94
+ for (const skip of result.skipped)
95
+ lines.push(` ${printable(skip.path)} ${c.gray(printable(skip.reason))}`);
96
+ }
97
+ else {
98
+ lines.push(c.gray(`Skipped ${plural(result.skipped.length, 'file')} (use --verbose to list them).`));
99
+ }
100
+ }
101
+ return lines;
102
+ }
103
+ /** Groups findings by target in order of first appearance, then by file within each target, keeping the incoming order otherwise. */
104
+ function groupByTarget(findings) {
105
+ const groups = new Map();
106
+ for (const finding of findings) {
107
+ const key = finding.target ?? '';
108
+ const list = groups.get(key);
109
+ if (list)
110
+ list.push(finding);
111
+ else
112
+ groups.set(key, [finding]);
113
+ }
114
+ return [...groups].map(([target, list]) => {
115
+ const fileOrder = new Map();
116
+ for (const f of list)
117
+ if (!fileOrder.has(f.file))
118
+ fileOrder.set(f.file, fileOrder.size);
119
+ const sorted = [...list].sort((a, b) => fileOrder.get(a.file) - fileOrder.get(b.file));
120
+ return { label: target ? printable(target) : 'Other files', findings: sorted };
121
+ });
122
+ }
123
+ function location(finding) {
124
+ let text = printable(finding.file);
125
+ if (finding.line !== undefined)
126
+ text += `:${finding.line}`;
127
+ if (finding.line !== undefined && finding.column !== undefined)
128
+ text += `:${finding.column}`;
129
+ return text;
130
+ }
131
+ function badge(severity, c) {
132
+ return colorFor(severity, c)(severity.toUpperCase().padEnd(BADGE_WIDTH));
133
+ }
134
+ function colorFor(severity, c) {
135
+ switch (severity) {
136
+ case 'critical':
137
+ return (s) => c.bold(c.magenta(s));
138
+ case 'high':
139
+ return (s) => c.bold(c.red(s));
140
+ case 'medium':
141
+ return c.yellow;
142
+ case 'low':
143
+ return c.blue;
144
+ case 'info':
145
+ return c.gray;
146
+ }
147
+ }
148
+ /**
149
+ * Short form of a path for headers. Display paths (relative, "~/..." or
150
+ * synthetic ids) pass through; absolute paths become "." or a path relative
151
+ * to cwd when inside it, "~/..." inside the home directory, otherwise stay
152
+ * absolute. Always with forward slashes.
153
+ */
154
+ export function shortPath(p, cwd = process.cwd()) {
155
+ if (p.includes('://'))
156
+ return p;
157
+ if (!path.isAbsolute(p))
158
+ return toSlash(p) || '.';
159
+ const abs = path.resolve(p);
160
+ const home = homeDir();
161
+ const fromCwd = path.relative(cwd, abs);
162
+ if (fromCwd === '')
163
+ return '.';
164
+ if (path.resolve(cwd) !== path.resolve(home) && isInside(fromCwd))
165
+ return toSlash(fromCwd);
166
+ const fromHome = path.relative(home, abs);
167
+ if (fromHome === '')
168
+ return '~';
169
+ if (isInside(fromHome))
170
+ return `~/${toSlash(fromHome)}`;
171
+ return toSlash(abs);
172
+ }
173
+ function isInside(relative) {
174
+ return relative !== '..' && !relative.startsWith(`..${path.sep}`) && !path.isAbsolute(relative);
175
+ }
176
+ function toSlash(p) {
177
+ return p.split(path.sep).join('/');
178
+ }
179
+ function plural(n, noun) {
180
+ return `${n} ${noun}${n === 1 ? '' : 's'}`;
181
+ }
182
+ function formatDuration(ms) {
183
+ return ms < 1000 ? `${Math.max(0, Math.round(ms))} ms` : `${(ms / 1000).toFixed(1)} s`;
184
+ }
185
+ /** Word-wraps each paragraph of text to width columns, prefixing every line with indent. */
186
+ function wrapText(text, width, indent) {
187
+ const out = [];
188
+ const room = Math.max(20, width - indent.length);
189
+ for (const paragraph of text.split(/\r?\n/)) {
190
+ let line = '';
191
+ for (const word of printable(paragraph).split(/\s+/)) {
192
+ if (!word)
193
+ continue;
194
+ if (line && line.length + 1 + word.length > room) {
195
+ out.push(indent + line);
196
+ line = word;
197
+ }
198
+ else {
199
+ line = line ? `${line} ${word}` : word;
200
+ }
201
+ }
202
+ if (line)
203
+ out.push(indent + line);
204
+ }
205
+ return out;
206
+ }
@@ -0,0 +1,15 @@
1
+ /**
2
+ * Dangerous-code rules: things a skill, hook, command file or MCP server
3
+ * script does that an attacker wants but a benign tool almost never needs.
4
+ *
5
+ * These rules also run over one-line command strings (the config scanner wraps
6
+ * a hook command, an MCP server command line or a package.json script in a
7
+ * synthetic ScanFile of kind 'script'), so every pattern must work on a single
8
+ * line as well as inside a multi-line script.
9
+ *
10
+ * All regexes are linear-time: bounded repetition, no nested quantifiers,
11
+ * anchored on a distinctive keyword so attacker-controlled filler cannot make
12
+ * the engine backtrack.
13
+ */
14
+ import type { Rule } from '../types.js';
15
+ export declare const codeRules: readonly Rule[];