Files
agents_and_robots/e2e/fixtures/device-audit.ts
T
egutierrez fc86edd94c chore: auto-commit (27 archivos)
- .claude/CLAUDE.md
- .claude/rules/create_agent.md
- agents/_specials/father-bot/prompts/system.md
- agents/_template/config.yaml
- agents/_template_robot/config.yaml
- cmd/agentctl/autoavatar.go
- cmd/launcher/sqlite.go
- dev-scripts/_common.sh
- dev-scripts/agent/create-full.sh
- dev-scripts/agent/delete-full.sh
- ...

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-26 19:38:16 +02:00

279 lines
8.3 KiB
TypeScript

/**
* device-audit.ts — read the local device_agent audit DB.
*
* The device_agent runs on the same WSL host as the tests and writes audit
* entries to /tmp/device_audit.db (configurable via DEVICE_AUDIT_DB env).
*
* Two tables:
* audit_log — id, ts, request_id, capability, args_hash,
* exit_code, prev_hash, this_hash (hash-chained)
* audit_shell_eval — audit_id, cmd, cwd, shell, stdout_b64, stderr_b64
*
* Used by DoD Capa 2 to *cross-check* that tools the bot claims to have
* invoked actually ran on the device.
*
* NOTE: better-sqlite3 is a native binary; if unavailable on this system the
* fallback path is `sqlite3` CLI via execFileSync.
*/
import { execFileSync } from "node:child_process";
import * as crypto from "node:crypto";
export interface AuditEntry {
id: number;
ts: number;
requestId: string;
capability: string;
argsHash: string;
exitCode: number;
prevHash: string;
thisHash: string;
}
export interface ShellEvalAudit {
auditId: number;
cmd: string;
cwd: string;
shell: string;
stdoutPreview: string;
stderrPreview: string;
}
const DEFAULT_DB =
process.env.DEVICE_AUDIT_DB ?? "/tmp/device_audit.db";
// ---------- sqlite shim: better-sqlite3 if installed, else CLI ----------
type Row = Record<string, unknown>;
function queryViaCli(dbPath: string, sql: string): Row[] {
// We use sqlite3 -json. We pass the SQL as argv to avoid shell interpolation.
// The runner is invoked via execFileSync (no shell), but sqlite3's own arg
// parsing handles quoting.
let out: string;
try {
out = execFileSync("sqlite3", ["-json", dbPath, sql], {
encoding: "utf8",
maxBuffer: 16 * 1024 * 1024,
});
} catch (err: any) {
throw new Error(
`sqlite3 query failed on ${dbPath}: ${err.message}\n` +
`stderr=${err?.stderr?.toString?.() ?? ""}`,
);
}
const trimmed = out.trim();
if (!trimmed) return [];
try {
return JSON.parse(trimmed) as Row[];
} catch {
return [];
}
}
interface DbHandle {
prepare(sql: string): {
all: (...params: unknown[]) => Row[];
get: (...params: unknown[]) => Row | undefined;
};
}
function openDb(dbPath: string): DbHandle {
try {
// Prefer better-sqlite3 when available (faster, no subprocess).
// eslint-disable-next-line @typescript-eslint/no-var-requires
const Better = require("better-sqlite3");
const db = new Better(dbPath, { readonly: true, fileMustExist: true });
return {
prepare(sql: string) {
const stmt = db.prepare(sql);
return {
all: (...params: unknown[]) => stmt.all(...params) as Row[],
get: (...params: unknown[]) => stmt.get(...params) as Row | undefined,
};
},
};
} catch {
// Fallback to sqlite3 CLI. We cannot bind parameters via CLI cleanly with
// arbitrary types, so we inline only numeric/string sanitized fragments.
return {
prepare(sql: string) {
return {
all: (...params: unknown[]) => queryViaCli(dbPath, interpolate(sql, params)),
get: (...params: unknown[]) => queryViaCli(dbPath, interpolate(sql, params))[0],
};
},
};
}
}
/** Naive parameter inliner — used ONLY against a local trusted DB path. */
function interpolate(sql: string, params: unknown[]): string {
let idx = 0;
return sql.replace(/\?/g, () => {
const v = params[idx++];
if (v === null || v === undefined) return "NULL";
if (typeof v === "number") return String(v);
if (typeof v === "boolean") return v ? "1" : "0";
// Escape single quotes for SQL string literal
return `'${String(v).replace(/'/g, "''")}'`;
});
}
// ---------- public API ----------
export interface FetchAuditOptions {
dbPath?: string;
sinceSeconds?: number;
capability?: string;
limit?: number;
}
function rowToAudit(r: Row): AuditEntry {
return {
id: Number(r.id),
ts: Number(r.ts),
requestId: String(r.request_id ?? ""),
capability: String(r.capability ?? ""),
argsHash: String(r.args_hash ?? ""),
exitCode: Number(r.exit_code),
prevHash: String(r.prev_hash ?? ""),
thisHash: String(r.this_hash ?? ""),
};
}
export async function fetchRecentAudit(
opts: FetchAuditOptions = {},
): Promise<AuditEntry[]> {
const dbPath = opts.dbPath ?? DEFAULT_DB;
const sinceSeconds = opts.sinceSeconds ?? 120;
const limit = opts.limit ?? 50;
const tsCutoff = Math.floor(Date.now() / 1000) - sinceSeconds;
const db = openDb(dbPath);
let sql =
"SELECT id, ts, request_id, capability, args_hash, exit_code, prev_hash, this_hash " +
"FROM audit_log WHERE ts >= ?";
const params: unknown[] = [tsCutoff];
if (opts.capability) {
sql += " AND capability = ?";
params.push(opts.capability);
}
sql += " ORDER BY id DESC LIMIT ?";
params.push(limit);
const rows = db.prepare(sql).all(...params);
return rows.map(rowToAudit);
}
/**
* Validate the hash chain from `fromId` to the latest row.
* Returns the first BROKEN entry (the one whose this_hash != recomputed) or null.
*
* The chain rule comes from audit.go:
* canonical = prev_hash | ts | request_id | capability | args_hash | exit_code
* this_hash = sha256(canonical)
* with prev_hash = "" for the very first row.
*/
export async function verifyHashChain(opts: {
dbPath?: string;
fromId?: number;
} = {}): Promise<AuditEntry | null> {
const dbPath = opts.dbPath ?? DEFAULT_DB;
const db = openDb(dbPath);
const fromId = opts.fromId ?? 0;
const rows = db
.prepare(
"SELECT id, ts, request_id, capability, args_hash, exit_code, prev_hash, this_hash " +
"FROM audit_log WHERE id >= ? ORDER BY id ASC",
)
.all(fromId);
let expectedPrev: string | null = null;
for (const r of rows) {
const entry = rowToAudit(r);
if (expectedPrev === null) {
// First row in the window: trust its prev_hash as the anchor.
// We can't verify prev_hash without history before fromId, but we still
// verify the computed this_hash matches.
expectedPrev = entry.prevHash;
} else if (entry.prevHash !== expectedPrev) {
return entry;
}
const canonical = `${entry.prevHash}|${entry.ts}|${entry.requestId}|${entry.capability}|${entry.argsHash}|${entry.exitCode}`;
const recomputed = crypto.createHash("sha256").update(canonical).digest("hex");
if (recomputed !== entry.thisHash) {
return entry;
}
expectedPrev = entry.thisHash;
}
return null;
}
function decodeBlob(s: string | null | undefined, max = 200): string {
if (!s) return "";
// The Go side uses prefix "plain:" (<=4KB) or "gz:" (gzip) before base64.
if (s.startsWith("plain:")) {
try {
const buf = Buffer.from(s.slice("plain:".length), "base64");
return buf.toString("utf8").slice(0, max);
} catch {
return s.slice(0, max);
}
}
if (s.startsWith("gz:")) {
try {
const zlib = require("node:zlib");
const buf = zlib.gunzipSync(Buffer.from(s.slice("gz:".length), "base64"));
return buf.toString("utf8").slice(0, max);
} catch {
return "[gz decode failed]";
}
}
return s.slice(0, max);
}
export async function fetchRecentShellEval(opts: {
dbPath?: string;
sinceSeconds?: number;
limit?: number;
} = {}): Promise<ShellEvalAudit[]> {
const dbPath = opts.dbPath ?? DEFAULT_DB;
const sinceSeconds = opts.sinceSeconds ?? 120;
const limit = opts.limit ?? 50;
const tsCutoff = Math.floor(Date.now() / 1000) - sinceSeconds;
const db = openDb(dbPath);
const rows = db
.prepare(
"SELECT s.audit_id AS audit_id, s.cmd AS cmd, s.cwd AS cwd, s.shell AS shell, " +
" s.stdout_b64 AS stdout_b64, s.stderr_b64 AS stderr_b64 " +
"FROM audit_shell_eval s JOIN audit_log a ON a.id = s.audit_id " +
"WHERE a.ts >= ? ORDER BY s.audit_id DESC LIMIT ?",
)
.all(tsCutoff, limit);
return rows.map((r) => ({
auditId: Number(r.audit_id),
cmd: String(r.cmd ?? ""),
cwd: String(r.cwd ?? ""),
shell: String(r.shell ?? ""),
stdoutPreview: decodeBlob(r.stdout_b64 as string),
stderrPreview: decodeBlob(r.stderr_b64 as string),
}));
}
/** Quick sanity probe: does the DB exist and have rows? */
export async function auditDbReady(dbPath = DEFAULT_DB): Promise<boolean> {
try {
const db = openDb(dbPath);
const row = db.prepare("SELECT COUNT(*) AS n FROM audit_log").get();
return Boolean(row);
} catch {
return false;
}
}