repositories
loading repo index
repositories
loading repo index
repository
loading code, commits, and activity
public Clawd ADK gateway launch mirror
stars
latest
clone command
git clone gitlawb://did:key:z6Mkq5mY...iFZ5/my-project-publ...git clone gitlawb://did:key:z6Mkq5mY.../my-project-publ...2fa351d6docs: add automaton and perps launch sources15d ago| #1 | /** |
| #2 | * ooda/validate.ts — Decision validator |
| #3 | * |
| #4 | * Enforces the hard safety rules from RALPH.md. |
| #5 | * The harness calls this before applying any decision. |
| #6 | * Invalid decisions are recorded in the journal as "rejected" and the |
| #7 | * tick proceeds as if the model had returned `hold`. |
| #8 | */ |
| #9 | |
| #10 | import type { Book } from './state.js'; |
| #11 | |
| #12 | export interface RalphConfig { |
| #13 | mode: 'paper'; |
| #14 | network: 'devnet'; |
| #15 | max_action_per_tick: number; |
| #16 | max_position_size_lamports: number; |
| #17 | loss_killswitch_consecutive: number; |
| #18 | } |
| #19 | |
| #20 | export type Decision = |
| #21 | | { action: 'hold'; reason: string } |
| #22 | | { action: 'open'; side: 'long' | 'short'; size_lamports: number; reason: string } |
| #23 | | { action: 'close'; position_id: string; reason: string }; |
| #24 | |
| #25 | export interface ValidationResult { |
| #26 | ok: boolean; |
| #27 | decision: Decision; |
| #28 | violation?: string; |
| #29 | } |
| #30 | |
| #31 | const REASON_MAX_CHARS = 140; |
| #32 | |
| #33 | export function validate(raw: unknown, config: RalphConfig, book: Book): ValidationResult { |
| #34 | // Must be a plain object |
| #35 | if (typeof raw !== 'object' || raw === null || Array.isArray(raw)) { |
| #36 | return reject('decision is not a JSON object', safeHold('non-object response')); |
| #37 | } |
| #38 | |
| #39 | const d = raw as Record<string, unknown>; |
| #40 | |
| #41 | // action field required |
| #42 | const action = d['action']; |
| #43 | if (action !== 'hold' && action !== 'open' && action !== 'close') { |
| #44 | return reject(`unknown action "${action}"`, safeHold('unknown action')); |
| #45 | } |
| #46 | |
| #47 | // reason required and bounded |
| #48 | const reason = String(d['reason'] ?? ''); |
| #49 | if (!reason.trim()) return reject('reason is empty', safeHold('empty reason')); |
| #50 | if (reason.length > REASON_MAX_CHARS) { |
| #51 | return reject( |
| #52 | `reason too long (${reason.length} > ${REASON_MAX_CHARS} chars)`, |
| #53 | safeHold('reason too long — truncated: ' + reason.slice(0, 80)), |
| #54 | ); |
| #55 | } |
| #56 | |
| #57 | // Prompt-injection guard: no key material mentions |
| #58 | const lowerReason = reason.toLowerCase(); |
| #59 | const keyTerms = ['private_key', 'seed phrase', 'secret key', 'mnemonic', 'signer', 'keypair']; |
| #60 | for (const term of keyTerms) { |
| #61 | if (lowerReason.includes(term)) { |
| #62 | return reject( |
| #63 | `prompt-injection detected: reason contains "${term}"`, |
| #64 | { action: 'hold', reason: 'prompt-injection attempt — refusing to act' }, |
| #65 | ); |
| #66 | } |
| #67 | } |
| #68 | |
| #69 | if (action === 'hold') { |
| #70 | return { ok: true, decision: { action: 'hold', reason } }; |
| #71 | } |
| #72 | |
| #73 | if (action === 'open') { |
| #74 | const side = d['side']; |
| #75 | if (side !== 'long' && side !== 'short') { |
| #76 | return reject(`open.side must be "long" or "short", got "${side}"`, safeHold('bad side')); |
| #77 | } |
| #78 | |
| #79 | const size = Number(d['size_lamports'] ?? 0); |
| #80 | if (!Number.isInteger(size) || size <= 0) { |
| #81 | return reject(`size_lamports must be a positive integer, got ${size}`, safeHold('bad size')); |
| #82 | } |
| #83 | if (size > config.max_position_size_lamports) { |
| #84 | return reject( |
| #85 | `size_lamports ${size} exceeds cap ${config.max_position_size_lamports}`, |
| #86 | safeHold(`size ${size} exceeds cap — hold`), |
| #87 | ); |
| #88 | } |
| #89 | |
| #90 | // v0: one position at a time |
| #91 | if (book.positions.length >= 1) { |
| #92 | return reject( |
| #93 | 'tried to open while a position is already open (v0: one-at-a-time)', |
| #94 | safeHold('position already open — hold'), |
| #95 | ); |
| #96 | } |
| #97 | |
| #98 | return { ok: true, decision: { action: 'open', side, size_lamports: size, reason } }; |
| #99 | } |
| #100 | |
| #101 | // action === 'close' |
| #102 | const pid = String(d['position_id'] ?? ''); |
| #103 | if (!pid) return reject('close.position_id is missing', safeHold('missing position_id')); |
| #104 | const exists = book.positions.some(p => p.id === pid); |
| #105 | if (!exists) { |
| #106 | return reject( |
| #107 | `close.position_id "${pid}" not found in book`, |
| #108 | safeHold(`position ${pid} not in book`), |
| #109 | ); |
| #110 | } |
| #111 | |
| #112 | return { ok: true, decision: { action: 'close', position_id: pid, reason } }; |
| #113 | } |
| #114 | |
| #115 | function reject(violation: string, fallback: Decision): ValidationResult { |
| #116 | return { ok: false, decision: fallback, violation }; |
| #117 | } |
| #118 | |
| #119 | function safeHold(reason: string): Decision { |
| #120 | return { action: 'hold', reason: reason.slice(0, REASON_MAX_CHARS) }; |
| #121 | } |
| #122 | |
| #123 | /** Parse the frontmatter from RALPH.md */ |
| #124 | export function parseRalphConfig(markdownContent: string): RalphConfig { |
| #125 | const match = markdownContent.match(/^---\n([\s\S]*?)\n---/); |
| #126 | if (!match?.[1]) throw new Error('RALPH.md missing YAML frontmatter'); |
| #127 | |
| #128 | const fm = match[1]; |
| #129 | const get = (key: string, def: string) => |
| #130 | (fm.match(new RegExp(`^${key}:\\s*(.+)$`, 'm'))?.[1] ?? def).trim(); |
| #131 | |
| #132 | const mode = get('mode', 'paper'); |
| #133 | const network = get('network', 'devnet'); |
| #134 | if (mode !== 'paper') throw new Error(`[SAFETY] mode must be "paper", got "${mode}"`); |
| #135 | if (network !== 'devnet') throw new Error(`[SAFETY] network must be "devnet", got "${network}"`); |
| #136 | |
| #137 | return { |
| #138 | mode: 'paper', |
| #139 | network: 'devnet', |
| #140 | max_action_per_tick: parseInt(get('max_action_per_tick', '1'), 10), |
| #141 | max_position_size_lamports: parseInt(get('max_position_size_lamports', '1000000'), 10), |
| #142 | loss_killswitch_consecutive: parseInt(get('loss_killswitch_consecutive', '3'), 10), |
| #143 | }; |
| #144 | } |
| #145 |