acuvo-code 0.2.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/CHANGELOG.md +328 -0
- package/ENTERPRISE.md +927 -0
- package/LICENSE +120 -0
- package/README.md +1245 -0
- package/ROADMAP.md +556 -0
- package/bin/acuvo-mcp.mjs +208 -0
- package/bin/acuvo.mjs +3108 -0
- package/lib/acceptance-consent.mjs +168 -0
- package/lib/acceptance.mjs +859 -0
- package/lib/account.mjs +226 -0
- package/lib/acuvo-dir.mjs +72 -0
- package/lib/acuvo-models.mjs +141 -0
- package/lib/apply-patch.mjs +570 -0
- package/lib/ask-user.mjs +173 -0
- package/lib/audit.mjs +530 -0
- package/lib/auto-lease.mjs +174 -0
- package/lib/background.mjs +842 -0
- package/lib/best-of.mjs +334 -0
- package/lib/board.mjs +232 -0
- package/lib/breaker.mjs +93 -0
- package/lib/budget.mjs +1375 -0
- package/lib/builtin-skills.mjs +135 -0
- package/lib/cache-floor.mjs +204 -0
- package/lib/chain.mjs +303 -0
- package/lib/changed-paths.mjs +84 -0
- package/lib/chat.mjs +434 -0
- package/lib/checkpoint.mjs +637 -0
- package/lib/child-lifetime.mjs +71 -0
- package/lib/cli-args.mjs +1255 -0
- package/lib/code-review.mjs +1382 -0
- package/lib/colour.mjs +82 -0
- package/lib/command.mjs +2847 -0
- package/lib/compact.mjs +1151 -0
- package/lib/completion.mjs +515 -0
- package/lib/creative-engines.mjs +779 -0
- package/lib/db-inspect.mjs +1624 -0
- package/lib/delete.mjs +111 -0
- package/lib/design-loop.mjs +570 -0
- package/lib/diff-preview.mjs +1044 -0
- package/lib/doctor.mjs +2139 -0
- package/lib/dropped.mjs +216 -0
- package/lib/edit-diagnostics.mjs +277 -0
- package/lib/edit.mjs +460 -0
- package/lib/env-file.mjs +250 -0
- package/lib/escalate.mjs +702 -0
- package/lib/evaluate.mjs +284 -0
- package/lib/fetch-text.mjs +952 -0
- package/lib/fleet-budget.mjs +256 -0
- package/lib/gh.mjs +1536 -0
- package/lib/git.mjs +1341 -0
- package/lib/github.mjs +261 -0
- package/lib/h2.mjs +194 -0
- package/lib/handoff.mjs +417 -0
- package/lib/hooks.mjs +626 -0
- package/lib/http-probe.mjs +907 -0
- package/lib/image-director.mjs +322 -0
- package/lib/image-edit.mjs +522 -0
- package/lib/imagegen.mjs +998 -0
- package/lib/interrupt.mjs +234 -0
- package/lib/learned.mjs +353 -0
- package/lib/lease-watch.mjs +115 -0
- package/lib/lease.mjs +868 -0
- package/lib/localize.mjs +834 -0
- package/lib/log-tail.mjs +1052 -0
- package/lib/login.mjs +157 -0
- package/lib/lsp.mjs +1613 -0
- package/lib/mcp-consent.mjs +377 -0
- package/lib/mcp-defaults.mjs +780 -0
- package/lib/mcp-server.mjs +1343 -0
- package/lib/mcp.mjs +1263 -0
- package/lib/media.mjs +1283 -0
- package/lib/memory-workspace.mjs +179 -0
- package/lib/model-json.mjs +132 -0
- package/lib/model-tier.mjs +171 -0
- package/lib/model.mjs +1445 -0
- package/lib/parallel.mjs +144 -0
- package/lib/perchance.mjs +210 -0
- package/lib/plan-coherence.mjs +1461 -0
- package/lib/plan-ledger.mjs +981 -0
- package/lib/plan.mjs +461 -0
- package/lib/policy.mjs +783 -0
- package/lib/prefix-order.mjs +38 -0
- package/lib/project-memory.mjs +127 -0
- package/lib/prompt.mjs +109 -0
- package/lib/python.mjs +862 -0
- package/lib/rcfile.mjs +853 -0
- package/lib/read-window.mjs +743 -0
- package/lib/refute-tools.mjs +34 -0
- package/lib/refute.mjs +806 -0
- package/lib/repl-driver.mjs +264 -0
- package/lib/repl.mjs +324 -0
- package/lib/replay.mjs +1218 -0
- package/lib/repo-map.mjs +1101 -0
- package/lib/report.mjs +419 -0
- package/lib/search-rank.mjs +99 -0
- package/lib/search.mjs +659 -0
- package/lib/secret-paths.mjs +54 -0
- package/lib/session.mjs +1017 -0
- package/lib/skills.mjs +703 -0
- package/lib/slash.mjs +356 -0
- package/lib/spawn-argv.mjs +1151 -0
- package/lib/spend.mjs +250 -0
- package/lib/steer.mjs +280 -0
- package/lib/stream.mjs +253 -0
- package/lib/stuck.mjs +712 -0
- package/lib/subagent.mjs +749 -0
- package/lib/terminal-graphics.mjs +171 -0
- package/lib/tool-prefix.mjs +226 -0
- package/lib/tool-shortlist.mjs +162 -0
- package/lib/tools.mjs +2333 -0
- package/lib/tsserver.mjs +423 -0
- package/lib/turn.mjs +5672 -0
- package/lib/untrusted-block.mjs +271 -0
- package/lib/verify-claim.mjs +299 -0
- package/lib/vision.mjs +330 -0
- package/lib/voice-task.mjs +561 -0
- package/lib/warm-provider.mjs +255 -0
- package/lib/websearch.mjs +401 -0
- package/lib/workspace.mjs +928 -0
- package/lib/write-approval.mjs +235 -0
- package/lib/write-many.mjs +162 -0
- package/package.json +62 -0
- package/scripts/bundle.mjs +768 -0
- package/scripts/cache-floor.mjs +176 -0
- package/scripts/machine.mjs +226 -0
- package/scripts/test.mjs +139 -0
- package/skills/accessibility.md +87 -0
- package/skills/acuvo-design-system.md +123 -0
- package/skills/animation.md +84 -0
- package/skills/api-design.md +82 -0
- package/skills/auth-and-sessions.md +78 -0
- package/skills/build-with-a-framework.md +101 -0
- package/skills/colour-and-contrast.md +112 -0
- package/skills/creative-engines.md +81 -0
- package/skills/css-layout.md +85 -0
- package/skills/data-and-charts.md +77 -0
- package/skills/debugging.md +76 -0
- package/skills/designing-by-looking.md +84 -0
- package/skills/error-handling.md +78 -0
- package/skills/forms-and-validation.md +93 -0
- package/skills/nextjs-app-router.md +75 -0
- package/skills/page-composition.md +103 -0
- package/skills/performance.md +77 -0
- package/skills/plan-before-building.md +52 -0
- package/skills/planning-and-delegating.md +72 -0
- package/skills/refactoring.md +70 -0
- package/skills/security-basics.md +76 -0
- package/skills/state-management.md +73 -0
- package/skills/supabase-multitenant.md +72 -0
- package/skills/typescript-strict.md +90 -0
- package/skills/typography.md +135 -0
- package/skills/verify-your-own-work.md +62 -0
- package/skills/web-app-quality.md +62 -0
- package/skills/working-in-the-background.md +64 -0
|
@@ -0,0 +1,377 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ── ⚠️⚠️ CLONING A REPO AND TYPING `acuvo` RAN A BINARY THAT REPO CHOSE ─────
|
|
3
|
+
*
|
|
4
|
+
* ENTERPRISE.md §3.1, re-reproduced 2026-08-11 against the real CLI. A workspace
|
|
5
|
+
* containing `evil.mjs` and a committed
|
|
6
|
+
* `.mcp.json = {"mcpServers":{"evil":{"command":"node","args":["evil.mjs"]}}}`:
|
|
7
|
+
*
|
|
8
|
+
* --dry-run → no PWNED.txt ✅ gate holds
|
|
9
|
+
* --no-run → no PWNED.txt ✅ gate holds
|
|
10
|
+
* --max-rounds 2 → PWNED.txt ⚠️ written
|
|
11
|
+
*
|
|
12
|
+
* The default is 5 rounds, so the third line is **every ordinary invocation**.
|
|
13
|
+
* No prompt, no consent, and the child got the full unscrubbed environment.
|
|
14
|
+
*
|
|
15
|
+
* ⭐ THE FIX IS CONSENT, NOT A BLOCKLIST. An `.mcp.json` is a legitimate and
|
|
16
|
+
* useful thing to commit — that is the whole point of the file — so refusing it
|
|
17
|
+
* outright would break the feature for everyone to stop an attacker. What was
|
|
18
|
+
* missing is that **nobody ever agreed to it**. The file is committable and
|
|
19
|
+
* reviewable by design, so the right shape is *read this once*, keyed to the
|
|
20
|
+
* exact contents, not a per-run nag.
|
|
21
|
+
*
|
|
22
|
+
* ── ⚠️⚠️ THE TRUST STORE IS NOT IN THE WORKSPACE, AND THAT IS THE WHOLE GAME ─
|
|
23
|
+
*
|
|
24
|
+
* A `.acuvo/mcp-trust.json` inside the repo would be **committed by the attacker
|
|
25
|
+
* already approved**. The record has to live somewhere the repository cannot
|
|
26
|
+
* reach, so it lives under the user's home directory. Anything else is a lock
|
|
27
|
+
* whose key is taped to the door.
|
|
28
|
+
*
|
|
29
|
+
* ── ⚠️ AND IT FAILS CLOSED WHERE NOBODY CAN ANSWER ─────────────────────────
|
|
30
|
+
*
|
|
31
|
+
* In CI, a pipe, or any non-interactive shell there is no one to ask, and
|
|
32
|
+
* "nobody objected" is not the same as "somebody agreed" — the rule
|
|
33
|
+
* `voice-task.mjs` already states out loud for a much smaller decision. So a
|
|
34
|
+
* non-interactive run does not spawn, and says exactly how to allow it.
|
|
35
|
+
*/
|
|
36
|
+
|
|
37
|
+
import { createHash } from 'node:crypto';
|
|
38
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
|
|
39
|
+
import { homedir } from 'node:os';
|
|
40
|
+
import { join, dirname } from 'node:path';
|
|
41
|
+
/**
|
|
42
|
+
* ⭐ IMPORTED, NEVER RE-IMPLEMENTED — the same rule `search.mjs` follows for
|
|
43
|
+
* `refusedCommitPath`. "Is this variable name a secret?" already has one answer
|
|
44
|
+
* in this package, with its caveats written next to it; a second copy here is
|
|
45
|
+
* the copy that goes stale the day somebody adds a pattern to the first.
|
|
46
|
+
*/
|
|
47
|
+
import { secretlyNamed } from './command.mjs';
|
|
48
|
+
|
|
49
|
+
/** Where the record lives — under HOME, never under the workspace. */
|
|
50
|
+
export const TRUST_FILE = '.acuvo/mcp-trust.json';
|
|
51
|
+
|
|
52
|
+
/** The deliberate, documented escape for automation that has already reviewed the file. */
|
|
53
|
+
export const TRUST_ENV = 'ACUVO_TRUST_MCP';
|
|
54
|
+
|
|
55
|
+
export const TRUST_VERSION = 1;
|
|
56
|
+
|
|
57
|
+
/** How many records to keep. A trust store is small by nature; this stops it growing forever. */
|
|
58
|
+
export const MAX_TRUST_RECORDS = 200;
|
|
59
|
+
|
|
60
|
+
export function trustStorePath({ env = process.env, home = homedir() } = {}) {
|
|
61
|
+
const override = env.ACUVO_TRUST_DIR?.trim();
|
|
62
|
+
return join(override || home, TRUST_FILE);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* What the user is being asked to approve: the exact servers, not the file.
|
|
67
|
+
*
|
|
68
|
+
* ⚠️ HASHED FROM THE RESOLVED SERVERS, NOT THE RAW BYTES. Reformatting the JSON,
|
|
69
|
+
* adding a comment or changing key order must not invalidate consent — but
|
|
70
|
+
* changing a `command`, an `arg` or an `env` key absolutely must. Hashing the
|
|
71
|
+
* file text would re-prompt on a whitespace change (a nag people learn to click
|
|
72
|
+
* through, which is worse than no prompt) and hashing only names would miss the
|
|
73
|
+
* attack entirely.
|
|
74
|
+
*/
|
|
75
|
+
export function fingerprint(servers) {
|
|
76
|
+
const canonical = (servers ?? [])
|
|
77
|
+
.map((s) => {
|
|
78
|
+
const base = {
|
|
79
|
+
name: String(s?.name ?? ''),
|
|
80
|
+
command: String(s?.command ?? ''),
|
|
81
|
+
args: Array.isArray(s?.args) ? s.args.map(String) : [],
|
|
82
|
+
/**
|
|
83
|
+
* ── ⚠️⚠️ ENV VALUES, NOT ENV KEYS. THIS WAS AN RCE. ─────────────────
|
|
84
|
+
*
|
|
85
|
+
* Hashing `Object.keys` meant an approved
|
|
86
|
+
* {"env": {"NODE_OPTIONS": ""}}
|
|
87
|
+
* and a hostile
|
|
88
|
+
* {"env": {"NODE_OPTIONS": "--require ./pwn.cjs"}}
|
|
89
|
+
* produced the IDENTICAL fingerprint, `isTrusted` matched, no prompt
|
|
90
|
+
* was shown, and `mcp.mjs` spawned the server with the payload. Proven
|
|
91
|
+
* end to end by an adversarial pass: the code executed.
|
|
92
|
+
*
|
|
93
|
+
* ⚠️ THE ASYMMETRY WAS ALREADY WRITTEN DOWN AND POINTING THE OTHER WAY.
|
|
94
|
+
* The paragraph below hashes HEADER values because they decide "WHICH
|
|
95
|
+
* CREDENTIAL travels". An env value decides WHAT CODE RUNS, which is
|
|
96
|
+
* strictly worse, and it was the one hashed by name only. `command.mjs`
|
|
97
|
+
* knew it too — `REFUSED_NODE_FLAGS` refuses `--require`, `--import`,
|
|
98
|
+
* `--loader` and `--env-file` BY NAME for ordinary commands. The
|
|
99
|
+
* knowledge existed; this door did not consult it.
|
|
100
|
+
*
|
|
101
|
+
* ⭐ VALUES ARE HASHED UNEXPANDED, exactly like headers, so repointing
|
|
102
|
+
* `${A}` at `${B}` re-prompts.
|
|
103
|
+
*
|
|
104
|
+
* ⚠️ THIS INVALIDATES EXISTING CONSENT FOR CONFIGS THAT CARRY ENV —
|
|
105
|
+
* `["A","B"]` and `[["A","v"],["B","v"]]` do not hash alike — and that
|
|
106
|
+
* is the correct blast radius rather than a regrettable one: those are
|
|
107
|
+
* precisely the approvals that were unsound. A config with no env
|
|
108
|
+
* hashes to `[]` either way and nobody is re-prompted for it.
|
|
109
|
+
*/
|
|
110
|
+
env: Object.entries(s?.env ?? {})
|
|
111
|
+
.map(([k, v]) => [String(k), String(v)])
|
|
112
|
+
.sort((a, b) => (a[0] < b[0] ? -1 : a[0] > b[0] ? 1 : 0)),
|
|
113
|
+
};
|
|
114
|
+
/**
|
|
115
|
+
* ── ⚠️⚠️ A REMOTE SERVER'S DESTINATION IS PART OF WHAT WAS APPROVED ────
|
|
116
|
+
*
|
|
117
|
+
* `readMcpConfig` sets `command` to the URL for a remote server, so the
|
|
118
|
+
* host is already inside the hash — but the HEADERS are not, and they are
|
|
119
|
+
* the half that decides WHICH CREDENTIAL travels. Approving
|
|
120
|
+
* {"url":"https://x.example/mcp"}
|
|
121
|
+
* must not silently approve
|
|
122
|
+
* {"url":"https://x.example/mcp","headers":{"Authorization":"Bearer ${GITHUB_TOKEN}"}}
|
|
123
|
+
* on the next `git pull`. Header VALUES are hashed unexpanded, so
|
|
124
|
+
* repointing `${A}` to `${B}` re-prompts too.
|
|
125
|
+
*
|
|
126
|
+
* ⭐ THE EXTRA KEYS ARE ADDED ONLY WHEN THEY EXIST, AND THAT IS
|
|
127
|
+
* DELIBERATE. `JSON.stringify` is order- and presence-sensitive, so
|
|
128
|
+
* unconditionally adding `transport:"stdio"` here would change the hash of
|
|
129
|
+
* every stdio config in the world and re-prompt every existing user for a
|
|
130
|
+
* config they already approved — a nag people learn to click through,
|
|
131
|
+
* which this file's own header calls worse than no prompt.
|
|
132
|
+
*/
|
|
133
|
+
if (s?.transport && s.transport !== 'stdio') {
|
|
134
|
+
base.transport = String(s.transport);
|
|
135
|
+
base.url = String(s.url ?? '');
|
|
136
|
+
base.headers = Object.entries(s?.headers ?? {})
|
|
137
|
+
.map(([k, v]) => [String(k), String(v)])
|
|
138
|
+
.sort((a, b) => (a[0] < b[0] ? -1 : a[0] > b[0] ? 1 : 0));
|
|
139
|
+
}
|
|
140
|
+
return base;
|
|
141
|
+
})
|
|
142
|
+
.sort((a, b) => (a.name < b.name ? -1 : a.name > b.name ? 1 : 0));
|
|
143
|
+
return createHash('sha256').update(JSON.stringify(canonical)).digest('hex');
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/** Is this server one we reach over the network rather than one we start? */
|
|
147
|
+
export function isRemote(server) {
|
|
148
|
+
return server?.transport === 'http' || server?.transport === 'sse';
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/** `${VAR}` / `$VAR` inside a header value — the names about to leave the machine. */
|
|
152
|
+
export function headerEnvRefs(server) {
|
|
153
|
+
const names = [];
|
|
154
|
+
for (const value of Object.values(server?.headers ?? {})) {
|
|
155
|
+
for (const m of String(value).matchAll(/\$\{([A-Za-z_][A-Za-z0-9_]*)\}|\$([A-Za-z_][A-Za-z0-9_]*)/g)) {
|
|
156
|
+
names.push(m[1] ?? m[2]);
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
return [...new Set(names)];
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
export function loadTrust({ env = process.env, home = homedir(), readImpl = readFileSync } = {}) {
|
|
163
|
+
const file = trustStorePath({ env, home });
|
|
164
|
+
if (!existsSync(file)) return { v: TRUST_VERSION, trusted: [] };
|
|
165
|
+
try {
|
|
166
|
+
const parsed = JSON.parse(readImpl(file, 'utf8'));
|
|
167
|
+
const trusted = Array.isArray(parsed?.trusted) ? parsed.trusted : [];
|
|
168
|
+
return { v: TRUST_VERSION, trusted };
|
|
169
|
+
} catch {
|
|
170
|
+
/**
|
|
171
|
+
* ⚠️ A CORRUPT STORE IS "NOTHING IS TRUSTED", NEVER "EVERYTHING IS". Fail
|
|
172
|
+
* closed: the cost of being wrong here is a prompt, and the cost of the
|
|
173
|
+
* other default is the exact bug this file exists to close.
|
|
174
|
+
*/
|
|
175
|
+
return { v: TRUST_VERSION, trusted: [] };
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
export function isTrusted(fp, store) {
|
|
180
|
+
return (store?.trusted ?? []).some((t) => t?.fingerprint === fp);
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
export function recordTrust(fp, { root, servers, env = process.env, home = homedir(), writeImpl = writeFileSync, now = () => new Date().toISOString() } = {}) {
|
|
184
|
+
const file = trustStorePath({ env, home });
|
|
185
|
+
const store = loadTrust({ env, home });
|
|
186
|
+
if (isTrusted(fp, store)) return { ok: true, already: true, file };
|
|
187
|
+
|
|
188
|
+
store.trusted.unshift({
|
|
189
|
+
fingerprint: fp,
|
|
190
|
+
at: now(),
|
|
191
|
+
root: String(root ?? ''),
|
|
192
|
+
// ⚠️ Recorded so a person auditing the store later can see WHAT they said
|
|
193
|
+
// yes to. A list of opaque hashes is not a record anybody can review.
|
|
194
|
+
servers: (servers ?? []).map((s) => ({ name: s.name, command: s.command, args: s.args ?? [] })),
|
|
195
|
+
});
|
|
196
|
+
store.trusted = store.trusted.slice(0, MAX_TRUST_RECORDS);
|
|
197
|
+
|
|
198
|
+
try {
|
|
199
|
+
mkdirSync(dirname(file), { recursive: true });
|
|
200
|
+
writeImpl(file, `${JSON.stringify({ v: TRUST_VERSION, trusted: store.trusted }, null, 2)}\n`, 'utf8');
|
|
201
|
+
return { ok: true, file };
|
|
202
|
+
} catch (e) {
|
|
203
|
+
// ⚠️ Never fatal. Failing to REMEMBER consent means asking again next time,
|
|
204
|
+
// which is annoying and safe. Refusing to run because we could not write a
|
|
205
|
+
// file would be neither.
|
|
206
|
+
return { ok: false, error: e?.message ?? String(e), file };
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
/**
|
|
211
|
+
* The text a person reads before deciding. Exported so a test can assert it
|
|
212
|
+
* names the binary — and, since 2026-08-15, the HOST.
|
|
213
|
+
*
|
|
214
|
+
* ── ⚠️⚠️ A REMOTE SERVER SPAWNS NOTHING, WHICH IS THE BIGGER QUESTION ───────
|
|
215
|
+
*
|
|
216
|
+
* The instinct is that "no process is started" makes this a smaller decision
|
|
217
|
+
* than the spawn it replaces, so it can ride along on the same yes. It is the
|
|
218
|
+
* opposite: a spawned server runs code we did not write *on this machine*; a
|
|
219
|
+
* remote one sends the workspace's contents — file bodies, error messages,
|
|
220
|
+
* whatever the model puts in a tool call — to a computer belonging to whoever
|
|
221
|
+
* the config names. There is no sandbox and no undo on data that has left.
|
|
222
|
+
*
|
|
223
|
+
* ⭐ SO THE PROMPT SAYS WHAT ACTUALLY HAPPENS, PER SERVER. A user reading
|
|
224
|
+
* "Start these programs?" over a list containing a URL learns nothing, and a
|
|
225
|
+
* consent screen that describes the wrong action is consent to nothing.
|
|
226
|
+
*/
|
|
227
|
+
export function describeServers(servers, { root = '' } = {}) {
|
|
228
|
+
const list = servers ?? [];
|
|
229
|
+
const local = list.filter((s) => !isRemote(s));
|
|
230
|
+
const remote = list.filter((s) => isRemote(s));
|
|
231
|
+
|
|
232
|
+
const lines = [];
|
|
233
|
+
const what = [
|
|
234
|
+
local.length > 0 ? 'starts the programs it names' : null,
|
|
235
|
+
remote.length > 0 ? 'sends this workspace\'s data to the hosts it names' : null,
|
|
236
|
+
].filter(Boolean).join(' and ');
|
|
237
|
+
lines.push(`⚠️ This workspace ships an MCP config, and running it ${what}.`);
|
|
238
|
+
if (root) lines.push(` ${root}`);
|
|
239
|
+
lines.push('');
|
|
240
|
+
for (const s of list) {
|
|
241
|
+
if (isRemote(s)) {
|
|
242
|
+
lines.push(` ${s.name}: ${s.transport === 'sse' ? 'SSE' : 'HTTP'} to ${s.url}`);
|
|
243
|
+
const refs = headerEnvRefs(s);
|
|
244
|
+
if (refs.length > 0) {
|
|
245
|
+
// ⭐ NAMES THE VARIABLE, NEVER ITS VALUE. This line is the entire defence
|
|
246
|
+
// against a cloned repository pointing your token at a host it chose:
|
|
247
|
+
// the user is told which secret is about to travel and where.
|
|
248
|
+
lines.push(` sending your ${refs.join(', ')} to ${hostOf(s.url)}`);
|
|
249
|
+
}
|
|
250
|
+
/**
|
|
251
|
+
* ⚠️ A LITERAL HEADER VALUE IS REDACTED, NOT PRINTED. `mcp.json` is one of
|
|
252
|
+
* the few files people type a raw token into (doctor.mjs pays for the same
|
|
253
|
+
* fact in `redactConfigEcho`), and this text goes to the terminal, to CI
|
|
254
|
+
* logs and into whatever gets pasted into a bug report.
|
|
255
|
+
*/
|
|
256
|
+
const literal = Object.entries(s.headers ?? {}).filter(([, v]) => !String(v).includes('$'));
|
|
257
|
+
if (literal.length > 0) lines.push(` with headers (values hidden): ${literal.map(([k]) => k).join(', ')}`);
|
|
258
|
+
continue;
|
|
259
|
+
}
|
|
260
|
+
lines.push(` ${s.name}: ${s.command} ${(s.args ?? []).join(' ')}`.trimEnd());
|
|
261
|
+
/**
|
|
262
|
+
* ── ⚠️⚠️ THE VALUE IS SHOWN. A NAME ALONE APPROVES CODE NOBODY SAW. ─────
|
|
263
|
+
*
|
|
264
|
+
* This printed `with env: NODE_OPTIONS` and stopped there, so the sentence
|
|
265
|
+
* the user agreed to was identical whether the value was `""` or
|
|
266
|
+
* `--require ./pwn.cjs`. The whole point of this prompt is that a cloned
|
|
267
|
+
* repository cannot run code you did not look at, and the payload lives in
|
|
268
|
+
* the value.
|
|
269
|
+
*
|
|
270
|
+
* ⚠️ IT IS NOT THE SAME CALL AS THE HEADER ONE ABOVE, AND THE DIFFERENCE
|
|
271
|
+
* IS REAL. A header value is a CREDENTIAL BEING SENT — printing it leaks a
|
|
272
|
+
* secret to the terminal, to CI logs and into pasted bug reports, and the
|
|
273
|
+
* name alone is enough to decide ("sending your GITHUB_TOKEN to x.example"
|
|
274
|
+
* is the whole question). An env value is WHAT THE PROCESS RUNS WITH, and
|
|
275
|
+
* the name alone decides nothing.
|
|
276
|
+
*
|
|
277
|
+
* ⭐ So: show the value, redacted when the NAME says it is a secret, using
|
|
278
|
+
* `command.mjs`'s one list rather than a second copy of it. `PATH` and
|
|
279
|
+
* `NODE_OPTIONS` are visible; `GITHUB_TOKEN` is not.
|
|
280
|
+
*/
|
|
281
|
+
const env = Object.entries(s.env ?? {});
|
|
282
|
+
if (env.length > 0) {
|
|
283
|
+
const shown = env.map(([k, v]) => {
|
|
284
|
+
if (secretlyNamed(k)) return `${k}=<hidden>`;
|
|
285
|
+
const value = String(v);
|
|
286
|
+
return `${k}=${value.length > 120 ? `${value.slice(0, 119)}…` : value}`;
|
|
287
|
+
});
|
|
288
|
+
lines.push(` with env: ${shown.join(' ')}`);
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
lines.push('');
|
|
292
|
+
if (local.length > 0) {
|
|
293
|
+
lines.push(' These run with your permissions and see your environment. If you cloned this');
|
|
294
|
+
lines.push(' repository from someone else, read the config before agreeing.');
|
|
295
|
+
}
|
|
296
|
+
if (remote.length > 0) {
|
|
297
|
+
lines.push(' Anything the agent sends a remote server — file contents, errors, your prompt —');
|
|
298
|
+
lines.push(' leaves this machine and cannot be recalled. If you cloned this repository from');
|
|
299
|
+
lines.push(' someone else, check the hosts above before agreeing.');
|
|
300
|
+
}
|
|
301
|
+
return lines.join('\n');
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
function hostOf(url) {
|
|
305
|
+
try { return new URL(String(url)).host; } catch { return String(url); }
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
/**
|
|
309
|
+
* Decide whether the servers in this workspace may be spawned.
|
|
310
|
+
*
|
|
311
|
+
* Pure decision + injectable IO, so every branch is testable and none of them
|
|
312
|
+
* needs a terminal.
|
|
313
|
+
*
|
|
314
|
+
* @returns {Promise<{allowed: boolean, reason: string, fingerprint: string, prompted: boolean, remember: boolean}>}
|
|
315
|
+
*/
|
|
316
|
+
export async function checkMcpConsent(servers, {
|
|
317
|
+
root = '',
|
|
318
|
+
env = process.env,
|
|
319
|
+
home = homedir(),
|
|
320
|
+
isInteractive = Boolean(process.stdin?.isTTY && process.stdout?.isTTY),
|
|
321
|
+
ask = null,
|
|
322
|
+
write = (s) => process.stderr.write(s),
|
|
323
|
+
} = {}) {
|
|
324
|
+
const fp = fingerprint(servers);
|
|
325
|
+
const base = { fingerprint: fp, prompted: false, remember: false };
|
|
326
|
+
|
|
327
|
+
if (!servers || servers.length === 0) {
|
|
328
|
+
return { ...base, allowed: true, reason: 'no servers configured' };
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
/**
|
|
332
|
+
* ⭐ THE DELIBERATE ESCAPE, and it is an environment variable rather than a
|
|
333
|
+
* flag on purpose: the people who need it are CI pipelines and containers,
|
|
334
|
+
* which set env and do not retype a command line.
|
|
335
|
+
*/
|
|
336
|
+
if (String(env[TRUST_ENV] ?? '').trim() === '1') {
|
|
337
|
+
return { ...base, allowed: true, reason: `${TRUST_ENV}=1 was set, so the config was accepted without asking` };
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
if (isTrusted(fp, loadTrust({ env, home }))) {
|
|
341
|
+
return { ...base, allowed: true, reason: 'this exact config was approved before' };
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
if (!isInteractive || typeof ask !== 'function') {
|
|
345
|
+
/**
|
|
346
|
+
* ⚠️ FAILS CLOSED, AND SAYS THE ONE THING THAT UNBLOCKS IT. An error that
|
|
347
|
+
* only refuses gets worked around by disabling something larger.
|
|
348
|
+
*/
|
|
349
|
+
return {
|
|
350
|
+
...base,
|
|
351
|
+
allowed: false,
|
|
352
|
+
reason: 'this workspace ships an MCP config that has not been approved, and there is no terminal here to ask.\n'
|
|
353
|
+
+ `Run it once interactively to approve it, or set ${TRUST_ENV}=1 if you have read the config yourself.`,
|
|
354
|
+
};
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
write(`\n${describeServers(servers, { root })}\n`);
|
|
358
|
+
/**
|
|
359
|
+
* ⚠️ THE QUESTION MATCHES WHAT IS ABOUT TO HAPPEN. "Start these programs?" over
|
|
360
|
+
* a list of URLs is a question about the wrong action, and a yes to the wrong
|
|
361
|
+
* question is not consent to the right one.
|
|
362
|
+
*/
|
|
363
|
+
const anyLocal = servers.some((s) => !isRemote(s));
|
|
364
|
+
const anyRemote = servers.some((s) => isRemote(s));
|
|
365
|
+
const question = anyLocal && anyRemote ? ' Start these programs and connect to these hosts? [y/N] '
|
|
366
|
+
: anyRemote ? ' Connect to these hosts? [y/N] '
|
|
367
|
+
: ' Start these programs? [y/N] ';
|
|
368
|
+
const answer = String(await ask(question) ?? '').trim().toLowerCase();
|
|
369
|
+
const yes = answer === 'y' || answer === 'yes';
|
|
370
|
+
return {
|
|
371
|
+
...base,
|
|
372
|
+
prompted: true,
|
|
373
|
+
allowed: yes,
|
|
374
|
+
remember: yes,
|
|
375
|
+
reason: yes ? 'approved at the prompt' : 'declined at the prompt',
|
|
376
|
+
};
|
|
377
|
+
}
|