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
41 changes: 29 additions & 12 deletions lib/api/rate-limit.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,8 @@ export class RateLimiter {
private readonly map = new Map<string, Window>();
private readonly limit: number;
private readonly windowMs: number;
/** Wall-clock of the last full sweep; gates the amortized prune in `check`. */
private lastPrune = 0;

constructor({ limit, windowMs }: RateLimiterOptions) {
this.limit = limit;
Expand All @@ -41,30 +43,45 @@ export class RateLimiter {
const now = Date.now();
const cutoff = now - this.windowMs;

let w = this.map.get(key);
if (w === undefined) {
w = { hits: [] };
this.map.set(key, w);
// F-01 hardening: an attacker who can vary the key (e.g. by spoofing
// X-Forwarded-For) would otherwise add an entry per request that `check`
// never reclaimed, growing the map without bound until OOM. Sweep stale
// entries at most once per window so the map stays bounded by the number of
// *distinct keys seen within a single window* at amortized O(1) per call.
if (now - this.lastPrune >= this.windowMs) {
this.pruneBefore(cutoff);
this.lastPrune = now;
}

// Slide: drop timestamps older than the window.
w.hits = w.hits.filter((t) => t > cutoff);
const existing = this.map.get(key);
const hits = (existing?.hits ?? []).filter((t) => t > cutoff);

if (w.hits.length >= this.limit) {
if (hits.length >= this.limit) {
this.map.set(key, { hits });
return false;
}

w.hits.push(now);
hits.push(now);
this.map.set(key, { hits });
return true;
}

/** Remove stale entries (call periodically if many IPs are expected). */
prune(): void {
const cutoff = Date.now() - this.windowMs;
/** Remove every entry whose hits are all at/older than `cutoff`. */
private pruneBefore(cutoff: number): void {
for (const [key, w] of this.map) {
if (w.hits.every((t) => t <= cutoff)) {
if (w.hits.length === 0 || w.hits.every((t) => t <= cutoff)) {
this.map.delete(key);
}
}
}

/** Remove stale entries (call periodically if many IPs are expected). */
prune(): void {
this.pruneBefore(Date.now() - this.windowMs);
}

/** Current number of tracked keys. Test/observability only. */
size(): number {
return this.map.size;
}
}
1 change: 1 addition & 0 deletions lib/db/repos/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,3 +14,4 @@ export * from './scores';
export * from './capital-allocations';
export * from './attestations';
export * from './kill-switch';
export * from './operator-actions';
93 changes: 57 additions & 36 deletions lib/rail/byreal/envelope.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,56 +27,63 @@ export class ByrealParseError extends Error {
}
}

const metaSchema = z
.object({ timestamp: z.string().optional(), version: z.string().optional() })
.passthrough();
// B-07: strip unknown top-level keys instead of passing them through. The
// parsed envelope is persisted verbatim in `executions.response_json`; a CLI
// that prints an extra top-level field (e.g. an echoed credential) must not have
// it carried into the database. Payload-bearing `data` stays `unknown` so the
// genuine result is preserved; only unrecognized *envelope/meta/error* keys drop.
const metaSchema = z.object({ timestamp: z.string().optional(), version: z.string().optional() });

const errorSchema = z.object({ code: z.string(), message: z.string() }).passthrough();
const errorSchema = z.object({ code: z.string(), message: z.string() });

/** The validated envelope. `data` stays `unknown` — payload schemas live in `parse.ts`. */
export const envelopeSchema = z
.object({
success: z.boolean(),
meta: metaSchema.optional(),
data: z.unknown().optional(),
error: errorSchema.optional(),
})
.passthrough();
export const envelopeSchema = z.object({
success: z.boolean(),
meta: metaSchema.optional(),
data: z.unknown().optional(),
error: errorSchema.optional(),
});

export type ByrealEnvelope = z.infer<typeof envelopeSchema>;

/** Strip ANSI/VT escape sequences a colourised CLI may interleave with JSON. */
const ANSI = /[\u001b\u009b][[()#;?]*(?:[0-9]{1,4}(?:;[0-9]{0,4})*)?[0-9A-PR-TZcf-ntqry=><]/g;

/**
* Extract the first balanced top-level JSON object from `text`, ignoring any
* leading/trailing noise (a banner line, a trailing newline, a stray warning).
* Returns the substring `{…}` or `null` when no balanced object is present.
* String literals are scanned so a `}` inside a JSON string never closes early.
* Extract *every* balanced top-level JSON object from `text`, in order, ignoring
* any leading/trailing/interleaving noise (a banner line, a trailing newline, a
* stray warning, a JSON log line). String literals are scanned so a `}` inside a
* JSON string never closes a level early. An unbalanced/truncated tail yields no
* extra object.
*/
function extractJsonObject(text: string): string | null {
const start = text.indexOf('{');
if (start === -1) return null;

function extractJsonObjects(text: string): string[] {
const objects: string[] = [];
let start = -1;
let depth = 0;
let inString = false;
let escaped = false;
for (let i = start; i < text.length; i += 1) {
for (let i = 0; i < text.length; i += 1) {
const ch = text[i];
if (inString) {
if (escaped) escaped = false;
else if (ch === '\\') escaped = true;
else if (ch === '"') inString = false;
continue;
}
if (ch === '"') inString = true;
else if (ch === '{') depth += 1;
else if (ch === '}') {
if (ch === '"') {
inString = true;
} else if (ch === '{') {
if (depth === 0) start = i;
depth += 1;
} else if (ch === '}' && depth > 0) {
depth -= 1;
if (depth === 0) return text.slice(start, i + 1);
if (depth === 0 && start !== -1) {
objects.push(text.slice(start, i + 1));
start = -1;
}
}
}
return null; // Unbalanced — truncated output.
return objects;
}

/**
Expand All @@ -91,21 +98,35 @@ export function parseEnvelope(stdout: string): ByrealEnvelope {
throw new ByrealParseError('byreal output exceeds size bound');
}

const json = extractJsonObject(stdout.replace(ANSI, ''));
if (json === null) {
const candidates = extractJsonObjects(stdout.replace(ANSI, ''));
if (candidates.length === 0) {
throw new ByrealParseError('byreal output contains no JSON object');
}

let raw: unknown;
try {
raw = JSON.parse(json);
} catch {
throw new ByrealParseError('byreal output is not valid JSON');
// B-06 (banner injection): collect *all* well-formed envelopes, not just the
// first balanced object. A non-envelope banner object (`{"debug":true}`) is
// skipped instead of aborting the parse. But the genuine CLI prints exactly
// one envelope, so if more than one envelope-shaped object appears we refuse
// to guess which is authoritative — a prepended/appended fake envelope must
// not be able to substitute a forged fill. Fail closed: the caller degrades to
// the deterministic seed fallback.
const envelopes: ByrealEnvelope[] = [];
for (const json of candidates) {
let raw: unknown;
try {
raw = JSON.parse(json);
} catch {
continue; // Not valid JSON (e.g. `{ not: json }`) — ignore this candidate.
}
const parsed = envelopeSchema.safeParse(raw);
if (parsed.success) envelopes.push(parsed.data);
}

const parsed = envelopeSchema.safeParse(raw);
if (!parsed.success) {
if (envelopes.length === 0) {
throw new ByrealParseError('byreal output is not a valid CLI envelope');
}
return parsed.data;
if (envelopes.length > 1) {
throw new ByrealParseError('byreal output contains multiple CLI envelopes');
}
return envelopes[0] as ByrealEnvelope;
}
19 changes: 16 additions & 3 deletions lib/rail/byreal/parse.ts
Original file line number Diff line number Diff line change
Expand Up @@ -229,15 +229,28 @@ export interface OutcomeParts {
* is unknown from the order result alone, so `'0'` (documented; credibility).
* - `pnl_marked` — the position's unrealized PnL (read), else `'0'`.
* - `pnl_realized` — booked by the order (closes only).
* - `position_delta` — signed filled size: an open takes the intent's side; a
* close reduces the position (negative of the filled size).
* - `position_delta` — signed filled size that moves the position toward zero
* on a close and away on an open. A `close` Intent carries no `side`
* (`closeShape`), so the close sign is derived from the *resulting* position
* read: closing a short (a still-negative residual size, or a buy-back) is a
* positive delta, closing a long is negative. When the close flattens the
* position the venue reports no residual (`position` is `undefined`); the side
* is then unknowable from the order result alone, so we keep the historical
* long-assumption (negative). This only affects the verifiable credibility
* surface — Byreal outcomes never feed the deterministic score.
* - `drawdown` — not derivable per-fill from the venue; `'0'` by contract (it is
* a scoring-only quantity and Byreal outcomes never feed the score).
*/
export function buildOutcome(parts: OutcomeParts): SeedOutcome {
const { order, position, openSide, isClose } = parts;
// A residual short position (negative size) means the close bought back size,
// so the delta is positive; otherwise (long residual, or flat → unknown) the
// close reduces a long and the delta is negative.
const closeIsShort = position !== undefined && position.size.startsWith('-');
const positionDelta = isClose
? negateDecimal(order.filledSize)
? closeIsShort
? absDecimal(order.filledSize)
: negateDecimal(order.filledSize)
: openSide === 'short'
? negateDecimal(order.filledSize)
: order.filledSize;
Expand Down
Loading