c64bridge 1.0.6 → 1.0.7
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/AGENTS.md +1 -1
- package/README.md +1 -1
- package/dist/.tsbuildinfo +1 -1
- package/dist/chargen.js +0 -4
- package/dist/config.js +5 -2
- package/dist/diagnostics.js +4 -3
- package/dist/logger.js +3 -2
- package/dist/loggingHttpClient.js +12 -1
- package/dist/mcp-server.js +7 -5
- package/dist/petscii.js +0 -5
- package/dist/redact.js +166 -0
- package/dist/tools/developer.js +10 -2
- package/dist/tools/input.js +2 -2
- package/dist/tools/machineControl.js +1 -1
- package/dist/tools/meta/config.js +5 -4
- package/dist/tools/meta/configInventory.js +25 -2
- package/dist/tools/meta/diagnostics.js +2 -1
- package/dist/tools/registry/batch.js +1 -7
- package/doc/audit/secret-handling.md +61 -0
- package/mcp/schemas/c64_config.schema.json +19 -2
- package/mcp/server.json +1 -1
- package/mcp/tools.json +19 -2
- package/mcp.json +1 -1
- package/package.json +5 -2
- package/server.json +20 -3
package/dist/chargen.js
CHANGED
|
@@ -184,10 +184,6 @@ export function getChargenGlyphs() {
|
|
|
184
184
|
loadGlyphs();
|
|
185
185
|
return glyphCache.glyphs;
|
|
186
186
|
}
|
|
187
|
-
export function findGlyphByPetscii(code) {
|
|
188
|
-
loadGlyphs();
|
|
189
|
-
return glyphCache.byPetscii.get(code);
|
|
190
|
-
}
|
|
191
187
|
export function findGlyphByBasicChar(char) {
|
|
192
188
|
if (!char) {
|
|
193
189
|
return undefined;
|
package/dist/config.js
CHANGED
|
@@ -2,6 +2,7 @@ import { readFileSync } from "fs";
|
|
|
2
2
|
import { dirname, join } from "path";
|
|
3
3
|
import os from "node:os";
|
|
4
4
|
import { fileURLToPath } from "url";
|
|
5
|
+
import { registerSecret } from "./redact.js";
|
|
5
6
|
const DEFAULT_HOST = "c64u";
|
|
6
7
|
const DEFAULT_PORT = 80;
|
|
7
8
|
export function loadConfig() {
|
|
@@ -25,7 +26,7 @@ export function loadConfig() {
|
|
|
25
26
|
if (error.code !== "ENOENT") {
|
|
26
27
|
// Deliberately omit file contents/credentials. Configuration mistakes
|
|
27
28
|
// should not prevent the MCP server from exposing a recoverable path.
|
|
28
|
-
console.error(`[c64bridge] ignoring configuration '${candidatePath}': ${error instanceof Error ? error.
|
|
29
|
+
console.error(`[c64bridge] ignoring configuration '${candidatePath}': ${error instanceof Error ? error.name : "unreadable"}`);
|
|
29
30
|
}
|
|
30
31
|
}
|
|
31
32
|
}
|
|
@@ -131,12 +132,14 @@ export function resolveUltimateEndpoint(profile, section, legacy) {
|
|
|
131
132
|
url.port = String(explicitPort);
|
|
132
133
|
baseUrl = stripTrailingSlash(url.toString());
|
|
133
134
|
}
|
|
135
|
+
const networkPassword = firstDefined(nonEmptyString(envPassword), nonEmptyString(section?.networkPassword), nonEmptyString(legacy?.networkPassword));
|
|
136
|
+
registerSecret(networkPassword);
|
|
134
137
|
return {
|
|
135
138
|
host,
|
|
136
139
|
port,
|
|
137
140
|
baseUrl,
|
|
138
141
|
c64_host: port === schemeDefaultPort ? hostLabel : `${hostLabel}:${port}`,
|
|
139
|
-
networkPassword
|
|
142
|
+
networkPassword,
|
|
140
143
|
};
|
|
141
144
|
}
|
|
142
145
|
function configuredString(value) {
|
package/dist/diagnostics.js
CHANGED
|
@@ -2,6 +2,7 @@ import { Buffer } from "node:buffer";
|
|
|
2
2
|
import fs from "node:fs";
|
|
3
3
|
import os from "node:os";
|
|
4
4
|
import path from "node:path";
|
|
5
|
+
import { redactValue } from "./redact.js";
|
|
5
6
|
let state = null;
|
|
6
7
|
let handlersInstalled = false;
|
|
7
8
|
let spanCounter = 0;
|
|
@@ -21,9 +22,6 @@ export function installProcessDiagnostics(component) {
|
|
|
21
22
|
export function getDiagnosticsSessionInfo() {
|
|
22
23
|
return state?.info ?? null;
|
|
23
24
|
}
|
|
24
|
-
export function getDiagnosticsDirectory() {
|
|
25
|
-
return resolveDiagnosticsDirectory();
|
|
26
|
-
}
|
|
27
25
|
export function writeDiagnosticEvent(event, details) {
|
|
28
26
|
if (!isDiagnosticsEnabled()) {
|
|
29
27
|
return;
|
|
@@ -300,6 +298,9 @@ function isDiagnosticsEnabled() {
|
|
|
300
298
|
return true;
|
|
301
299
|
}
|
|
302
300
|
function sanitize(value, depth = 0) {
|
|
301
|
+
return sanitizeUnredacted(redactValue(value), depth);
|
|
302
|
+
}
|
|
303
|
+
function sanitizeUnredacted(value, depth = 0) {
|
|
303
304
|
if (value instanceof Error) {
|
|
304
305
|
const extra = value;
|
|
305
306
|
return {
|
package/dist/logger.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { Buffer } from "node:buffer";
|
|
2
|
+
import { redactText, redactValue } from "./redact.js";
|
|
2
3
|
const LEVEL_ORDER = {
|
|
3
4
|
debug: 0,
|
|
4
5
|
info: 1,
|
|
@@ -24,9 +25,9 @@ function createPrefixedLogger(prefix) {
|
|
|
24
25
|
return;
|
|
25
26
|
}
|
|
26
27
|
const consoleMethod = selectConsole(level);
|
|
27
|
-
const label = `[${prefix}] ${message}`;
|
|
28
|
+
const label = `[${prefix}] ${redactText(message)}`;
|
|
28
29
|
if (details && Object.keys(details).length > 0) {
|
|
29
|
-
consoleMethod(label, details);
|
|
30
|
+
consoleMethod(label, redactValue(details));
|
|
30
31
|
}
|
|
31
32
|
else {
|
|
32
33
|
consoleMethod(label);
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { HttpClient } from "../generated/c64/index.js";
|
|
2
|
+
import { registerSecret, scrubRequestError } from "./redact.js";
|
|
2
3
|
import { formatErrorMessage, formatPayloadForDebug, loggerFor, payloadByteLength } from "./logger.js";
|
|
3
4
|
/** Headers are useful in diagnostics, but credentials must never leave the
|
|
4
5
|
* transport layer. Axios normalises some keys and preserves others, so do a
|
|
@@ -14,6 +15,7 @@ function redactHeaders(headers) {
|
|
|
14
15
|
}
|
|
15
16
|
export function createLoggingHttpClient(config, logger = loggerFor("c64u")) {
|
|
16
17
|
const http = new HttpClient(config);
|
|
18
|
+
registerSecret(readPasswordHeader(config?.headers));
|
|
17
19
|
http.instance.interceptors.request.use((request) => {
|
|
18
20
|
const method = (request.method ?? "get").toUpperCase();
|
|
19
21
|
const path = resolvePath(request);
|
|
@@ -33,10 +35,19 @@ export function createLoggingHttpClient(config, logger = loggerFor("c64u")) {
|
|
|
33
35
|
return response;
|
|
34
36
|
}, (error) => {
|
|
35
37
|
handleError(error, logger);
|
|
36
|
-
return Promise.reject(error);
|
|
38
|
+
return Promise.reject(scrubRequestError(error));
|
|
37
39
|
});
|
|
38
40
|
return http;
|
|
39
41
|
}
|
|
42
|
+
function readPasswordHeader(headers) {
|
|
43
|
+
if (!headers || typeof headers !== "object")
|
|
44
|
+
return undefined;
|
|
45
|
+
for (const [key, value] of Object.entries(headers)) {
|
|
46
|
+
if (/^x-password$/i.test(key) && typeof value === "string")
|
|
47
|
+
return value;
|
|
48
|
+
}
|
|
49
|
+
return undefined;
|
|
50
|
+
}
|
|
40
51
|
function handleResponse(response, logger) {
|
|
41
52
|
const meta = extractMeta(response.config);
|
|
42
53
|
const latency = meta ? Date.now() - meta.startedAt : 0;
|
package/dist/mcp-server.js
CHANGED
|
@@ -18,6 +18,7 @@ import { unknownErrorResult } from "./tools/errors.js";
|
|
|
18
18
|
import { createPromptRegistry } from "./prompts/registry.js";
|
|
19
19
|
import { describePlatformCapabilities, getPlatformStatus, setPlatform } from "./platform.js";
|
|
20
20
|
import axios from "axios";
|
|
21
|
+
import { redactValue, registerSecret } from "./redact.js";
|
|
21
22
|
import { loggerFor, payloadByteLength, formatPayloadForDebug, formatErrorMessage } from "./logger.js";
|
|
22
23
|
import { getDiagnosticsSessionInfo, installProcessDiagnostics, withDiagnosticSpan, writeDiagnosticEvent } from "./diagnostics.js";
|
|
23
24
|
const __filename = fileURLToPath(import.meta.url);
|
|
@@ -558,6 +559,7 @@ async function logConnectivity(client, baseUrl, networkPassword) {
|
|
|
558
559
|
let response = null;
|
|
559
560
|
try {
|
|
560
561
|
// Send the network password like every other request, or a protected device answers 403.
|
|
562
|
+
registerSecret(networkPassword);
|
|
561
563
|
const probeResponse = await axios.get(infoUrl, {
|
|
562
564
|
timeout: 2000,
|
|
563
565
|
...(networkPassword ? { headers: { "X-Password": networkPassword } } : {}),
|
|
@@ -606,13 +608,13 @@ async function logConnectivity(client, baseUrl, networkPassword) {
|
|
|
606
608
|
console.error(`Zero-page probe skipped or failed (may be unsupported on current backend): ${message}`);
|
|
607
609
|
}
|
|
608
610
|
}
|
|
609
|
-
function toCallToolResult(result) {
|
|
610
|
-
const base = { content: result.content };
|
|
611
|
+
export function toCallToolResult(result) {
|
|
612
|
+
const base = { content: redactValue(result.content) };
|
|
611
613
|
if (result.structuredContent !== undefined) {
|
|
612
|
-
base.structuredContent = result.structuredContent;
|
|
614
|
+
base.structuredContent = redactValue(result.structuredContent);
|
|
613
615
|
}
|
|
614
616
|
if (result.metadata !== undefined) {
|
|
615
|
-
base.metadata = result.metadata;
|
|
617
|
+
base.metadata = redactValue(result.metadata);
|
|
616
618
|
}
|
|
617
619
|
if (result.isError !== undefined) {
|
|
618
620
|
base.isError = result.isError;
|
|
@@ -643,7 +645,7 @@ if (process.env.C64BRIDGE_SKIP_AUTO_START !== "1") {
|
|
|
643
645
|
diagnosticsFile: getDiagnosticsSessionInfo()?.filePath,
|
|
644
646
|
error,
|
|
645
647
|
});
|
|
646
|
-
console.error("Fatal error in MCP server:", error);
|
|
648
|
+
console.error("Fatal error in MCP server:", redactValue(error));
|
|
647
649
|
process.exit(1);
|
|
648
650
|
});
|
|
649
651
|
}
|
package/dist/petscii.js
CHANGED
|
@@ -137,11 +137,6 @@ const NAMED_CHARS = Object.freeze({
|
|
|
137
137
|
"leftarrow": 95,
|
|
138
138
|
"pi": 222,
|
|
139
139
|
});
|
|
140
|
-
export function listNamedPetscii() {
|
|
141
|
-
return Object.entries(NAMED_CHARS)
|
|
142
|
-
.sort((a, b) => a[0].localeCompare(b[0]))
|
|
143
|
-
.map(([name, code]) => ({ name, code, hex: `$${code.toString(16).toUpperCase().padStart(2, "0")}` }));
|
|
144
|
-
}
|
|
145
140
|
export function lookupNamedPetscii(name) {
|
|
146
141
|
const key = name.trim().toLowerCase().replace(/\s+/g, "_");
|
|
147
142
|
// Own keys only, so names like "constructor" don't resolve to Object.prototype members.
|
package/dist/redact.js
ADDED
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared redaction for the Ultimate network password and other credentials.
|
|
3
|
+
*
|
|
4
|
+
* The password is meant to leave the process only as the `X-Password` request
|
|
5
|
+
* header. Every sink that can carry arbitrary values (console logging,
|
|
6
|
+
* diagnostics files, MCP tool results) passes its content through this module.
|
|
7
|
+
*
|
|
8
|
+
* Two mechanisms are combined:
|
|
9
|
+
* - values registered with `registerSecret` are replaced wherever they appear
|
|
10
|
+
* inside a string, including error messages and stack traces;
|
|
11
|
+
* - properties whose name is a credential header are replaced regardless of value.
|
|
12
|
+
*/
|
|
13
|
+
const REDACTED = "***";
|
|
14
|
+
const SENSITIVE_KEY = /^(x-password|authorization|proxy-authorization|cookie|set-cookie|x-api-key)$/i;
|
|
15
|
+
const MIN_SECRET_LENGTH = 1;
|
|
16
|
+
const secrets = new Set();
|
|
17
|
+
export function registerSecret(value) {
|
|
18
|
+
if (typeof value === "string" && value.length >= MIN_SECRET_LENGTH) {
|
|
19
|
+
secrets.add(value);
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
/** Test hook: forget all registered secrets. */
|
|
23
|
+
export function clearRegisteredSecrets() {
|
|
24
|
+
secrets.clear();
|
|
25
|
+
}
|
|
26
|
+
export function isSensitiveKey(key) {
|
|
27
|
+
return SENSITIVE_KEY.test(key);
|
|
28
|
+
}
|
|
29
|
+
export function redactText(text) {
|
|
30
|
+
let result = text;
|
|
31
|
+
// Longest first, so a secret that contains another one is replaced as a whole.
|
|
32
|
+
for (const secret of [...secrets].sort((a, b) => b.length - a.length)) {
|
|
33
|
+
if (result.includes(secret)) {
|
|
34
|
+
result = result.split(secret).join(REDACTED);
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
return result;
|
|
38
|
+
}
|
|
39
|
+
function isPlainObject(value) {
|
|
40
|
+
const proto = Object.getPrototypeOf(value);
|
|
41
|
+
return proto === Object.prototype || proto === null;
|
|
42
|
+
}
|
|
43
|
+
/**
|
|
44
|
+
* Returns a copy of `value` that is safe to log, persist or return to a client.
|
|
45
|
+
* Errors (including Axios errors) are reduced to name, message, stack, code and
|
|
46
|
+
* HTTP status, so their request configuration is never copied.
|
|
47
|
+
*/
|
|
48
|
+
export function redactValue(value, seen = new WeakSet()) {
|
|
49
|
+
if (typeof value === "string") {
|
|
50
|
+
return redactText(value);
|
|
51
|
+
}
|
|
52
|
+
if (value === null || typeof value !== "object") {
|
|
53
|
+
return value;
|
|
54
|
+
}
|
|
55
|
+
if (seen.has(value)) {
|
|
56
|
+
return "[Circular]";
|
|
57
|
+
}
|
|
58
|
+
if (value instanceof Error) {
|
|
59
|
+
seen.add(value);
|
|
60
|
+
const extra = value;
|
|
61
|
+
return {
|
|
62
|
+
name: value.name,
|
|
63
|
+
message: redactText(value.message),
|
|
64
|
+
...(value.stack ? { stack: redactText(value.stack) } : {}),
|
|
65
|
+
...(extra.code !== undefined ? { code: redactValue(extra.code, seen) } : {}),
|
|
66
|
+
...(extra.response?.status !== undefined ? { status: extra.response.status } : {}),
|
|
67
|
+
...(extra.cause !== undefined ? { cause: redactValue(extra.cause, seen) } : {}),
|
|
68
|
+
};
|
|
69
|
+
}
|
|
70
|
+
if (Array.isArray(value)) {
|
|
71
|
+
seen.add(value);
|
|
72
|
+
return value.map((entry) => redactValue(entry, seen));
|
|
73
|
+
}
|
|
74
|
+
if (!isPlainObject(value)) {
|
|
75
|
+
return value;
|
|
76
|
+
}
|
|
77
|
+
seen.add(value);
|
|
78
|
+
return Object.fromEntries(Object.entries(value).map(([key, entry]) => [
|
|
79
|
+
redactText(key),
|
|
80
|
+
isSensitiveKey(key) ? REDACTED : redactValue(entry, seen),
|
|
81
|
+
]));
|
|
82
|
+
}
|
|
83
|
+
function scrubHeaders(headers) {
|
|
84
|
+
if (!headers || typeof headers !== "object")
|
|
85
|
+
return;
|
|
86
|
+
for (const key of Object.keys(headers)) {
|
|
87
|
+
if (isSensitiveKey(key)) {
|
|
88
|
+
headers[key] = REDACTED;
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
function scrubRequestHolder(holder) {
|
|
93
|
+
if (!holder || typeof holder !== "object")
|
|
94
|
+
return;
|
|
95
|
+
const target = holder;
|
|
96
|
+
scrubHeaders(target.config?.headers);
|
|
97
|
+
// The raw ClientRequest keeps the serialized header block, including X-Password.
|
|
98
|
+
if (target.request !== undefined) {
|
|
99
|
+
target.request = undefined;
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
/**
|
|
103
|
+
* Removes credentials from a rejected Axios error in place and returns it, so
|
|
104
|
+
* callers that inspect, log or serialize the error cannot expose them.
|
|
105
|
+
* Status, code, message and response data stay available.
|
|
106
|
+
*/
|
|
107
|
+
export function scrubRequestError(error) {
|
|
108
|
+
if (!error || typeof error !== "object")
|
|
109
|
+
return error;
|
|
110
|
+
scrubRequestHolder(error);
|
|
111
|
+
const response = error.response;
|
|
112
|
+
scrubRequestHolder(response);
|
|
113
|
+
// A device that echoes the rejected password in its error body.
|
|
114
|
+
if (response && typeof response.data === "string") {
|
|
115
|
+
response.data = redactText(response.data);
|
|
116
|
+
}
|
|
117
|
+
return error;
|
|
118
|
+
}
|
|
119
|
+
/**
|
|
120
|
+
* Firmware configuration items that hold credentials. The Ultimate config API
|
|
121
|
+
* lists items by display name (for example "Network Password" in the
|
|
122
|
+
* "Network settings" category), so matching is by item name: passwords,
|
|
123
|
+
* passphrases and pre-shared keys, plus a bare "key" item in a Wi-Fi category.
|
|
124
|
+
*/
|
|
125
|
+
const SENSITIVE_CONFIG_ITEM = /pass(word|wd|phrase|code)?\b|\bpsk\b|secret/i;
|
|
126
|
+
const WIFI_CATEGORY = /wi-?fi|wlan|wireless/i;
|
|
127
|
+
const BARE_KEY_ITEM = /^(wifi |wlan )?(key|wpa key|network key)$/i;
|
|
128
|
+
export function isSensitiveConfigItem(category, item) {
|
|
129
|
+
if (!item)
|
|
130
|
+
return false;
|
|
131
|
+
if (SENSITIVE_CONFIG_ITEM.test(item))
|
|
132
|
+
return true;
|
|
133
|
+
return BARE_KEY_ITEM.test(item.trim()) && (item.toLowerCase().includes("wifi") || WIFI_CATEGORY.test(category ?? ""));
|
|
134
|
+
}
|
|
135
|
+
/**
|
|
136
|
+
* Returns a copy of a firmware configuration response in which the value of
|
|
137
|
+
* every credential item is replaced with `***`. Item names stay, so a client can
|
|
138
|
+
* see that the item exists. `category` and `item` describe the request: when an
|
|
139
|
+
* item was requested and the response is its bare value, the value is replaced.
|
|
140
|
+
*/
|
|
141
|
+
export function redactConfigValue(value, category, item) {
|
|
142
|
+
if (item && isSensitiveConfigItem(category, item)) {
|
|
143
|
+
return value !== null && typeof value === "object" ? redactConfigTree(value, category) : REDACTED;
|
|
144
|
+
}
|
|
145
|
+
return redactConfigTree(value, category);
|
|
146
|
+
}
|
|
147
|
+
function redactConfigTree(value, category) {
|
|
148
|
+
if (Array.isArray(value)) {
|
|
149
|
+
return value.map((entry) => redactConfigTree(entry, category));
|
|
150
|
+
}
|
|
151
|
+
if (value === null || typeof value !== "object") {
|
|
152
|
+
return value;
|
|
153
|
+
}
|
|
154
|
+
const out = {};
|
|
155
|
+
for (const [key, entry] of Object.entries(value)) {
|
|
156
|
+
if (isSensitiveConfigItem(category, key) && entry !== null && entry !== undefined) {
|
|
157
|
+
out[key] = REDACTED;
|
|
158
|
+
}
|
|
159
|
+
else {
|
|
160
|
+
// Keys at the top level of a response are category names.
|
|
161
|
+
out[key] = redactConfigTree(entry, entry !== null && typeof entry === "object" && !Array.isArray(entry) ? key : category);
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
return out;
|
|
165
|
+
}
|
|
166
|
+
export const REDACTED_PLACEHOLDER = REDACTED;
|
package/dist/tools/developer.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { defineToolModule } from "./types.js";
|
|
2
|
-
import { objectSchema, optionalSchema, stringSchema, } from "./schema.js";
|
|
2
|
+
import { booleanSchema, objectSchema, optionalSchema, stringSchema, } from "./schema.js";
|
|
3
3
|
import { jsonResult, textResult } from "./responses.js";
|
|
4
|
+
import { redactConfigValue } from "../redact.js";
|
|
4
5
|
import { ToolError, ToolExecutionError, ToolValidationError, toolErrorResult, unknownErrorResult, } from "./errors.js";
|
|
5
6
|
function toRecord(details) {
|
|
6
7
|
if (details === undefined || details === null) {
|
|
@@ -61,6 +62,10 @@ const configGetArgsSchema = objectSchema({
|
|
|
61
62
|
properties: {
|
|
62
63
|
category: categorySchema,
|
|
63
64
|
item: itemSchema,
|
|
65
|
+
includeSecretValues: optionalSchema(booleanSchema({
|
|
66
|
+
description: "Return credential values (network password, FTP/Telnet passwords, Wi-Fi keys) in plain text. Default false: those values are replaced with ***.",
|
|
67
|
+
default: false,
|
|
68
|
+
}), false),
|
|
64
69
|
},
|
|
65
70
|
required: ["category"],
|
|
66
71
|
additionalProperties: false,
|
|
@@ -190,7 +195,10 @@ export const developerModule = defineToolModule({
|
|
|
190
195
|
category: parsed.category,
|
|
191
196
|
item: parsed.item ?? null,
|
|
192
197
|
});
|
|
193
|
-
const
|
|
198
|
+
const raw = await ctx.client.configGet(parsed.category, parsed.item);
|
|
199
|
+
const details = parsed.includeSecretValues === true
|
|
200
|
+
? raw
|
|
201
|
+
: redactConfigValue(raw, parsed.category, parsed.item);
|
|
194
202
|
return withStructuredJson({ value: details }, {
|
|
195
203
|
success: true,
|
|
196
204
|
category: parsed.category,
|
package/dist/tools/input.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { createOperationDispatcher, defineToolModule, discriminatedUnionSchema, } from "./types.js";
|
|
2
2
|
import { arraySchema, literalSchema, numberSchema, objectSchema, optionalSchema, stringSchema, } from "./schema.js";
|
|
3
3
|
import { jsonResult, textResult } from "./responses.js";
|
|
4
|
-
import { ToolValidationError, toolErrorResult, unknownErrorResult, } from "./errors.js";
|
|
4
|
+
import { ToolExecutionError, ToolValidationError, toolErrorResult, unknownErrorResult, } from "./errors.js";
|
|
5
5
|
function isMissingNativeInputEndpoint(error) {
|
|
6
6
|
return typeof error === "object"
|
|
7
7
|
&& error !== null
|
|
@@ -9,7 +9,7 @@ function isMissingNativeInputEndpoint(error) {
|
|
|
9
9
|
&& error.response?.status === 404;
|
|
10
10
|
}
|
|
11
11
|
function nativeInputUnavailableResult(operation) {
|
|
12
|
-
return
|
|
12
|
+
return toolErrorResult(new ToolExecutionError(`Native physical input is unavailable on this firmware, so ${operation} was not sent. Use c64://platform/status before choosing native input; use key or write_text for ordinary C64 input.`, { code: "native_input_unavailable", details: { fallback: "key_or_write_text" } }));
|
|
13
13
|
}
|
|
14
14
|
// ---------------------------------------------------------------------------
|
|
15
15
|
// PETSCII token expansion table
|
|
@@ -342,7 +342,7 @@ export const machineControlModule = defineToolModule({
|
|
|
342
342
|
if (error instanceof ToolError)
|
|
343
343
|
return toolErrorResult(error);
|
|
344
344
|
if (isMissingMenuScreenEndpoint(error)) {
|
|
345
|
-
return
|
|
345
|
+
return toolErrorResult(new ToolExecutionError("No menu matrix is available. Open the Ultimate menu and retry; if it remains unavailable, this firmware does not provide machine:menu_screen. Use REST configuration instead of menu navigation where possible.", { code: "native_menu_screen_unavailable", details: { fallback: "rest_configuration_or_manual_menu" }, cause: error }));
|
|
346
346
|
}
|
|
347
347
|
return unknownErrorResult(error);
|
|
348
348
|
}
|
|
@@ -4,13 +4,14 @@ import { ToolError, ToolUnsupportedPlatformError, ToolExecutionError, ToolValida
|
|
|
4
4
|
import { getPlatformStatus } from "../../platform.js";
|
|
5
5
|
import { promises as fs } from "node:fs";
|
|
6
6
|
import { dirname, resolve as resolvePath } from "node:path";
|
|
7
|
-
import { captureConfigSnapshot, validateSnapshotCategories } from "./configInventory.js";
|
|
7
|
+
import { captureConfigSnapshot, stripRedactedSecrets, validateSnapshotCategories } from "./configInventory.js";
|
|
8
8
|
import { normalizeErrorDetails } from "./util.js";
|
|
9
9
|
const configSnapshotAndRestoreArgsSchema = objectSchema({
|
|
10
10
|
description: "Snapshot or restore full device configuration. Snapshot writes versioned JSON.",
|
|
11
11
|
properties: {
|
|
12
12
|
action: stringSchema({ description: "One of: snapshot|restore|diff", enum: ["snapshot", "restore", "diff"] }),
|
|
13
13
|
path: stringSchema({ description: "Snapshot file path", minLength: 1 }),
|
|
14
|
+
includeSecretValues: optionalSchema(booleanSchema({ description: "Snapshot and diff only: store credential values (network password, FTP/Telnet passwords, Wi-Fi keys) in plain text in the snapshot file. Default false: they are written as ***, and restore skips them.", default: false }), false),
|
|
14
15
|
applyToFlash: optionalSchema(booleanSchema({ description: "Save configuration to flash after restore (C64U/U64 and U2 only)", default: false }), false),
|
|
15
16
|
},
|
|
16
17
|
required: ["action", "path"],
|
|
@@ -37,7 +38,7 @@ export const tools = [
|
|
|
37
38
|
const [version, info, snapshotData] = await Promise.all([
|
|
38
39
|
ctx.client.version(),
|
|
39
40
|
ctx.client.info(),
|
|
40
|
-
captureConfigSnapshot(ctx.client),
|
|
41
|
+
captureConfigSnapshot(ctx.client, { includeSecretValues: parsed.includeSecretValues === true }),
|
|
41
42
|
]);
|
|
42
43
|
const snapshot = {
|
|
43
44
|
createdAt: new Date().toISOString(),
|
|
@@ -61,7 +62,7 @@ export const tools = [
|
|
|
61
62
|
}
|
|
62
63
|
let payload;
|
|
63
64
|
try {
|
|
64
|
-
payload = validateSnapshotCategories(snapshot.categories);
|
|
65
|
+
payload = stripRedactedSecrets(validateSnapshotCategories(snapshot.categories));
|
|
65
66
|
}
|
|
66
67
|
catch {
|
|
67
68
|
throw new ToolValidationError("Invalid snapshot file", { path: "$.path" });
|
|
@@ -81,7 +82,7 @@ export const tools = [
|
|
|
81
82
|
const text = await fs.readFile(path, "utf8");
|
|
82
83
|
const snapshot = JSON.parse(text);
|
|
83
84
|
const snapCats = validateSnapshotCategories(snapshot.categories ?? {});
|
|
84
|
-
const current = (await captureConfigSnapshot(ctx.client)).categories;
|
|
85
|
+
const current = (await captureConfigSnapshot(ctx.client, { includeSecretValues: parsed.includeSecretValues === true })).categories;
|
|
85
86
|
const diff = {};
|
|
86
87
|
for (const [cat, snapVal] of Object.entries(snapCats)) {
|
|
87
88
|
const curVal = current[cat];
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { isSensitiveConfigItem, redactConfigValue, REDACTED_PLACEHOLDER } from "../../redact.js";
|
|
1
2
|
export function normalizeConfigInventory(details) {
|
|
2
3
|
const categories = details?.categories;
|
|
3
4
|
if (!Array.isArray(categories)) {
|
|
@@ -55,12 +56,15 @@ export function normalizeConfigInventory(details) {
|
|
|
55
56
|
}
|
|
56
57
|
return inventory;
|
|
57
58
|
}
|
|
58
|
-
export async function captureConfigSnapshot(client) {
|
|
59
|
+
export async function captureConfigSnapshot(client, options = {}) {
|
|
59
60
|
const inventory = normalizeConfigInventory(await client.configsList());
|
|
60
61
|
const categories = {};
|
|
61
62
|
for (const entry of inventory) {
|
|
62
63
|
try {
|
|
63
|
-
const
|
|
64
|
+
const normalized = normalizeSnapshotValue(await client.configGet(entry.category, entry.item), entry);
|
|
65
|
+
const value = options.includeSecretValues
|
|
66
|
+
? normalized
|
|
67
|
+
: redactConfigValue(normalized, entry.category, entry.item);
|
|
64
68
|
setSnapshotValue(categories, entry, value);
|
|
65
69
|
}
|
|
66
70
|
catch (error) {
|
|
@@ -71,6 +75,25 @@ export async function captureConfigSnapshot(client) {
|
|
|
71
75
|
}
|
|
72
76
|
return { inventory, categories };
|
|
73
77
|
}
|
|
78
|
+
/**
|
|
79
|
+
* Drops redacted credential placeholders from a snapshot payload before it is
|
|
80
|
+
* written back, so restoring a default snapshot never sets a password to `***`.
|
|
81
|
+
*/
|
|
82
|
+
export function stripRedactedSecrets(categories) {
|
|
83
|
+
const strip = (category, value) => {
|
|
84
|
+
if (value === null || typeof value !== "object" || Array.isArray(value))
|
|
85
|
+
return value;
|
|
86
|
+
const kept = {};
|
|
87
|
+
for (const [key, entry] of Object.entries(value)) {
|
|
88
|
+
if (entry === REDACTED_PLACEHOLDER && isSensitiveConfigItem(category, key))
|
|
89
|
+
continue;
|
|
90
|
+
// Per-item snapshot entries keep the firmware wrapper: { category: { item: value } }.
|
|
91
|
+
kept[key] = strip(entry !== null && typeof entry === "object" && !Array.isArray(entry) ? key : category, entry);
|
|
92
|
+
}
|
|
93
|
+
return kept;
|
|
94
|
+
};
|
|
95
|
+
return Object.fromEntries(Object.entries(categories).map(([category, items]) => [category, strip(category, items)]));
|
|
96
|
+
}
|
|
74
97
|
export function validateSnapshotCategories(value) {
|
|
75
98
|
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
|
76
99
|
throw new Error("Snapshot categories payload must be an object");
|
|
@@ -74,7 +74,8 @@ export const tools = [
|
|
|
74
74
|
version,
|
|
75
75
|
info,
|
|
76
76
|
};
|
|
77
|
-
|
|
77
|
+
const result = jsonResult(report, { success: report.isHealthy });
|
|
78
|
+
return report.isHealthy ? result : { ...result, isError: true };
|
|
78
79
|
}
|
|
79
80
|
catch (error) {
|
|
80
81
|
if (error instanceof ToolError)
|
|
@@ -2,7 +2,7 @@ import { defineToolModule, } from "../types.js";
|
|
|
2
2
|
import { getPlatformStatus } from "../../platform.js";
|
|
3
3
|
import { arraySchema, booleanSchema, objectSchema, optionalSchema, stringSchema, } from "../schema.js";
|
|
4
4
|
import { jsonResult } from "../responses.js";
|
|
5
|
-
import {
|
|
5
|
+
import { unknownErrorResult } from "../errors.js";
|
|
6
6
|
const commandSchema = objectSchema({
|
|
7
7
|
description: "A single batch command.",
|
|
8
8
|
properties: {
|
|
@@ -155,12 +155,6 @@ export const batchModuleGroup = defineToolModule({
|
|
|
155
155
|
return jsonResult(summary, { success: failed === 0, succeeded, failed, total: parsed.commands.length });
|
|
156
156
|
}
|
|
157
157
|
catch (error) {
|
|
158
|
-
if (error instanceof ToolValidationError) {
|
|
159
|
-
return {
|
|
160
|
-
content: [{ type: "text", text: error.message }],
|
|
161
|
-
isError: true,
|
|
162
|
-
};
|
|
163
|
-
}
|
|
164
158
|
return unknownErrorResult(error);
|
|
165
159
|
}
|
|
166
160
|
},
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
# Ultimate network password handling audit
|
|
2
|
+
|
|
3
|
+
**Summary: yes, a leak exists.** An Ultimate request failure can leave the password in an Axios error's request configuration, which reaches stderr when a screen read fails during BASIC execution or polling. This is a source audit, supplemented by a local synthetic Axios serialization/console check; it does not establish that a real password was exposed. Diagnostics usually reduce actual `Error` objects to selected fields, and ordinary MCP error conversion usually uses only the message, but neither is a general secret boundary. Firmware-returned data and arbitrary error messages are not redacted.
|
|
4
|
+
|
|
5
|
+
## Paths checked
|
|
6
|
+
|
|
7
|
+
- **Password source and transport — leak (error object).** `src/config.ts:139`–`146` and `src/config.ts:201`–`205` resolve the environment or configuration password; `src/c64Client.ts:351`–`354`, `src/device.ts:221`–`230`, `src/device.ts:1550`–`1551`, and `src/mcp-server.ts:642`–`645` attach it to `X-Password`. Generated `HttpClient.request` returns the Axios request directly (`generated/c64/index.ts:299`–`334`). The HTTP interceptor rejects the original error (`src/loggingHttpClient.ts:49`–`57`), preserving `error.config.headers`. The installed Axios implementation serializes config in `AxiosError.toJSON` unless opt-in redaction is configured (`node_modules/axios/lib/core/AxiosError.js:171`–`196`); a synthetic `JSON.stringify` of an Axios error with this header printed the password. This is an in-process disclosure to error consumers, and makes downstream serialization dangerous.
|
|
8
|
+
|
|
9
|
+
- **HTTP logging — safe for the configured header on the normal interceptor path.** `src/loggingHttpClient.ts:17`–`23` redacts sensitive header names case-insensitively; request metadata stores the redacted copy (`:32`–`:45`), and success/error debug records use it (`:63`–`:115`). The error summary uses only the message (`src/logger.ts:191`–`199`). Request bodies, parameters, response bodies and messages are not redacted, so a server echo or a password entered separately as data is outside this safe verdict.
|
|
10
|
+
|
|
11
|
+
- **General logger and console — leak.** `src/logger.ts:37`–`47` passes details to console unchanged; `src/bootstrap/stdio-logger.ts:25`–`33` directs console output to stderr. `C64Client.readScreen` calls `readMemoryRaw` (`src/c64Client.ts:900`–`904`), which rethrows a non-unsupported facade error (`:2169`–`:2177`). After BASIC upload, `src/tools/programRunners.ts:381`–`385` logs that raw error as details (`:34`–`:41`); Node console inspection prints Axios `config.headers`, including the password. With debug logging enabled, `src/tools/pollValidator.ts:169`–`170` and `:192`–`:193` likewise log the raw error (also `:255`–`:256`, `:279`–`:280`). Other console sites were inspected by search: startup connectivity logs a message rather than the Axios object (`src/mcp-server.ts:650`–`660`), while fatal logging prints an arbitrary raw error (`:738`–`:745`), so it has the same unsafe sink if such an error reaches it. `src/config.ts:62`–`69` prints JSON parser error messages; malformed configuration can put a fragment of the file in that message, so this is another potential credential disclosure, dependent on the malformed input and Node version.
|
|
12
|
+
|
|
13
|
+
- **Diagnostic event files — safe for ordinary Axios Error configuration; otherwise unclear.** `writeDiagnosticEvent` calls `sanitize` before appending NDJSON (`src/diagnostics.ts:112`–`128`); actual `Error` instances are reduced to name, message, stack, code and sanitized cause, omitting Axios config (`:444`–`:453`). This applies to the raw connectivity error (`src/mcp-server.ts:650`–`659`), span failures (`src/diagnostics.ts:198`–`211`), MCP failure events (`src/mcp-server.ts:395`–`415`), and process handlers (`src/diagnostics.ts:410`–`422`). However, plain objects are traversed without key redaction (`:483`–`:488`), and strings, messages and stacks are retained (`:445`–`:456`); call-tool arguments are recorded (`src/mcp-server.ts:350`–`357`). Thus a serialized error, echoed credential, or user-supplied password can be persisted; no automatic flow from the configured header into those plain-object events was established. Diagnostics are enabled by default outside tests unless explicitly disabled (`src/diagnostics.ts:434`–`441`).
|
|
14
|
+
|
|
15
|
+
- **MCP results and errors — safe for ordinary Axios config; unclear for returned firmware data.** `C64Client.normaliseError` selects message, status and response data for Axios errors (`src/c64Client.ts:2426`–`2439`); for example `readMemory` returns those details (`:927`–`:949`). `unknownErrorResult` uses an Error's message rather than serializing the Error (`src/tools/errors.ts:104`–`121`), while `toolErrorResult` includes supplied details (`:78`–`:93`) and `toCallToolResult` passes result fields through (`src/mcp-server.ts:690`–`713`). `version`, `info` and `configGet` can propagate raw Axios errors (`src/device.ts:357`–`358`, `:418`–`:422`; `src/c64Client.ts:1145`–`1152`, `:1416`–`:1423`), but their ordinary tool catches use the message. No direct serialization of one of these raw Axios errors into a client result was established. Response data is unfiltered, however: `config_get` returns firmware values (`src/tools/developer.ts:219`–`237`); whether firmware exposes its password there or echoes it on error is not established. User-supplied config values can also be returned (`:255`–`:280`).
|
|
16
|
+
|
|
17
|
+
- **MCP resources and diagnostic reports — unclear for indirect content.** Platform status constructs backend and capability text, not endpoint credentials (`src/mcp-server.ts:197`–`219`, `:543`–`:604`); resource failures log messages, record sanitized errors and rethrow (`:236`–`:249`). `performance_report` reads diagnostic records and returns a summary (`src/tools/meta/diagnostics.ts:75`–`99`; `src/diagnostics.ts:235`–`249`, `:256` onward), so any sensitive content retained in the diagnostic timeline may reach a client. No direct configured-header-to-resource leak was established.
|
|
18
|
+
|
|
19
|
+
- **Thrown and rethrown errors — leak across internal error boundary.** The original Axios error is rethrown by `readMemoryRaw` (`src/c64Client.ts:2169`–`2177`), by `withDiagnosticSpan` (`src/diagnostics.ts:198`–`211`), and by stream retry and capture paths (`src/c64Client.ts:1963`–`1971`, `:1978`–`:1983`, `:1633`–`:1638`, `:1883`–`:1895`). It still contains the request config; `JSON.stringify` invokes Axios's `toJSON` and exposes the header without opt-in redaction. `ensureStreamSuccess` embeds serialized result details in a new error (`:2023`–`:2027`), but no proven route from a raw Axios config into those details was found. Raw errors reaching generic console sinks remain a concrete risk.
|
|
20
|
+
|
|
21
|
+
- **Other files — unclear for firmware-returned secrets.** Config snapshot collection stores returned values (`src/tools/meta/configInventory.ts:86`–`101`) and snapshot writes them to a file (`src/tools/meta/config.ts:40`–`54`). Whether the firmware returns the network password is unverified; the bridge does not insert its configured header value into the snapshot itself.
|
|
22
|
+
|
|
23
|
+
## Recommended fixes
|
|
24
|
+
|
|
25
|
+
1. In `createLoggingHttpClient`, replace or sanitize rejected Axios errors before they leave the transport, removing sensitive headers from config and request objects; apply the same boundary to direct Axios calls in `logConnectivity` and `C64uBackend.ping`. Preserve useful status and message fields without retaining credential-bearing causes.
|
|
26
|
+
2. In `createPrefixedLogger` and the error logging in `pollBasicOutcome`, the ASM polling function, and BASIC program execution, log safe error summaries rather than raw objects; in `loadConfig`, avoid printing parser excerpts from configuration files. Review the fatal `main().catch` console sink as well.
|
|
27
|
+
3. In `sanitize` and `writeDiagnosticEvent`, redact sensitive keys recursively and handle serialized Axios errors, arguments and error strings deliberately. In `normaliseError`, `unknownErrorResult`, `toolErrorResult`, and `toCallToolResult`, enforce a safe output boundary for errors and echoed data.
|
|
28
|
+
4. In `config_get`'s `execute` and `captureConfigSnapshot`, decide how sensitive firmware configuration values should be handled and redact them if returned. Add focused tests with a synthetic password covering stderr, diagnostics, serialized errors, tool responses and snapshots.
|
|
29
|
+
|
|
30
|
+
## Outcome of the fix
|
|
31
|
+
|
|
32
|
+
The audit findings above describe the code before this change. This change adds one shared helper, `src/redact.ts`, and applies it at each sink.
|
|
33
|
+
|
|
34
|
+
- `registerSecret` records the password. It is called from `resolveUltimateEndpoint` (`src/config.ts`), from `createLoggingHttpClient` (which reads the `X-Password` header it is configured with), and before the direct connectivity probe in `src/mcp-server.ts`.
|
|
35
|
+
- `scrubRequestError` is applied to every rejected request in `createLoggingHttpClient`. It replaces sensitive values in `error.config.headers` and `error.response.config.headers`, clears `error.request` and `error.response.request` (the raw client request keeps the serialised header block), and redacts the password in a string response body. The status, code, message and response data otherwise remain available to callers. This closes the "thrown and rethrown errors" and `JSON.stringify` leaks.
|
|
36
|
+
- `redactValue` and `redactText` are applied in `createPrefixedLogger` (`src/logger.ts`) to the message and details, in `sanitize` (`src/diagnostics.ts`) before the existing size limiting, in `toCallToolResult` (`src/mcp-server.ts`) to `content`, `structuredContent` and `metadata`, and to the fatal error in `main().catch`. `redactValue` reduces `Error` objects to name, message, stack, code, status and cause, redacts credential header names at any depth, and replaces registered secrets in all strings.
|
|
37
|
+
- `loadConfig` now logs only the error name for an unreadable configuration file, because the JSON parser message can quote part of the file.
|
|
38
|
+
|
|
39
|
+
Remaining limitation: redaction replaces the exact registered password string. A transformed form of the password (for example base64-encoded) is not detected. Firmware configuration values are handled separately, see below.
|
|
40
|
+
|
|
41
|
+
Regression test: `test/secretRedaction.test.mjs` configures a distinctive password through `C64U_PASSWORD`, then drives HTTP 401 (with the password echoed in the body), HTTP 403, a client timeout and a refused connection against local servers. It asserts that the password is absent from captured console output, diagnostics files and records, `util.inspect`, `JSON.stringify`, `toJSON` and `stack` of the errors, and from `toCallToolResult` output for `toolErrorResult` and `unknownErrorResult`. With `src/redact.ts` replaced by no-op functions and the other `src/` changes reverted, the test fails with "password leaked in: AxiosError: Request failed with status code 401". With the fix it passes. `npm test` passes.
|
|
42
|
+
|
|
43
|
+
## Firmware-returned configuration values
|
|
44
|
+
|
|
45
|
+
**Evidence in the repository.** The repository does not list individual configuration item names for the credential settings. What it records:
|
|
46
|
+
|
|
47
|
+
- `doc/c64u/c64-rest-api.md:17`: firmware 3.12 has an optional "Network Password".
|
|
48
|
+
- `doc/c64u/c64-rest-api.md:93`–`95` (and the captured `scripts/compare-rest/rest-compare-2025-11-01T00-25-21-830Z.json:371`–`376`): the category list contains "Network settings", "WiFi settings" and "Modem Settings". These are the categories that can hold network, Wi-Fi and service credentials (network password, FTP/Telnet passwords, Wi-Fi key).
|
|
49
|
+
- `doc/c64u/c64-rest-api.md:108`–`143`: category reads return `{ "<Category>": { "<Item>": value } }`, and item reads return `{ "<Category>": { "<Item>": { current, min, max, ... } } }`.
|
|
50
|
+
- `doc/c64u/c64-openapi.yaml` and `doc/u2/u2-openapi.yaml` describe `X-Password` only; they define no password item.
|
|
51
|
+
|
|
52
|
+
Whether the firmware returns the password through `/v1/configs` is therefore not established by repository data. The fix assumes that it can.
|
|
53
|
+
|
|
54
|
+
**Behaviour.** `config_get` and `config_snapshot_and_restore` (snapshot and diff) now replace the value of credential items with `***` and keep the item name. An item is treated as a credential when its name contains `password`, `passwd`, `passphrase`, `passcode`, `psk` or `secret`, or is a bare Wi-Fi `key`. Matching is by item name (`isSensitiveConfigItem` in `src/redact.ts`), not by category, so it also applies to items in categories not listed above. For item reads, the whole item object (including `min`/`max`/`default`) is replaced by `***`.
|
|
55
|
+
|
|
56
|
+
- Opt-in: `includeSecretValues: true` on `config_get` and on `config_snapshot_and_restore` returns or stores the values in plain text. Without the flag the values are never returned or written. The configured network password is still replaced by the registered-secret redaction in `toCallToolResult`, even with the flag.
|
|
57
|
+
- Restore: a default snapshot contains `***`. `stripRedactedSecrets` removes those placeholders from the restore payload, so a restore never sets a password to the literal `***`. Credentials in a default snapshot are therefore not restored. Snapshots made with `includeSecretValues: true` restore them.
|
|
58
|
+
- Diff: both sides are redacted by default, so a changed password is not reported unless the flag is set when the snapshot is taken and when diffing.
|
|
59
|
+
- Limitation: detection is by item name. A credential item with a name that does not match the patterns above is not redacted.
|
|
60
|
+
|
|
61
|
+
**Test.** `test/configSecretRedaction.test.mjs` uses a mocked firmware with "Network Password", "FTP Password", "Telnet Password" and a Wi-Fi "Password". It asserts redaction in the `config_get` result and in the written snapshot file, that other values (Hostname, Volume, access point name) are unchanged, that restore does not write `***`, and that the opt-in flag returns plain values. With `isSensitiveConfigItem` changed to always return false, 3 of its 4 tests fail.
|