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.
- package/LICENSE +21 -0
- package/README.md +302 -2
- package/dist/cli.d.ts +2 -0
- package/dist/cli.js +80 -0
- package/dist/clients/configs.d.ts +124 -0
- package/dist/clients/configs.js +403 -0
- package/dist/commands/approve.d.ts +15 -0
- package/dist/commands/approve.js +128 -0
- package/dist/commands/diff.d.ts +6 -0
- package/dist/commands/diff.js +114 -0
- package/dist/commands/pins.d.ts +18 -0
- package/dist/commands/pins.js +88 -0
- package/dist/commands/proxy.d.ts +24 -0
- package/dist/commands/proxy.js +144 -0
- package/dist/commands/rules.d.ts +15 -0
- package/dist/commands/rules.js +173 -0
- package/dist/commands/scan.d.ts +57 -0
- package/dist/commands/scan.js +323 -0
- package/dist/commands/unwrap.d.ts +5 -0
- package/dist/commands/unwrap.js +69 -0
- package/dist/commands/wrap.d.ts +131 -0
- package/dist/commands/wrap.js +518 -0
- package/dist/index.d.ts +20 -0
- package/dist/index.js +13 -0
- package/dist/patterns/indicators.d.ts +41 -0
- package/dist/patterns/indicators.js +85 -0
- package/dist/patterns/secrets.d.ts +20 -0
- package/dist/patterns/secrets.js +70 -0
- package/dist/proxy/audit.d.ts +53 -0
- package/dist/proxy/audit.js +79 -0
- package/dist/proxy/canonical.d.ts +13 -0
- package/dist/proxy/canonical.js +34 -0
- package/dist/proxy/diff.d.ts +22 -0
- package/dist/proxy/diff.js +124 -0
- package/dist/proxy/fingerprint.d.ts +34 -0
- package/dist/proxy/fingerprint.js +140 -0
- package/dist/proxy/firewall.d.ts +132 -0
- package/dist/proxy/firewall.js +665 -0
- package/dist/proxy/framing.d.ts +45 -0
- package/dist/proxy/framing.js +86 -0
- package/dist/proxy/lockfile.d.ts +58 -0
- package/dist/proxy/lockfile.js +258 -0
- package/dist/proxy/names.d.ts +27 -0
- package/dist/proxy/names.js +175 -0
- package/dist/proxy/policy.d.ts +72 -0
- package/dist/proxy/policy.js +86 -0
- package/dist/proxy/proxy.d.ts +18 -0
- package/dist/proxy/proxy.js +237 -0
- package/dist/proxy/spawn.d.ts +51 -0
- package/dist/proxy/spawn.js +110 -0
- package/dist/report/index.d.ts +35 -0
- package/dist/report/index.js +25 -0
- package/dist/report/json.d.ts +22 -0
- package/dist/report/json.js +40 -0
- package/dist/report/markdown.d.ts +12 -0
- package/dist/report/markdown.js +87 -0
- package/dist/report/sarif.d.ts +89 -0
- package/dist/report/sarif.js +142 -0
- package/dist/report/text.d.ts +16 -0
- package/dist/report/text.js +206 -0
- package/dist/rules/code.d.ts +15 -0
- package/dist/rules/code.js +729 -0
- package/dist/rules/config.d.ts +3 -0
- package/dist/rules/config.js +2164 -0
- package/dist/rules/index.d.ts +3 -0
- package/dist/rules/index.js +8 -0
- package/dist/rules/secrets.d.ts +2 -0
- package/dist/rules/secrets.js +108 -0
- package/dist/rules/text.d.ts +2 -0
- package/dist/rules/text.js +1698 -0
- package/dist/scan/discover.d.ts +37 -0
- package/dist/scan/discover.js +525 -0
- package/dist/scan/engine.d.ts +24 -0
- package/dist/scan/engine.js +111 -0
- package/dist/scan/ignore.d.ts +28 -0
- package/dist/scan/ignore.js +46 -0
- package/dist/types.d.ts +98 -0
- package/dist/types.js +25 -0
- package/dist/util/colors.d.ts +12 -0
- package/dist/util/colors.js +24 -0
- package/dist/util/frontmatter.d.ts +20 -0
- package/dist/util/frontmatter.js +136 -0
- package/dist/util/glob.d.ts +7 -0
- package/dist/util/glob.js +44 -0
- package/dist/util/hash.d.ts +3 -0
- package/dist/util/hash.js +24 -0
- package/dist/util/jsonc.d.ts +14 -0
- package/dist/util/jsonc.js +113 -0
- package/dist/util/paths.d.ts +9 -0
- package/dist/util/paths.js +37 -0
- package/dist/util/text.d.ts +29 -0
- package/dist/util/text.js +96 -0
- package/dist/util/toml-lite.d.ts +40 -0
- package/dist/util/toml-lite.js +507 -0
- package/dist/version.d.ts +1 -0
- package/dist/version.js +1 -0
- package/package.json +57 -4
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
import { redactSecrets } from '../patterns/secrets.js';
|
|
2
|
+
import { allRules } from '../rules/index.js';
|
|
3
|
+
import { atLeast, SEVERITY_RANK } from '../types.js';
|
|
4
|
+
import { lineAt, lineColAt, printable, truncate } from '../util/text.js';
|
|
5
|
+
import { emptyIgnore, isFindingIgnored } from './ignore.js';
|
|
6
|
+
const SNIPPET_MAX = 160;
|
|
7
|
+
const LOWER = { critical: 'high', high: 'medium', medium: 'low', low: 'info', info: 'info' };
|
|
8
|
+
/**
|
|
9
|
+
* Test code in plugins and MCP servers is full of deliberately hostile strings
|
|
10
|
+
* and is not what the agent runs, so its findings drop one level (critical
|
|
11
|
+
* still shows as high). Judged on the path inside the scanned root, so scanning
|
|
12
|
+
* a directory that itself lives under test/ is not affected.
|
|
13
|
+
*/
|
|
14
|
+
export function isTestPath(relPath) {
|
|
15
|
+
return (/(?:^|\/)(?:tests?|__tests__|spec|specs|testdata|__fixtures__)\//i.test(relPath) ||
|
|
16
|
+
/(?:^|\/)(?:test_[^/]*\.py|[^/]*_test\.(?:py|go)|[^/]*\.(?:test|spec)\.[cm]?[jt]sx?)$/i.test(relPath));
|
|
17
|
+
}
|
|
18
|
+
export function scanFile(file, options = {}) {
|
|
19
|
+
const rules = options.rules ?? allRules;
|
|
20
|
+
const ignore = options.ignore ?? emptyIgnore();
|
|
21
|
+
const findings = [];
|
|
22
|
+
const seen = new Set();
|
|
23
|
+
for (const rule of rules) {
|
|
24
|
+
if (!rule.kinds.includes(file.kind))
|
|
25
|
+
continue;
|
|
26
|
+
if (isFindingIgnored(rule.id, file.displayPath, ignore))
|
|
27
|
+
continue;
|
|
28
|
+
let matches;
|
|
29
|
+
try {
|
|
30
|
+
matches = rule.check(file);
|
|
31
|
+
}
|
|
32
|
+
catch (error) {
|
|
33
|
+
options.onRuleError?.(rule, file, error);
|
|
34
|
+
continue;
|
|
35
|
+
}
|
|
36
|
+
const inTests = file.kind === 'script' && isTestPath(file.rootRelPath ?? file.displayPath);
|
|
37
|
+
for (const match of matches) {
|
|
38
|
+
const finding = toFinding(rule, file, match);
|
|
39
|
+
if (inTests && finding.severity !== 'info') {
|
|
40
|
+
finding.severity = LOWER[finding.severity];
|
|
41
|
+
finding.message += ' (in a test file, so severity is lowered one level)';
|
|
42
|
+
}
|
|
43
|
+
if (options.minSeverity && !atLeast(finding.severity, options.minSeverity))
|
|
44
|
+
continue;
|
|
45
|
+
const key = `${finding.ruleId}|${finding.line ?? 0}|${finding.message}`;
|
|
46
|
+
if (seen.has(key))
|
|
47
|
+
continue;
|
|
48
|
+
seen.add(key);
|
|
49
|
+
findings.push(finding);
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
return findings;
|
|
53
|
+
}
|
|
54
|
+
export function scanFiles(files, options = {}) {
|
|
55
|
+
return sortFindings(files.flatMap((file) => scanFile(file, options)));
|
|
56
|
+
}
|
|
57
|
+
export function toFinding(rule, file, match) {
|
|
58
|
+
let line = match.line;
|
|
59
|
+
let column;
|
|
60
|
+
if (match.index !== undefined) {
|
|
61
|
+
const pos = lineColAt(file.content, match.index);
|
|
62
|
+
line = pos.line;
|
|
63
|
+
column = pos.column;
|
|
64
|
+
}
|
|
65
|
+
const finding = {
|
|
66
|
+
ruleId: rule.id,
|
|
67
|
+
severity: match.severity ?? rule.severity,
|
|
68
|
+
category: rule.category,
|
|
69
|
+
title: rule.title,
|
|
70
|
+
message: match.message,
|
|
71
|
+
file: file.displayPath,
|
|
72
|
+
};
|
|
73
|
+
if (line !== undefined)
|
|
74
|
+
finding.line = line;
|
|
75
|
+
if (column !== undefined)
|
|
76
|
+
finding.column = column;
|
|
77
|
+
const snippet = makeSnippet(match.snippet ?? (line !== undefined ? lineAt(file.content, line) : undefined), column);
|
|
78
|
+
if (snippet)
|
|
79
|
+
finding.snippet = snippet;
|
|
80
|
+
if (file.target)
|
|
81
|
+
finding.target = file.target;
|
|
82
|
+
if (rule.remediation)
|
|
83
|
+
finding.remediation = rule.remediation;
|
|
84
|
+
return finding;
|
|
85
|
+
}
|
|
86
|
+
/** Redacts secrets, escapes invisible characters and trims long lines to a window around the column. */
|
|
87
|
+
export function makeSnippet(raw, column) {
|
|
88
|
+
if (raw === undefined)
|
|
89
|
+
return undefined;
|
|
90
|
+
let text = redactSecrets(raw);
|
|
91
|
+
text = printable(text).trim();
|
|
92
|
+
if (!text)
|
|
93
|
+
return undefined;
|
|
94
|
+
if (text.length > SNIPPET_MAX && column !== undefined && column > 60) {
|
|
95
|
+
const start = Math.min(column - 40, text.length - SNIPPET_MAX + 1);
|
|
96
|
+
text = `…${text.slice(Math.max(0, start))}`;
|
|
97
|
+
}
|
|
98
|
+
return truncate(text, SNIPPET_MAX);
|
|
99
|
+
}
|
|
100
|
+
export function sortFindings(findings) {
|
|
101
|
+
return findings.sort((a, b) => SEVERITY_RANK[b.severity] - SEVERITY_RANK[a.severity] ||
|
|
102
|
+
a.file.localeCompare(b.file) ||
|
|
103
|
+
(a.line ?? 0) - (b.line ?? 0) ||
|
|
104
|
+
a.ruleId.localeCompare(b.ruleId));
|
|
105
|
+
}
|
|
106
|
+
export function countBySeverity(findings) {
|
|
107
|
+
const counts = { critical: 0, high: 0, medium: 0, low: 0, info: 0 };
|
|
108
|
+
for (const f of findings)
|
|
109
|
+
counts[f.severity]++;
|
|
110
|
+
return counts;
|
|
111
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* User-controlled suppressions, read from .toolmoatignore in the directory
|
|
3
|
+
* toolmoat runs from (or --ignore-file). Ignore files and comments inside a
|
|
4
|
+
* scanned artifact are never honoured: the artifact is the thing under
|
|
5
|
+
* suspicion, so it must not be able to switch the scanner off.
|
|
6
|
+
*/
|
|
7
|
+
export interface IgnoreConfig {
|
|
8
|
+
/** Rule ids suppressed everywhere. */
|
|
9
|
+
rules: Set<string>;
|
|
10
|
+
/** Globs for files skipped entirely. */
|
|
11
|
+
paths: string[];
|
|
12
|
+
/** Rule ids suppressed only for files matching the glob. */
|
|
13
|
+
pathRules: {
|
|
14
|
+
pattern: string;
|
|
15
|
+
ruleId: string;
|
|
16
|
+
}[];
|
|
17
|
+
}
|
|
18
|
+
export declare function emptyIgnore(): IgnoreConfig;
|
|
19
|
+
/**
|
|
20
|
+
* Format, one entry per line, # for comments:
|
|
21
|
+
* TM-PI-001 suppress a rule everywhere
|
|
22
|
+
* test/fixtures/** skip matching files
|
|
23
|
+
* docs/*.md:TM-PI-001 suppress a rule for matching files only
|
|
24
|
+
*/
|
|
25
|
+
export declare function parseIgnoreFile(text: string): IgnoreConfig;
|
|
26
|
+
export declare function mergeIgnore(a: IgnoreConfig, b: IgnoreConfig): IgnoreConfig;
|
|
27
|
+
export declare function isPathIgnored(displayPath: string, ignore: IgnoreConfig): boolean;
|
|
28
|
+
export declare function isFindingIgnored(ruleId: string, displayPath: string, ignore: IgnoreConfig): boolean;
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import { matchesGlob } from '../util/glob.js';
|
|
2
|
+
export function emptyIgnore() {
|
|
3
|
+
return { rules: new Set(), paths: [], pathRules: [] };
|
|
4
|
+
}
|
|
5
|
+
const RULE_ID = /^TM-[A-Z]+-\d{3}$/;
|
|
6
|
+
/**
|
|
7
|
+
* Format, one entry per line, # for comments:
|
|
8
|
+
* TM-PI-001 suppress a rule everywhere
|
|
9
|
+
* test/fixtures/** skip matching files
|
|
10
|
+
* docs/*.md:TM-PI-001 suppress a rule for matching files only
|
|
11
|
+
*/
|
|
12
|
+
export function parseIgnoreFile(text) {
|
|
13
|
+
const config = emptyIgnore();
|
|
14
|
+
for (const raw of text.split(/\r?\n/)) {
|
|
15
|
+
const line = raw.trim();
|
|
16
|
+
if (!line || line.startsWith('#'))
|
|
17
|
+
continue;
|
|
18
|
+
if (RULE_ID.test(line)) {
|
|
19
|
+
config.rules.add(line);
|
|
20
|
+
continue;
|
|
21
|
+
}
|
|
22
|
+
const scoped = /^(.+):(TM-[A-Z]+-\d{3})$/.exec(line);
|
|
23
|
+
if (scoped)
|
|
24
|
+
config.pathRules.push({ pattern: scoped[1], ruleId: scoped[2] });
|
|
25
|
+
else
|
|
26
|
+
config.paths.push(line);
|
|
27
|
+
}
|
|
28
|
+
return config;
|
|
29
|
+
}
|
|
30
|
+
export function mergeIgnore(a, b) {
|
|
31
|
+
return {
|
|
32
|
+
rules: new Set([...a.rules, ...b.rules]),
|
|
33
|
+
paths: [...a.paths, ...b.paths],
|
|
34
|
+
pathRules: [...a.pathRules, ...b.pathRules],
|
|
35
|
+
};
|
|
36
|
+
}
|
|
37
|
+
export function isPathIgnored(displayPath, ignore) {
|
|
38
|
+
return ignore.paths.some((pattern) => matchesGlob(displayPath, pattern));
|
|
39
|
+
}
|
|
40
|
+
export function isFindingIgnored(ruleId, displayPath, ignore) {
|
|
41
|
+
if (ignore.rules.has(ruleId))
|
|
42
|
+
return true;
|
|
43
|
+
if (isPathIgnored(displayPath, ignore))
|
|
44
|
+
return true;
|
|
45
|
+
return ignore.pathRules.some((entry) => entry.ruleId === ruleId && matchesGlob(displayPath, entry.pattern));
|
|
46
|
+
}
|
package/dist/types.d.ts
ADDED
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
export type Severity = 'critical' | 'high' | 'medium' | 'low' | 'info';
|
|
2
|
+
export declare const SEVERITIES: readonly Severity[];
|
|
3
|
+
export declare const SEVERITY_RANK: Record<Severity, number>;
|
|
4
|
+
export declare function isSeverity(value: string): value is Severity;
|
|
5
|
+
export declare function atLeast(severity: Severity, threshold: Severity): boolean;
|
|
6
|
+
export type Category = 'prompt-injection' | 'hidden-content' | 'exfiltration' | 'credential-access' | 'dangerous-execution' | 'obfuscation' | 'persistence' | 'destructive' | 'excessive-permissions' | 'supply-chain' | 'secrets' | 'insecure-transport';
|
|
7
|
+
/**
|
|
8
|
+
* What a file is to the agent. Rules declare the kinds they apply to, and
|
|
9
|
+
* discovery assigns exactly one kind per file.
|
|
10
|
+
*/
|
|
11
|
+
export type FileKind =
|
|
12
|
+
/** SKILL.md: frontmatter plus instructions loaded straight into the agent's context. */
|
|
13
|
+
'skill'
|
|
14
|
+
/** Other markdown an agent reads: CLAUDE.md, AGENTS.md, commands/*.md, agents/*.md, reference files inside a skill. */
|
|
15
|
+
| 'instructions'
|
|
16
|
+
/** Human-facing docs the agent normally does not load: README, CHANGELOG, CONTRIBUTING. */
|
|
17
|
+
| 'docs'
|
|
18
|
+
/** Executable code shipped with a skill, plugin, hook or MCP server (.sh, .ps1, .py, .js, .ts, ...). */
|
|
19
|
+
| 'script'
|
|
20
|
+
/** MCP client config: claude_desktop_config.json, .mcp.json, ~/.claude.json, Cursor, Windsurf, VS Code, Codex TOML. */
|
|
21
|
+
| 'mcp-config'
|
|
22
|
+
/** Agent harness settings holding hooks and permissions: .claude/settings*.json, hooks/hooks.json. */
|
|
23
|
+
| 'agent-settings'
|
|
24
|
+
/** Plugin and marketplace manifests: .claude-plugin/plugin.json, marketplace.json. */
|
|
25
|
+
| 'plugin-manifest'
|
|
26
|
+
/** Dependency manifests: package.json, pyproject.toml, requirements.txt. */
|
|
27
|
+
| 'package-manifest'
|
|
28
|
+
/** A live MCP tool definition (name, description, schema) observed by the runtime proxy. */
|
|
29
|
+
| 'tool-definition';
|
|
30
|
+
export declare const FILE_KINDS: readonly FileKind[];
|
|
31
|
+
export interface ScanFile {
|
|
32
|
+
/** Absolute path on disk, or a synthetic id such as "mcp://server/tool" for live tool definitions. */
|
|
33
|
+
path: string;
|
|
34
|
+
/** Path shown in reports: relative to the scan root when possible, always with forward slashes. */
|
|
35
|
+
displayPath: string;
|
|
36
|
+
kind: FileKind;
|
|
37
|
+
content: string;
|
|
38
|
+
/** Logical owner shown in reports: a skill name, plugin name or MCP server name. */
|
|
39
|
+
target?: string;
|
|
40
|
+
/** Path relative to the scan root the file was found under, with forward slashes. */
|
|
41
|
+
rootRelPath?: string;
|
|
42
|
+
}
|
|
43
|
+
/** What a rule returns for each problem it sees. The engine turns matches into findings. */
|
|
44
|
+
export interface RuleMatch {
|
|
45
|
+
message: string;
|
|
46
|
+
/** Character offset into file.content where the problem starts. The engine derives line and column from it. */
|
|
47
|
+
index?: number;
|
|
48
|
+
/** Explicit 1-based line, used when there is no meaningful offset. Ignored when index is set. */
|
|
49
|
+
line?: number;
|
|
50
|
+
/** Override the computed snippet, for example to show decoded hidden text. Redacted by the engine. */
|
|
51
|
+
snippet?: string;
|
|
52
|
+
/** Override the rule's default severity for this match, for example to escalate inside a tool definition. */
|
|
53
|
+
severity?: Severity;
|
|
54
|
+
}
|
|
55
|
+
export interface Rule {
|
|
56
|
+
/** Stable id such as "TM-PI-001". Never reuse or renumber an id. */
|
|
57
|
+
id: string;
|
|
58
|
+
title: string;
|
|
59
|
+
/** One or two sentences explaining the threat, shown by `toolmoat rules` and in SARIF help. */
|
|
60
|
+
description: string;
|
|
61
|
+
category: Category;
|
|
62
|
+
severity: Severity;
|
|
63
|
+
kinds: readonly FileKind[];
|
|
64
|
+
remediation?: string;
|
|
65
|
+
/** Must be synchronous, side-effect free and linear-time on attacker-controlled input. */
|
|
66
|
+
check(file: ScanFile): RuleMatch[];
|
|
67
|
+
}
|
|
68
|
+
export interface Finding {
|
|
69
|
+
ruleId: string;
|
|
70
|
+
severity: Severity;
|
|
71
|
+
category: Category;
|
|
72
|
+
title: string;
|
|
73
|
+
message: string;
|
|
74
|
+
/** The file's displayPath. */
|
|
75
|
+
file: string;
|
|
76
|
+
/** 1-based. */
|
|
77
|
+
line?: number;
|
|
78
|
+
/** 1-based. */
|
|
79
|
+
column?: number;
|
|
80
|
+
/** Single-line excerpt with secrets redacted, at most ~160 characters. */
|
|
81
|
+
snippet?: string;
|
|
82
|
+
target?: string;
|
|
83
|
+
remediation?: string;
|
|
84
|
+
}
|
|
85
|
+
export interface SkippedFile {
|
|
86
|
+
path: string;
|
|
87
|
+
reason: string;
|
|
88
|
+
}
|
|
89
|
+
export interface ScanResult {
|
|
90
|
+
findings: Finding[];
|
|
91
|
+
filesScanned: number;
|
|
92
|
+
/** Distinct targets (skills, plugins, servers) that were scanned. */
|
|
93
|
+
targets: string[];
|
|
94
|
+
skipped: SkippedFile[];
|
|
95
|
+
/** The rules that were active, so reporters such as SARIF can describe them. */
|
|
96
|
+
rules: readonly Rule[];
|
|
97
|
+
durationMs: number;
|
|
98
|
+
}
|
package/dist/types.js
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
export const SEVERITIES = ['critical', 'high', 'medium', 'low', 'info'];
|
|
2
|
+
export const SEVERITY_RANK = {
|
|
3
|
+
critical: 4,
|
|
4
|
+
high: 3,
|
|
5
|
+
medium: 2,
|
|
6
|
+
low: 1,
|
|
7
|
+
info: 0,
|
|
8
|
+
};
|
|
9
|
+
export function isSeverity(value) {
|
|
10
|
+
return SEVERITIES.includes(value);
|
|
11
|
+
}
|
|
12
|
+
export function atLeast(severity, threshold) {
|
|
13
|
+
return SEVERITY_RANK[severity] >= SEVERITY_RANK[threshold];
|
|
14
|
+
}
|
|
15
|
+
export const FILE_KINDS = [
|
|
16
|
+
'skill',
|
|
17
|
+
'instructions',
|
|
18
|
+
'docs',
|
|
19
|
+
'script',
|
|
20
|
+
'mcp-config',
|
|
21
|
+
'agent-settings',
|
|
22
|
+
'plugin-manifest',
|
|
23
|
+
'package-manifest',
|
|
24
|
+
'tool-definition',
|
|
25
|
+
];
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
export interface Palette {
|
|
2
|
+
bold(s: string): string;
|
|
3
|
+
dim(s: string): string;
|
|
4
|
+
red(s: string): string;
|
|
5
|
+
yellow(s: string): string;
|
|
6
|
+
blue(s: string): string;
|
|
7
|
+
magenta(s: string): string;
|
|
8
|
+
cyan(s: string): string;
|
|
9
|
+
green(s: string): string;
|
|
10
|
+
gray(s: string): string;
|
|
11
|
+
}
|
|
12
|
+
export declare function palette(stream?: NodeJS.WriteStream, force?: boolean): Palette;
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
function enabled(stream) {
|
|
2
|
+
if (process.env.NO_COLOR !== undefined && process.env.NO_COLOR !== '')
|
|
3
|
+
return false;
|
|
4
|
+
if (process.env.FORCE_COLOR !== undefined && process.env.FORCE_COLOR !== '0')
|
|
5
|
+
return true;
|
|
6
|
+
return Boolean(stream.isTTY);
|
|
7
|
+
}
|
|
8
|
+
function wrap(open, close, on) {
|
|
9
|
+
return (s) => (on ? `\u001b[${open}m${s}\u001b[${close}m` : s);
|
|
10
|
+
}
|
|
11
|
+
export function palette(stream = process.stdout, force) {
|
|
12
|
+
const on = force ?? enabled(stream);
|
|
13
|
+
return {
|
|
14
|
+
bold: wrap(1, 22, on),
|
|
15
|
+
dim: wrap(2, 22, on),
|
|
16
|
+
red: wrap(31, 39, on),
|
|
17
|
+
yellow: wrap(33, 39, on),
|
|
18
|
+
blue: wrap(34, 39, on),
|
|
19
|
+
magenta: wrap(35, 39, on),
|
|
20
|
+
cyan: wrap(36, 39, on),
|
|
21
|
+
green: wrap(32, 39, on),
|
|
22
|
+
gray: wrap(90, 39, on),
|
|
23
|
+
};
|
|
24
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
export type FrontmatterValue = string | string[];
|
|
2
|
+
export interface Frontmatter {
|
|
3
|
+
/** Parsed top-level keys. Nested maps are kept as raw text under their key. */
|
|
4
|
+
data: Record<string, FrontmatterValue>;
|
|
5
|
+
/** 1-based line of each top-level key, for pointing findings at it. */
|
|
6
|
+
lines: Record<string, number>;
|
|
7
|
+
/** Markdown after the closing fence. */
|
|
8
|
+
body: string;
|
|
9
|
+
/** 1-based line where the body starts. */
|
|
10
|
+
bodyLine: number;
|
|
11
|
+
}
|
|
12
|
+
/**
|
|
13
|
+
* Parses the YAML frontmatter of a SKILL.md or command file. Handles the
|
|
14
|
+
* subset these files use in practice: scalars, quoted strings, inline [a, b]
|
|
15
|
+
* lists, "- item" lists and | or > block scalars. Returns null when the file
|
|
16
|
+
* has no frontmatter. Never throws.
|
|
17
|
+
*/
|
|
18
|
+
export declare function parseFrontmatter(content: string): Frontmatter | null;
|
|
19
|
+
/** Reads a frontmatter value as a list: inline lists, "- item" lists and comma- or space-separated strings. */
|
|
20
|
+
export declare function asList(value: FrontmatterValue | undefined): string[];
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Parses the YAML frontmatter of a SKILL.md or command file. Handles the
|
|
3
|
+
* subset these files use in practice: scalars, quoted strings, inline [a, b]
|
|
4
|
+
* lists, "- item" lists and | or > block scalars. Returns null when the file
|
|
5
|
+
* has no frontmatter. Never throws.
|
|
6
|
+
*/
|
|
7
|
+
export function parseFrontmatter(content) {
|
|
8
|
+
const text = content.startsWith('') ? content.slice(1) : content;
|
|
9
|
+
const lines = text.split(/\r?\n/);
|
|
10
|
+
if (lines[0]?.trim() !== '---')
|
|
11
|
+
return null;
|
|
12
|
+
let end = -1;
|
|
13
|
+
for (let i = 1; i < lines.length; i++) {
|
|
14
|
+
if (lines[i].trim() === '---') {
|
|
15
|
+
end = i;
|
|
16
|
+
break;
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
if (end === -1)
|
|
20
|
+
return null;
|
|
21
|
+
const data = {};
|
|
22
|
+
const keyLines = {};
|
|
23
|
+
let i = 1;
|
|
24
|
+
while (i < end) {
|
|
25
|
+
const line = lines[i];
|
|
26
|
+
const m = /^([A-Za-z0-9_-]+)\s*:\s*(.*)$/.exec(line);
|
|
27
|
+
if (!m) {
|
|
28
|
+
i++;
|
|
29
|
+
continue;
|
|
30
|
+
}
|
|
31
|
+
const key = m[1];
|
|
32
|
+
const rest = m[2].trim();
|
|
33
|
+
keyLines[key] = i + 1;
|
|
34
|
+
i++;
|
|
35
|
+
if (rest === '|' || rest === '>' || rest === '|-' || rest === '>-' || rest === '|+' || rest === '>+') {
|
|
36
|
+
const block = [];
|
|
37
|
+
while (i < end && (lines[i].startsWith(' ') || lines[i].startsWith('\t') || lines[i].trim() === '')) {
|
|
38
|
+
block.push(lines[i].trim());
|
|
39
|
+
i++;
|
|
40
|
+
}
|
|
41
|
+
data[key] = rest.startsWith('>') ? block.join(' ').replace(/\s+/g, ' ').trim() : block.join('\n').trim();
|
|
42
|
+
}
|
|
43
|
+
else if (rest === '') {
|
|
44
|
+
const items = [];
|
|
45
|
+
const raw = [];
|
|
46
|
+
while (i < end && (lines[i].startsWith(' ') || lines[i].startsWith('\t') || lines[i].trim() === '')) {
|
|
47
|
+
const item = /^\s*-\s+(.*)$/.exec(lines[i]);
|
|
48
|
+
if (item)
|
|
49
|
+
items.push(unquote(item[1].trim()));
|
|
50
|
+
raw.push(lines[i]);
|
|
51
|
+
i++;
|
|
52
|
+
}
|
|
53
|
+
data[key] = items.length > 0 ? items : raw.join('\n').trim();
|
|
54
|
+
}
|
|
55
|
+
else if (rest.startsWith('[') && rest.endsWith(']')) {
|
|
56
|
+
data[key] = splitInlineList(rest.slice(1, -1));
|
|
57
|
+
}
|
|
58
|
+
else {
|
|
59
|
+
data[key] = unquote(rest);
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
return { data, lines: keyLines, body: lines.slice(end + 1).join('\n'), bodyLine: end + 2 };
|
|
63
|
+
}
|
|
64
|
+
function unquote(value) {
|
|
65
|
+
if (value.length >= 2 && ((value.startsWith('"') && value.endsWith('"')) || (value.startsWith("'") && value.endsWith("'")))) {
|
|
66
|
+
return value.slice(1, -1);
|
|
67
|
+
}
|
|
68
|
+
return value;
|
|
69
|
+
}
|
|
70
|
+
function splitInlineList(inner) {
|
|
71
|
+
const out = [];
|
|
72
|
+
let current = '';
|
|
73
|
+
let quote = null;
|
|
74
|
+
let depth = 0;
|
|
75
|
+
for (const ch of inner) {
|
|
76
|
+
if (quote) {
|
|
77
|
+
if (ch === quote)
|
|
78
|
+
quote = null;
|
|
79
|
+
current += ch;
|
|
80
|
+
}
|
|
81
|
+
else if (ch === '"' || ch === "'") {
|
|
82
|
+
quote = ch;
|
|
83
|
+
current += ch;
|
|
84
|
+
}
|
|
85
|
+
else if (ch === '(') {
|
|
86
|
+
depth++;
|
|
87
|
+
current += ch;
|
|
88
|
+
}
|
|
89
|
+
else if (ch === ')') {
|
|
90
|
+
depth = Math.max(0, depth - 1);
|
|
91
|
+
current += ch;
|
|
92
|
+
}
|
|
93
|
+
else if (ch === ',' && depth === 0) {
|
|
94
|
+
if (current.trim())
|
|
95
|
+
out.push(unquote(current.trim()));
|
|
96
|
+
current = '';
|
|
97
|
+
}
|
|
98
|
+
else {
|
|
99
|
+
current += ch;
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
if (current.trim())
|
|
103
|
+
out.push(unquote(current.trim()));
|
|
104
|
+
return out;
|
|
105
|
+
}
|
|
106
|
+
/** Reads a frontmatter value as a list: inline lists, "- item" lists and comma- or space-separated strings. */
|
|
107
|
+
export function asList(value) {
|
|
108
|
+
if (value === undefined)
|
|
109
|
+
return [];
|
|
110
|
+
if (Array.isArray(value))
|
|
111
|
+
return value;
|
|
112
|
+
return splitInlineList(value).flatMap(splitOutsideParens).filter(Boolean);
|
|
113
|
+
}
|
|
114
|
+
/** Splits on whitespace that is not inside parentheses, so "Read Bash(git status:*)" gives two tools. */
|
|
115
|
+
function splitOutsideParens(text) {
|
|
116
|
+
const out = [];
|
|
117
|
+
let current = '';
|
|
118
|
+
let depth = 0;
|
|
119
|
+
for (const ch of text) {
|
|
120
|
+
if (ch === '(')
|
|
121
|
+
depth++;
|
|
122
|
+
else if (ch === ')')
|
|
123
|
+
depth = Math.max(0, depth - 1);
|
|
124
|
+
if (/\s/.test(ch) && depth === 0) {
|
|
125
|
+
if (current)
|
|
126
|
+
out.push(current);
|
|
127
|
+
current = '';
|
|
128
|
+
}
|
|
129
|
+
else {
|
|
130
|
+
current += ch;
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
if (current)
|
|
134
|
+
out.push(current);
|
|
135
|
+
return out;
|
|
136
|
+
}
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Minimal glob matcher for ignore files: `**` crosses directories, `*` and `?`
|
|
3
|
+
* stay within one path segment. A pattern without a slash matches a file or
|
|
4
|
+
* directory name at any depth, like .gitignore.
|
|
5
|
+
*/
|
|
6
|
+
export declare function globToRegExp(pattern: string): RegExp;
|
|
7
|
+
export declare function matchesGlob(filePath: string, pattern: string): boolean;
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Minimal glob matcher for ignore files: `**` crosses directories, `*` and `?`
|
|
3
|
+
* stay within one path segment. A pattern without a slash matches a file or
|
|
4
|
+
* directory name at any depth, like .gitignore.
|
|
5
|
+
*/
|
|
6
|
+
export function globToRegExp(pattern) {
|
|
7
|
+
let p = pattern.trim().replace(/\\/g, '/');
|
|
8
|
+
const anchored = p.startsWith('/');
|
|
9
|
+
if (anchored)
|
|
10
|
+
p = p.slice(1);
|
|
11
|
+
if (p.endsWith('/'))
|
|
12
|
+
p = `${p}**`;
|
|
13
|
+
const floating = !anchored && !p.includes('/');
|
|
14
|
+
let re = '';
|
|
15
|
+
for (let i = 0; i < p.length; i++) {
|
|
16
|
+
const ch = p[i];
|
|
17
|
+
if (ch === '*') {
|
|
18
|
+
if (p[i + 1] === '*') {
|
|
19
|
+
i++;
|
|
20
|
+
if (p[i + 1] === '/') {
|
|
21
|
+
i++;
|
|
22
|
+
re += '(?:[^/]*/)*';
|
|
23
|
+
}
|
|
24
|
+
else {
|
|
25
|
+
re += '.*';
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
else {
|
|
29
|
+
re += '[^/]*';
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
else if (ch === '?') {
|
|
33
|
+
re += '[^/]';
|
|
34
|
+
}
|
|
35
|
+
else {
|
|
36
|
+
re += ch.replace(/[.+^${}()|[\]\\]/g, '\\$&');
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
const prefix = floating ? '(?:^|.*/)' : '^';
|
|
40
|
+
return new RegExp(`${prefix}${re}(?:/.*)?$`, process.platform === 'win32' ? 'i' : '');
|
|
41
|
+
}
|
|
42
|
+
export function matchesGlob(filePath, pattern) {
|
|
43
|
+
return globToRegExp(pattern).test(filePath.replace(/\\/g, '/'));
|
|
44
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
2
|
+
/** JSON with object keys sorted at every level, so equal values always serialize identically. */
|
|
3
|
+
export function canonicalJson(value) {
|
|
4
|
+
return JSON.stringify(sortKeys(value));
|
|
5
|
+
}
|
|
6
|
+
function sortKeys(value) {
|
|
7
|
+
if (Array.isArray(value))
|
|
8
|
+
return value.map(sortKeys);
|
|
9
|
+
if (value !== null && typeof value === 'object') {
|
|
10
|
+
// Null prototype, so a "__proto__" key from parsed JSON stays an ordinary own key
|
|
11
|
+
// instead of being swallowed by the prototype setter and dropped from the output.
|
|
12
|
+
const out = Object.create(null);
|
|
13
|
+
for (const key of Object.keys(value).sort()) {
|
|
14
|
+
const v = value[key];
|
|
15
|
+
if (v !== undefined)
|
|
16
|
+
out[key] = sortKeys(v);
|
|
17
|
+
}
|
|
18
|
+
return out;
|
|
19
|
+
}
|
|
20
|
+
return value;
|
|
21
|
+
}
|
|
22
|
+
export function sha256(text) {
|
|
23
|
+
return `sha256:${createHash('sha256').update(text, 'utf8').digest('hex')}`;
|
|
24
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Parses JSON that may contain // and /* *\/ comments and trailing commas, as
|
|
3
|
+
* VS Code style config files do. Returns undefined instead of throwing.
|
|
4
|
+
*/
|
|
5
|
+
export declare function parseJsonc(text: string): unknown;
|
|
6
|
+
/** Replaces comments with spaces so character offsets stay the same. */
|
|
7
|
+
export declare function stripComments(text: string): string;
|
|
8
|
+
/**
|
|
9
|
+
* Best-effort offset of a JSON string value (or key) in the raw text, used to
|
|
10
|
+
* point findings at the right line. Searches for the JSON-encoded form first,
|
|
11
|
+
* then the raw value. Returns -1 when not found.
|
|
12
|
+
*/
|
|
13
|
+
export declare function indexOfJsonString(text: string, value: string, from?: number): number;
|
|
14
|
+
export declare function isRecord(value: unknown): value is Record<string, unknown>;
|