@weave_protocol/mund 0.3.1 → 0.4.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/DETECTION_CLAIMS.md +48 -0
- package/README.md +21 -0
- package/dist/agent-audit.d.ts +139 -0
- package/dist/agent-audit.d.ts.map +1 -0
- package/dist/agent-audit.js +424 -0
- package/dist/agent-audit.js.map +1 -0
- package/dist/analyzers/mcp-server-analyzer.d.ts.map +1 -1
- package/dist/analyzers/mcp-server-analyzer.js +4 -0
- package/dist/analyzers/mcp-server-analyzer.js.map +1 -1
- package/dist/cli/agent.d.ts +9 -0
- package/dist/cli/agent.d.ts.map +1 -0
- package/dist/cli/agent.js +91 -0
- package/dist/cli/agent.js.map +1 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +9 -0
- package/dist/index.js.map +1 -1
- package/dist/rugpull.d.ts +6 -0
- package/dist/rugpull.d.ts.map +1 -1
- package/dist/rugpull.js +18 -5
- package/dist/rugpull.js.map +1 -1
- package/dist/tools/monitoring.js +2 -2
- package/dist/tools/monitoring.js.map +1 -1
- package/package.json +1 -1
- package/src/agent-audit.ts +499 -0
- package/src/analyzers/mcp-server-analyzer.ts +3 -0
- package/src/cli/agent.ts +80 -0
- package/src/index.ts +9 -0
- package/src/rugpull.ts +19 -5
- package/src/tools/monitoring.ts +2 -2
- package/tests/analyzers/mcp-server-analyzer.test.ts +1 -3
- package/tests/fixtures/preinstall/fake-mcp-server.mjs +8 -0
- package/tests/fixtures/preinstall/mcp-config/clean-pinned.json +13 -0
- package/tests/fixtures/preinstall/mcp-config/env-reference.json +15 -0
- package/tests/fixtures/preinstall/mcp-config/exfil-service-url.json +10 -0
- package/tests/fixtures/preinstall/mcp-config/inline-token.json +16 -0
- package/tests/fixtures/preinstall/mcp-config/name-typosquat.json +13 -0
- package/tests/fixtures/preinstall/mcp-config/plain-http-remote.json +10 -0
- package/tests/fixtures/preinstall/mcp-config/scope-typosquat.json +13 -0
- package/tests/fixtures/preinstall/mcp-config/shell-pipeline-launcher.json +13 -0
- package/tests/fixtures/preinstall/mcp-config/unpinned-package.json +14 -0
- package/tests/fixtures/preinstall/skills/bidi-override/SKILL.md +6 -0
- package/tests/fixtures/preinstall/skills/bidi-override/expected.json +4 -0
- package/tests/fixtures/preinstall/skills/clean/SKILL.md +6 -0
- package/tests/fixtures/preinstall/skills/clean/expected.json +3 -0
- package/tests/fixtures/preinstall/skills/curl-bash-installer/SKILL.md +6 -0
- package/tests/fixtures/preinstall/skills/curl-bash-installer/expected.json +4 -0
- package/tests/fixtures/preinstall/skills/embedded-secret/SKILL.md +6 -0
- package/tests/fixtures/preinstall/skills/embedded-secret/expected.json +4 -0
- package/tests/fixtures/preinstall/skills/exfil-url/SKILL.md +6 -0
- package/tests/fixtures/preinstall/skills/exfil-url/expected.json +4 -0
- package/tests/fixtures/preinstall/skills/hidden-tag-characters/SKILL.md +6 -0
- package/tests/fixtures/preinstall/skills/hidden-tag-characters/expected.json +4 -0
- package/tests/preinstall.test.ts +153 -0
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
# Mund detection claims
|
|
2
|
+
|
|
3
|
+
Every claim below is backed by a fixture in this repository and a test that checks Mund returns the stated verdict. If a claim stops holding, CI fails.
|
|
4
|
+
|
|
5
|
+
```bash
|
|
6
|
+
cd mund && npm ci && npx vitest run tests/preinstall.test.ts tests/fixtures.test.ts
|
|
7
|
+
```
|
|
8
|
+
|
|
9
|
+
Verdicts: **block** (deny), **warn** (ask the user), **allow**. All fixture content is benign: the checks look at *how* something is built (hidden characters, package names, URLs), so no real attack payloads are needed to prove them.
|
|
10
|
+
|
|
11
|
+
## Skills: checked before install
|
|
12
|
+
|
|
13
|
+
| Claim: Mund catches… | Fixture | Verdict |
|
|
14
|
+
|---|---|---|
|
|
15
|
+
| Invisible Unicode tag characters (ASCII smuggling: text the model reads but a reviewer can't see) | [`skills/hidden-tag-characters`](tests/fixtures/preinstall/skills/hidden-tag-characters/SKILL.md) | **block** |
|
|
16
|
+
| Bidirectional override characters that reorder what a reviewer sees | [`skills/bidi-override`](tests/fixtures/preinstall/skills/bidi-override/SKILL.md) | **block** |
|
|
17
|
+
| A credential embedded in a skill | [`skills/embedded-secret`](tests/fixtures/preinstall/skills/embedded-secret/SKILL.md) | **block** |
|
|
18
|
+
| Instructions to POST data to a collection service | [`skills/exfil-url`](tests/fixtures/preinstall/skills/exfil-url/SKILL.md) | **block** |
|
|
19
|
+
| `curl … \| bash` installers | [`skills/curl-bash-installer`](tests/fixtures/preinstall/skills/curl-bash-installer/SKILL.md) | warn |
|
|
20
|
+
| …and leaves a plain skill alone | [`skills/clean`](tests/fixtures/preinstall/skills/clean/SKILL.md) | allow |
|
|
21
|
+
|
|
22
|
+
## MCP servers: how they're launched (static, nothing is started)
|
|
23
|
+
|
|
24
|
+
| Claim: Mund catches… | Fixture | Verdict |
|
|
25
|
+
|---|---|---|
|
|
26
|
+
| A package scope imitating `@modelcontextprotocol` | [`scope-typosquat`](tests/fixtures/preinstall/mcp-config/scope-typosquat.json) | **block** |
|
|
27
|
+
| A server URL pointing at a data-collection service | [`exfil-service-url`](tests/fixtures/preinstall/mcp-config/exfil-service-url.json) | **block** |
|
|
28
|
+
| A package name within two edits of a known server (`githbu`) | [`name-typosquat`](tests/fixtures/preinstall/mcp-config/name-typosquat.json) | warn |
|
|
29
|
+
| A remote server over plain HTTP | [`plain-http-remote`](tests/fixtures/preinstall/mcp-config/plain-http-remote.json) | warn |
|
|
30
|
+
| A token written into the config instead of `${ENV_VAR}` | [`inline-token`](tests/fixtures/preinstall/mcp-config/inline-token.json) | warn |
|
|
31
|
+
| A launcher that pipes a download into a shell | [`shell-pipeline-launcher`](tests/fixtures/preinstall/mcp-config/shell-pipeline-launcher.json) | warn |
|
|
32
|
+
| An unpinned package (flagged, low severity) | [`unpinned-package`](tests/fixtures/preinstall/mcp-config/unpinned-package.json) | allow |
|
|
33
|
+
| …and accepts `${ENV_VAR}` references and pinned official packages | [`env-reference`](tests/fixtures/preinstall/mcp-config/env-reference.json), [`clean-pinned`](tests/fixtures/preinstall/mcp-config/clean-pinned.json) | allow |
|
|
34
|
+
|
|
35
|
+
## MCP servers: live checks and rug pulls
|
|
36
|
+
|
|
37
|
+
Tested against a local fake MCP server ([`fake-mcp-server.mjs`](tests/fixtures/preinstall/fake-mcp-server.mjs)) in [`tests/preinstall.test.ts`](tests/preinstall.test.ts) and [`tests/fixtures/rugpull/`](tests/fixtures/rugpull):
|
|
38
|
+
|
|
39
|
+
| Claim | Verdict |
|
|
40
|
+
|---|---|
|
|
41
|
+
| A tool description that changes after approval (a rug pull) is flagged until you accept it | warn |
|
|
42
|
+
| A rug pull that adds a command-execution tool | **block** |
|
|
43
|
+
| Mund never starts a project server you haven't approved; it checks it statically | — |
|
|
44
|
+
| Mund never starts a server whose package or URL it already distrusts | — |
|
|
45
|
+
|
|
46
|
+
## Limits
|
|
47
|
+
|
|
48
|
+
Detection is pattern-based. These claims cover the attack shapes listed, not every variant: a paraphrased instruction with no hidden characters, URL or secret won't be caught by these checks. See the [OWASP coverage tables](README.md#️-owasp-coverage) for what is and isn't covered.
|
package/README.md
CHANGED
|
@@ -333,6 +333,27 @@ A rug pull is an MCP server that passes review at install time and then changes
|
|
|
333
333
|
|
|
334
334
|
Re-check servers on startup or on a schedule. After a human reviews a change, pass `accept_changes: true` to update the baseline. Baselines are stored in `~/.config/mund/mcp-baselines.json` (override with `MUND_BASELINE_PATH`).
|
|
335
335
|
|
|
336
|
+
## 🚪 Pre-Install Checks for Skills and MCP Servers
|
|
337
|
+
|
|
338
|
+
Mund checks agent skills and MCP servers **before they get to act**. Every check is backed by a fixture; see [DETECTION_CLAIMS.md](DETECTION_CLAIMS.md).
|
|
339
|
+
|
|
340
|
+
```bash
|
|
341
|
+
npx @weave_protocol/mund agent-audit --live # audit installed skills + configured MCP servers
|
|
342
|
+
npx @weave_protocol/mund check-skill ./my-skill/ # check one skill before you install it
|
|
343
|
+
```
|
|
344
|
+
|
|
345
|
+
- **Skills:** hidden Unicode (invisible tag characters, bidi overrides), prompt injection, exfiltration instructions, dangerous commands, and embedded secrets, across `SKILL.md` and every file the skill ships.
|
|
346
|
+
- **MCP servers, static:** package typosquats (including scopes imitating `@modelcontextprotocol`), unpinned packages, plain-HTTP or data-collection URLs, inline secrets, and shell-pipeline launchers.
|
|
347
|
+
- **MCP servers, live (`--live`):** connects, lists the tools, scans their descriptions, and compares them with the rug-pull baseline.
|
|
348
|
+
|
|
349
|
+
Verdicts are **block**, **warn** or **allow**. Configs are read from Claude Code (`.mcp.json`, `~/.claude.json`, Claude Desktop) or, with `--harness=antigravity`, from Antigravity (`.agents/`, `~/.gemini/config/`).
|
|
350
|
+
|
|
351
|
+
**Safety:** the live check never starts a project or workspace server you haven't approved, or a server whose package or URL it already distrusts. Those get static checks only.
|
|
352
|
+
|
|
353
|
+
**Enforcement** comes from the harness adapters, which run these checks as hooks:
|
|
354
|
+
- [`@weave_protocol/adapter-claudecode`](../adapter-claudecode#mund-pre-install-hooks): `weave-claude-code mund install`
|
|
355
|
+
- [`@weave_protocol/adapter-antigravity`](../adapter-antigravity#mund-pre-install-hooks): `weave-antigravity mund install`
|
|
356
|
+
|
|
336
357
|
## 🐤 Canary Tokens (System Prompt Leakage)
|
|
337
358
|
|
|
338
359
|
Put a canary in your system prompt. If a model is ever talked into revealing its instructions, the canary comes out with them, and Mund flags it in any scanned output. That includes copies with changed case, spacing or zero-width characters inserted, base64 encoding, or reversal.
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Mund - The Guardian Protocol
|
|
3
|
+
* Agent audit: pre-install checks for skills and MCP servers
|
|
4
|
+
*
|
|
5
|
+
* Backs the "pre-install" hooks in the Weave Protocol harness adapters:
|
|
6
|
+
*
|
|
7
|
+
* - Skills: scan SKILL.md and every text file in a skill directory for
|
|
8
|
+
* prompt injection, exfiltration, dangerous code, secrets and hidden
|
|
9
|
+
* Unicode (invisible "tag" characters can smuggle instructions that a
|
|
10
|
+
* human reviewer never sees).
|
|
11
|
+
* - MCP servers: static checks on how each configured server is launched
|
|
12
|
+
* (package typosquats, unpinned packages, insecure or exfil URLs, inline
|
|
13
|
+
* secrets), plus an optional live check that connects, lists the tools,
|
|
14
|
+
* scans their descriptions, and compares them with the rug-pull baseline.
|
|
15
|
+
*
|
|
16
|
+
* SAFETY: the live check starts server processes. It only does that for
|
|
17
|
+
* servers the user configured themselves (user/local scope) or explicitly
|
|
18
|
+
* approved for this project. Project `.mcp.json` servers that Claude Code
|
|
19
|
+
* would still ask about are checked statically only, so opening a
|
|
20
|
+
* malicious repository never runs its servers through Mund.
|
|
21
|
+
*/
|
|
22
|
+
import type { SecurityIssue } from './types.js';
|
|
23
|
+
import { type McpServerManifest } from './analyzers/mcp-server-analyzer.js';
|
|
24
|
+
import { type RiskScore } from './scoring.js';
|
|
25
|
+
import { BaselineStore, type RugPullResult } from './rugpull.js';
|
|
26
|
+
export type Verdict = 'allow' | 'warn' | 'block';
|
|
27
|
+
/** Which agent harness's config layout to audit. */
|
|
28
|
+
export type Harness = 'claude' | 'antigravity';
|
|
29
|
+
export interface SkillAudit {
|
|
30
|
+
name: string;
|
|
31
|
+
path: string;
|
|
32
|
+
scope: 'project' | 'user' | 'workspace' | 'global' | 'plugin';
|
|
33
|
+
verdict: Verdict;
|
|
34
|
+
risk: RiskScore;
|
|
35
|
+
issues: SecurityIssue[];
|
|
36
|
+
}
|
|
37
|
+
export interface McpServerConfig {
|
|
38
|
+
type?: string;
|
|
39
|
+
command?: string;
|
|
40
|
+
args?: string[];
|
|
41
|
+
env?: Record<string, string>;
|
|
42
|
+
url?: string;
|
|
43
|
+
/** Antigravity spelling of url */
|
|
44
|
+
serverUrl?: string;
|
|
45
|
+
headers?: Record<string, string>;
|
|
46
|
+
disabled?: boolean;
|
|
47
|
+
}
|
|
48
|
+
export interface McpServerAudit {
|
|
49
|
+
name: string;
|
|
50
|
+
scope: 'project' | 'local' | 'user' | 'desktop' | 'workspace' | 'global';
|
|
51
|
+
transport: 'stdio' | 'http' | 'sse' | 'unknown';
|
|
52
|
+
target: string;
|
|
53
|
+
verdict: Verdict;
|
|
54
|
+
risk: RiskScore;
|
|
55
|
+
issues: SecurityIssue[];
|
|
56
|
+
live: {
|
|
57
|
+
status: 'ok' | 'error' | 'skipped';
|
|
58
|
+
reason?: string;
|
|
59
|
+
tools?: number;
|
|
60
|
+
rugpull?: RugPullResult['status'];
|
|
61
|
+
};
|
|
62
|
+
}
|
|
63
|
+
export interface AgentAuditReport {
|
|
64
|
+
version: 1;
|
|
65
|
+
tool: {
|
|
66
|
+
name: 'mund';
|
|
67
|
+
version: string;
|
|
68
|
+
};
|
|
69
|
+
generatedAt: string;
|
|
70
|
+
cwd: string;
|
|
71
|
+
servers: McpServerAudit[];
|
|
72
|
+
skills: SkillAudit[];
|
|
73
|
+
summary: Record<Verdict, number>;
|
|
74
|
+
}
|
|
75
|
+
/**
|
|
76
|
+
* critical → block; high in a blocking category (injection, exfiltration,
|
|
77
|
+
* hidden Unicode) → block; other high/medium → warn; else allow.
|
|
78
|
+
*/
|
|
79
|
+
export declare function verdictFor(issues: Array<Pick<SecurityIssue, 'severity' | 'type'>>): Verdict;
|
|
80
|
+
/** Scan one piece of skill content (a SKILL.md or a file shipped with the skill). */
|
|
81
|
+
export declare function scanSkillContent(content: string): Promise<SecurityIssue[]>;
|
|
82
|
+
export declare function scanSkillDir(dir: string, scope?: SkillAudit['scope']): Promise<SkillAudit>;
|
|
83
|
+
export declare function skillRoots(cwd: string, home?: string, harness?: Harness): Array<[string, SkillAudit['scope']]>;
|
|
84
|
+
export declare function discoverSkillDirs(cwd: string, home?: string, harness?: Harness): Array<{
|
|
85
|
+
dir: string;
|
|
86
|
+
scope: SkillAudit['scope'];
|
|
87
|
+
}>;
|
|
88
|
+
interface DiscoveredServer {
|
|
89
|
+
name: string;
|
|
90
|
+
scope: McpServerAudit['scope'];
|
|
91
|
+
config: McpServerConfig;
|
|
92
|
+
/** Started by the user's own configuration or explicitly approved; safe to start for a live check. */
|
|
93
|
+
approved: boolean;
|
|
94
|
+
}
|
|
95
|
+
/** Claude Desktop's config, which also supplies MCP servers to its Code tab. */
|
|
96
|
+
export declare function claudeDesktopConfigPath(home?: string): string;
|
|
97
|
+
/**
|
|
98
|
+
* Claude Code MCP config: <cwd>/.mcp.json (project), ~/.claude.json (user +
|
|
99
|
+
* per-project local), and Claude Desktop's claude_desktop_config.json.
|
|
100
|
+
*/
|
|
101
|
+
export declare function discoverMcpServers(cwd: string, home?: string): DiscoveredServer[];
|
|
102
|
+
/**
|
|
103
|
+
* Antigravity MCP config: <workspace>/.agents/mcp_config.json and
|
|
104
|
+
* ~/.gemini/config/mcp_config.json. Workspace servers are checked
|
|
105
|
+
* statically only (same consent rule as Claude Code project servers);
|
|
106
|
+
* servers marked "disabled" are never started.
|
|
107
|
+
*/
|
|
108
|
+
export declare function discoverAntigravityMcpServers(cwd: string, home?: string): DiscoveredServer[];
|
|
109
|
+
/** The package a launcher like `npx -y pkg@1.2.3` runs, if any. */
|
|
110
|
+
export declare function launchedPackage(config: McpServerConfig): {
|
|
111
|
+
name: string;
|
|
112
|
+
version?: string;
|
|
113
|
+
} | null;
|
|
114
|
+
/** Static checks on how a server is launched. No processes are started. */
|
|
115
|
+
export declare function checkMcpServerConfig(name: string, config: McpServerConfig): Promise<SecurityIssue[]>;
|
|
116
|
+
/** Connect to a server, list its tools, and return them as a manifest. */
|
|
117
|
+
export declare function fetchLiveManifest(name: string, config: McpServerConfig, timeoutMs?: number): Promise<McpServerManifest>;
|
|
118
|
+
export declare function calibrateLiveFindings(issues: SecurityIssue[]): SecurityIssue[];
|
|
119
|
+
export declare function auditMcpServer(server: DiscoveredServer, opts?: {
|
|
120
|
+
live?: boolean;
|
|
121
|
+
store?: BaselineStore;
|
|
122
|
+
timeoutMs?: number;
|
|
123
|
+
accept?: string[];
|
|
124
|
+
harness?: Harness;
|
|
125
|
+
}): Promise<McpServerAudit>;
|
|
126
|
+
/** Cache key: sha256(resolved cwd), plus the harness for non-Claude layouts. */
|
|
127
|
+
export declare function auditCachePath(cwd: string, harness?: Harness): string;
|
|
128
|
+
export declare function runAgentAudit(opts?: {
|
|
129
|
+
cwd?: string;
|
|
130
|
+
live?: boolean;
|
|
131
|
+
home?: string;
|
|
132
|
+
store?: BaselineStore;
|
|
133
|
+
timeoutMs?: number;
|
|
134
|
+
writeCache?: boolean;
|
|
135
|
+
accept?: string[];
|
|
136
|
+
harness?: Harness;
|
|
137
|
+
}): Promise<AgentAuditReport>;
|
|
138
|
+
export {};
|
|
139
|
+
//# sourceMappingURL=agent-audit.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"agent-audit.d.ts","sourceRoot":"","sources":["../src/agent-audit.ts"],"names":[],"mappings":"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"}
|
|
@@ -0,0 +1,424 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Mund - The Guardian Protocol
|
|
3
|
+
* Agent audit: pre-install checks for skills and MCP servers
|
|
4
|
+
*
|
|
5
|
+
* Backs the "pre-install" hooks in the Weave Protocol harness adapters:
|
|
6
|
+
*
|
|
7
|
+
* - Skills: scan SKILL.md and every text file in a skill directory for
|
|
8
|
+
* prompt injection, exfiltration, dangerous code, secrets and hidden
|
|
9
|
+
* Unicode (invisible "tag" characters can smuggle instructions that a
|
|
10
|
+
* human reviewer never sees).
|
|
11
|
+
* - MCP servers: static checks on how each configured server is launched
|
|
12
|
+
* (package typosquats, unpinned packages, insecure or exfil URLs, inline
|
|
13
|
+
* secrets), plus an optional live check that connects, lists the tools,
|
|
14
|
+
* scans their descriptions, and compares them with the rug-pull baseline.
|
|
15
|
+
*
|
|
16
|
+
* SAFETY: the live check starts server processes. It only does that for
|
|
17
|
+
* servers the user configured themselves (user/local scope) or explicitly
|
|
18
|
+
* approved for this project. Project `.mcp.json` servers that Claude Code
|
|
19
|
+
* would still ask about are checked statically only, so opening a
|
|
20
|
+
* malicious repository never runs its servers through Mund.
|
|
21
|
+
*/
|
|
22
|
+
import { createHash } from 'crypto';
|
|
23
|
+
import { existsSync, readFileSync, readdirSync, statSync } from 'fs';
|
|
24
|
+
import { mkdir, writeFile } from 'fs/promises';
|
|
25
|
+
import { homedir } from 'os';
|
|
26
|
+
import { dirname, join, resolve } from 'path';
|
|
27
|
+
import { DEFAULT_RULES, DANGEROUS_URL_PATTERNS } from './constants.js';
|
|
28
|
+
import { AnalyzerEngine } from './analyzers/index.js';
|
|
29
|
+
import { SecretScanner } from './analyzers/secret-scanner.js';
|
|
30
|
+
import { McpServerAnalyzer } from './analyzers/mcp-server-analyzer.js';
|
|
31
|
+
import { computeRiskScore } from './scoring.js';
|
|
32
|
+
import { checkRugPull, changesToIssues } from './rugpull.js';
|
|
33
|
+
import { getVersion } from './version.js';
|
|
34
|
+
// mcp_rugpull is deliberately absent: a plain change (reworded description,
|
|
35
|
+
// new optional parameter) warns until accepted; only escalated changes
|
|
36
|
+
// (now malicious-looking or newly executing commands) are critical → block.
|
|
37
|
+
const BLOCK_TYPES = new Set(['injection', 'exfiltration', 'hidden_unicode']);
|
|
38
|
+
const MAX_FILE = 512 * 1024;
|
|
39
|
+
/**
|
|
40
|
+
* critical → block; high in a blocking category (injection, exfiltration,
|
|
41
|
+
* hidden Unicode) → block; other high/medium → warn; else allow.
|
|
42
|
+
*/
|
|
43
|
+
export function verdictFor(issues) {
|
|
44
|
+
let verdict = 'allow';
|
|
45
|
+
for (const i of issues) {
|
|
46
|
+
if (i.severity === 'critical' || (i.severity === 'high' && BLOCK_TYPES.has(i.type)))
|
|
47
|
+
return 'block';
|
|
48
|
+
if (i.severity === 'high' || i.severity === 'medium')
|
|
49
|
+
verdict = 'warn';
|
|
50
|
+
}
|
|
51
|
+
return verdict;
|
|
52
|
+
}
|
|
53
|
+
// ============================================================================
|
|
54
|
+
// Skills
|
|
55
|
+
// ============================================================================
|
|
56
|
+
const engine = new AnalyzerEngine();
|
|
57
|
+
/** Invisible characters used to hide instructions from human reviewers. */
|
|
58
|
+
const HIDDEN_CHARS = [
|
|
59
|
+
{ re: /[\u{E0000}-\u{E007F}]/u, name: 'Unicode tag characters (invisible ASCII smuggling)', severity: 'critical' },
|
|
60
|
+
{ re: /[--]/, name: 'bidirectional override characters', severity: 'high' },
|
|
61
|
+
{ re: /[-]{3,}/, name: 'runs of zero-width characters', severity: 'high' },
|
|
62
|
+
];
|
|
63
|
+
function hiddenUnicode(content) {
|
|
64
|
+
const issues = [];
|
|
65
|
+
for (const h of HIDDEN_CHARS) {
|
|
66
|
+
const m = h.re.exec(content);
|
|
67
|
+
if (!m)
|
|
68
|
+
continue;
|
|
69
|
+
const before = content.slice(0, m.index).split('\n');
|
|
70
|
+
issues.push({
|
|
71
|
+
rule_id: 'hidden_unicode',
|
|
72
|
+
rule_name: `Hidden text: ${h.name}`,
|
|
73
|
+
type: 'hidden_unicode',
|
|
74
|
+
severity: h.severity,
|
|
75
|
+
action: 'block',
|
|
76
|
+
match: `U+${m[0].codePointAt(0).toString(16).toUpperCase()}`,
|
|
77
|
+
location: { start: m.index, end: m.index + m[0].length, line: before.length, column: before[before.length - 1].length + 1 },
|
|
78
|
+
suggestion: 'Text that is invisible to reviewers but readable by the model. Remove it or reject this skill.',
|
|
79
|
+
});
|
|
80
|
+
}
|
|
81
|
+
return issues;
|
|
82
|
+
}
|
|
83
|
+
/** Scan one piece of skill content (a SKILL.md or a file shipped with the skill). */
|
|
84
|
+
export async function scanSkillContent(content) {
|
|
85
|
+
return [...hiddenUnicode(content), ...(await engine.analyzeAll(content, DEFAULT_RULES))];
|
|
86
|
+
}
|
|
87
|
+
function textFiles(dir, out = [], depth = 0) {
|
|
88
|
+
if (depth > 4)
|
|
89
|
+
return out;
|
|
90
|
+
for (const e of readdirSync(dir, { withFileTypes: true })) {
|
|
91
|
+
if (e.name === 'node_modules' || e.name === '.git')
|
|
92
|
+
continue;
|
|
93
|
+
const p = join(dir, e.name);
|
|
94
|
+
if (e.isDirectory())
|
|
95
|
+
textFiles(p, out, depth + 1);
|
|
96
|
+
else if (e.isFile() && statSync(p).size <= MAX_FILE)
|
|
97
|
+
out.push(p);
|
|
98
|
+
}
|
|
99
|
+
return out;
|
|
100
|
+
}
|
|
101
|
+
export async function scanSkillDir(dir, scope = 'project') {
|
|
102
|
+
const issues = [];
|
|
103
|
+
for (const file of textFiles(dir)) {
|
|
104
|
+
const buf = readFileSync(file);
|
|
105
|
+
if (buf.subarray(0, 4096).includes(0))
|
|
106
|
+
continue; // binary
|
|
107
|
+
for (const issue of await scanSkillContent(buf.toString('utf8'))) {
|
|
108
|
+
issues.push({ ...issue, rule_name: `${issue.rule_name} (${file.slice(dir.length + 1)})` });
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
return { name: dir.split(/[\\/]/).pop(), path: dir, scope, verdict: verdictFor(issues), risk: computeRiskScore(issues), issues };
|
|
112
|
+
}
|
|
113
|
+
export function skillRoots(cwd, home = homedir(), harness = 'claude') {
|
|
114
|
+
if (harness === 'antigravity') {
|
|
115
|
+
const roots = [
|
|
116
|
+
[join(cwd, '.agents', 'skills'), 'workspace'],
|
|
117
|
+
[join(cwd, '.agent', 'skills'), 'workspace'],
|
|
118
|
+
[join(home, '.gemini', 'config', 'skills'), 'global'],
|
|
119
|
+
[join(home, '.gemini', 'antigravity-cli', 'skills'), 'global'],
|
|
120
|
+
[join(home, '.gemini', 'antigravity', 'skills'), 'global'],
|
|
121
|
+
];
|
|
122
|
+
const plugins = join(home, '.gemini', 'antigravity-cli', 'plugins');
|
|
123
|
+
if (existsSync(plugins)) {
|
|
124
|
+
for (const p of readdirSync(plugins, { withFileTypes: true }))
|
|
125
|
+
if (p.isDirectory())
|
|
126
|
+
roots.push([join(plugins, p.name, 'skills'), 'plugin']);
|
|
127
|
+
}
|
|
128
|
+
return roots;
|
|
129
|
+
}
|
|
130
|
+
return [[join(cwd, '.claude', 'skills'), 'project'], [join(home, '.claude', 'skills'), 'user']];
|
|
131
|
+
}
|
|
132
|
+
export function discoverSkillDirs(cwd, home = homedir(), harness = 'claude') {
|
|
133
|
+
const roots = skillRoots(cwd, home, harness);
|
|
134
|
+
const out = [];
|
|
135
|
+
for (const [root, scope] of roots) {
|
|
136
|
+
if (!existsSync(root))
|
|
137
|
+
continue;
|
|
138
|
+
for (const e of readdirSync(root, { withFileTypes: true })) {
|
|
139
|
+
if (e.isDirectory() && existsSync(join(root, e.name, 'SKILL.md')))
|
|
140
|
+
out.push({ dir: join(root, e.name), scope });
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
return out;
|
|
144
|
+
}
|
|
145
|
+
function readJson(path) {
|
|
146
|
+
try {
|
|
147
|
+
return JSON.parse(readFileSync(path, 'utf8'));
|
|
148
|
+
}
|
|
149
|
+
catch {
|
|
150
|
+
return null;
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
/** Claude Desktop's config, which also supplies MCP servers to its Code tab. */
|
|
154
|
+
export function claudeDesktopConfigPath(home = homedir()) {
|
|
155
|
+
if (process.platform === 'win32')
|
|
156
|
+
return join(process.env.APPDATA ?? join(home, 'AppData', 'Roaming'), 'Claude', 'claude_desktop_config.json');
|
|
157
|
+
if (process.platform === 'darwin')
|
|
158
|
+
return join(home, 'Library', 'Application Support', 'Claude', 'claude_desktop_config.json');
|
|
159
|
+
return join(home, '.config', 'Claude', 'claude_desktop_config.json');
|
|
160
|
+
}
|
|
161
|
+
/**
|
|
162
|
+
* Claude Code MCP config: <cwd>/.mcp.json (project), ~/.claude.json (user +
|
|
163
|
+
* per-project local), and Claude Desktop's claude_desktop_config.json.
|
|
164
|
+
*/
|
|
165
|
+
export function discoverMcpServers(cwd, home = homedir()) {
|
|
166
|
+
const out = [];
|
|
167
|
+
const claudeJson = readJson(join(home, '.claude.json')) ?? {};
|
|
168
|
+
const projectEntry = claudeJson.projects?.[cwd] ?? {};
|
|
169
|
+
const settings = [join(cwd, '.claude', 'settings.json'), join(cwd, '.claude', 'settings.local.json'), join(home, '.claude', 'settings.json')]
|
|
170
|
+
.map(readJson)
|
|
171
|
+
.filter(Boolean);
|
|
172
|
+
const enableAll = settings.some((s) => s.enableAllProjectMcpServers === true) || projectEntry.enableAllProjectMcpServers === true;
|
|
173
|
+
const enabled = new Set([
|
|
174
|
+
...(projectEntry.enabledMcpjsonServers ?? []),
|
|
175
|
+
...settings.flatMap((s) => s.enabledMcpjsonServers ?? []),
|
|
176
|
+
]);
|
|
177
|
+
const disabled = new Set([...(projectEntry.disabledMcpjsonServers ?? []), ...settings.flatMap((s) => s.disabledMcpjsonServers ?? [])]);
|
|
178
|
+
for (const [name, config] of Object.entries(readJson(join(cwd, '.mcp.json'))?.mcpServers ?? {})) {
|
|
179
|
+
out.push({ name, scope: 'project', config, approved: !disabled.has(name) && (enableAll || enabled.has(name)) });
|
|
180
|
+
}
|
|
181
|
+
for (const [name, config] of Object.entries(projectEntry.mcpServers ?? {})) {
|
|
182
|
+
out.push({ name, scope: 'local', config, approved: true });
|
|
183
|
+
}
|
|
184
|
+
for (const [name, config] of Object.entries(claudeJson.mcpServers ?? {})) {
|
|
185
|
+
out.push({ name, scope: 'user', config, approved: true });
|
|
186
|
+
}
|
|
187
|
+
const seen = new Set(out.map((s) => s.name));
|
|
188
|
+
for (const [name, config] of Object.entries(readJson(claudeDesktopConfigPath(home))?.mcpServers ?? {})) {
|
|
189
|
+
if (!seen.has(name))
|
|
190
|
+
out.push({ name, scope: 'desktop', config, approved: true });
|
|
191
|
+
}
|
|
192
|
+
return out;
|
|
193
|
+
}
|
|
194
|
+
/**
|
|
195
|
+
* Antigravity MCP config: <workspace>/.agents/mcp_config.json and
|
|
196
|
+
* ~/.gemini/config/mcp_config.json. Workspace servers are checked
|
|
197
|
+
* statically only (same consent rule as Claude Code project servers);
|
|
198
|
+
* servers marked "disabled" are never started.
|
|
199
|
+
*/
|
|
200
|
+
export function discoverAntigravityMcpServers(cwd, home = homedir()) {
|
|
201
|
+
const out = [];
|
|
202
|
+
const add = (path, scope, approved) => {
|
|
203
|
+
for (const [name, raw] of Object.entries(readJson(path)?.mcpServers ?? {})) {
|
|
204
|
+
const config = { ...raw, url: raw.url ?? raw.serverUrl };
|
|
205
|
+
out.push({ name, scope, config, approved: approved && !raw.disabled });
|
|
206
|
+
}
|
|
207
|
+
};
|
|
208
|
+
add(join(cwd, '.agents', 'mcp_config.json'), 'workspace', false);
|
|
209
|
+
add(join(home, '.gemini', 'config', 'mcp_config.json'), 'global', true);
|
|
210
|
+
return out;
|
|
211
|
+
}
|
|
212
|
+
/** Expand ${VAR} and ${VAR:-default} the way Claude Code does in .mcp.json. */
|
|
213
|
+
function expand(value, env = process.env) {
|
|
214
|
+
return value.replace(/\$\{([A-Za-z_][A-Za-z0-9_]*)(?::-([^}]*))?\}/g, (_, k, d) => env[k] ?? d ?? '');
|
|
215
|
+
}
|
|
216
|
+
const PACKAGE_RUNNERS = new Set(['npx', 'bunx', 'uvx', 'pipx', 'pnpx']);
|
|
217
|
+
const OFFICIAL_SCOPE = 'modelcontextprotocol';
|
|
218
|
+
function lev(a, b) {
|
|
219
|
+
const d = Array.from({ length: a.length + 1 }, (_, i) => [i, ...Array(b.length).fill(0)]);
|
|
220
|
+
for (let j = 1; j <= b.length; j++)
|
|
221
|
+
d[0][j] = j;
|
|
222
|
+
for (let i = 1; i <= a.length; i++)
|
|
223
|
+
for (let j = 1; j <= b.length; j++)
|
|
224
|
+
d[i][j] = Math.min(d[i - 1][j] + 1, d[i][j - 1] + 1, d[i - 1][j - 1] + (a[i - 1] === b[j - 1] ? 0 : 1));
|
|
225
|
+
return d[a.length][b.length];
|
|
226
|
+
}
|
|
227
|
+
/** The package a launcher like `npx -y pkg@1.2.3` runs, if any. */
|
|
228
|
+
export function launchedPackage(config) {
|
|
229
|
+
const cmd = (config.command ?? '').split(/[\\/]/).pop() ?? '';
|
|
230
|
+
let args = config.args ?? [];
|
|
231
|
+
if (cmd === 'pnpm' || cmd === 'yarn')
|
|
232
|
+
args = args[0] === 'dlx' ? args.slice(1) : [];
|
|
233
|
+
else if (cmd === 'pipx')
|
|
234
|
+
args = args[0] === 'run' ? args.slice(1) : [];
|
|
235
|
+
else if (!PACKAGE_RUNNERS.has(cmd))
|
|
236
|
+
return null;
|
|
237
|
+
const spec = args.find((a) => !a.startsWith('-'));
|
|
238
|
+
if (!spec)
|
|
239
|
+
return null;
|
|
240
|
+
const at = spec.lastIndexOf('@');
|
|
241
|
+
return at > 0 ? { name: spec.slice(0, at), version: spec.slice(at + 1) } : { name: spec };
|
|
242
|
+
}
|
|
243
|
+
const finding = (rule_id, rule_name, severity, type, match, suggestion) => ({
|
|
244
|
+
rule_id, rule_name, severity, type, action: severity === 'critical' ? 'block' : 'alert', match, suggestion,
|
|
245
|
+
});
|
|
246
|
+
/** Static checks on how a server is launched. No processes are started. */
|
|
247
|
+
export async function checkMcpServerConfig(name, config) {
|
|
248
|
+
const issues = [];
|
|
249
|
+
// Inline secrets in args, env, headers or URL
|
|
250
|
+
const secrets = await new SecretScanner().analyze([...(config.args ?? []), ...Object.values(config.env ?? {}), ...Object.values(config.headers ?? {}), config.url ?? ''].filter((v) => !/^\$\{[^}]+\}$/.test(v)).join('\n'), DEFAULT_RULES);
|
|
251
|
+
for (const s of secrets) {
|
|
252
|
+
issues.push(finding('mcp_config_inline_secret', `Secret written into MCP config: ${s.rule_name}`, 'high', 'secret', s.match, 'Reference it as ${ENV_VAR} instead; config files get committed and shared.'));
|
|
253
|
+
}
|
|
254
|
+
// Package typosquats and unpinned packages
|
|
255
|
+
const pkg = launchedPackage(config);
|
|
256
|
+
if (pkg) {
|
|
257
|
+
const scope = pkg.name.startsWith('@') ? pkg.name.slice(1).split('/')[0] : null;
|
|
258
|
+
if (scope && scope !== OFFICIAL_SCOPE && lev(scope, OFFICIAL_SCOPE) <= 3) {
|
|
259
|
+
issues.push(finding('mcp_package_scope_typosquat', `Package scope "@${scope}" imitates the official "@${OFFICIAL_SCOPE}"`, 'critical', 'supply_chain', pkg.name, 'Do not run this server. Check the package name character by character.'));
|
|
260
|
+
}
|
|
261
|
+
const short = pkg.name.split('/').pop().replace(/^(mcp-server-|server-|mcp-)|(-mcp-server|-mcp|-server)$/g, '');
|
|
262
|
+
for (const t of new McpServerAnalyzer().checkTyposquatting({ name: short, version: '0' })) {
|
|
263
|
+
issues.push({ ...t, type: 'supply_chain', rule_name: `${t.rule_name} (package ${pkg.name})` });
|
|
264
|
+
}
|
|
265
|
+
if (!pkg.version || pkg.version === 'latest') {
|
|
266
|
+
issues.push(finding('mcp_unpinned_package', `Package ${pkg.name} is not pinned to a version`, 'low', 'supply_chain', pkg.name, `Pin it (e.g. ${pkg.name}@1.2.3) so an upstream update can't change the server under you.`));
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
// Remote URL checks
|
|
270
|
+
if (config.url) {
|
|
271
|
+
const url = expand(config.url);
|
|
272
|
+
for (const p of DANGEROUS_URL_PATTERNS) {
|
|
273
|
+
if (p.test(url))
|
|
274
|
+
issues.push(finding('mcp_dangerous_url', 'MCP server URL points at a data-collection service', 'critical', 'exfiltration', url, 'Remove this server.'));
|
|
275
|
+
}
|
|
276
|
+
try {
|
|
277
|
+
const u = new URL(url);
|
|
278
|
+
const local = ['localhost', '127.0.0.1', '::1', '[::1]'].includes(u.hostname);
|
|
279
|
+
if (u.protocol === 'http:' && !local) {
|
|
280
|
+
issues.push(finding('mcp_insecure_transport', 'Remote MCP server over plain HTTP', 'medium', 'transport', url, 'Use https:// so tool traffic and tokens are encrypted.'));
|
|
281
|
+
}
|
|
282
|
+
if (/^\d{1,3}(\.\d{1,3}){3}$/.test(u.hostname) && !local) {
|
|
283
|
+
issues.push(finding('mcp_ip_url', 'Remote MCP server addressed by raw IP', 'medium', 'transport', url, 'Prefer a named, TLS-verified host.'));
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
catch {
|
|
287
|
+
issues.push(finding('mcp_bad_url', 'MCP server URL does not parse', 'medium', 'transport', url, 'Fix the URL.'));
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
// Dangerous launch commands (e.g. sh -c "curl … | bash")
|
|
291
|
+
const launch = [config.command ?? '', ...(config.args ?? [])].join(' ');
|
|
292
|
+
for (const i of await engine.analyzeAll(launch, DEFAULT_RULES.filter((r) => r.type === 'code_pattern'))) {
|
|
293
|
+
issues.push({ ...i, rule_name: `Launch command: ${i.rule_name}` });
|
|
294
|
+
}
|
|
295
|
+
void name;
|
|
296
|
+
return issues;
|
|
297
|
+
}
|
|
298
|
+
/** Connect to a server, list its tools, and return them as a manifest. */
|
|
299
|
+
export async function fetchLiveManifest(name, config, timeoutMs = 8000) {
|
|
300
|
+
const { Client } = await import('@modelcontextprotocol/sdk/client/index.js');
|
|
301
|
+
const client = new Client({ name: 'mund-agent-audit', version: getVersion() });
|
|
302
|
+
let transport;
|
|
303
|
+
if (config.url) {
|
|
304
|
+
if (config.type === 'sse')
|
|
305
|
+
throw new Error('SSE transport not checked live (deprecated); static checks only');
|
|
306
|
+
const { StreamableHTTPClientTransport } = await import('@modelcontextprotocol/sdk/client/streamableHttp.js');
|
|
307
|
+
const headers = Object.fromEntries(Object.entries(config.headers ?? {}).map(([k, v]) => [k, expand(v)]));
|
|
308
|
+
transport = new StreamableHTTPClientTransport(new URL(expand(config.url)), { requestInit: { headers } });
|
|
309
|
+
}
|
|
310
|
+
else if (config.command) {
|
|
311
|
+
const { StdioClientTransport } = await import('@modelcontextprotocol/sdk/client/stdio.js');
|
|
312
|
+
transport = new StdioClientTransport({
|
|
313
|
+
command: expand(config.command),
|
|
314
|
+
args: (config.args ?? []).map((a) => expand(a)),
|
|
315
|
+
env: { ...process.env, ...Object.fromEntries(Object.entries(config.env ?? {}).map(([k, v]) => [k, expand(v)])) },
|
|
316
|
+
stderr: 'ignore',
|
|
317
|
+
});
|
|
318
|
+
}
|
|
319
|
+
else {
|
|
320
|
+
throw new Error('no command or url');
|
|
321
|
+
}
|
|
322
|
+
const timeout = new Promise((_, rej) => setTimeout(() => rej(new Error(`timed out after ${timeoutMs}ms`)), timeoutMs).unref());
|
|
323
|
+
try {
|
|
324
|
+
await Promise.race([client.connect(transport), timeout]);
|
|
325
|
+
const tools = [];
|
|
326
|
+
let cursor;
|
|
327
|
+
do {
|
|
328
|
+
const page = await Promise.race([client.listTools(cursor ? { cursor } : undefined), timeout]);
|
|
329
|
+
for (const t of page.tools)
|
|
330
|
+
tools.push({ name: t.name, description: t.description, inputSchema: t.inputSchema });
|
|
331
|
+
cursor = page.nextCursor;
|
|
332
|
+
} while (cursor && tools.length < 2000);
|
|
333
|
+
const info = client.getServerVersion();
|
|
334
|
+
return { name: info?.name ?? name, version: info?.version ?? '0', tools };
|
|
335
|
+
}
|
|
336
|
+
finally {
|
|
337
|
+
await client.close().catch(() => { });
|
|
338
|
+
}
|
|
339
|
+
}
|
|
340
|
+
/**
|
|
341
|
+
* Live manifest findings, calibrated for a pre-install verdict:
|
|
342
|
+
* - typosquat rules are dropped: a server's self-reported name proves
|
|
343
|
+
* nothing, and the launch package is already checked statically;
|
|
344
|
+
* - capability findings ("can run commands", "touches files", many tools)
|
|
345
|
+
* describe what a server does, not that it's malicious, so they're kept
|
|
346
|
+
* as info. A server that newly GAINS execution is still escalated by
|
|
347
|
+
* rug-pull detection.
|
|
348
|
+
*/
|
|
349
|
+
const DROP_LIVE = new Set(['mcp_typosquatting', 'mcp_typosquatting_pattern', 'mcp_missing_version']);
|
|
350
|
+
const DESCRIPTIVE_LIVE = /^mcp_capability_|^mcp_execution_warning$|^mcp_excessive_tools$/;
|
|
351
|
+
export function calibrateLiveFindings(issues) {
|
|
352
|
+
return issues
|
|
353
|
+
.filter((i) => !DROP_LIVE.has(i.rule_id))
|
|
354
|
+
.map((i) => (DESCRIPTIVE_LIVE.test(i.rule_id) ? { ...i, severity: 'info', action: 'log' } : i));
|
|
355
|
+
}
|
|
356
|
+
export async function auditMcpServer(server, opts = {}) {
|
|
357
|
+
const { name, scope, config } = server;
|
|
358
|
+
const issues = await checkMcpServerConfig(name, config);
|
|
359
|
+
const transport = config.url ? (config.type === 'sse' ? 'sse' : 'http') : config.command ? 'stdio' : 'unknown';
|
|
360
|
+
let live = { status: 'skipped', reason: 'live checks disabled' };
|
|
361
|
+
// Never launch a server the static checks already distrust: a typosquat
|
|
362
|
+
// package or an exfil URL must not be started "to have a look".
|
|
363
|
+
const distrusted = verdictFor(issues) === 'block' ||
|
|
364
|
+
issues.some((i) => (i.severity === 'critical' || i.severity === 'high') && (i.type === 'supply_chain' || i.type === 'exfiltration'));
|
|
365
|
+
if (opts.live) {
|
|
366
|
+
if (!server.approved) {
|
|
367
|
+
live = { status: 'skipped', reason: server.config.disabled ? 'server is disabled in config' : 'workspace/project server not approved for automatic start; static checks only' };
|
|
368
|
+
}
|
|
369
|
+
else if (distrusted) {
|
|
370
|
+
live = { status: 'skipped', reason: 'not started: static checks already flag its package or URL' };
|
|
371
|
+
}
|
|
372
|
+
else {
|
|
373
|
+
try {
|
|
374
|
+
const manifest = await fetchLiveManifest(name, config, opts.timeoutMs);
|
|
375
|
+
issues.push(...calibrateLiveFindings(await new McpServerAnalyzer().analyze(JSON.stringify(manifest), [])));
|
|
376
|
+
const accept = !!opts.accept?.some((a) => a === 'all' || a === name);
|
|
377
|
+
const rug = await checkRugPull(`${opts.harness ?? 'claude'}:${scope}:${name}`, manifest, { store: opts.store, acceptChanges: accept });
|
|
378
|
+
issues.push(...changesToIssues(rug));
|
|
379
|
+
live = { status: 'ok', tools: manifest.tools?.length ?? 0, rugpull: rug.status };
|
|
380
|
+
}
|
|
381
|
+
catch (err) {
|
|
382
|
+
live = { status: 'error', reason: err.message };
|
|
383
|
+
}
|
|
384
|
+
}
|
|
385
|
+
}
|
|
386
|
+
return {
|
|
387
|
+
name,
|
|
388
|
+
scope,
|
|
389
|
+
transport,
|
|
390
|
+
target: config.url ?? [config.command, ...(config.args ?? [])].filter(Boolean).join(' '),
|
|
391
|
+
verdict: verdictFor(issues),
|
|
392
|
+
risk: computeRiskScore(issues),
|
|
393
|
+
issues,
|
|
394
|
+
live,
|
|
395
|
+
};
|
|
396
|
+
}
|
|
397
|
+
// ============================================================================
|
|
398
|
+
// Full audit + cache
|
|
399
|
+
// ============================================================================
|
|
400
|
+
/** Cache key: sha256(resolved cwd), plus the harness for non-Claude layouts. */
|
|
401
|
+
export function auditCachePath(cwd, harness = 'claude') {
|
|
402
|
+
const base = process.env.MUND_AUDIT_CACHE_DIR || join(process.env.XDG_CACHE_HOME || join(homedir(), '.cache'), 'mund', 'agent-audit');
|
|
403
|
+
const key = createHash('sha256').update(resolve(cwd)).digest('hex').slice(0, 16);
|
|
404
|
+
return join(base, harness === 'claude' ? `${key}.json` : `${key}.${harness}.json`);
|
|
405
|
+
}
|
|
406
|
+
export async function runAgentAudit(opts = {}) {
|
|
407
|
+
const cwd = resolve(opts.cwd ?? process.cwd());
|
|
408
|
+
const home = opts.home ?? homedir();
|
|
409
|
+
const [servers, skills] = await Promise.all([
|
|
410
|
+
Promise.all((opts.harness === 'antigravity' ? discoverAntigravityMcpServers(cwd, home) : discoverMcpServers(cwd, home)).map((s) => auditMcpServer(s, opts))),
|
|
411
|
+
Promise.all(discoverSkillDirs(cwd, home, opts.harness).map(({ dir, scope }) => scanSkillDir(dir, scope))),
|
|
412
|
+
]);
|
|
413
|
+
const summary = { allow: 0, warn: 0, block: 0 };
|
|
414
|
+
for (const x of [...servers, ...skills])
|
|
415
|
+
summary[x.verdict]++;
|
|
416
|
+
const report = { version: 1, tool: { name: 'mund', version: getVersion() }, generatedAt: new Date().toISOString(), cwd, servers, skills, summary };
|
|
417
|
+
if (opts.writeCache !== false) {
|
|
418
|
+
const path = auditCachePath(cwd, opts.harness);
|
|
419
|
+
await mkdir(dirname(path), { recursive: true });
|
|
420
|
+
await writeFile(path, JSON.stringify(report, null, 2));
|
|
421
|
+
}
|
|
422
|
+
return report;
|
|
423
|
+
}
|
|
424
|
+
//# sourceMappingURL=agent-audit.js.map
|