Skip to content

Commit 8efa433

Browse files
fix(tui): write the command history owner-only
The pit persists every line typed at the mosh prompt to ~/.moshcode_history, and the documented flows put secrets on those lines: `/mcp install <url> -H "Authorization: Bearer …"`, `/secrets`, `/coinpay`, and the `!` shell escape. saveHistory() passed no mode, so the file was created 0644 under a normal umask and every other account on the machine could read it. Write it 0600 and chmod it on every save, since `mode` is ignored when the file already exists — that is what fixes the history files existing installs have already written. Matches what auth.mjs already does for credentials.json.
1 parent 5029919 commit 8efa433

2 files changed

Lines changed: 54 additions & 2 deletions

File tree

src/tui.mjs

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -37,7 +37,14 @@ function loadHistory() {
3737
}
3838
function saveHistory() {
3939
try {
40-
fs.writeFileSync(HISTORY_FILE, history.slice(0, HISTORY_MAX).join("\n") + "\n");
40+
// Owner-only, like credentials.json: the pit records whatever was typed at
41+
// the prompt, and that includes secrets by design — `/mcp install <url> -H
42+
// "Authorization: Bearer …"`, `/secrets`, `/coinpay`, and `!` shell escapes.
43+
fs.writeFileSync(HISTORY_FILE, history.slice(0, HISTORY_MAX).join("\n") + "\n", { mode: 0o600 });
44+
// `mode` only applies when the file is created, so a history file that
45+
// already exists keeps whatever the umask gave it (0644 on most systems).
46+
// Tighten it every save so existing installs get fixed too.
47+
fs.chmodSync(HISTORY_FILE, 0o600);
4148
} catch {
4249
/* best effort — history is a convenience, never fatal */
4350
}

test/tui.test.mjs

Lines changed: 46 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import assert from "node:assert/strict";
22
import { spawn, spawnSync } from "node:child_process";
3-
import { mkdirSync, mkdtempSync, writeFileSync } from "node:fs";
3+
import { chmodSync, mkdirSync, mkdtempSync, readFileSync, statSync, writeFileSync } from "node:fs";
44
import { tmpdir } from "node:os";
55
import { join } from "node:path";
66
import test from "node:test";
@@ -117,3 +117,48 @@ test("TUI /install rejects an Object.prototype name instead of crashing the pit"
117117
assert.match(result.stdout, /unknown engine or tool "constructor"/);
118118
assert.doesNotMatch(result.stderr, /TypeError/);
119119
});
120+
121+
// The pit persists every line typed at the prompt to ~/.moshcode_history, and
122+
// the documented flows put secrets on those lines (`/mcp install <url> -H
123+
// "Authorization: Bearer …"`, `/secrets`, `/coinpay`, `!export TOKEN=…`). The
124+
// file must be owner-only, the way credentials.json already is.
125+
const posixMode = { skip: process.platform === "win32" ? "POSIX permission bits" : false };
126+
127+
function runTuiWithHome(home, input) {
128+
return new Promise((resolve, reject) => {
129+
const child = spawn(process.execPath, [BIN], {
130+
stdio: ["pipe", "pipe", "pipe"],
131+
env: { ...process.env, HOME: home, USERPROFILE: home },
132+
});
133+
let stdout = "";
134+
let stderr = "";
135+
child.stdout.on("data", (chunk) => { stdout += chunk; });
136+
child.stderr.on("data", (chunk) => { stderr += chunk; });
137+
child.on("error", reject);
138+
child.on("close", (status, signal) => resolve({ status, signal, stdout, stderr }));
139+
child.stdin.end(input);
140+
});
141+
}
142+
143+
test("TUI writes the command history owner-only", posixMode, async () => {
144+
const home = mkdtempSync(join(tmpdir(), "moshcode-history-"));
145+
146+
const result = await runTuiWithHome(home, "/quit\n");
147+
148+
assert.equal(result.status, 0, result.stderr || result.stdout);
149+
const file = join(home, ".moshcode_history");
150+
assert.equal(statSync(file).mode & 0o777, 0o600);
151+
});
152+
153+
test("TUI tightens a history file that was already world-readable", posixMode, async () => {
154+
const home = mkdtempSync(join(tmpdir(), "moshcode-history-"));
155+
const file = join(home, ".moshcode_history");
156+
writeFileSync(file, "/mcp install https://mcp.example.com/sse -H \"Authorization: Bearer sk-live\"\n");
157+
chmodSync(file, 0o644);
158+
159+
const result = await runTuiWithHome(home, "/quit\n");
160+
161+
assert.equal(result.status, 0, result.stderr || result.stdout);
162+
assert.equal(statSync(file).mode & 0o777, 0o600);
163+
assert.match(readFileSync(file, "utf8"), /Bearer sk-live/); // history itself survives
164+
});

0 commit comments

Comments
 (0)