diff --git a/src/core/commands/daemon.js b/src/core/commands/daemon.js index 912a4fb3..0ecd1187 100644 --- a/src/core/commands/daemon.js +++ b/src/core/commands/daemon.js @@ -5,6 +5,7 @@ import { parseCommandArgv } from '../cli/verb_codec.js' import process from 'node:process' import { readObservabilityEnv } from '../observability/env.js' +import { sanitizeLabel } from '../util/json_util.js' /** * @import { CommandRunContext } from '../../../hypaware-plugin-kernel-types.js' @@ -56,6 +57,66 @@ export async function runDaemonRun(argv, ctx) { } } +/** + * How much of an `error=` line a hostile status file may spend. Wider than a + * label's 120, because unlike a name this carries a real error message - + * typically an fs error naming a full path - and the clamp exists to stop the + * line being bloated, not to bound an identifier. + */ +const MAX_ERROR_CHARS = 400 + +/** + * A value out of `status.json` on its way to the terminal, made safe to print. + * + * `hyp status` cleans what it reads back out of this same file at the last + * point before render, for a reason that is a property of the file and not of + * that command: `status.json` is a *file*, and core cannot assume the daemon + * that wrote it was this version, this build, or well behaved + * (LLP 0164#status-reads-it-from-the-status-file). Nothing validates a field + * on read, so a raw value can carry an escape sequence that repaints the + * operator's screen or a newline that forges a plausible extra status line. + * + * Cleaning happens here rather than in `readStatusFile` because the reader + * also feeds `--json`, which is the machine copy and must stay byte-exact: + * escaping is a render's job, never a read's (LLP 0225). + * + * @param {unknown} value + * @param {number} [max] + * @returns {string} + * @ref LLP 0164#status-reads-it-from-the-status-file [constrained-by]: what core reads back out of status.json is cleaned at the last point before render, on every surface that prints it + */ +function printable(value, max) { + return sanitizeLabel(value, max) ?? '' +} + +/** + * A field `DaemonStatus` types as a number, printed as itself when the file + * really holds one and cleaned as a label when it does not. `String` is safe + * over anything `JSON.parse` produced, so a wrong-typed field still shows + * *something* rather than vanishing into an empty column. + * + * @param {unknown} value + * @returns {string} + */ +function printableNumber(value) { + if (typeof value === 'number' && Number.isFinite(value)) return String(value) + return printable(String(value)) +} + +/** + * A `sources` / `sinks` entry list out of the status file. Anything that is + * not an array is no list at all: the walk below would throw straight out + * through the CLI, which is the same raw-stack failure the read is guarded + * against. + * + * @param {unknown} value + * @returns {Record[]} + */ +function entryList(value) { + if (!Array.isArray(value)) return [] + return value.filter((entry) => !!entry && typeof entry === 'object') +} + /** * @param {string[]} argv * @param {CommandRunContext} ctx @@ -65,8 +126,23 @@ export async function runDaemonStatus(argv, ctx) { const { readStatusFile } = await import('../daemon/status.js') const { readPidFile, processIsAlive } = await import('../daemon/pid.js') const stateDir = readObservabilityEnv(ctx.env).stateDir - const status = readStatusFile(stateDir) - const pidEntry = readPidFile(stateDir) + /** @type {ReturnType} */ + let status + /** @type {ReturnType} */ + let pidEntry + try { + status = readStatusFile(stateDir) + pidEntry = readPidFile(stateDir) + } catch (err) { + // Both readers throw on a file they cannot make sense of, and + // `JSON.parse`'s message quotes an excerpt of the input verbatim. Left + // uncaught that reached the operator as a raw stack trace carrying the + // file's own bytes: useless as a diagnosis, and the one path on which the + // file reached the terminal entirely unfiltered. + const message = err instanceof Error ? err.message : String(err) + ctx.stderr.write(`hyp daemon status: ${printable(message, MAX_ERROR_CHARS)}\n`) + return 1 + } const running = !!(pidEntry && processIsAlive(pidEntry.pid)) if (!status) { if (json) { @@ -84,26 +160,31 @@ export async function runDaemonStatus(argv, ctx) { ctx.stdout.write(JSON.stringify(payload, null, 2) + '\n') return 0 } - ctx.stdout.write(`daemon: ${status.state}${running ? '' : ' (no live process)'}\n`) - ctx.stdout.write(` pid: ${status.pid}\n`) - ctx.stdout.write(` startedAt: ${status.startedAt}\n`) - if (status.healthyAt) ctx.stdout.write(` healthyAt: ${status.healthyAt}\n`) - if (status.stoppedAt) ctx.stdout.write(` stoppedAt: ${status.stoppedAt}\n`) - ctx.stdout.write(` uptime_ms: ${liveUptimeMs}\n`) + // Everything below this line came out of the file and is going to a + // terminal, so everything below this line is cleaned on the way. + ctx.stdout.write(`daemon: ${printable(status.state)}${running ? '' : ' (no live process)'}\n`) + ctx.stdout.write(` pid: ${printableNumber(status.pid)}\n`) + ctx.stdout.write(` startedAt: ${printable(status.startedAt)}\n`) + if (status.healthyAt) ctx.stdout.write(` healthyAt: ${printable(status.healthyAt)}\n`) + if (status.stoppedAt) ctx.stdout.write(` stoppedAt: ${printable(status.stoppedAt)}\n`) + ctx.stdout.write(` uptime_ms: ${printableNumber(liveUptimeMs)}\n`) + const sources = entryList(status.sources) + const sinks = entryList(status.sinks) ctx.stdout.write(' sources:\n') - if (status.sources.length === 0) { + if (sources.length === 0) { ctx.stdout.write(' (none)\n') } else { - for (const source of status.sources) { - ctx.stdout.write(` - ${source.name} (${source.plugin}): ${source.state}${source.error ? ' - ' + source.error : ''}\n`) + for (const source of sources) { + const error = printable(source.error, MAX_ERROR_CHARS) + ctx.stdout.write(` - ${printable(source.name)} (${printable(source.plugin)}): ${printable(source.state)}${error ? ' - ' + error : ''}\n`) } } ctx.stdout.write(' sinks:\n') - if (status.sinks.length === 0) { + if (sinks.length === 0) { ctx.stdout.write(' (none)\n') } else { - for (const sink of status.sinks) { - ctx.stdout.write(` - ${sink.instance} (${sink.plugin}, ${sink.kind})\n`) + for (const sink of sinks) { + ctx.stdout.write(` - ${printable(sink.instance)} (${printable(sink.plugin)}, ${printable(sink.kind)})\n`) } } return 0 diff --git a/src/core/commands/status.js b/src/core/commands/status.js index 6f061bf1..83079c13 100644 --- a/src/core/commands/status.js +++ b/src/core/commands/status.js @@ -310,35 +310,43 @@ export function renderStatusJson({ report, clientNames, datasets, cacheRoot }) { } /** - * How much of the daemon line's `error=` a hostile status file may spend. - * Wider than a label's 120, because unlike a name this carries a real error - * message - typically an fs error naming a full path - and the clamp exists - * to stop the line being bloated, not to bound an identifier. Cutting a path - * short is the one way this cleaning could cost a reader an answer. + * How much of an `error` line a hostile file may spend. Wider than a label's + * 120, because unlike a name this carries a real error message - typically an + * fs or parser error naming a full path - and the clamp exists to stop the + * line being bloated, not to bound an identifier. Cutting a path short is the + * one way this cleaning could cost a reader an answer. */ -const MAX_DAEMON_ERROR_CHARS = 400 +const MAX_ERROR_CHARS = 400 /** * A string on its way into the text surface, made safe to print. * - * `renderStatusText` is the last point before a terminal, and several of the - * strings it interpolates were read back out of `status.json` - * (`daemon.state`, `daemon.mode`, and - with no runtime attached - every - * `sources[]` / `sinks[]` entry). That file is a *file*: core cannot assume - * the daemon that wrote it was this version, this build, or well behaved, so - * a raw value from it can carry an escape sequence that repaints the - * operator's screen or a newline that forges a plausible extra status line. + * `renderStatusText` is the last point before a terminal, and not everything + * it interpolates was written by this build. Several of these strings were + * read back out of `status.json` (`daemon.state`, `daemon.mode`, and - with + * no runtime attached - every `sources[]` / `sinks[]` entry), and that file + * is a *file*: core cannot assume the daemon that wrote it was this version, + * this build, or well behaved. The remote-config block prints etags authored + * by whatever server the install joined, read back through a local state file + * that nothing validates; the client block prints an attach probe's error, + * which for a settings file that is not valid JSON is `JSON.parse`'s message + * and therefore quotes an excerpt of that file verbatim. Any of them can + * carry an escape sequence that repaints the operator's screen or a newline + * that forges a plausible extra status line. * - * Cleaning happens *here* rather than in the collector because these values - * are not display-only: `sources[].name` and `sinks[].instance` are identity - * keys and part of the `--json` contract, which a consumer escapes for - * itself. Cleaning at the interpolation closes the terminal path and leaves - * both intact - including the raw values the provenance lookups below match - * on. + * Cleaning happens *here* rather than in the collector because `--json` + * renders the same values for a program, which escapes for itself and must + * receive what was actually recorded (LLP 0225): escaping is a render's job, + * and only of the render a person reads. That is not merely a display + * nicety, either - `sources[].name` and `sinks[].instance` are identity keys + * as well as `--json` contract, and cleaning at the interpolation closes the + * terminal path while leaving both intact, including the raw values the + * provenance lookups below match on. * - * @param {string | undefined} value + * @param {unknown} value * @param {number} [max] * @returns {string} + * @ref LLP 0225#decision [implements]: the text surface escapes what a person reads; --json stays byte-exact * @ref LLP 0164#status-reads-it-from-the-status-file [constrained-by]: what core reads back out of status.json is cleaned at the last point before render, whichever field it came from */ function printable(value, max) { @@ -421,7 +429,11 @@ export function renderStatusText({ report, clientNames, datasets, cacheRoot, std // @ref LLP 0229#status-derives-by-the-same-gate [implements]: the clients row says attach n/a, not "not attached", for a probe-less client state.push(c.attachable === false ? 'attach n/a' : c.attached ? 'attached' : 'not attached') stdout.write(` - ${c.name} [${state.join(', ')}]${provenanceTag(report.layered, isCentralPlugin(report.layered, c.plugin))}\n`) - if (c.error) stdout.write(` error: ${c.error}\n`) + // The probe's error is not this build's prose: a settings file that is + // not valid JSON surfaces here as `JSON.parse`'s message, which echoes + // an excerpt of the file back. The client wrote that file, so it is + // cleaned on the way into the line. + if (c.error) stdout.write(` error: ${printable(c.error, MAX_ERROR_CHARS)}\n`) } for (const name of clientNames) { if (seen.has(name)) continue @@ -536,16 +548,21 @@ export function renderStatusText({ report, clientNames, datasets, cacheRoot, std // show. A never-joined install keeps the V1 status surface. const rc = report.remoteConfig if (rc && (rc.runningEtag || rc.probation || rc.lastRollback || rc.badEtag)) { + // Every value in this block is remote-authored or read back out of a + // local state file that nothing validates, and all of it is display-only + // here, so all of it is cleaned at the interpolation. An etag is whatever + // the joined server put in the header; a `reason` and a timestamp are + // this build's own vocabulary only if this build wrote the file. stdout.write(' remote config:\n') - if (rc.runningEtag) stdout.write(` running etag: ${rc.runningEtag}\n`) + if (rc.runningEtag) stdout.write(` running etag: ${printable(rc.runningEtag)}\n`) if (rc.probation) { - stdout.write(` probation: ${rc.probation.etag} until ${rc.probation.until}\n`) + stdout.write(` probation: ${printable(rc.probation.etag)} until ${printable(rc.probation.until)}\n`) } if (rc.lastRollback) { - stdout.write(` last rollback: ${rc.lastRollback.etag} at ${rc.lastRollback.at} (${rc.lastRollback.reason})\n`) + stdout.write(` last rollback: ${printable(rc.lastRollback.etag)} at ${printable(rc.lastRollback.at)} (${printable(rc.lastRollback.reason)})\n`) } if (rc.badEtag) { - stdout.write(` bad etag: ${rc.badEtag.etag} (${rc.badEtag.reason})\n`) + stdout.write(` bad etag: ${printable(rc.badEtag.etag)} (${printable(rc.badEtag.reason)})\n`) } } @@ -705,7 +722,7 @@ function describeDaemon(daemon) { if (daemon.state) parts.push(`state=${printable(daemon.state)}`) if (daemon.pid) parts.push(`pid=${daemon.pid}`) if (daemon.mode) parts.push(`mode=${printable(daemon.mode)}`) - if (daemon.error) parts.push(`error=${printable(daemon.error, MAX_DAEMON_ERROR_CHARS)}`) + if (daemon.error) parts.push(`error=${printable(daemon.error, MAX_ERROR_CHARS)}`) return parts.join(', ') } diff --git a/src/core/daemon/status.js b/src/core/daemon/status.js index d8851833..497c38f5 100644 --- a/src/core/daemon/status.js +++ b/src/core/daemon/status.js @@ -1139,10 +1139,31 @@ export async function collectHypAwareStatus(opts = {}) { remoteConfig = readConfigControlStatus({ stateRoot }) } catch { /* best-effort probe */ } if (remoteConfig?.lastRollback) { + // The three values in this message are display-only, and none of them is + // this build's own: the etag is whatever the joined server served, and + // all three come back through `config-control/state.json`, which is read + // with no validation beyond "is an object". A diagnostic message is prose + // assembled for a person, so its components are cleaned here, where the + // prose is assembled, rather than in `readConfigControlStatus`. + // + // Be precise about what that does and does not mean. The message is + // assembled once, here, so the cleaned prose is also what `--json` carries + // at `diagnostics[].message`: a machine consumer of *that string* reads + // the sanitized line, not the file's bytes, with each component stripped + // and clamped to `sanitizeLabel`'s 120. That is accepted, not overlooked. + // The prose line is not a parsing surface on either render, and the + // unedited values stay one key away and structured, at + // `remote_config.last_rollback`, which is where a program reads them and + // which stays byte-exact. Cleaning at the single `${d.message}` + // interpolation in `renderStatusText` instead would keep the prose raw for + // `--json` too, but it would have to pick one clamp width that holds for + // every diagnostic kind, not just this one. + // @ref LLP 0225#decision [implements]: assembled prose is cleaned where it is assembled; the machine copy of the same values, `remote_config.last_rollback`, stays byte-exact + const rolledBack = remoteConfig.lastRollback diagnostics.push({ severity: 'warning', kind: 'remote_config_rolled_back', - message: `remote config ${remoteConfig.lastRollback.etag} rolled back at ${remoteConfig.lastRollback.at} (${remoteConfig.lastRollback.reason})`, + message: `remote config ${sanitizeLabel(rolledBack.etag) ?? ''} rolled back at ${sanitizeLabel(rolledBack.at) ?? ''} (${sanitizeLabel(rolledBack.reason) ?? ''})`, repair: ['fix the central config revision; the gateway re-applies when the served etag changes'], }) } diff --git a/test/core/daemon-status-hostile.test.js b/test/core/daemon-status-hostile.test.js new file mode 100644 index 00000000..1611e7ad --- /dev/null +++ b/test/core/daemon-status-hostile.test.js @@ -0,0 +1,254 @@ +// @ts-check + +import test from 'node:test' +import assert from 'node:assert/strict' +import fs from 'node:fs/promises' +import os from 'node:os' +import path from 'node:path' +import process from 'node:process' + +import { runDaemonStatus } from '../../src/core/commands/daemon.js' + +// `hyp daemon status` reads `status.json` and prints it. That file is a +// *file*: core cannot assume the daemon that wrote it was this version, this +// build, or well behaved, and everything read out of it is on its way to a +// terminal. `hyp status` already cleans what it reads back out of the same +// file at the last point before render (LLP 0164); this surface read the same +// bytes and printed them raw, so every field it prints was a way to repaint +// the operator's screen or forge a plausible extra status line. +// +// @ref LLP 0164#status-reads-it-from-the-status-file [tests]: what core reads back out of status.json is cleaned before it reaches a terminal, on every surface that prints it + +// Every character that drives or reorders a terminal, minus the newline this +// surface legitimately writes itself. +const CONTROL_EXCEPT_NEWLINE = /[\u0000-\u0009\u000b-\u001f\u007f-\u009f\u2028\u2029]/ + +// An erase-line sequence plus a newline: together enough to forge a plausible +// extra line the operator never chose to trust. +const ERASE_LINE = '\u001b[2K' +const ZERO_WIDTH = '\u200b' + +async function makeHome() { + const hypHome = await fs.mkdtemp(path.join(os.tmpdir(), 'hyp-daemon-status-hostile-')) + const runDir = path.join(hypHome, 'hypaware', 'run') + await fs.mkdir(runDir, { recursive: true }) + return { hypHome, runDir } +} + +/** + * Write `status.json` from arbitrary JSON rather than from a `DaemonStatus`: + * the whole point of these tests is what a file this build did not write can + * hold. + * + * @param {string} runDir + * @param {unknown} value + */ +async function writeRawStatus(runDir, value) { + await fs.writeFile(path.join(runDir, 'status.json'), JSON.stringify(value)) +} + +function makeBuf() { + let value = '' + return { + write(/** @type {string} */ chunk) { + value += String(chunk) + return true + }, + text() { + return value + }, + } +} + +/** + * @param {string} hypHome + * @param {string[]} [argv] + */ +async function run(hypHome, argv = []) { + const stdout = makeBuf() + const stderr = makeBuf() + const ctx = /** @type {any} */ ({ + env: { ...process.env, HYP_HOME: hypHome }, + stdout, + stderr, + argv, + }) + const code = await runDaemonStatus(argv, ctx) + return { code, out: stdout.text(), err: stderr.text() } +} + +test('a hostile state and timestamps cannot drive the terminal from hyp daemon status', async () => { + const { hypHome, runDir } = await makeHome() + await writeRawStatus(runDir, { + state: `healthy${ERASE_LINE}\ndaemon: all good`, + pid: 4321, + startedAt: '2026-05-21T00:00:00.000Z\u001b[31m', + healthyAt: '2026-05-21T00:00:01.000Z', + stoppedAt: `never${ZERO_WIDTH}${ZERO_WIDTH}`, + uptimeMs: 1000, + sources: [], + sinks: [], + }) + + const { code, out } = await run(hypHome) + + assert.equal(code, 0) + assert.ok(!CONTROL_EXCEPT_NEWLINE.test(out), 'no control byte reaches stdout') + assert.ok(!out.includes(ZERO_WIDTH), 'and no zero-width run does either') + assert.match(out, /daemon: healthy/, 'the printable part of the state still names it') + assert.ok( + !/^daemon: all good$/m.test(out), + 'the embedded newline cannot forge a second status line', + ) +}) + +test('a non-numeric pid and uptime from the status file are cleaned, not printed raw', async () => { + const { hypHome, runDir } = await makeHome() + await writeRawStatus(runDir, { + state: 'healthy', + // Both are typed `number`, and neither is validated on read: a file this + // build did not write can put anything here. + pid: `4321${ERASE_LINE}\ndaemon: all good`, + startedAt: '2026-05-21T00:00:00.000Z', + uptimeMs: `1000${ERASE_LINE}`, + sources: [], + sinks: [], + }) + + const { code, out } = await run(hypHome) + + assert.equal(code, 0) + assert.ok(!CONTROL_EXCEPT_NEWLINE.test(out), 'no control byte reaches stdout') + assert.match(out, /pid: +4321/, 'the printable part of the pid still shows') + assert.match(out, /uptime_ms: +1000/, 'and so does the uptime') +}) + +test('a well-formed pid and uptime still print as the numbers they are', async () => { + const { hypHome, runDir } = await makeHome() + await writeRawStatus(runDir, { + state: 'healthy', + pid: 4321, + startedAt: '2026-05-21T00:00:00.000Z', + uptimeMs: 1000, + sources: [], + sinks: [], + }) + + const { code, out } = await run(hypHome) + + assert.equal(code, 0) + assert.match(out, /pid: +4321\n/, 'a real pid is untouched') + assert.match(out, /uptime_ms: +1000\n/, 'and so is a real uptime') +}) + +test('hostile source and sink fields cannot drive the terminal from hyp daemon status', async () => { + const { hypHome, runDir } = await makeHome() + await writeRawStatus(runDir, { + state: 'healthy', + pid: 4321, + startedAt: '2026-05-21T00:00:00.000Z', + uptimeMs: 1000, + sources: [ + { + name: `gate${ERASE_LINE}way`, + plugin: `@hypaware/ai-${ZERO_WIDTH}${ZERO_WIDTH}gateway`, + state: 'failed\n - forged (x): started', + error: 'EADDRINUSE\u001b[31m', + }, + ], + sinks: [ + { + instance: `loc${ERASE_LINE}al`, + plugin: `@hypaware/local-${ZERO_WIDTH}fs`, + kind: 'blob\n - forged (y, z)', + }, + ], + }) + + const { code, out } = await run(hypHome) + + assert.equal(code, 0) + assert.ok(!CONTROL_EXCEPT_NEWLINE.test(out), 'no control byte reaches stdout') + assert.ok(!out.includes(ZERO_WIDTH), 'and no zero-width run does either') + assert.equal( + out.split('\n').filter((line) => line.startsWith(' - forged')).length, + 0, + 'an embedded newline cannot forge an extra source or sink row', + ) + // The ESC is dropped and the rest of the sequence stays as the ordinary + // text it is: cleaning bounds what a value *does*, not what it looks like. + assert.match(out, /- gate\[2Kway \(@hypaware\/ai-gateway\): failed/, 'the printable parts still read') + assert.match(out, /- loc\[2Kal \(@hypaware\/local-fs, blob/, 'on the sink line too') +}) + +test('an unbounded field from the status file is clamped', async () => { + const { hypHome, runDir } = await makeHome() + const long = 'a'.repeat(5000) + await writeRawStatus(runDir, { + state: long, + pid: 4321, + startedAt: '2026-05-21T00:00:00.000Z', + uptimeMs: 1000, + sources: [{ name: long, plugin: 'p', state: 's' }], + sinks: [], + }) + + const { code, out } = await run(hypHome) + + assert.equal(code, 0) + assert.ok(!out.includes(long), 'the raw value is not printed whole') + // `sanitizeLabel`'s 120-character clamp, truncation marker included. + assert.ok(out.includes('a'.repeat(117) + '...'), 'it is clamped, and marked truncated') +}) + +// A `status.json` that is not valid JSON reaches `JSON.parse`, whose message +// echoes an excerpt of the input verbatim. Uncaught, that surfaced as a raw +// stack trace with the file's own bytes in it: both useless to an operator and +// a way for the file to reach the terminal entirely unfiltered. +test('a malformed status file reports a clean error rather than a raw stack', async () => { + const { hypHome, runDir } = await makeHome() + await fs.writeFile(path.join(runDir, 'status.json'), `not json at all ${ERASE_LINE}`) + + const { code, out, err } = await run(hypHome) + + assert.equal(code, 1, 'a status file that cannot be read is a failure, not a healthy report') + assert.match(err, /^hyp daemon status: /, 'and it reads like every other daemon failure') + assert.ok(!/\n\s+at /.test(err), 'no stack frame reaches the operator') + assert.ok(!CONTROL_EXCEPT_NEWLINE.test(err), 'and no control byte from the file does either') + assert.equal(out, '', 'nothing is printed as if it were a status') +}) + +test('a status file holding the wrong shape does not crash the command', async () => { + const { hypHome, runDir } = await makeHome() + // Well-formed JSON, an object, and nothing else the reader expects: the + // `sources` / `sinks` walks used to throw straight out through the CLI. + await writeRawStatus(runDir, { state: 'healthy' }) + + const { code, out } = await run(hypHome) + + assert.equal(code, 0) + assert.match(out, /daemon: healthy/) + assert.match(out, /sources:\n {4}\(none\)/) + assert.match(out, /sinks:\n {4}\(none\)/) +}) + +// The cleaning is for the surface a person reads. `--json` is the machine +// copy and stays byte-exact, or a consumer that escapes for itself is handed +// something that no longer matches the file it is reporting on (LLP 0225). +test('hyp daemon status --json stays byte-exact', async () => { + const { hypHome, runDir } = await makeHome() + const hostile = `healthy${ERASE_LINE}\ndaemon: all good` + await writeRawStatus(runDir, { + state: hostile, + pid: 4321, + startedAt: '2026-05-21T00:00:00.000Z', + uptimeMs: 1000, + sources: [], + sinks: [], + }) + + const { code, out } = await run(hypHome, ['--json']) + + assert.equal(code, 0) + assert.equal(JSON.parse(out).state, hostile, 'the machine surface reports what the file holds') +}) diff --git a/test/core/status-hostile-non-status-file.test.js b/test/core/status-hostile-non-status-file.test.js new file mode 100644 index 00000000..d2485b01 --- /dev/null +++ b/test/core/status-hostile-non-status-file.test.js @@ -0,0 +1,314 @@ +// @ts-check + +import test from 'node:test' +import assert from 'node:assert/strict' +import fs from 'node:fs/promises' +import os from 'node:os' +import path from 'node:path' + +import { collectHypAwareStatus } from '../../src/core/daemon/status.js' +import { renderStatusJson, renderStatusText } from '../../src/core/commands/status.js' +import { defaultConfigPath } from '../../src/core/config/schema.js' + +// `status.json` is not the only file whose bytes reach `hyp status`'s text +// surface. Two more do: +// +// - `config-control/state.json` and the per-slot etag sidecar. An etag is +// authored by whatever server the install joined, and the state file is +// read back with no validation beyond "is an object", so a `reason` or a +// timestamp is this build's own vocabulary only if this build wrote it. +// - a client's own settings file, by way of the attach probe's `error`: a +// settings file that is not valid JSON surfaces as `JSON.parse`'s +// message, which quotes an excerpt of the file verbatim. +// +// Both are display-only on this surface and both were interpolated raw, so +// each was a way for a file the operator never chose to trust to repaint the +// screen or forge a plausible extra status line. +// +// @ref LLP 0225#decision [tests]: the render a person reads is cleaned, whichever file the string came from; --json is not + +const CONTROL_EXCEPT_NEWLINE = /[\u0000-\u0009\u000b-\u001f\u007f-\u009f\u2028\u2029]/ + +// An erase-line sequence plus a newline: together enough to forge a plausible +// extra line the operator never chose to trust. +const ERASE_LINE = '\u001b[2K' +const ZERO_WIDTH = '\u200b' + +async function makeHome() { + const hypHome = await fs.mkdtemp(path.join(os.tmpdir(), 'hyp-status-hostile-')) + await fs.mkdir(path.join(hypHome, 'hypaware'), { recursive: true }) + await fs.writeFile(defaultConfigPath(hypHome), JSON.stringify({ version: 2, plugins: [] }) + '\n') + return hypHome +} + +/** @param {string} hypHome */ +function env(hypHome) { + return { ...process.env, HYP_HOME: hypHome, HYP_CONFIG: '' } +} + +function makeBuf() { + let value = '' + return { + write(/** @type {string} */ chunk) { + value += String(chunk) + return true + }, + text() { + return value + }, + } +} + +/** + * Seed the apply engine's on-disk state. Written as raw JSON rather than + * through the engine because the point is what a file this build did not + * write can hold. + * + * @param {string} hypHome + * @param {Record} state + */ +async function writeControlState(hypHome, state) { + const controlDir = path.join(hypHome, 'hypaware', 'config-control') + await fs.mkdir(controlDir, { recursive: true }) + await fs.writeFile(path.join(controlDir, 'state.json'), JSON.stringify(state)) +} + +/** @param {string} hypHome */ +async function render(hypHome) { + const report = await collectHypAwareStatus({ env: env(hypHome) }) + const stdout = makeBuf() + renderStatusText({ + report, + clientNames: [], + datasets: [], + cacheRoot: path.join(hypHome, 'hypaware', 'cache'), + stdout: /** @type {any} */ (stdout), + }) + return { report, text: stdout.text() } +} + +const HOSTILE_ETAG = `W/"rev-9${ERASE_LINE}\n running etag: W/"trusted"` + +test('a hostile remote-config etag cannot drive the terminal from hyp status', async () => { + const hypHome = await makeHome() + await writeControlState(hypHome, { + probation: { + etag: HOSTILE_ETAG, + applied_at: '2026-05-21T00:00:00.000Z', + until: `2026-05-21T00:05:00.000Z${ERASE_LINE}`, + slot: 'a', + previous_slot: 'b', + }, + bad_etag: { + etag: `W/"rev-8${ZERO_WIDTH}${ZERO_WIDTH}"`, + reason: `probation_expired${ERASE_LINE}`, + recorded_at: '2026-05-21T00:00:00.000Z', + }, + }) + + const { text } = await render(hypHome) + + assert.match(text, /remote config:/, 'the block is rendered at all') + assert.ok(!CONTROL_EXCEPT_NEWLINE.test(text), 'no control byte reaches the text surface') + assert.ok(!text.includes(ZERO_WIDTH), 'and no zero-width run does either') + assert.equal( + text.split('\n').filter((line) => line.startsWith(' running etag:')).length, + 0, + 'the embedded newline cannot forge a running-etag line the file never had', + ) +}) + +test('a hostile rollback reason cannot drive the terminal, in the block or the diagnostic', async () => { + const hypHome = await makeHome() + await writeControlState(hypHome, { + last_rollback: { + etag: `W/"rev-7${ERASE_LINE}`, + reason: `probation_expired\n overall: healthy`, + at: `2026-05-21T00:00:00.000Z${ZERO_WIDTH}`, + }, + }) + + const { report, text } = await render(hypHome) + + const diag = report.diagnostics.find((d) => d.kind === 'remote_config_rolled_back') + assert.ok(diag, 'the rollback is diagnosed') + assert.ok(!CONTROL_EXCEPT_NEWLINE.test(diag.message), 'no control byte reaches the diagnostic') + assert.ok(!/\n/.test(diag.message), 'and the message stays one line') + assert.ok(!CONTROL_EXCEPT_NEWLINE.test(text), 'nor the text surface that prints it') + assert.ok(!text.includes(ZERO_WIDTH), 'and no zero-width run does either') + assert.equal( + text.split('\n').filter((line) => line.startsWith(' overall: healthy')).length, + 0, + 'the embedded newline cannot forge an overall line', + ) + assert.match(text, /last rollback: W\/"rev-7/, 'the printable part still names the revision') +}) + +test('an unbounded remote-config etag is clamped', async () => { + const hypHome = await makeHome() + const long = 'a'.repeat(5000) + await writeControlState(hypHome, { + last_rollback: { etag: long, reason: 'probation_expired', at: '2026-05-21T00:00:00.000Z' }, + }) + + const { text } = await render(hypHome) + + assert.ok(!text.includes(long), 'the raw etag is not printed whole') + // `sanitizeLabel`'s 120-character clamp, truncation marker included. + assert.ok(text.includes('a'.repeat(117) + '...'), 'it is clamped, and marked truncated') +}) + +// A client's attach probe error is the client's file talking, not this +// build's prose. +test('a hostile client probe error cannot drive the terminal from hyp status', async () => { + const hypHome = await makeHome() + const report = await collectHypAwareStatus({ env: env(hypHome) }) + report.clients = report.clients.filter((c) => c.name !== 'codex') + report.clients.push({ + name: 'codex', + plugin: '@hypaware/codex', + configured: false, + attachable: true, + attached: false, + // What `JSON.parse` says about a settings file whose bytes are hostile: + // the message quotes the input back. + error: `Unexpected token '${ERASE_LINE}', "${ERASE_LINE}\n - forged [configured, attached]" is not valid JSON`, + }) + + const stdout = makeBuf() + renderStatusText({ + report, + clientNames: [], + datasets: [], + cacheRoot: path.join(hypHome, 'hypaware', 'cache'), + stdout: /** @type {any} */ (stdout), + }) + const text = stdout.text() + + assert.match(text, /error: Unexpected token/, 'the error still reaches the surface') + assert.ok(!CONTROL_EXCEPT_NEWLINE.test(text), 'no control byte reaches it') + assert.equal( + text.split('\n').filter((line) => line.startsWith(' - forged')).length, + 0, + 'the embedded newline cannot forge an extra client row', + ) +}) + +test('an unbounded client probe error is clamped', async () => { + const hypHome = await makeHome() + const report = await collectHypAwareStatus({ env: env(hypHome) }) + const long = 'b'.repeat(5000) + report.clients = report.clients.filter((c) => c.name !== 'codex') + report.clients.push({ name: 'codex', plugin: '@hypaware/codex', configured: false, attachable: true, attached: false, error: long }) + + const stdout = makeBuf() + renderStatusText({ + report, + clientNames: [], + datasets: [], + cacheRoot: path.join(hypHome, 'hypaware', 'cache'), + stdout: /** @type {any} */ (stdout), + }) + const text = stdout.text() + + assert.ok(!text.includes(long), 'the raw error is not printed whole') + // Wider than a label's 120: an error message typically names a full path, + // and cutting that short is the one way this cleaning costs a reader. + assert.ok(text.includes('b'.repeat(397) + '...'), 'it is clamped at the error width') +}) + +// The cleaning is for the surface a person reads. `--json` is the machine +// copy, and a consumer that escapes for itself must receive what was +// recorded, not a version already edited on its behalf (LLP 0225). +test('hyp status --json still reports the etag and probe error the files hold', async () => { + const hypHome = await makeHome() + await writeControlState(hypHome, { + probation: { + etag: HOSTILE_ETAG, + applied_at: '2026-05-21T00:00:00.000Z', + until: '2026-05-21T00:05:00.000Z', + slot: 'a', + previous_slot: 'b', + }, + }) + const report = await collectHypAwareStatus({ env: env(hypHome) }) + const probeError = `Unexpected token '${ERASE_LINE}'` + report.clients = report.clients.filter((c) => c.name !== 'codex') + report.clients.push({ name: 'codex', plugin: '@hypaware/codex', configured: false, attachable: true, attached: false, error: probeError }) + + const payload = renderStatusJson({ + report, + clientNames: [], + datasets: [], + cacheRoot: path.join(hypHome, 'hypaware', 'cache'), + }) + + assert.equal(payload.remote_config?.probation?.etag, HOSTILE_ETAG) + assert.equal(payload.client_attach.find((c) => c.name === 'codex')?.error, probeError) +}) + +// The guard above seeds `probation`, which only ever reaches the text render, +// so on its own it would let you believe `--json` is untouched everywhere. It +// is not, and this pins the one place it is not. `remote_config_rolled_back`'s +// message is assembled once, in the collector, out of cleaned components, so +// the machine render receives that same cleaned prose. The split that actually +// holds is prose-versus-values, not text-versus-json: the assembled sentence is +// cleaned wherever it is read, and the values it was assembled from stay +// byte-exact one key away at `remote_config.last_rollback`. +test('hyp status --json carries the cleaned rollback prose, and the raw values beside it', async () => { + const hypHome = await makeHome() + const rollback = { + etag: `W/"rev-7${ERASE_LINE}`, + reason: 'probation_expired\nFORGED', + at: `2026-05-21T00:00:00.000Z${ZERO_WIDTH}`, + } + await writeControlState(hypHome, { last_rollback: rollback }) + + const report = await collectHypAwareStatus({ env: env(hypHome) }) + const payload = renderStatusJson({ + report, + clientNames: [], + datasets: [], + cacheRoot: path.join(hypHome, 'hypaware', 'cache'), + }) + + const diag = payload.diagnostics.find((d) => d.kind === 'remote_config_rolled_back') + assert.ok(diag, 'the rollback is diagnosed under --json too') + assert.ok(!CONTROL_EXCEPT_NEWLINE.test(diag.message), 'the --json message carries no control byte') + assert.ok(!diag.message.includes(ZERO_WIDTH), 'nor a zero-width run') + assert.ok(!diag.message.includes('\n'), 'and it is still one line under --json') + // The strip is a strip, not an escape (LLP 0225 #escape-not-strip applies to + // the query plane; this is the label plane), so the newline closes up and a + // consumer of this string cannot tell the two spellings apart. That is the + // cost of assembling the sentence once, and it is why the values below and + // not this sentence are what a program should read. + assert.ok(diag.message.includes('probation_expiredFORGED'), 'the stripped reason closes up') + + assert.deepEqual( + payload.remote_config?.last_rollback, + rollback, + 'the values the file holds are reported byte-exact under --json', + ) +}) + +test('a long rollback etag is clamped in the --json message but whole in the --json values', async () => { + const hypHome = await makeHome() + const long = 'a'.repeat(5000) + await writeControlState(hypHome, { + last_rollback: { etag: long, reason: 'probation_expired', at: '2026-05-21T00:00:00.000Z' }, + }) + + const report = await collectHypAwareStatus({ env: env(hypHome) }) + const payload = renderStatusJson({ + report, + clientNames: [], + datasets: [], + cacheRoot: path.join(hypHome, 'hypaware', 'cache'), + }) + + const diag = payload.diagnostics.find((d) => d.kind === 'remote_config_rolled_back') + assert.ok(diag, 'the rollback is diagnosed') + assert.ok(!diag.message.includes(long), 'the assembled sentence does not carry the whole etag') + assert.ok(diag.message.includes('a'.repeat(117) + '...'), 'it is clamped at a label width, and marked truncated') + assert.equal(payload.remote_config?.last_rollback?.etag, long, 'the values beside it are not clamped') +})