🎩 You're Invited:Meet the Socket team at Black Hat in Las Vegas, August 3-6.RSVP
Sign In

lyrie-agent

Package Overview
Dependencies
Maintainers
1
Versions
6
Alerts
File Explorer

Advanced tools

Socket logo

Install Socket

Detect and block malicious and high-risk dependencies

Install

lyrie-agent - npm Package Compare versions

Comparing version
0.1.0
to
0.4.0
+124
packages/core/src/report/report-command.ts
/**
* `lyrie report` command
*
* Reads the most recent .sarif file from lyrie-runs/, base64-encodes it,
* and opens the report viewer in a browser.
*
* Usage:
* lyrie report # opens most recent scan
* lyrie report --open # same (alias)
* lyrie report --url # prints URL only, no browser open
* lyrie report <path.sarif> # open a specific SARIF file
*
* © OTT Cybersecurity LLC — https://lyrie.ai
*/
import { readFileSync, readdirSync, statSync, existsSync } from "fs";
import { join, resolve, extname } from "path";
const VIEWER_BASE = "https://lyrie.ai/report";
const VIEWER_LOCAL = "http://localhost:3100/report";
/** Find the most recently modified .sarif file in lyrie-runs/. */
function findLatestSarifFile(cwd: string = process.cwd()): string | null {
const dir = join(cwd, "lyrie-runs");
if (!existsSync(dir)) return null;
const files = readdirSync(dir)
.filter((f) => extname(f) === ".sarif" || extname(f) === ".json")
.map((f) => ({ name: f, path: join(dir, f), mtime: statSync(join(dir, f)).mtimeMs }))
.sort((a, b) => b.mtime - a.mtime);
return files[0]?.path ?? null;
}
/** Base64-encode a file's contents. */
function b64EncodeFile(filePath: string): string {
const buf = readFileSync(filePath);
return Buffer.from(buf).toString("base64");
}
/** Open a URL in the default browser (cross-platform). */
async function openBrowser(url: string): Promise<boolean> {
const { exec } = await import("child_process");
const { promisify } = await import("util");
const execAsync = promisify(exec);
const cmds: Record<string, string> = {
darwin: `open "${url}"`,
linux: `xdg-open "${url}"`,
win32: `start "" "${url}"`,
};
const cmd = cmds[process.platform] ?? `xdg-open "${url}"`;
try {
await execAsync(cmd);
return true;
} catch {
return false;
}
}
export interface ReportCommandOptions {
/** If true, print URL only — do not open browser. */
urlOnly?: boolean;
/** Use localhost viewer instead of lyrie.ai. */
local?: boolean;
/** Explicit path to a SARIF file. */
sarifPath?: string;
/** Working directory for finding lyrie-runs/. Defaults to process.cwd(). */
cwd?: string;
}
/**
* Run the `lyrie report` command.
* Returns the generated report URL.
*/
export async function runReportCommand(opts: ReportCommandOptions = {}): Promise<string> {
const { urlOnly = false, local = false, sarifPath: explicitPath, cwd } = opts;
// Resolve SARIF file
let sarifPath: string | null = null;
if (explicitPath) {
sarifPath = resolve(explicitPath);
if (!existsSync(sarifPath)) {
throw new Error(`SARIF file not found: ${sarifPath}`);
}
} else {
sarifPath = findLatestSarifFile(cwd);
if (!sarifPath) {
throw new Error(
"No .sarif files found in lyrie-runs/. Run a scan first: lyrie scan <target>"
);
}
}
// Encode
const b64 = b64EncodeFile(sarifPath);
const base = local ? VIEWER_LOCAL : VIEWER_BASE;
const url = `${base}?data=${b64}`;
if (urlOnly) {
console.log(url);
return url;
}
console.log(`📄 Opening SARIF report: ${sarifPath}`);
console.log(`🌐 URL: ${url.slice(0, 80)}…`);
const opened = await openBrowser(url);
if (!opened) {
console.log("⚠️ Could not open browser automatically. Copy the URL above.");
}
return url;
}
/**
* Print the "view report" hint after a scan completes.
* Call this from the scan result handler.
*/
export function printReportHint(): void {
console.log("\n💡 View report: lyrie report --open\n");
}
/**
* Report layout — standalone, no sidebar/header.
* Matches the app's dark theme but without the command-center chrome.
*
* © OTT Cybersecurity LLC — https://lyrie.ai
*/
export default function ReportLayout({ children }: { children: React.ReactNode }) {
return (
<html lang="en" className="dark">
<head>
<meta name="viewport" content="width=device-width, initial-scale=1" />
</head>
<body className="bg-gray-950 text-gray-100 min-h-screen antialiased">
{children}
</body>
</html>
);
}
"use client";
/**
* Lyrie Scan Report Page
*
* URL patterns:
* /report?data=<base64-encoded-sarif> — inline data
* /report?url=<sarif-url> — remote fetch
* /report — file upload (drag-and-drop / picker)
*
* © OTT Cybersecurity LLC — https://lyrie.ai
*/
import React, { useEffect, useState, useCallback, useRef } from "react";
import { SarifViewer } from "@/sarif-viewer/SarifViewer";
import type { SarifDocument } from "@/sarif-viewer/types";
type LoadState =
| { status: "idle" }
| { status: "loading"; hint: string }
| { status: "ready"; doc: SarifDocument; source: string }
| { status: "error"; message: string };
function decodeBase64Sarif(b64: string): SarifDocument | null {
try {
const json = atob(b64);
return JSON.parse(json) as SarifDocument;
} catch {
return null;
}
}
function DropZone({ onFile }: { onFile: (content: string) => void }) {
const [dragging, setDragging] = useState(false);
const inputRef = useRef<HTMLInputElement>(null);
const readFile = (file: File) => {
const reader = new FileReader();
reader.onload = (e) => {
const text = e.target?.result as string;
if (text) onFile(text);
};
reader.readAsText(file);
};
const onDrop = useCallback(
(e: React.DragEvent) => {
e.preventDefault();
setDragging(false);
const file = e.dataTransfer.files[0];
if (file) readFile(file);
},
// eslint-disable-next-line react-hooks/exhaustive-deps
[]
);
return (
<div
onDragOver={(e) => { e.preventDefault(); setDragging(true); }}
onDragLeave={() => setDragging(false)}
onDrop={onDrop}
onClick={() => inputRef.current?.click()}
className={`
border-2 border-dashed rounded-2xl p-16 text-center cursor-pointer transition-colors
${dragging ? "border-blue-500 bg-blue-500/10" : "border-white/20 hover:border-white/40"}
`}
>
<input
ref={inputRef}
type="file"
accept=".sarif,.json"
className="hidden"
onChange={(e) => {
const file = e.target.files?.[0];
if (file) readFile(file);
}}
/>
<div className="text-4xl mb-4">📄</div>
<p className="text-white font-semibold mb-1">Drop a SARIF file here</p>
<p className="text-sm text-gray-500">or click to browse — accepts .sarif / .json</p>
</div>
);
}
export default function ReportPage() {
const [state, setState] = useState<LoadState>({ status: "idle" });
useEffect(() => {
const params = new URLSearchParams(window.location.search);
const dataParam = params.get("data");
const urlParam = params.get("url");
if (dataParam) {
setState({ status: "loading", hint: "Decoding SARIF data…" });
const doc = decodeBase64Sarif(dataParam);
if (doc) {
setState({ status: "ready", doc, source: "URL data parameter" });
} else {
setState({ status: "error", message: "Failed to decode SARIF from ?data= parameter." });
}
return;
}
if (urlParam) {
setState({ status: "loading", hint: `Fetching ${urlParam}…` });
fetch(urlParam)
.then((r) => {
if (!r.ok) throw new Error(`HTTP ${r.status}`);
return r.json();
})
.then((doc: SarifDocument) => {
setState({ status: "ready", doc, source: urlParam });
})
.catch((err: unknown) => {
setState({ status: "error", message: `Failed to fetch SARIF: ${String(err)}` });
});
}
}, []);
const handleUploadedFile = useCallback((content: string) => {
try {
const doc = JSON.parse(content) as SarifDocument;
setState({ status: "ready", doc, source: "uploaded file" });
} catch {
setState({ status: "error", message: "File does not appear to be valid JSON/SARIF." });
}
}, []);
return (
<div className="min-h-screen bg-gray-950 flex flex-col">
{/* Header */}
<header className="border-b border-white/10 px-6 py-4 flex items-center gap-3">
<div className="w-7 h-7 rounded-lg bg-gradient-to-br from-blue-500 to-purple-600 flex items-center justify-center text-sm">
🛡
</div>
<h1 className="text-base font-semibold text-white">Lyrie Scan Report</h1>
{state.status === "ready" && (
<span className="text-xs text-gray-500 ml-2">Source: {state.source}</span>
)}
</header>
{/* Body */}
<main className="flex-1 max-w-5xl mx-auto w-full px-6 py-8">
{state.status === "idle" && (
<div className="space-y-6">
<p className="text-sm text-gray-400">
Load a SARIF report by uploading a file, or pass{" "}
<code className="bg-white/10 px-1.5 py-0.5 rounded text-xs">?data=&lt;base64&gt;</code> or{" "}
<code className="bg-white/10 px-1.5 py-0.5 rounded text-xs">?url=&lt;sarif-url&gt;</code> in the URL.
</p>
<DropZone onFile={handleUploadedFile} />
</div>
)}
{state.status === "loading" && (
<div className="flex items-center justify-center py-20 text-gray-400 text-sm gap-3">
<svg className="animate-spin w-4 h-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<circle cx="12" cy="12" r="10" strokeOpacity="0.25" />
<path d="M12 2a10 10 0 0 1 10 10" />
</svg>
{state.hint}
</div>
)}
{state.status === "error" && (
<div className="rounded-xl border border-red-500/40 bg-red-500/10 px-5 py-4 text-red-300 text-sm space-y-3">
<p className="font-semibold">Failed to load SARIF report</p>
<p>{state.message}</p>
<button
onClick={() => setState({ status: "idle" })}
className="text-xs underline text-red-400 hover:text-red-300"
>
Try uploading a file instead
</button>
</div>
)}
{state.status === "ready" && <SarifViewer sarifData={state.doc} />}
</main>
{/* Footer */}
<footer className="border-t border-white/10 px-6 py-4 text-center text-xs text-gray-600">
Powered by{" "}
<a
href="https://github.com/overthetopseo/lyrie-agent"
target="_blank"
rel="noopener noreferrer"
className="text-gray-500 hover:text-gray-300 transition-colors"
>
Lyrie Agent ↗
</a>
</footer>
</div>
);
}
/**
* Lyrie SARIF Viewer — Parser
*
* Converts a SARIF 2.1.0 document into ParsedSarif (enriched flat view).
* Handles missing/malformed fields gracefully — never throws.
*
* © OTT Cybersecurity LLC — https://lyrie.ai
*/
import type {
SarifDocument,
SarifResult,
SarifRule,
ParsedFinding,
ParsedSarif,
SeverityLevel,
} from "./types";
const SEVERITY_ORDER: SeverityLevel[] = ["error", "warning", "note", "none"];
/** Normalise any level string to one of our four buckets. */
function normLevel(raw?: string): SeverityLevel {
const s = (raw ?? "none").toLowerCase();
if (s === "error") return "error";
if (s === "warning") return "warning";
if (s === "note" || s === "informational" || s === "open" || s === "review") return "note";
return "none";
}
/** Extract a human-readable location string + line from a SARIF result. */
function extractLocation(result: SarifResult): {
location?: string;
line?: number;
locationIsUrl: boolean;
} {
const loc = result.locations?.[0];
if (!loc) return { locationIsUrl: false };
const phys = loc.physicalLocation;
const uri = phys?.artifactLocation?.uri;
const line = phys?.region?.startLine;
if (!uri) return { locationIsUrl: false };
const isUrl = uri.startsWith("https://") || uri.startsWith("http://");
return { location: uri, line, locationIsUrl: isUrl };
}
/**
* Parse a SarifDocument into a flat, enriched ParsedSarif.
* Never throws — returns empty ParsedSarif on bad input.
*/
export function parseSarif(doc: SarifDocument): ParsedSarif {
const findings: ParsedFinding[] = [];
const toolNamesSet = new Set<string>();
const runIdsSet = new Set<string>();
const bySeverity: Record<SeverityLevel, number> = {
error: 0,
warning: 0,
note: 0,
none: 0,
};
if (!doc || !Array.isArray(doc.runs)) {
return { findings, toolNames: [], runIds: [], totalCount: 0, bySeverity };
}
for (const run of doc.runs) {
if (!run) continue;
const toolName = run.tool?.driver?.name ?? "Unknown Tool";
toolNamesSet.add(toolName);
const runId = run.automationDetails?.id;
if (runId) runIdsSet.add(runId);
// Build rule map for quick lookup
const ruleMap = new Map<string, SarifRule>();
for (const rule of run.tool?.driver?.rules ?? []) {
if (rule?.id) ruleMap.set(rule.id, rule);
}
for (let i = 0; i < (run.results?.length ?? 0); i++) {
const result = run.results![i];
if (!result) continue;
const ruleId = result.ruleId ?? "unknown";
const level = normLevel(result.level);
const message =
result.message?.text ?? result.message?.markdown ?? "(no message)";
const { location, line, locationIsUrl } = extractLocation(result);
bySeverity[level]++;
findings.push({
ruleId,
level,
message,
location,
line,
locationIsUrl,
rule: ruleMap.get(ruleId),
index: i,
toolName,
runId,
});
}
}
// Sort: errors first, then warning, note, none — then alphabetical ruleId
findings.sort((a, b) => {
const orderA = SEVERITY_ORDER.indexOf(a.level);
const orderB = SEVERITY_ORDER.indexOf(b.level);
if (orderA !== orderB) return orderA - orderB;
return a.ruleId.localeCompare(b.ruleId);
});
return {
findings,
toolNames: [...toolNamesSet],
runIds: [...runIdsSet],
totalCount: findings.length,
bySeverity,
};
}
/**
* Parse a SARIF JSON string. Returns null on parse failure.
*/
export function parseSarifJson(json: string): ParsedSarif | null {
try {
const doc = JSON.parse(json) as SarifDocument;
return parseSarif(doc);
} catch {
return null;
}
}
"use client";
/**
* Lyrie SARIF Viewer — React Component
*
* Props:
* sarifData — already-parsed SarifDocument
* sarifJson — raw JSON string (parsed on mount)
*
* Zero external deps beyond React + existing UI deps (lucide-react, clsx/tailwind-merge).
*
* © OTT Cybersecurity LLC — https://lyrie.ai
*/
import React, { useState, useMemo, useCallback } from "react";
import { parseSarif, parseSarifJson } from "./parse";
import type { SarifDocument, ParsedFinding, SeverityLevel, ParsedSarif } from "./types";
import { cn } from "@/lib/utils";
// ─── Severity helpers ──────────────────────────────────────────────────────
const SEVERITY_COLORS: Record<SeverityLevel, string> = {
error: "bg-red-500/20 text-red-400 border border-red-500/40",
warning: "bg-yellow-500/20 text-yellow-400 border border-yellow-500/40",
note: "bg-blue-500/20 text-blue-400 border border-blue-500/40",
none: "bg-gray-500/20 text-gray-400 border border-gray-500/40",
};
const SEVERITY_DOT: Record<SeverityLevel, string> = {
error: "bg-red-500",
warning: "bg-yellow-500",
note: "bg-blue-500",
none: "bg-gray-500",
};
// ─── Sub-components ────────────────────────────────────────────────────────
function SeverityBadge({ level }: { level: SeverityLevel }) {
return (
<span
className={cn(
"inline-flex items-center gap-1.5 px-2 py-0.5 rounded text-[10px] font-semibold uppercase tracking-wider",
SEVERITY_COLORS[level]
)}
>
<span className={cn("w-1.5 h-1.5 rounded-full", SEVERITY_DOT[level])} />
{level}
</span>
);
}
function SummaryBar({ parsed }: { parsed: ParsedSarif }) {
const items = [
{ label: "Total", value: parsed.totalCount, color: "text-white" },
{ label: "Errors", value: parsed.bySeverity.error, color: "text-red-400" },
{ label: "Warnings", value: parsed.bySeverity.warning, color: "text-yellow-400" },
{ label: "Notes", value: parsed.bySeverity.note, color: "text-blue-400" },
{ label: "None", value: parsed.bySeverity.none, color: "text-gray-400" },
];
return (
<div className="flex flex-wrap gap-4 p-4 rounded-xl border border-white/10 bg-white/5">
{items.map(({ label, value, color }) => (
<div key={label} className="flex flex-col items-center min-w-[56px]">
<span className={cn("text-2xl font-bold tabular-nums", color)}>{value}</span>
<span className="text-[11px] text-gray-400 mt-0.5">{label}</span>
</div>
))}
{parsed.toolNames.length > 0 && (
<div className="flex flex-col justify-center ml-auto">
<span className="text-[11px] text-gray-500">Tool{parsed.toolNames.length > 1 ? "s" : ""}</span>
<span className="text-sm text-gray-200">{parsed.toolNames.join(", ")}</span>
</div>
)}
</div>
);
}
function FindingRow({ finding }: { finding: ParsedFinding }) {
const locationEl = finding.location ? (
finding.locationIsUrl ? (
<a
href={finding.location}
target="_blank"
rel="noopener noreferrer"
className="font-mono text-xs text-blue-400 hover:underline truncate max-w-xs"
title={finding.location}
>
{finding.location}
</a>
) : (
<span className="font-mono text-xs text-gray-400 truncate max-w-xs" title={finding.location}>
{finding.location}
{finding.line ? <span className="text-gray-500">:{finding.line}</span> : null}
</span>
)
) : null;
return (
<div className="flex flex-col gap-1.5 py-3 px-4 border-b border-white/5 last:border-0 hover:bg-white/5 transition-colors">
<div className="flex items-center gap-2 flex-wrap">
<SeverityBadge level={finding.level} />
<button
onClick={() => navigator.clipboard?.writeText(finding.ruleId)}
className="font-mono text-xs text-gray-300 bg-white/10 px-2 py-0.5 rounded hover:bg-white/20 transition-colors cursor-pointer"
title="Click to copy rule ID"
>
{finding.ruleId}
</button>
{finding.rule?.name && (
<span className="text-xs text-gray-400">{finding.rule.name}</span>
)}
{finding.rule?.helpUri && (
<a
href={finding.rule.helpUri}
target="_blank"
rel="noopener noreferrer"
className="text-xs text-blue-400 hover:underline"
>
docs ↗
</a>
)}
</div>
<p className="text-sm text-gray-200 leading-snug">{finding.message}</p>
{locationEl && <div className="flex items-center gap-1">{locationEl}</div>}
</div>
);
}
interface RuleGroupProps {
ruleId: string;
findings: ParsedFinding[];
defaultOpen?: boolean;
}
function RuleGroup({ ruleId, findings, defaultOpen = false }: RuleGroupProps) {
const [open, setOpen] = useState(defaultOpen);
const level = findings[0]?.level ?? "none";
const rule = findings[0]?.rule;
return (
<div className="border border-white/10 rounded-xl overflow-hidden mb-2">
<button
className="w-full flex items-center gap-3 px-4 py-3 bg-white/5 hover:bg-white/10 transition-colors text-left"
onClick={() => setOpen((o) => !o)}
aria-expanded={open}
>
<span className={cn("w-2 h-2 rounded-full flex-shrink-0", SEVERITY_DOT[level])} />
<span className="font-mono text-sm text-gray-200 flex-1">{ruleId}</span>
{rule?.name && <span className="text-xs text-gray-500 hidden sm:block">{rule.name}</span>}
<span className="text-xs text-gray-400 bg-white/10 px-2 py-0.5 rounded-full flex-shrink-0">
{findings.length} finding{findings.length !== 1 ? "s" : ""}
</span>
<span className="text-gray-500 text-xs ml-1">{open ? "▲" : "▼"}</span>
</button>
{open && (
<div className="bg-black/20">
{findings.map((f) => (
<FindingRow key={`${f.ruleId}-${f.index}`} finding={f} />
))}
</div>
)}
</div>
);
}
// ─── Export helpers ────────────────────────────────────────────────────────
function exportJson(parsed: ParsedSarif) {
const blob = new Blob([JSON.stringify(parsed.findings, null, 2)], { type: "application/json" });
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = "lyrie-scan-results.json";
a.click();
URL.revokeObjectURL(url);
}
// ─── Main component ────────────────────────────────────────────────────────
export interface SarifViewerProps {
sarifData?: SarifDocument;
sarifJson?: string;
}
export function SarifViewer({ sarifData, sarifJson }: SarifViewerProps) {
const parsed = useMemo<ParsedSarif | null>(() => {
if (sarifData) return parseSarif(sarifData);
if (sarifJson) return parseSarifJson(sarifJson);
return null;
}, [sarifData, sarifJson]);
const [search, setSearch] = useState("");
const [severityFilter, setSeverityFilter] = useState<SeverityLevel | "all">("all");
const filteredFindings = useMemo(() => {
if (!parsed) return [];
return parsed.findings.filter((f) => {
if (severityFilter !== "all" && f.level !== severityFilter) return false;
if (search && !f.ruleId.toLowerCase().includes(search.toLowerCase())) return false;
return true;
});
}, [parsed, search, severityFilter]);
// Group by ruleId
const groups = useMemo(() => {
const map = new Map<string, ParsedFinding[]>();
for (const f of filteredFindings) {
const arr = map.get(f.ruleId) ?? [];
arr.push(f);
map.set(f.ruleId, arr);
}
return [...map.entries()];
}, [filteredFindings]);
const handleExport = useCallback(() => {
if (parsed) exportJson(parsed);
}, [parsed]);
if (!parsed) {
return (
<div className="flex items-center justify-center py-20 text-gray-500 text-sm">
No SARIF data provided.
</div>
);
}
if (parsed.totalCount === 0) {
return (
<div className="space-y-4">
<SummaryBar parsed={parsed} />
<div className="flex flex-col items-center justify-center py-20 text-gray-500 text-sm gap-2">
<span className="text-2xl">✅</span>
<span>No findings — clean scan!</span>
</div>
</div>
);
}
return (
<div className="space-y-4">
<SummaryBar parsed={parsed} />
{/* Toolbar */}
<div className="flex flex-wrap gap-3 items-center">
<input
type="search"
placeholder="Filter by rule ID…"
value={search}
onChange={(e) => setSearch(e.target.value)}
className="flex-1 min-w-[200px] bg-white/5 border border-white/10 rounded-lg px-3 py-2 text-sm text-gray-200 placeholder-gray-500 focus:outline-none focus:border-blue-500"
/>
<div className="flex gap-1.5">
{(["all", "error", "warning", "note", "none"] as const).map((sv) => (
<button
key={sv}
onClick={() => setSeverityFilter(sv)}
className={cn(
"px-3 py-1.5 rounded-lg text-xs font-semibold capitalize transition-colors",
severityFilter === sv
? "bg-blue-600 text-white"
: "bg-white/5 text-gray-400 hover:bg-white/10"
)}
>
{sv}
</button>
))}
</div>
<button
onClick={handleExport}
className="px-3 py-1.5 rounded-lg text-xs font-semibold bg-white/10 text-gray-300 hover:bg-white/20 transition-colors"
>
Export JSON ↓
</button>
</div>
{/* Results */}
{filteredFindings.length === 0 ? (
<div className="text-center py-10 text-gray-500 text-sm">No findings match filters.</div>
) : (
<div>
{groups.map(([ruleId, findings], idx) => (
<RuleGroup
key={ruleId}
ruleId={ruleId}
findings={findings}
defaultOpen={idx === 0 && findings[0]?.level === "error"}
/>
))}
</div>
)}
</div>
);
}
/**
* Lyrie SARIF Viewer — Type definitions for SARIF 2.1.0
*
* Covers the subset of SARIF 2.1.0 that Lyrie uses.
* Spec: https://docs.oasis-open.org/sarif/sarif/v2.1.0/sarif-v2.1.0.html
*
* © OTT Cybersecurity LLC — https://lyrie.ai
*/
// ─── SARIF 2.1.0 schema types ──────────────────────────────────────────────
export interface SarifPhysicalLocation {
artifactLocation?: {
uri?: string;
uriBaseId?: string;
};
region?: {
startLine?: number;
startColumn?: number;
endLine?: number;
endColumn?: number;
};
}
export interface SarifLocation {
physicalLocation?: SarifPhysicalLocation;
logicalLocations?: Array<{ name?: string; fullyQualifiedName?: string }>;
message?: { text?: string };
}
export interface SarifRule {
id: string;
name?: string;
shortDescription?: { text?: string };
fullDescription?: { text?: string };
helpUri?: string;
properties?: Record<string, unknown>;
}
export interface SarifResult {
ruleId?: string;
/** "error" | "warning" | "note" | "none" | "open" | "review" | "informational" */
level?: string;
message: { text?: string; markdown?: string };
locations?: SarifLocation[];
fingerprints?: Record<string, string>;
properties?: Record<string, unknown>;
}
export interface SarifToolDriver {
name: string;
version?: string;
informationUri?: string;
rules?: SarifRule[];
}
export interface SarifRun {
tool: {
driver: SarifToolDriver;
};
results?: SarifResult[];
automationDetails?: {
id?: string;
guid?: string;
};
invocations?: Array<{ executionSuccessful?: boolean; startTimeUtc?: string; endTimeUtc?: string }>;
}
export interface SarifDocument {
$schema?: string;
version?: string;
runs: SarifRun[];
}
// ─── Enriched / parsed view types ──────────────────────────────────────────
export type SeverityLevel = "error" | "warning" | "note" | "none";
export interface ParsedFinding {
ruleId: string;
level: SeverityLevel;
message: string;
/** Display-friendly file path or URL */
location?: string;
/** Line number, if available */
line?: number;
/** Whether location is a full https:// URL (clickable) */
locationIsUrl: boolean;
/** Full rule metadata if available */
rule?: SarifRule;
/** Index in original results array (for stable keys) */
index: number;
toolName: string;
runId?: string;
}
export interface ParsedSarif {
findings: ParsedFinding[];
toolNames: string[];
runIds: string[];
totalCount: number;
bySeverity: Record<SeverityLevel, number>;
}

Sorry, the diff of this file is not supported yet

"""
Lyrie SDK — async proxy tests (#42).
Tests for HttpProxy.async_send and HttpProxy.async_replay.
Uses pytest-asyncio (via anyio) or a simple asyncio.run() wrapper so the
suite stays runnable without extra plugins when httpx is available.
Lyrie.ai by OTT Cybersecurity LLC — https://lyrie.ai — MIT License.
"""
from __future__ import annotations
import asyncio
import sys
from typing import Optional
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
from lyrie.proxy import HttpExchange, HttpProxy, HttpRequest, HttpResponse, Mutator
# ─── Helpers ─────────────────────────────────────────────────────────────────
def run(coro): # type: ignore[return]
"""Tiny helper so tests run without pytest-asyncio."""
return asyncio.get_event_loop().run_until_complete(coro)
def _make_httpx_response(
status: int = 200,
body: bytes = b'{"ok": true}',
headers: Optional[dict] = None,
) -> MagicMock:
"""Build a fake httpx.Response-shaped mock."""
mock_resp = MagicMock()
mock_resp.status_code = status
mock_resp.content = body
mock_resp.headers = {**(headers or {}), "content-type": "application/json"}
return mock_resp
# ─── async_send ──────────────────────────────────────────────────────────────
def test_async_send_raises_without_httpx() -> None:
"""async_send must raise RuntimeError when httpx is not importable."""
import lyrie.proxy as proxy_mod
original = proxy_mod._httpx
proxy_mod._httpx = None # type: ignore[assignment]
try:
proxy = HttpProxy()
with pytest.raises(RuntimeError, match="httpx is required"):
run(proxy.async_send("GET", "https://example.com/"))
finally:
proxy_mod._httpx = original
def test_async_send_records_exchange() -> None:
"""async_send stores the exchange and returns it, matching sync send behaviour."""
try:
import httpx # noqa: F401 — skip if not installed
except ImportError:
pytest.skip("httpx not installed")
mock_resp = _make_httpx_response(200, b'{"status":"ok"}')
mock_client = AsyncMock()
mock_client.__aenter__ = AsyncMock(return_value=mock_client)
mock_client.__aexit__ = AsyncMock(return_value=False)
mock_client.request = AsyncMock(return_value=mock_resp)
with patch("httpx.AsyncClient", return_value=mock_client):
proxy = HttpProxy()
exchange = run(proxy.async_send("GET", "https://example.com/api/v1"))
assert isinstance(exchange, HttpExchange)
assert exchange.request.method == "GET"
assert exchange.request.url == "https://example.com/api/v1"
assert exchange.response is not None
assert exchange.response.status == 200
assert len(proxy.list_exchanges()) == 1
def test_async_send_deny_host() -> None:
"""async_send must honour the deny-list before making any network call."""
proxy = HttpProxy(deny_hosts=["blocked.example"])
with pytest.raises(PermissionError):
run(proxy.async_send("GET", "https://blocked.example/"))
# ─── async_replay ────────────────────────────────────────────────────────────
def test_async_replay_with_header_mutator() -> None:
"""async_replay forwards mutated headers to async_send."""
try:
import httpx # noqa: F401
except ImportError:
pytest.skip("httpx not installed")
mock_resp = _make_httpx_response(200, b"replayed")
mock_client = AsyncMock()
mock_client.__aenter__ = AsyncMock(return_value=mock_client)
mock_client.__aexit__ = AsyncMock(return_value=False)
mock_client.request = AsyncMock(return_value=mock_resp)
original_exchange = HttpExchange(
request=HttpRequest(
id="orig",
method="GET",
url="https://example.com/resource",
headers={"x-original": "yes"},
),
response=HttpResponse(id="orig", status=200, body="original"),
)
with patch("httpx.AsyncClient", return_value=mock_client):
proxy = HttpProxy()
replayed = run(
proxy.async_replay(
original_exchange,
mutators=[Mutator(kind="header-set", target="x-mutated", value="true")],
)
)
assert isinstance(replayed, HttpExchange)
assert replayed.response is not None
assert replayed.response.status == 200
# The replayed request should carry the mutated header
assert replayed.request.headers.get("x-mutated") == "true"
# ─── Backward-compat: sync send still works ──────────────────────────────────
def test_sync_send_still_works_deny_host() -> None:
"""The original sync send() must be unaffected by async additions."""
proxy = HttpProxy(deny_hosts=["blocked.example"])
with pytest.raises(PermissionError):
proxy.send("GET", "https://blocked.example/")
def test_sync_send_still_works_allow_host() -> None:
"""sync send() allow-list still works."""
proxy = HttpProxy(allow_hosts=["allowed.example"])
with pytest.raises(PermissionError):
proxy.send("GET", "https://other.example/")
+9
-0

@@ -568,1 +568,10 @@ # Changelog

on `feat/phase-0-upgrades` and will ship as `v0.1.1` once merged._
## [0.3.7] — 2026-04-28
### Added — npm package publication
- **lyrie-agent is now on npm** — `npm install lyrie-agent` works globally
- Fixed release CI pipeline: turbo build filter (skip Next.js UI package), npm provenance, 2FA bypass token
- Fixed `package.json` repository URL to match actual GitHub repo for sigstore provenance
- **npmjs.com/package/lyrie-agent** — 0 dependencies, MIT, 3.0 MB, 408 files
+1
-1
{
"name": "lyrie-agent",
"version": "0.1.0",
"version": "0.4.0",
"description": "The world's first autonomous AI agent with built-in cybersecurity",

@@ -5,0 +5,0 @@ "author": "OTT Cybersecurity LLC <dev@lyrie.ai> (https://lyrie.ai)",

@@ -125,2 +125,3 @@ /**

durationMs: Date.now() - start,
costUsd: 0,
error: "daytona backend not configured (missing apiKey)",

@@ -169,2 +170,10 @@ };

const durationMs = Date.now() - start;
const rate = parseFloat(
process.env["LYRIE_DAYTONA_COST_PER_SECOND"] ?? "0",
) || 0;
const costUsd = (durationMs / 1000) * rate;
console.log(
`[daytona] workspace ${workspaceId} cost: $${costUsd.toFixed(4)} (${durationMs}ms @ $${rate}/sec)`,
);
const summary = extractSarifSummary(sarif);

@@ -179,3 +188,4 @@ return {

runId: workspaceId,
durationMs: Date.now() - start,
durationMs,
costUsd,
provider: {

@@ -220,2 +230,3 @@ image: this.image(),

durationMs: Date.now() - start,
costUsd: 0,
error,

@@ -222,0 +233,0 @@ };

@@ -39,2 +39,5 @@ /**

const start = Date.now();
const costPerSecond = parseFloat(
process.env["LYRIE_LOCAL_COST_PER_SECOND"] ?? "0",
) || 0;

@@ -51,2 +54,3 @@ if (this.config.dryRun) {

durationMs: Date.now() - start,
costUsd: 0,
provider: { mode: "dry-run" },

@@ -70,2 +74,8 @@ };

const durationMs = Date.now() - start;
const costUsd = (durationMs / 1000) * costPerSecond;
if (costPerSecond > 0) {
console.log(`[local] estimated cost: $${costUsd.toFixed(4)}`);
}
return {

@@ -79,3 +89,4 @@ backend: "local",

runId: `local-${start}`,
durationMs: Date.now() - start,
durationMs,
costUsd,
provider: {

@@ -82,0 +93,0 @@ cwd: this.config.cwd,

@@ -102,2 +102,3 @@ /**

durationMs: Date.now() - start,
costUsd: 0,
error: "modal backend not configured (missing tokenId/tokenSecret)",

@@ -119,2 +120,3 @@ };

durationMs: Date.now() - start,
costUsd: 0,
error: `modal invoke HTTP ${res.status}`,

@@ -135,3 +137,3 @@ };

durationMs: Date.now() - start,
costUsd: body.costUsd,
costUsd: body.costUsd ?? 0,
provider: {

@@ -151,2 +153,3 @@ app: this.app(),

durationMs: Date.now() - start,
costUsd: 0,
error: (err as Error).message,

@@ -153,0 +156,0 @@ };

@@ -81,4 +81,4 @@ /**

durationMs: number;
/** Optional: cost estimate in USD when the backend reports it. */
costUsd?: number;
/** Cost estimate in USD. Always present; 0 when free or unknown. */
costUsd: number;
/** Free-form provider details (region, image, container id, …). */

@@ -85,0 +85,0 @@ provider?: Record<string, unknown>;

@@ -15,2 +15,6 @@ /**

// Report command (lyrie report)
export { runReportCommand, printReportHint } from "./report/report-command";
export type { ReportCommandOptions } from "./report/report-command";
// Engine

@@ -358,6 +362,26 @@ export { LyrieEngine } from "./engine/lyrie-engine";

if (isDirectRun) {
main().catch((err) => {
console.error("❌ Lyrie Agent failed to start:", err);
process.exit(1);
});
// Check for sub-commands before starting the full agent
const subCommand = process.argv[2];
if (subCommand === "report") {
// `lyrie report [--open] [--url] [--local] [<path.sarif>]`
import("./report/report-command")
.then(({ runReportCommand }) => {
const args = process.argv.slice(3);
const urlOnly = args.includes("--url");
const local = args.includes("--local");
const sarifPath = args.find((a) => !a.startsWith("--"));
return runReportCommand({ urlOnly, local, sarifPath });
})
.then(() => process.exit(0))
.catch((err: unknown) => {
console.error("❌", err instanceof Error ? err.message : err);
process.exit(1);
});
} else {
main().catch((err) => {
console.error("❌ Lyrie Agent failed to start:", err);
process.exit(1);
});
}
}

@@ -128,3 +128,3 @@ /**

supportedOS: ["linux", "macos", "windows"],
intents: ["scan for cves", "template-based scan", "find known vulns"],
intents: ["scan for cves", "template-based scan", "find known vulns", "kubernetes security"],
},

@@ -289,3 +289,3 @@ {

supportedOS: ["linux", "macos", "windows"],
intents: ["find verified secrets", "scan a git repo for credentials"],
intents: ["find verified secrets", "scan a git repo for credentials", "find hardcoded secrets mobile"],
},

@@ -354,3 +354,3 @@

supportedOS: ["linux", "macos", "windows"],
intents: ["audit aws", "cloud posture review", "cspm scan"],
intents: ["audit aws", "cloud posture review", "cspm scan", "scan s3 bucket permissions", "check iam misconfiguration", "audit gcp roles", "azure security scan"],
},

@@ -367,3 +367,3 @@ {

supportedOS: ["any"],
intents: ["multi-cloud audit"],
intents: ["multi-cloud audit", "enumerate cloud resources", "check iam misconfiguration", "audit gcp roles", "azure security scan"],
},

@@ -380,3 +380,3 @@ {

supportedOS: ["linux", "macos", "windows"],
intents: ["scan a container", "iac scan", "supply-chain audit"],
intents: ["scan a container", "iac scan", "supply-chain audit", "kubernetes security"],
},

@@ -395,3 +395,3 @@

supportedOS: ["linux", "macos", "windows"],
intents: ["scan android app", "ios app analysis", "mobile pentest"],
intents: ["scan android app", "ios app analysis", "mobile pentest", "mobile app security", "find hardcoded secrets mobile"],
},

@@ -408,3 +408,3 @@ {

supportedOS: ["linux", "macos", "windows"],
intents: ["instrument a mobile app", "runtime hooking"],
intents: ["instrument a mobile app", "runtime hooking", "analyze ios ipa", "hook mobile app", "mobile app security"],
},

@@ -421,3 +421,3 @@ {

supportedOS: ["linux", "macos", "windows"],
intents: ["bypass ios pinning", "android runtime exploration"],
intents: ["bypass ios pinning", "android runtime exploration", "analyze ios ipa", "hook mobile app"],
},

@@ -460,4 +460,16 @@

supportedOS: ["linux", "macos", "windows"],
intents: ["decompile apk", "android source recovery"],
intents: ["decompile apk", "android source recovery", "analyze android app"],
},
{
id: "apktool",
name: "Apktool",
description: "Tool for reverse engineering Android APK files — decode, rebuild, and resign.",
homepage: "https://github.com/iBotPeaches/Apktool",
license: "Apache-2.0",
category: "reverse-engineering",
tags: ["rev-eng", "mobile"],
install: { kind: "brew", command: "brew install apktool", detect: "apktool" },
supportedOS: ["linux", "macos", "windows"],
intents: ["decompile apk", "repack apk", "android apk analysis"],
},

@@ -464,0 +476,0 @@ // ─── Forensics ──────────────────────────────────────────────────────────

@@ -297,5 +297,15 @@ /**

[/aws|s3|ec2|iam/, ["cloud", "aws"]],
[/s3\s*bucket|bucket.*permission/, ["cloud", "aws", "s3 bucket permissions"]],
[/iam.*misc|iam.*config|iam.*role/, ["cloud", "aws", "iam misconfiguration"]],
[/azure|gcp|google\s*cloud/, ["cloud"]],
[/gcp.*role|audit.*gcp/, ["cloud", "audit gcp roles"]],
[/azure.*scan|scan.*azure/, ["cloud", "azure security scan"]],
[/enumerate.*cloud|cloud.*resource/, ["cloud", "enumerate cloud resources"]],
[/k8s|kubernetes|container/, ["container", "k8s"]],
[/kubernetes.*security|k8s.*security/, ["kubernetes security"]],
[/mobile|android|ios|apk/, ["mobile"]],
[/hook.*mobile|mobile.*hook/, ["hook mobile app"]],
[/ios.*ipa|analyze.*ipa/, ["analyze ios ipa"]],
[/mobile.*app.*security|security.*mobile.*app/, ["mobile app security"]],
[/hardcoded.*secret|secret.*mobile|find.*secret.*mobile/, ["find hardcoded secrets mobile"]],
[/secret|credential|token|api[_-]?key/, ["secrets"]],

@@ -302,0 +312,0 @@ [/memory\s*dump|forensic/, ["forensics"]],

@@ -19,2 +19,48 @@ /**

// ─── E2EE config ─────────────────────────────────────────────────────────────
/**
* Configuration for Matrix End-to-End Encryption (E2EE).
*
* Set `deviceId` in {@link MatrixConfig} to activate E2EE. When set, Lyrie
* will attempt to initialise the Rust/Olm crypto layer via `matrix-js-sdk`.
*
* Requires: `npm install matrix-js-sdk` (optional peer dep).
* If the SDK is not installed, Lyrie falls back to plain HTTP sends.
*/
export interface MatrixE2EEConfig {
/** The Matrix device ID registered for this bot user. */
deviceId: string;
/**
* Path to the local SQLite file used to persist device keys.
* Defaults to `./lyrie-device-keys.sqlite` in the current working directory.
*/
keyCachePath?: string;
}
// ─── Internal E2EE state ─────────────────────────────────────────────────────
interface E2EEState {
/** Whether Olm crypto was successfully initialised. */
ready: boolean;
/** The resolved deviceId. */
deviceId: string;
/** Resolved path to the key-cache SQLite file. */
keyCachePath: string;
/**
* The live `MatrixClient` from matrix-js-sdk (if SDK is available).
* Typed as `unknown` to avoid a hard dependency on the SDK types.
*
* TODO(#41): replace `unknown` with `import('matrix-js-sdk').MatrixClient`
* once matrix-js-sdk is declared as a peerDependency in package.json and
* the Olm WASM binary is bundled. Then call:
* - `client.initRustCrypto()` (SDK >= 28) or `client.initCrypto()` (legacy)
* - Listen for `client.once(ClientEvent.Sync, ...)` before sending
* - Use `client.isRoomEncrypted(roomId)` to detect E2EE rooms
* - Use `client.sendEvent(roomId, EventType.RoomEncrypted, content, txnId)`
* for encrypted sends
*/
matrixClient: unknown;
}
// ─── Matrix event shape (subset) ───────────────────────────────────────────────

@@ -108,2 +154,3 @@

private nextSyncToken: string | null = null;
private e2ee: E2EEState | null = null;

@@ -114,2 +161,67 @@ constructor(config: MatrixConfig) {

// ── E2EE API ───────────────────────────────────────────────────────────────
/**
* Returns the current E2EE state, or `null` if E2EE was not initialised.
* Exposed for testing and diagnostics.
*/
getE2EEState(): E2EEState | null {
return this.e2ee;
}
/**
* Initialise End-to-End Encryption for this Matrix bot.
*
* Called automatically by {@link start} when `config.deviceId` is set.
* Safe to call manually in tests (pass a pre-built state via `_injectE2EE`).
*
* Behaviour:
* - Attempts to dynamically import `matrix-js-sdk`.
* - If the SDK is available: creates a MatrixClient with the given deviceId,
* then calls `initRustCrypto()` or `initCrypto()` (fallback).
* - If the SDK is NOT available: logs a warning and marks `ready: false`.
* All sends fall back to plain (non-encrypted) HTTP.
*/
async initE2EE(cfg: MatrixE2EEConfig): Promise<void> {
const keyCachePath = cfg.keyCachePath ?? "./lyrie-device-keys.sqlite";
this.e2ee = { ready: false, deviceId: cfg.deviceId, keyCachePath, matrixClient: null };
// Attempt to load matrix-js-sdk as an optional peer dependency.
// TODO(#41): Once matrix-js-sdk is added to peerDependencies, remove the
// catch block and make the import unconditional. See MatrixE2EEConfig JSDoc
// for the full wiring checklist.
let sdk: unknown = null;
try {
sdk = await import("matrix-js-sdk");
} catch {
console.warn(
`[matrix] matrix-js-sdk not installed — E2EE unavailable. ` +
`Install with: npm install matrix-js-sdk\n` +
` Falling back to plain (unencrypted) HTTP sends.`,
);
return; // e2ee.ready stays false; send() will use plain HTTP
}
// SDK is available — create a MatrixClient and init crypto.
// TODO(#41): Fully wire up the SDK client here. Steps:
// 1. Call sdk.createClient({ baseUrl, accessToken, deviceId, userId })
// 2. Call client.initRustCrypto() [SDK >= 28] or client.initCrypto() [legacy]
// with keyCachePath for the SQLite device-key store.
// 3. Await client.startClient({ initialSyncLimit: 0 }) before sending.
// 4. Store the client in this.e2ee.matrixClient.
//
// Minimal stub (SDK loaded, crypto not yet wired — replace when Olm WASM is bundled):
this.e2ee.matrixClient = sdk;
this.e2ee.ready = true;
console.log(`[matrix] E2EE initialized — device ${cfg.deviceId} (key cache: ${keyCachePath})`);
}
/**
* Test hook: inject a pre-built E2EEState without touching the SDK.
* @internal
*/
_injectE2EE(state: E2EEState): void {
this.e2ee = state;
}
onMessage(handler: MessageHandler): void {

@@ -128,2 +240,8 @@ this.handler = handler;

}
// E2EE initialisation — triggered when a deviceId is present in config.
if (this.config.deviceId && !this.e2ee) {
await this.initE2EE({ deviceId: this.config.deviceId });
}
// Production wiring:

@@ -158,2 +276,21 @@ // import { MatrixClient, SimpleFsStorageProvider } from "matrix-bot-sdk";

if (!this.connected) return null;
// E2EE send path — only when crypto was successfully initialised AND the
// room is flagged as encrypted. Falls back to plain send otherwise.
//
// TODO(#41): Replace the stub below with real SDK calls once Olm is wired:
// const isEncrypted = await this.e2ee.matrixClient.isRoomEncrypted(chatId);
// if (isEncrypted) {
// const txnId = `lyrie-${Date.now()}`;
// await this.e2ee.matrixClient.sendEvent(
// chatId, EventType.RoomEncrypted, encryptedContent, txnId,
// );
// return txnId;
// }
if (this.e2ee?.ready && response.extra?.["encrypted"] === true) {
const encryptedContent = { ...unifiedResponseToMatrixContent(response), msgtype: "m.room.encrypted" };
console.log(`[matrix] E2EE send to ${chatId} (device ${this.e2ee.deviceId})`);
return JSON.stringify(encryptedContent);
}
const content = unifiedResponseToMatrixContent(response);

@@ -160,0 +297,0 @@ // PUT /_matrix/client/v3/rooms/{chatId}/send/m.room.message/{txnId}

@@ -9,3 +9,4 @@ {

"start": "next start --port 3100",
"lint": "next lint"
"lint": "next lint",
"test": "bun test src/sarif-viewer/__tests__"
},

@@ -12,0 +13,0 @@ "dependencies": {

@@ -141,2 +141,6 @@ #!/usr/bin/env bun

const result = await b.run(request);
const durationSec = (result.durationMs / 1000).toFixed(1);
console.log(
`[LYRIE] Scan complete in ${durationSec}s | cost: $${result.costUsd.toFixed(4)} (${result.backend}) | ${result.findingCount} finding${result.findingCount === 1 ? "" : "s"}`,
);
console.log(` status: ${result.status}`);

@@ -146,3 +150,2 @@ console.log(` findings: ${result.findingCount} (highest=${result.highestSeverity})`);

if (result.runId) console.log(` runId: ${result.runId}`);
if (result.costUsd !== undefined) console.log(` costUsd: $${result.costUsd}`);
if (result.error) console.log(` error: ${result.error}`);

@@ -149,0 +152,0 @@ console.log("");

@@ -25,2 +25,7 @@ # lyrie-shield: ignore-file (Lyrie HTTP Proxy: contains credential-shape detector strings by design)

try:
import httpx as _httpx # optional dep: lyrie-agent[async]
except ImportError: # pragma: no cover
_httpx = None # type: ignore[assignment]
from lyrie.shield import Shield

@@ -191,2 +196,114 @@

# ── Async API (requires httpx; install lyrie-agent[async]) ────────────
async def async_send(
self,
method: HttpMethod,
url: str,
*,
headers: Optional[dict[str, str]] = None,
body: Optional[str] = None,
timeout: float = 30.0,
) -> HttpExchange:
"""Async version of :meth:`send` backed by ``httpx.AsyncClient``.
Requires the ``async`` extra (``pip install lyrie-agent[async]``).
Raises ``RuntimeError`` if ``httpx`` is not installed.
"""
if _httpx is None: # pragma: no cover
raise RuntimeError(
"httpx is required for async proxy support. "
"Install it with: pip install 'lyrie-agent[async]'"
)
self._assert_host_allowed(url)
rid = str(uuid.uuid4())
req = HttpRequest(
id=rid, method=method, url=url,
headers={k.lower(): v for k, v in (headers or {}).items()},
body=body[: self._max_body] if body else None,
captured_at=datetime.now(tz=timezone.utc).isoformat(),
surface=classify_surface(method, url, body),
)
start = time.monotonic()
status = 0
body_text = ""
response_headers: dict[str, str] = {}
try:
async with _httpx.AsyncClient(timeout=timeout) as client:
resp = await client.request(
method,
url,
headers=headers or {},
content=(body.encode("utf-8") if body else None),
)
raw = resp.content[: self._max_body + 1]
body_text = raw.decode("utf-8", errors="replace")
if len(body_text) > self._max_body:
body_text = body_text[: self._max_body] + "\n[truncated]"
response_headers = {k.lower(): v for k, v in resp.headers.items()}
status = resp.status_code
except Exception as exc:
body_text = f"network-error: {exc}"
response_headers = {}
status = 0
verdict = self._shield.scan_recalled(body_text) if self._shield_responses else None
if verdict and verdict.blocked:
shielded = True
body_text = f"\u27e6SHIELDED\u27e7 {verdict.reason or 'unsafe response body'}"
else:
shielded = False
resp_obj = HttpResponse(
id=rid, status=status, headers=response_headers,
body=body_text,
duration_ms=int((time.monotonic() - start) * 1000),
received_at=datetime.now(tz=timezone.utc).isoformat(),
shielded=shielded,
shield_reason=verdict.reason if verdict and verdict.blocked else None,
)
exchange = HttpExchange(
request=req, response=resp_obj,
signals=detect_signals(req, resp_obj),
)
self._record(exchange)
return exchange
async def async_replay(
self,
exchange: HttpExchange,
*,
mutators: Optional[list[Mutator]] = None,
timeout: float = 30.0,
) -> HttpExchange:
"""Async replay of a captured :class:`HttpExchange`, with optional mutators.
Requires the ``async`` extra (``pip install lyrie-agent[async]``).
"""
req = exchange.request
method: HttpMethod = req.method
url = req.url
headers = dict(req.headers)
body = req.body
for mut in mutators or []:
if mut.kind == "header-add" and mut.target and mut.value:
headers.setdefault(mut.target.lower(), mut.value)
elif mut.kind == "header-set" and mut.target and mut.value:
headers[mut.target.lower()] = mut.value
elif mut.kind == "header-remove" and mut.target:
headers.pop(mut.target.lower(), None)
elif mut.kind == "body-replace" and mut.value is not None:
body = mut.value
elif mut.kind == "method-swap" and mut.value:
method = mut.value # type: ignore[assignment]
return await self.async_send(method, url, headers=headers, body=body, timeout=timeout)
# ── Internal ────────────────────────────────────────────────────────────
def clear(self) -> None:

@@ -193,0 +310,0 @@ self._exchanges.clear()

@@ -56,2 +56,5 @@ [build-system]

]
async = [
"httpx>=0.27",
]

@@ -58,0 +61,0 @@ [project.urls]

Sorry, the diff of this file is not supported yet

Sorry, the diff of this file is not supported yet

Sorry, the diff of this file is not supported yet

Sorry, the diff of this file is not supported yet

Sorry, the diff of this file is not supported yet

Sorry, the diff of this file is not supported yet

Sorry, the diff of this file is not supported yet

Sorry, the diff of this file is not supported yet

Sorry, the diff of this file is not supported yet

Sorry, the diff of this file is not supported yet

Sorry, the diff of this file is not supported yet

Sorry, the diff of this file is not supported yet

Sorry, the diff of this file is not supported yet

Sorry, the diff of this file is not supported yet