Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,8 @@ A session chain entry prints as one line: the step number within the session, th

An audit entry prints with its outcome and the failure that caused it. The banner is reprinted whenever an audit entry changes it. It shows the last posture an audit entry carried, which is the observed posture and not the negotiated one, and the running count of fail-open proceeds.

Every field read from a log is escaped before it is printed. The Guardian records an envelope's method and id before it validates them, and it is unauthenticated, so a request author chooses what those fields hold. Each C0 control (including newline), DEL and each C1 control prints as a visible `\xNN` escape, for example `\x1b[2J`, so a field cannot clear the screen, move the cursor, set the window title or start a second line. The Inspector's own colours are added after the escape and are unchanged. Characters that are not controls, such as bidirectional overrides, are printed as they are.

The three logs are tailed independently. The audit log's raw session id is never matched against the envelope log's derived one; the [host adapter README](../host-adapter/README.md#the-audit-log) says why they differ.

## Files
Expand All @@ -54,11 +56,12 @@ The three logs are tailed independently. The audit log's raw session id is never
| `src/main.ts` | The `bun run inspector` entry point, flag parsing, and the loop |
| `src/tail-envelope-log.ts`, `src/tail-audit-log.ts`, `src/tail-session-context.ts` | One tailer per log |
| `src/render.ts` | Every rendered line, the decision badge, the posture banner, and the chain check |
| `src/terminal-safe.ts` | The control-character escape every logged field passes through |

## Tests

```bash
bun test packages/inspector
```

Four files. They render fixed entries and tail fixed files.
Five files. They render fixed entries, render hostile fields, and tail fixed files.
58 changes: 36 additions & 22 deletions reference-implementations/agt/packages/inspector/src/render.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,10 +29,17 @@
* `renderDecisionBadge` for a decision, `renderRpcError` for a response that
* carried none -- and `renderOutcome` dispatches between them for the
* stream renderer.
*
* Every string a renderer takes from a log goes through `safe` (see
* terminal-safe.ts) before it is interpolated, and always before `paint`
* wraps it: the logs record what a request author chose, and a control
* character written to a terminal is an instruction to it. The Inspector's
* own ANSI styling is added afterwards and is not touched.
*/
import type { AuditEntry } from "./tail-audit-log.ts";
import type { EnvelopeLogEntry } from "./tail-envelope-log.ts";
import type { SessionContextLogEntry } from "./tail-session-context.ts";
import { escapeControlCharacters as safe, escapeControlCharactersKeepingNewlines } from "./terminal-safe.ts";

export type RenderOptions = { color?: boolean; indent?: number };

Expand Down Expand Up @@ -93,7 +100,7 @@ function paint(text: string, color: string, enabled: boolean): string {
* locale-independent, so rendered output is the same everywhere. */
function clockOf(recordedAt: string): string {
const time = recordedAt.slice(11, 23);
return time.length === 12 ? time : recordedAt;
return safe(time.length === 12 ? time : recordedAt);
}

function stringList(value: unknown): string[] {
Expand All @@ -116,7 +123,9 @@ function policyReferenceList(value: unknown): PolicyReference[] {
* not require `rule_id` on a policy_reference, so that fallback is a real
* shape this renders deliberately, not a defect. */
function formatReferences(references: PolicyReference[]): string[] {
return references.map((ref) => (ref.rule_id ? `${ref.policy_id ?? "?"}#${ref.rule_id}` : `${ref.policy_id ?? "?"}`));
return references.map((ref) =>
ref.rule_id ? `${safe(ref.policy_id ?? "?")}#${safe(ref.rule_id)}` : `${safe(ref.policy_id ?? "?")}`,
);
}

/**
Expand Down Expand Up @@ -166,8 +175,8 @@ export function outcomeMessageOf(entry: EnvelopeLogEntry): OutcomeMessage | null
*/
export function renderRpcError(message: RpcErrorMessage, options: RenderOptions = {}): string {
const color = options.color ?? false;
const code = message.code ?? "?";
const text = message.message;
const code = safe(message.code ?? "?");
const text = safe(message.message);
return paint(`✖ ERROR ${code}${text ? ` ${text}` : ""}`, RED, color);
}

Expand All @@ -193,7 +202,7 @@ export function renderDecisionBadge(message: DecisionMessage, options: RenderOpt
} else if (message.decision === "allow") {
head = paint("○ ALLOW", GREEN, color);
} else {
head = paint(`◆ ${message.decision.toUpperCase()}`, CYAN, color);
head = paint(`◆ ${safe(message.decision).toUpperCase()}`, CYAN, color);
}

// Dimmed rather than left plain: with color:true, painting only `head`
Expand All @@ -202,7 +211,7 @@ export function renderDecisionBadge(message: DecisionMessage, options: RenderOpt
// color:false output the exact-string tests above assert byte-for-byte.
const parts = [head];
if (reasonCodes.length > 0) {
parts.push(paint(`reason_codes=[${reasonCodes.join(", ")}]`, DIM, color));
parts.push(paint(`reason_codes=[${reasonCodes.map(safe).join(", ")}]`, DIM, color));
}
if (references.length > 0) {
parts.push(paint(`policy_references=[${references.join(", ")}]`, DIM, color));
Expand Down Expand Up @@ -234,18 +243,23 @@ export function renderOutcome(message: OutcomeMessage, options: RenderOptions =
export function renderEnvelopeLogEntry(entry: EnvelopeLogEntry, options: RenderOptions = {}): string {
const color = options.color ?? false;
const arrow = { request: "→ REQUEST ", response: "← RESPONSE", finding: "! FINDING " }[entry.direction];
const method = entry.method ?? "(no method)";
const id = entry.rpc_id === null ? "(unpaired)" : `id=${entry.rpc_id}`;
const method = safe(entry.method ?? "(no method)");
const id = entry.rpc_id === null ? "(unpaired)" : `id=${safe(entry.rpc_id)}`;

const header = paint(`── #${entry.seq} ${clockOf(entry.recorded_at)} ${arrow} ${method} ${id}`, DIM, color);
const header = paint(`── #${safe(entry.seq)} ${clockOf(entry.recorded_at)} ${arrow} ${method} ${id}`, DIM, color);
const message = outcomeMessageOf(entry);
const outcome = message === null ? null : renderOutcome(message, options);
// `JSON.stringify` returns `undefined` -- not a string -- for an entry
// whose `envelope` key is absent, and `join` would coerce that to an empty
// line indistinguishable from a real blank body. `isEnvelopeLogEntryShape`
// does not require `envelope` (it is `unknown` by design), so a
// hand-written or truncated envelope-log line can reach here without one.
const body = JSON.stringify(entry.envelope, null, options.indent ?? 2) ?? "(no envelope recorded)";
// `JSON.stringify` escapes C0 controls inside strings but passes DEL and C1
// controls through, so the body is escaped too -- keeping the newlines the
// pretty-printer itself put between lines.
const body = escapeControlCharactersKeepingNewlines(
JSON.stringify(entry.envelope, null, options.indent ?? 2) ?? "(no envelope recorded)",
);

return [header, ...(outcome === null ? [] : [outcome]), body].join("\n");
}
Expand Down Expand Up @@ -324,22 +338,22 @@ export function renderAuditEntry(entry: AuditEntry, options: RenderOptions = {})
const color = options.color ?? false;
const outcomeLabel = entry.outcome === "blocked" ? "BLOCKED" : entry.outcome === "proceeded" ? "PROCEEDED" : "UNGOVERNED";
const outcomeColor = entry.outcome === "blocked" ? RED : YELLOW;
const id = entry.rpc_id === null ? "(unpaired)" : `id=${entry.rpc_id}`;
const id = entry.rpc_id === null ? "(unpaired)" : `id=${safe(entry.rpc_id)}`;
// Same fallback renderEnvelopeLogEntry uses for a log line with no method,
// and for the same reason: an entry written before any request could be built
// has no ACS method, and this renderer has no other vocabulary to fall back
// on.
const method = entry.method ?? "(no method)";
const method = safe(entry.method ?? "(no method)");

// No `posture=` on an ungoverned line, because no posture was consulted --
// printing this session's declared one there would read as "the deployment
// chose to proceed", which is not what happened. The writer does not carry
// the field on that arm at all, so this is the type being honest rather
// than this renderer choosing to omit something it was given.
const postureField = entry.outcome === "ungoverned" ? "" : `posture=${entry.posture}/${entry.posture_source} `;
const postureField = entry.outcome === "ungoverned" ? "" : `posture=${safe(entry.posture)}/${safe(entry.posture_source)} `;
const header = paint(
`── #${entry.seq} ${clockOf(entry.recorded_at)} ${outcomeLabel} ${method} ${id} ` +
`${postureField}audit_session=${entry.session_id}`,
`── #${safe(entry.seq)} ${clockOf(entry.recorded_at)} ${outcomeLabel} ${method} ${id} ` +
`${postureField}audit_session=${safe(entry.session_id)}`,
outcomeColor,
color,
);
Expand All @@ -348,21 +362,21 @@ export function renderAuditEntry(entry: AuditEntry, options: RenderOptions = {})
// exists to make visible is a `tools` list that stopped matching the names
// the host sends, and neither half shows it alone.
if (entry.outcome === "ungoverned") {
const declared = entry.ungoverned.tools.length === 0 ? "(none)" : entry.ungoverned.tools.join(", ");
return [header, paint(`ungoverned=${entry.ungoverned.tool}: not in this gate's tools [${declared}]`, DIM, color)].join(
const declared = entry.ungoverned.tools.length === 0 ? "(none)" : entry.ungoverned.tools.map(safe).join(", ");
return [header, paint(`ungoverned=${safe(entry.ungoverned.tool)}: not in this gate's tools [${declared}]`, DIM, color)].join(
"\n",
);
}

const failureLine = paint(`failure=${entry.failure.kind}: ${entry.failure.message}`, DIM, color);
const failureLine = paint(`failure=${safe(entry.failure.kind)}: ${safe(entry.failure.message)}`, DIM, color);
// A second, separately labelled line rather than a merged one: the session's
// configuration failing and this step's decision failing are different
// events, and the whole point of recording both is that neither gets
// attributed to the other.
const sessionLine =
entry.session_failure === undefined
? []
: [paint(`session_failure=${entry.session_failure.kind}: ${entry.session_failure.message}`, DIM, color)];
: [paint(`session_failure=${safe(entry.session_failure.kind)}: ${safe(entry.session_failure.message)}`, DIM, color)];

return [header, failureLine, ...sessionLine].join("\n");
}
Expand Down Expand Up @@ -470,10 +484,10 @@ export function renderSessionChainRow(
const findings =
entry.findings === undefined || entry.findings.length === 0
? ""
: ` ! ${entry.findings.join(", ")}${entry.request_id_ref === undefined ? "" : ` ref=${entry.request_id_ref}`}`;
: ` ! ${entry.findings.map(safe).join(", ")}${entry.request_id_ref === undefined ? "" : ` ref=${safe(entry.request_id_ref)}`}`;
const row =
`#${entry.seq} ${entry.tool_name} hash=${entry.hash.slice(0, SHORT_HASH_LENGTH)} ` +
`session_id=${entry.session_id}${findings}`;
`#${safe(entry.seq)} ${safe(entry.tool_name)} hash=${safe(entry.hash.slice(0, SHORT_HASH_LENGTH))} ` +
`session_id=${safe(entry.session_id)}${findings}`;
return link.broken ? paint(`✖ CHAIN BREAK ${row}`, RED, color) : row;
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,7 @@
* not its absence was ever reported.
*/
import { closeSync, existsSync, openSync, readSync, statSync } from "node:fs";
import { escapeControlCharacters } from "./terminal-safe.ts";

/** What every audit line carries, whichever kind it is. */
type AuditEntryIdentity = {
Expand Down Expand Up @@ -433,7 +434,9 @@ function reportPollError(onPollError: (error: unknown) => void, error: unknown):

function warnMalformedLine(line: string, error: unknown): void {
const message = error instanceof Error ? error.message : String(error);
console.error(`skipping unparseable audit-log line (${message}): ${line.slice(0, 120)}`);
console.error(
`skipping unparseable audit-log line (${escapeControlCharacters(message)}): ${escapeControlCharacters(line.slice(0, 120))}`,
);
}

function warnPollError(error: unknown): void {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,7 @@
* line.
*/
import { closeSync, existsSync, openSync, readSync, statSync } from "node:fs";
import { escapeControlCharacters } from "./terminal-safe.ts";

// "finding" is a fact the Guardian established about a request that the spec
// requires it to record, such as an unresolved request_id_ref. Its envelope is
Expand Down Expand Up @@ -315,7 +316,9 @@ function reportMalformedLine(

function warnMalformedLine(line: string, error: unknown): void {
const message = error instanceof Error ? error.message : String(error);
console.error(`skipping unparseable envelope-log line (${message}): ${line.slice(0, 120)}`);
console.error(
`skipping unparseable envelope-log line (${escapeControlCharacters(message)}): ${escapeControlCharacters(line.slice(0, 120))}`,
);
}

function warnPollError(error: unknown): void {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@
* entries happened to interleave.
*/
import { closeSync, existsSync, openSync, readSync, statSync } from "node:fs";
import { escapeControlCharacters } from "./terminal-safe.ts";

/**
* The Inspector's own reader-side entry type. Declares its own shape rather
Expand Down Expand Up @@ -348,7 +349,9 @@ function reportPollError(onPollError: (error: unknown) => void, error: unknown):

function warnMalformedLine(line: string, error: unknown): void {
const message = error instanceof Error ? error.message : String(error);
console.error(`skipping unparseable session-context-log line (${message}): ${line.slice(0, 120)}`);
console.error(
`skipping unparseable session-context-log line (${escapeControlCharacters(message)}): ${escapeControlCharacters(line.slice(0, 120))}`,
);
}

function warnPollError(error: unknown): void {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
/**
* Makes a string from a log safe to write to an operator's terminal.
*
* The logs this package tails hold values a request author chose: the
* Guardian records an envelope's `method` and `id` before it validates them,
* and it is unauthenticated. JSON escapes control characters on disk, but the
* log is parsed before it is rendered, so a field arrives here as the raw
* characters again. Written to a terminal as they are, an ESC byte starts a
* sequence the terminal obeys -- clear the screen, move the cursor over the
* DENY line, retitle the window, write the clipboard -- and a C1 control
* (U+0080-U+009F) does the same on terminals that decode it.
*
* Each such character is replaced by a visible `\xNN` escape instead of being
* dropped, so an operator reading a line can still see that something was
* there. The set matches `[\x00-\x1f\x7f-\x9f]`, which covers every C0
* control, DEL, and every C1 control, and therefore the introducers of CSI,
* OSC, DCS and the other escape sequences. What remains of a sequence, such
* as `[2J`, is printable text and does nothing.
*
* This is applied to a field before the renderer paints it, never to the
* painted result, so the Inspector's own ANSI styling is left intact.
*
* It does not touch printable characters, so it does not address bidirectional
* overrides or other Unicode that renders confusingly without being a control.
*/

const CONTROL_CHARACTERS = /[\u0000-\u001f\u007f-\u009f]/g;
const CONTROL_CHARACTERS_EXCEPT_NEWLINE = /[\u0000-\u0009\u000b-\u001f\u007f-\u009f]/g;

function escapeCharacter(character: string): string {
return `\\x${character.charCodeAt(0).toString(16).padStart(2, "0")}`;
}

/** Escapes every control character in `value`, newlines included, so the
* result is guaranteed to stay on one line. Use for a single field. */
export function escapeControlCharacters(value: unknown): string {
return String(value).replace(CONTROL_CHARACTERS, escapeCharacter);
}

/** Like `escapeControlCharacters`, but leaves `\n` alone. Use for text whose
* line structure the renderer itself produced, such as pretty-printed JSON:
* `JSON.stringify` escapes C0 controls inside strings, but not DEL or C1. */
export function escapeControlCharactersKeepingNewlines(value: string): string {
return value.replace(CONTROL_CHARACTERS_EXCEPT_NEWLINE, escapeCharacter);
}
Loading
Loading