2026-10-23 14:15:14 -04:00
|
|
|
/**
|
|
|
|
|
* @fileoverview Short-lived, bounded guarded prompt receipts. No prompt content is
|
|
|
|
|
* retained: only caller/session identity, lifecycle/input revisions and SHA-256.
|
|
|
|
|
* A receipt is consumed synchronously before verification/delivery, including failures.
|
|
|
|
|
*/
|
|
|
|
|
import { createHash, randomUUID } from 'node:crypto';
|
|
|
|
|
import { stripAnsi } from './utils/regex-patterns.js';
|
|
|
|
|
import { visibleWidth } from './tui/tui-ansi.js';
|
2026-10-24 22:54:17 -04:00
|
|
|
import { compileVersionRegex } from './config/cli-registry/patterns.js';
|
|
|
|
|
import type { CliEntry } from './config/cli-registry/types.js';
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* The screen chrome a guarded composer read relies on, both from the CLI registry
|
|
|
|
|
* (`capabilities.workDetect`): the composer glyph and the pattern every row below an
|
|
|
|
|
* idle composer must match. Nothing here names a CLI; a CLI takes part in guarded
|
|
|
|
|
* input by declaring `composerFooterLine`.
|
|
|
|
|
*/
|
|
|
|
|
export interface GuardedComposerChrome {
|
|
|
|
|
promptGlyph: string;
|
|
|
|
|
footerLine: RegExp;
|
|
|
|
|
}
|
|
|
|
|
/** The chrome a registry entry declares, or null (no guarded input for that CLI). */
|
|
|
|
|
export function guardedComposerChrome(entry: CliEntry | undefined): GuardedComposerChrome | null {
|
|
|
|
|
const detect = entry?.capabilities.workDetect;
|
|
|
|
|
if (!detect?.promptGlyph || !detect.composerFooterLine) return null;
|
|
|
|
|
// Config-supplied (~/.codeman/clis.json): compiled through the ReDoS guard, null on refusal.
|
|
|
|
|
const footerLine = compileVersionRegex(detect.composerFooterLine);
|
|
|
|
|
return footerLine ? { promptGlyph: detect.promptGlyph, footerLine } : null;
|
|
|
|
|
}
|
|
|
|
|
const escapeRegex = (text: string) => text.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
|
|
|
const composerRow = (chrome: GuardedComposerChrome) =>
|
|
|
|
|
new RegExp(String.raw`^\s*${escapeRegex(chrome.promptGlyph)}(?:\s|$)`);
|
2026-10-23 14:15:14 -04:00
|
|
|
export const STAGE_MAX_BYTES = 8192;
|
|
|
|
|
export const promptSha256 = (text: string): string => createHash('sha256').update(text).digest('hex');
|
|
|
|
|
export interface GuardedInputSnapshot {
|
|
|
|
|
generation: string;
|
|
|
|
|
revision: number;
|
|
|
|
|
composerSha: string;
|
2026-10-23 16:22:46 -04:00
|
|
|
/** Actual captured pane width minus the rendered composer prefix (including indentation). */
|
|
|
|
|
availableColumns: number;
|
2026-10-23 14:15:14 -04:00
|
|
|
}
|
|
|
|
|
export interface GuardedInputTarget {
|
|
|
|
|
id: string;
|
|
|
|
|
snapshot(): GuardedInputSnapshot | null;
|
|
|
|
|
write(text: string): Promise<boolean>;
|
|
|
|
|
submit(expected: GuardedInputSnapshot): Promise<boolean>;
|
|
|
|
|
}
|
|
|
|
|
interface Receipt extends GuardedInputSnapshot {
|
|
|
|
|
owner: string;
|
|
|
|
|
sessionId: string;
|
|
|
|
|
expiresAt: number;
|
|
|
|
|
sha256: string;
|
|
|
|
|
}
|
|
|
|
|
export class GuardedInputConflict extends Error {}
|
|
|
|
|
|
|
|
|
|
/**
|
2026-10-24 22:54:17 -04:00
|
|
|
* Conservatively read a composer: exactly one visible, unwrapped row and a known
|
|
|
|
|
* footer, both as the registry describes them. Never remove internal spaces, infer
|
|
|
|
|
* an unknown shell prompt, or interpret dialog options as authority. An unsupported
|
|
|
|
|
* frame yields no proof.
|
2026-10-23 14:15:14 -04:00
|
|
|
*/
|
2026-10-24 22:54:17 -04:00
|
|
|
export function readGuardedComposer(screen: string, chrome: GuardedComposerChrome): string | null {
|
|
|
|
|
const isComposer = composerRow(chrome);
|
2026-10-23 14:15:14 -04:00
|
|
|
const lines = stripAnsi(screen)
|
|
|
|
|
.split('\n')
|
|
|
|
|
.map((line) => line.trimEnd());
|
2026-10-24 22:54:17 -04:00
|
|
|
const rows = lines.map((line, index) => ({ line, index })).filter((row) => isComposer.test(row.line));
|
2026-10-23 14:15:14 -04:00
|
|
|
const last = rows.at(-1);
|
|
|
|
|
if (!last) return null;
|
|
|
|
|
const below = lines.slice(last.index + 1).filter((line) => line.trim());
|
2026-10-24 22:54:17 -04:00
|
|
|
if (below.length === 0 || below.length > 3 || below.some((line) => !chrome.footerLine.test(line))) return null;
|
2026-10-23 14:15:14 -04:00
|
|
|
// Preserve exact printable content; only SGR-dim placeholder/suggestion text is excluded.
|
|
|
|
|
const raw = screen.split('\n')[last.index];
|
|
|
|
|
let dim = false;
|
|
|
|
|
let visible = '';
|
|
|
|
|
// eslint-disable-next-line no-control-regex
|
|
|
|
|
for (const token of raw.split(/(\x1b\[[0-9;]*m)/)) {
|
|
|
|
|
// eslint-disable-next-line no-control-regex
|
|
|
|
|
if (/^\x1b\[/.test(token)) {
|
|
|
|
|
const codes = token.slice(2, -1).split(';').map(Number);
|
|
|
|
|
for (let i = 0; i < codes.length; i++) {
|
|
|
|
|
// Extended color payloads may contain 0, 2 or 22; those are not SGR modes.
|
|
|
|
|
if (codes[i] === 38 || codes[i] === 48 || codes[i] === 58) {
|
|
|
|
|
i += codes[i + 1] === 2 ? 4 : codes[i + 1] === 5 ? 2 : 0;
|
|
|
|
|
} else if (codes[i] === 0 || codes[i] === 22) dim = false;
|
|
|
|
|
else if (codes[i] === 2) dim = true;
|
|
|
|
|
}
|
|
|
|
|
} else if (!dim) visible += token;
|
|
|
|
|
}
|
2026-10-24 22:54:17 -04:00
|
|
|
// The glyph and the one space after it are the prefix, not input.
|
|
|
|
|
return stripAnsi(visible)
|
|
|
|
|
.trimStart()
|
|
|
|
|
.slice(chrome.promptGlyph.length + 1)
|
|
|
|
|
.trimEnd();
|
2026-10-23 14:15:14 -04:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/** Require the capture's caret at the exact end of the unwrapped composer. */
|
2026-10-24 22:54:17 -04:00
|
|
|
export function readGuardedSnapshot(snapshot: string, chrome: GuardedComposerChrome): string | null {
|
|
|
|
|
return readGuardedSnapshotDetails(snapshot, chrome)?.composer ?? null;
|
2026-10-23 16:22:46 -04:00
|
|
|
}
|
|
|
|
|
/** Exact input-row prefix width, shared by proof and preflight wrapping checks. */
|
2026-10-24 22:54:17 -04:00
|
|
|
export function readGuardedSnapshotDetails(
|
|
|
|
|
snapshot: string,
|
|
|
|
|
chrome: GuardedComposerChrome
|
|
|
|
|
): { composer: string; prefixWidth: number } | null {
|
2026-10-23 14:15:14 -04:00
|
|
|
// formatPaneSnapshot emits one absolute row address per line, then the caret.
|
|
|
|
|
// Plain captures lack caret evidence and cannot prove invisible trailing edits.
|
|
|
|
|
// eslint-disable-next-line no-control-regex
|
|
|
|
|
const positions = [...snapshot.matchAll(/\x1b\[(\d+);(\d+)H/g)];
|
|
|
|
|
if (positions.length < 2) return null;
|
|
|
|
|
const caret = positions.at(-1)!;
|
|
|
|
|
if (caret.index! + caret[0].length !== snapshot.length) return null;
|
|
|
|
|
const rows: string[] = [];
|
|
|
|
|
for (let i = 0; i < positions.length - 1; i++) {
|
|
|
|
|
const position = positions[i];
|
|
|
|
|
if (Number(position[1]) !== i + 1 || position[2] !== '1') return null;
|
|
|
|
|
rows.push(snapshot.slice(position.index! + position[0].length, positions[i + 1].index));
|
|
|
|
|
}
|
2026-10-24 22:54:17 -04:00
|
|
|
const composer = readGuardedComposer(rows.join('\n'), chrome);
|
2026-10-23 14:15:14 -04:00
|
|
|
if (composer === null) return null;
|
2026-10-24 22:54:17 -04:00
|
|
|
const isComposer = composerRow(chrome);
|
|
|
|
|
let row = -1;
|
2026-10-23 14:15:14 -04:00
|
|
|
for (let i = rows.length - 1; i >= 0; i--)
|
2026-10-24 22:54:17 -04:00
|
|
|
if (isComposer.test(stripAnsi(rows[i]))) {
|
|
|
|
|
row = i;
|
2026-10-23 14:15:14 -04:00
|
|
|
break;
|
|
|
|
|
}
|
2026-10-24 22:54:17 -04:00
|
|
|
const prefix = stripAnsi(rows[row]).match(new RegExp(String.raw`^(\s*${escapeRegex(chrome.promptGlyph)}[ \u00a0])`));
|
|
|
|
|
if (!prefix || Number(caret[1]) !== row + 1 || Number(caret[2]) !== visibleWidth(prefix[1] + composer) + 1)
|
2026-10-23 14:15:14 -04:00
|
|
|
return null;
|
2026-10-23 16:22:46 -04:00
|
|
|
return { composer, prefixWidth: visibleWidth(prefix[1]) };
|
2026-10-23 14:15:14 -04:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export class GuardedStageStore {
|
|
|
|
|
private readonly receipts = new Map<string, Receipt>();
|
|
|
|
|
private readonly busy = new Set<string>();
|
|
|
|
|
constructor(
|
|
|
|
|
private readonly now = Date.now,
|
|
|
|
|
private readonly capacity = 1024,
|
|
|
|
|
private readonly ttlMs = 120000
|
|
|
|
|
) {}
|
|
|
|
|
async stage(target: GuardedInputTarget, owner: string, message: string) {
|
|
|
|
|
// eslint-disable-next-line no-control-regex
|
|
|
|
|
const hasControls = /[\x00-\x1f\x7f-\x9f]/.test(message);
|
|
|
|
|
if (
|
|
|
|
|
!message ||
|
|
|
|
|
message !== message.trimEnd() ||
|
|
|
|
|
Buffer.byteLength(message) > STAGE_MAX_BYTES ||
|
|
|
|
|
Buffer.from(message).toString('utf8') !== message ||
|
|
|
|
|
hasControls
|
|
|
|
|
)
|
|
|
|
|
throw new GuardedInputConflict(
|
|
|
|
|
'Stage requires printable single-line input within 8192 UTF8 bytes, without trailing whitespace'
|
|
|
|
|
);
|
|
|
|
|
const key = target.id;
|
|
|
|
|
if (this.busy.has(key)) throw new GuardedInputConflict('Another guarded input operation is in progress');
|
|
|
|
|
this.busy.add(key);
|
|
|
|
|
try {
|
|
|
|
|
const before = target.snapshot();
|
|
|
|
|
if (!before || before.composerSha !== promptSha256(''))
|
|
|
|
|
throw new GuardedInputConflict('An empty, verifiable composer is required');
|
2026-10-23 16:22:46 -04:00
|
|
|
if (!Number.isFinite(before.availableColumns) || visibleWidth(message) >= before.availableColumns)
|
|
|
|
|
throw new GuardedInputConflict('Message would reach the pane edge or wrap; shorten it or widen the pane');
|
2026-10-23 14:15:14 -04:00
|
|
|
for (const [id, receipt] of this.receipts)
|
|
|
|
|
if (receipt.expiresAt <= this.now() || receipt.sessionId === target.id) this.receipts.delete(id);
|
|
|
|
|
// Refuse capacity before typing. Do not evict a caller's still-live receipt.
|
|
|
|
|
// Include in-flight stages (this operation already owns one busy slot),
|
|
|
|
|
// so different sessions cannot type concurrently into the same last slot.
|
|
|
|
|
if (this.receipts.size + this.busy.size > this.capacity)
|
|
|
|
|
throw new GuardedInputConflict('Guarded stage receipt capacity reached');
|
|
|
|
|
if (!(await target.write(message)))
|
|
|
|
|
throw new GuardedInputConflict('Staging delivery failed; inspect the pane before trying again');
|
|
|
|
|
const after = target.snapshot();
|
2026-10-23 16:22:46 -04:00
|
|
|
if (
|
|
|
|
|
!after ||
|
|
|
|
|
after.generation !== before.generation ||
|
|
|
|
|
after.revision !== before.revision + 1 ||
|
|
|
|
|
after.availableColumns !== before.availableColumns
|
|
|
|
|
)
|
2026-10-23 14:15:14 -04:00
|
|
|
throw new GuardedInputConflict('Input or pane changed while staging; no receipt was issued');
|
|
|
|
|
const stageId = randomUUID();
|
|
|
|
|
const expiresAt = this.now() + this.ttlMs;
|
|
|
|
|
const sha256 = promptSha256(message);
|
|
|
|
|
this.receipts.set(stageId, { ...after, owner, sessionId: target.id, expiresAt, sha256 });
|
|
|
|
|
return { stageId, sha256, expiresAt, sessionId: target.id, submitted: false as const };
|
|
|
|
|
} finally {
|
|
|
|
|
this.busy.delete(key);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
async submit(target: GuardedInputTarget, owner: string, stageId: string, confirmSha: string) {
|
|
|
|
|
const receipt = this.receipts.get(stageId);
|
|
|
|
|
if (!receipt || receipt.owner !== owner || receipt.sessionId !== target.id)
|
|
|
|
|
throw new GuardedInputConflict('Unknown, used, or foreign stage receipt');
|
|
|
|
|
// One shot, even a wrong hash or unprovable composer. No retry of ambiguous Enter.
|
|
|
|
|
this.receipts.delete(stageId);
|
|
|
|
|
if (this.busy.has(target.id)) throw new GuardedInputConflict('Another guarded input operation is in progress');
|
|
|
|
|
this.busy.add(target.id);
|
|
|
|
|
try {
|
|
|
|
|
if (receipt.expiresAt <= this.now() || receipt.sha256 !== confirmSha)
|
|
|
|
|
throw new GuardedInputConflict('Expired stage receipt or confirmation hash mismatch');
|
|
|
|
|
const current = target.snapshot();
|
|
|
|
|
if (
|
|
|
|
|
!current ||
|
|
|
|
|
current.generation !== receipt.generation ||
|
|
|
|
|
current.revision !== receipt.revision ||
|
2026-10-23 16:22:46 -04:00
|
|
|
current.availableColumns !== receipt.availableColumns ||
|
2026-10-23 14:15:14 -04:00
|
|
|
current.composerSha !== receipt.sha256
|
|
|
|
|
)
|
|
|
|
|
throw new GuardedInputConflict('Staged composer, input, or pane identity changed; nothing submitted');
|
|
|
|
|
if (!(await target.submit(current)))
|
|
|
|
|
throw new GuardedInputConflict('Submission delivery failed or ambiguous; receipt consumed, inspect the pane');
|
|
|
|
|
return { stageId, sessionId: target.id, submitted: true as const };
|
|
|
|
|
} finally {
|
|
|
|
|
this.busy.delete(target.id);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
clear(): void {
|
|
|
|
|
this.receipts.clear();
|
|
|
|
|
this.busy.clear();
|
|
|
|
|
}
|
|
|
|
|
}
|