From b0339c9835b5627ffac6cf0e2865515fe277c4a7 Mon Sep 17 00:00:00 2001 From: Koral Chapnik Verbun Date: Tue, 21 Jul 2026 14:28:11 +0300 Subject: [PATCH 01/55] replace SSE with JSON polling in iron-swarm events endpoint Signed-off-by: Koral Chapnik Verbun --- .../nemo_iron_swarm_plugin/api/v2/events.py | 128 ++++++++++++++++++ .../nemo-iron-swarm/tests/unit/test_events.py | 76 +++++++++++ 2 files changed, 204 insertions(+) create mode 100644 plugins/nemo-iron-swarm/src/nemo_iron_swarm_plugin/api/v2/events.py create mode 100644 plugins/nemo-iron-swarm/tests/unit/test_events.py diff --git a/plugins/nemo-iron-swarm/src/nemo_iron_swarm_plugin/api/v2/events.py b/plugins/nemo-iron-swarm/src/nemo_iron_swarm_plugin/api/v2/events.py new file mode 100644 index 0000000000..a4008920b8 --- /dev/null +++ b/plugins/nemo-iron-swarm/src/nemo_iron_swarm_plugin/api/v2/events.py @@ -0,0 +1,128 @@ +# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +"""Durable event relay for a war-game run: the run POSTs events here, Studio polls for them. + +Poll model: iron-swarm's EventBus POSTs each event to ``POST /runs/{name}/events``; the +:class:`EventHub` appends it to a per-run ``events.jsonl`` (write-through). The file is the +durable history — the full per-agent transcript survives a service restart. Each event's id is +its 1-based line number in the file. +""" + +from __future__ import annotations + +import contextlib +import json +import logging +from pathlib import Path +from typing import Any + +from fastapi import APIRouter +from nemo_iron_swarm_plugin._perms import IronSwarmRunPerms +from nemo_iron_swarm_plugin.authz import scope +from nemo_iron_swarm_plugin.config import IronSwarmConfig +from nemo_platform_plugin.authz import CallerKind, path_rule +from pydantic import BaseModel + +logger = logging.getLogger(__name__) + +_QUEUE_MAX = 2000 # bound each live subscriber queue; history replay comes from the file, not this queue + + +def _events_path(workspace: str, run_name: str) -> Path: + """Durable per-run events log: ``/run-events//.jsonl``.""" + safe = "".join(ch if ch.isalnum() or ch in "-._" else "_" for ch in run_name) or "run" + return IronSwarmConfig.get().state_dir / "run-events" / workspace / f"{safe}.jsonl" + + +class EventIn(BaseModel): + """Body for ``POST /runs/{name}/events`` — one event emitted by the run's EventBus.""" + + event: str + payload: dict[str, Any] = {} + + +class _RunStream: + """One run's durable ``events.jsonl`` (all on the event loop). + + The file is the source of truth for history + sequence ids (id == 1-based line number); ``_seq`` is + seeded from the existing line count so ids stay monotonic across a restart. + """ + + def __init__(self, path: Path) -> None: + self._path = path + self._seq = self._line_count() + + def _line_count(self) -> int: + if not self._path.exists(): + return 0 + with self._path.open("r", encoding="utf-8") as handle: + return sum(1 for _ in handle) + + def publish(self, event: dict[str, Any]) -> None: + self._path.parent.mkdir(parents=True, exist_ok=True) + with self._path.open("a", encoding="utf-8") as handle: + handle.write(json.dumps(event) + "\n") + self._seq += 1 + + def history(self, after_id: int) -> list[tuple[int, dict[str, Any]]]: + """Replay persisted events with a line-number id greater than *after_id*.""" + if not self._path.exists(): + return [] + items: list[tuple[int, dict[str, Any]]] = [] + with self._path.open("r", encoding="utf-8") as handle: + for line_no, line in enumerate(handle, start=1): + if line_no <= after_id or not line.strip(): + continue + with contextlib.suppress(json.JSONDecodeError): + items.append((line_no, json.loads(line))) + return items + + +class EventHub: + """Per-run event streams for this plugin process (created on first publish/subscribe). + + Keyed by ``(workspace, run_name)`` so runs sharing a name across workspaces never cross streams. + """ + + def __init__(self) -> None: + self._streams: dict[tuple[str, str], _RunStream] = {} + + def stream(self, workspace: str, run_name: str) -> _RunStream: + key = (workspace, run_name) + if key not in self._streams: + self._streams[key] = _RunStream(_events_path(workspace, run_name)) + return self._streams[key] + + +hub = EventHub() +router = APIRouter() + + +@router.post("/runs/{name}/events", status_code=204, tags=["Iron Swarm Events"]) +@scope.write +# The war-game run posts its events here; allow both a human operator (local CLI) and the +# job's service principal (platform-executed run) as ingest callers. +@path_rule( + callers=[CallerKind.PRINCIPAL, CallerKind.SERVICE_PRINCIPAL], + permissions=[IronSwarmRunPerms.EVENTS_WRITE], +) +async def ingest_event(workspace: str, name: str, body: EventIn) -> None: + """Ingest one run event (the run's EventBus POSTs here).""" + hub.stream(workspace, name).publish({"event": body.event, "payload": body.payload}) + + +class EventsResponse(BaseModel): + """Response for GET /runs/{name}/events — events after the given sequence id.""" + + events: list[dict[str, Any]] + + +@router.get("/runs/{name}/events", tags=["Iron Swarm Events"]) +@scope.read +@path_rule(callers=[CallerKind.PRINCIPAL], permissions=[IronSwarmRunPerms.EVENTS_READ]) +async def get_events(workspace: str, name: str, after: int = 0) -> EventsResponse: + """Return all persisted run events with sequence id greater than *after*.""" + return EventsResponse( + events=[{"id": seq, **event} for seq, event in hub.stream(workspace, name).history(after_id=after)] + ) diff --git a/plugins/nemo-iron-swarm/tests/unit/test_events.py b/plugins/nemo-iron-swarm/tests/unit/test_events.py new file mode 100644 index 0000000000..ffc4f0acc9 --- /dev/null +++ b/plugins/nemo-iron-swarm/tests/unit/test_events.py @@ -0,0 +1,76 @@ +# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +"""Tests for the event relay: durable ``events.jsonl`` history, ingest, and polling.""" + +from __future__ import annotations + +import json +from pathlib import Path + +import pytest +from fastapi import FastAPI +from nemo_iron_swarm_plugin.api.v2 import events +from starlette.testclient import TestClient + + +def test_history_filters_by_last_seen_id(tmp_path: Path) -> None: + stream = events._RunStream(tmp_path / "events.jsonl") + for i in range(3): + stream.publish({"event": f"e{i}", "payload": {}}) + assert [seq for seq, _ in stream.history(after_id=0)] == [1, 2, 3] + assert [seq for seq, _ in stream.history(after_id=2)] == [3] + + +def test_publish_persists_and_a_fresh_stream_replays(tmp_path: Path) -> None: + # Durability: events written by one stream survive on disk and replay from a brand-new stream on the + # same file (as after a service restart), with ids continuing monotonically from the file's line count. + path = tmp_path / "events.jsonl" + first = events._RunStream(path) + first.publish({"event": "agent_exchange", "payload": {"agent_name": "Direct Prompt Attacker"}}) + first.publish({"event": "agent_completed", "payload": {"agent_name": "Direct Prompt Attacker"}}) + + assert path.exists() + assert [json.loads(line)["event"] for line in path.read_text().splitlines()] == [ + "agent_exchange", + "agent_completed", + ] + + reopened = events._RunStream(path) + assert [seq for seq, _ in reopened.history(after_id=0)] == [1, 2] + reopened.publish({"event": "round_completed", "payload": {}}) # id continues from the persisted count + assert reopened.history(after_id=2)[0][0] == 3 + + +def test_ingest_endpoint_publishes_to_hub(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setattr(events, "hub", events.EventHub()) + monkeypatch.setattr(events, "_events_path", lambda ws, name: tmp_path / f"{ws}-{name}.jsonl") + app = FastAPI() + app.include_router(events.router, prefix="/v2/workspaces/{workspace}") + with TestClient(app) as client: + resp = client.post( + "/v2/workspaces/default/runs/run-42/events", + json={"event": "FINAL_VERDICT", "payload": {"passed": True}}, + ) + assert resp.status_code == 204 + persisted = events.hub.stream("default", "run-42").history(after_id=0) + assert persisted[-1][1] == {"event": "FINAL_VERDICT", "payload": {"passed": True}} + + +def test_get_events_endpoint_returns_events_after_id(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setattr(events, "hub", events.EventHub()) + monkeypatch.setattr(events, "_events_path", lambda ws, name: tmp_path / f"{ws}-{name}.jsonl") + app = FastAPI() + app.include_router(events.router, prefix="/v2/workspaces/{workspace}") + with TestClient(app) as client: + for i in range(3): + client.post( + "/v2/workspaces/default/runs/run-1/events", + json={"event": f"e{i}", "payload": {"i": i}}, + ) + resp = client.get("/v2/workspaces/default/runs/run-1/events?after=1") + assert resp.status_code == 200 + body = resp.json() + assert [e["id"] for e in body["events"]] == [2, 3] + assert body["events"][0]["event"] == "e1" + assert body["events"][1]["event"] == "e2" From a78392cf656ca593c62fbf8669ca918336703e22 Mon Sep 17 00:00:00 2001 From: Koral Chapnik Verbun Date: Tue, 21 Jul 2026 14:29:15 +0300 Subject: [PATCH 02/55] remove dead _QUEUE_MAX constant Signed-off-by: Koral Chapnik Verbun --- .../nemo-iron-swarm/src/nemo_iron_swarm_plugin/api/v2/events.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/plugins/nemo-iron-swarm/src/nemo_iron_swarm_plugin/api/v2/events.py b/plugins/nemo-iron-swarm/src/nemo_iron_swarm_plugin/api/v2/events.py index a4008920b8..8a21d4c9f8 100644 --- a/plugins/nemo-iron-swarm/src/nemo_iron_swarm_plugin/api/v2/events.py +++ b/plugins/nemo-iron-swarm/src/nemo_iron_swarm_plugin/api/v2/events.py @@ -26,8 +26,6 @@ logger = logging.getLogger(__name__) -_QUEUE_MAX = 2000 # bound each live subscriber queue; history replay comes from the file, not this queue - def _events_path(workspace: str, run_name: str) -> Path: """Durable per-run events log: ``/run-events//.jsonl``.""" From 6010349ff93fd8628c7547385baaf5b8be8f2c1e Mon Sep 17 00:00:00 2001 From: Koral Chapnik Verbun Date: Tue, 21 Jul 2026 14:30:55 +0300 Subject: [PATCH 03/55] fix stale EventHub docstring Signed-off-by: Koral Chapnik Verbun --- .../nemo-iron-swarm/src/nemo_iron_swarm_plugin/api/v2/events.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/plugins/nemo-iron-swarm/src/nemo_iron_swarm_plugin/api/v2/events.py b/plugins/nemo-iron-swarm/src/nemo_iron_swarm_plugin/api/v2/events.py index 8a21d4c9f8..082ba0185c 100644 --- a/plugins/nemo-iron-swarm/src/nemo_iron_swarm_plugin/api/v2/events.py +++ b/plugins/nemo-iron-swarm/src/nemo_iron_swarm_plugin/api/v2/events.py @@ -78,7 +78,7 @@ def history(self, after_id: int) -> list[tuple[int, dict[str, Any]]]: class EventHub: - """Per-run event streams for this plugin process (created on first publish/subscribe). + """Per-run event streams for this plugin process (created on first access). Keyed by ``(workspace, run_name)`` so runs sharing a name across workspaces never cross streams. """ From 8aa72f84f0f4f539e0c8cbadd92669fb5f8df2fd Mon Sep 17 00:00:00 2001 From: Koral Chapnik Verbun Date: Tue, 21 Jul 2026 14:39:14 +0300 Subject: [PATCH 04/55] =?UTF-8?q?update=20SDK=20=E2=80=94=20replace=20SSE?= =?UTF-8?q?=20events=20with=20JSON=20polling?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Koral Chapnik Verbun --- web/packages/sdk/generated/iron-swarm/api.ts | 4472 +++++++++++++++++ .../ironSwarm/swarm/useSwarmEvents.ts | 57 + 2 files changed, 4529 insertions(+) create mode 100644 web/packages/sdk/generated/iron-swarm/api.ts create mode 100644 web/packages/studio/src/components/ironSwarm/swarm/useSwarmEvents.ts diff --git a/web/packages/sdk/generated/iron-swarm/api.ts b/web/packages/sdk/generated/iron-swarm/api.ts new file mode 100644 index 0000000000..3613aaf688 --- /dev/null +++ b/web/packages/sdk/generated/iron-swarm/api.ts @@ -0,0 +1,4472 @@ +/** + * SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. + * SPDX-License-Identifier: Apache-2.0 + * + * Generated by Orval 🍺 + * Do not edit manually. + * iron-swarm (plugin) + */ +import { useMutation, useQuery, useSuspenseQuery } from '@tanstack/react-query'; +import type { + DataTag, + DefinedInitialDataOptions, + DefinedUseQueryResult, + MutationFunction, + QueryClient, + QueryFunction, + QueryKey, + UndefinedInitialDataOptions, + UseMutationOptions, + UseMutationResult, + UseQueryOptions, + UseQueryResult, + UseSuspenseQueryOptions, + UseSuspenseQueryResult, +} from '@tanstack/react-query'; + +import type { + ApplyMitigationRequest, + ApplyMitigationResponse, + ComposeDefenseRequest, + ComposeDefenseResponse, + EventIn, + HTTPValidationError, + HealthzApisIronSwarmV1HealthzGet200, + InspectAgentRequest, + InspectAgentResponse, + InspectProjectRequest, + InspectProjectResponse, + IronSwarmGetJobLogsParams, + IronSwarmListJobsParams, + IronSwarmListManifests200, + IronSwarmListManifestsParams, + IronSwarmListRuns200, + IronSwarmListRunsParams, + IronSwarmManifest, + IronSwarmRun, + ManifestInit, + ManifestUpdate, + ModelConfigDefaults, + PlatformJobListResultResponse, + PlatformJobLogPage, + PlatformJobResultResponse, + PlatformJobStatusResponse, + ValidateModelRequest, + ValidateModelResponse, + WarGameJob, + WarGameJobRequest, + WarGameJobsPage, +} from './schema'; + +import { customFetch } from '../fetchers/iron-swarm.ts'; +import type { ErrorType } from '../fetchers/iron-swarm.ts'; + +export interface IronSwarmGetRunEventsParams { + after?: number; + [key: string]: unknown; +} + +export interface EventsResponse { + events: Record[]; +} + +const withQueryKey = (query: T, queryKey: K): T & { queryKey: K } => { + const result = { queryKey } as T & { queryKey: K }; + for (const key of Object.keys(query)) { + // The explicit queryKey always wins, matching the previous + // `{ ...query, queryKey }` spread where it was set last. + if (key === 'queryKey') continue; + Object.defineProperty(result, key, { + enumerable: true, + configurable: true, + get: () => (query as Record)[key], + }); + } + return result; +}; + +/** + * @summary Healthz + */ +export const healthz_apis_iron_swarm_v1_healthz_get = (signal?: AbortSignal) => { + return customFetch({ + url: `/apis/iron-swarm/v1/healthz`, + method: 'GET', + signal, + }); +}; + +export const getHealthzApisIronSwarmV1HealthzGetQueryKey = () => { + return [`/apis/iron-swarm/v1/healthz`] as const; +}; + +export const getHealthzApisIronSwarmV1HealthzGetQueryOptions = < + TData = Awaited>, + TError = ErrorType, +>(options?: { + query?: Partial< + UseQueryOptions< + Awaited>, + TError, + TData + > + >; +}) => { + const { query: queryOptions } = options ?? {}; + + const queryKey = queryOptions?.queryKey ?? getHealthzApisIronSwarmV1HealthzGetQueryKey(); + + const queryFn: QueryFunction< + Awaited> + > = ({ signal }) => healthz_apis_iron_swarm_v1_healthz_get(signal); + + return { queryKey, queryFn, ...queryOptions } as UseQueryOptions< + Awaited>, + TError, + TData + > & { queryKey: DataTag }; +}; + +export type HealthzApisIronSwarmV1HealthzGetQueryResult = NonNullable< + Awaited> +>; +export type HealthzApisIronSwarmV1HealthzGetQueryError = ErrorType; + +export function useHealthzApisIronSwarmV1HealthzGet< + TData = Awaited>, + TError = ErrorType, +>( + options: { + query: Partial< + UseQueryOptions< + Awaited>, + TError, + TData + > + > & + Pick< + DefinedInitialDataOptions< + Awaited>, + TError, + Awaited> + >, + 'initialData' + >; + }, + queryClient?: QueryClient +): DefinedUseQueryResult & { queryKey: DataTag }; +export function useHealthzApisIronSwarmV1HealthzGet< + TData = Awaited>, + TError = ErrorType, +>( + options?: { + query?: Partial< + UseQueryOptions< + Awaited>, + TError, + TData + > + > & + Pick< + UndefinedInitialDataOptions< + Awaited>, + TError, + Awaited> + >, + 'initialData' + >; + }, + queryClient?: QueryClient +): UseQueryResult & { queryKey: DataTag }; +export function useHealthzApisIronSwarmV1HealthzGet< + TData = Awaited>, + TError = ErrorType, +>( + options?: { + query?: Partial< + UseQueryOptions< + Awaited>, + TError, + TData + > + >; + }, + queryClient?: QueryClient +): UseQueryResult & { queryKey: DataTag }; +/** + * @summary Healthz + */ + +export function useHealthzApisIronSwarmV1HealthzGet< + TData = Awaited>, + TError = ErrorType, +>( + options?: { + query?: Partial< + UseQueryOptions< + Awaited>, + TError, + TData + > + >; + }, + queryClient?: QueryClient +): UseQueryResult & { queryKey: DataTag } { + const queryOptions = getHealthzApisIronSwarmV1HealthzGetQueryOptions(options); + + const query = useQuery(queryOptions, queryClient) as UseQueryResult & { + queryKey: DataTag; + }; + + return withQueryKey(query, queryOptions.queryKey); +} + +export const getHealthzApisIronSwarmV1HealthzGetSuspenseQueryOptions = < + TData = Awaited>, + TError = ErrorType, +>(options?: { + query?: Partial< + UseSuspenseQueryOptions< + Awaited>, + TError, + TData + > + >; +}) => { + const { query: queryOptions } = options ?? {}; + + const queryKey = queryOptions?.queryKey ?? getHealthzApisIronSwarmV1HealthzGetQueryKey(); + + const queryFn: QueryFunction< + Awaited> + > = ({ signal }) => healthz_apis_iron_swarm_v1_healthz_get(signal); + + return { queryKey, queryFn, ...queryOptions } as UseSuspenseQueryOptions< + Awaited>, + TError, + TData + > & { queryKey: DataTag }; +}; + +export type HealthzApisIronSwarmV1HealthzGetSuspenseQueryResult = NonNullable< + Awaited> +>; +export type HealthzApisIronSwarmV1HealthzGetSuspenseQueryError = ErrorType; + +export function useHealthzApisIronSwarmV1HealthzGetSuspense< + TData = Awaited>, + TError = ErrorType, +>( + options: { + query: Partial< + UseSuspenseQueryOptions< + Awaited>, + TError, + TData + > + >; + }, + queryClient?: QueryClient +): UseSuspenseQueryResult & { queryKey: DataTag }; +export function useHealthzApisIronSwarmV1HealthzGetSuspense< + TData = Awaited>, + TError = ErrorType, +>( + options?: { + query?: Partial< + UseSuspenseQueryOptions< + Awaited>, + TError, + TData + > + >; + }, + queryClient?: QueryClient +): UseSuspenseQueryResult & { queryKey: DataTag }; +export function useHealthzApisIronSwarmV1HealthzGetSuspense< + TData = Awaited>, + TError = ErrorType, +>( + options?: { + query?: Partial< + UseSuspenseQueryOptions< + Awaited>, + TError, + TData + > + >; + }, + queryClient?: QueryClient +): UseSuspenseQueryResult & { queryKey: DataTag }; +/** + * @summary Healthz + */ + +export function useHealthzApisIronSwarmV1HealthzGetSuspense< + TData = Awaited>, + TError = ErrorType, +>( + options?: { + query?: Partial< + UseSuspenseQueryOptions< + Awaited>, + TError, + TData + > + >; + }, + queryClient?: QueryClient +): UseSuspenseQueryResult & { queryKey: DataTag } { + const queryOptions = getHealthzApisIronSwarmV1HealthzGetSuspenseQueryOptions(options); + + const query = useSuspenseQuery(queryOptions, queryClient) as UseSuspenseQueryResult< + TData, + TError + > & { queryKey: DataTag }; + + return withQueryKey(query, queryOptions.queryKey); +} + +/** + * @summary Create Job + */ +export const ironSwarmCreateJob = ( + workspace: string, + warGameJobRequest: WarGameJobRequest, + signal?: AbortSignal +) => { + return customFetch({ + url: `/apis/iron-swarm/v2/workspaces/${encodeURIComponent(String(workspace))}/jobs`, + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + data: warGameJobRequest, + signal, + }); +}; + +export const getIronSwarmCreateJobMutationOptions = < + TError = ErrorType, + TContext = unknown, +>(options?: { + mutation?: UseMutationOptions< + Awaited>, + TError, + { workspace: string; data: WarGameJobRequest }, + TContext + >; +}): UseMutationOptions< + Awaited>, + TError, + { workspace: string; data: WarGameJobRequest }, + TContext +> => { + const mutationKey = ['ironSwarmCreateJob']; + const { mutation: mutationOptions } = options + ? options.mutation && 'mutationKey' in options.mutation && options.mutation.mutationKey + ? options + : { ...options, mutation: { ...options.mutation, mutationKey } } + : { mutation: { mutationKey } }; + + const mutationFn: MutationFunction< + Awaited>, + { workspace: string; data: WarGameJobRequest } + > = (props) => { + const { workspace, data } = props ?? {}; + + return ironSwarmCreateJob(workspace, data); + }; + + return { mutationFn, ...mutationOptions }; +}; + +export type IronSwarmCreateJobMutationResult = NonNullable< + Awaited> +>; +export type IronSwarmCreateJobMutationBody = WarGameJobRequest; +export type IronSwarmCreateJobMutationError = ErrorType; + +/** + * @summary Create Job + */ +export const useIronSwarmCreateJob = , TContext = unknown>( + options?: { + mutation?: UseMutationOptions< + Awaited>, + TError, + { workspace: string; data: WarGameJobRequest }, + TContext + >; + }, + queryClient?: QueryClient +): UseMutationResult< + Awaited>, + TError, + { workspace: string; data: WarGameJobRequest }, + TContext +> => { + return useMutation(getIronSwarmCreateJobMutationOptions(options), queryClient); +}; + +/** + * @summary List Jobs + */ +export const ironSwarmListJobs = ( + workspace: string, + params?: IronSwarmListJobsParams, + signal?: AbortSignal +) => { + return customFetch({ + url: `/apis/iron-swarm/v2/workspaces/${encodeURIComponent(String(workspace))}/jobs`, + method: 'GET', + params, + signal, + }); +}; + +export const getIronSwarmListJobsQueryKey = ( + workspace: string, + params?: IronSwarmListJobsParams +) => { + return [`/apis/iron-swarm/v2/workspaces/${workspace}/jobs`, ...(params ? [params] : [])] as const; +}; + +export const getIronSwarmListJobsQueryOptions = < + TData = Awaited>, + TError = ErrorType, +>( + workspace: string, + params?: IronSwarmListJobsParams, + options?: { + query?: Partial>, TError, TData>>; + } +) => { + const { query: queryOptions } = options ?? {}; + + const queryKey = queryOptions?.queryKey ?? getIronSwarmListJobsQueryKey(workspace, params); + + const queryFn: QueryFunction>> = ({ signal }) => + ironSwarmListJobs(workspace, params, signal); + + return { + queryKey, + queryFn, + enabled: workspace !== null && workspace !== undefined, + ...queryOptions, + } as UseQueryOptions>, TError, TData> & { + queryKey: DataTag; + }; +}; + +export type IronSwarmListJobsQueryResult = NonNullable< + Awaited> +>; +export type IronSwarmListJobsQueryError = ErrorType; + +export function useIronSwarmListJobs< + TData = Awaited>, + TError = ErrorType, +>( + workspace: string, + params: undefined | IronSwarmListJobsParams, + options: { + query: Partial>, TError, TData>> & + Pick< + DefinedInitialDataOptions< + Awaited>, + TError, + Awaited> + >, + 'initialData' + >; + }, + queryClient?: QueryClient +): DefinedUseQueryResult & { queryKey: DataTag }; +export function useIronSwarmListJobs< + TData = Awaited>, + TError = ErrorType, +>( + workspace: string, + params?: IronSwarmListJobsParams, + options?: { + query?: Partial>, TError, TData>> & + Pick< + UndefinedInitialDataOptions< + Awaited>, + TError, + Awaited> + >, + 'initialData' + >; + }, + queryClient?: QueryClient +): UseQueryResult & { queryKey: DataTag }; +export function useIronSwarmListJobs< + TData = Awaited>, + TError = ErrorType, +>( + workspace: string, + params?: IronSwarmListJobsParams, + options?: { + query?: Partial>, TError, TData>>; + }, + queryClient?: QueryClient +): UseQueryResult & { queryKey: DataTag }; +/** + * @summary List Jobs + */ + +export function useIronSwarmListJobs< + TData = Awaited>, + TError = ErrorType, +>( + workspace: string, + params?: IronSwarmListJobsParams, + options?: { + query?: Partial>, TError, TData>>; + }, + queryClient?: QueryClient +): UseQueryResult & { queryKey: DataTag } { + const queryOptions = getIronSwarmListJobsQueryOptions(workspace, params, options); + + const query = useQuery(queryOptions, queryClient) as UseQueryResult & { + queryKey: DataTag; + }; + + return withQueryKey(query, queryOptions.queryKey); +} + +export const getIronSwarmListJobsSuspenseQueryOptions = < + TData = Awaited>, + TError = ErrorType, +>( + workspace: string, + params?: IronSwarmListJobsParams, + options?: { + query?: Partial< + UseSuspenseQueryOptions>, TError, TData> + >; + } +) => { + const { query: queryOptions } = options ?? {}; + + const queryKey = queryOptions?.queryKey ?? getIronSwarmListJobsQueryKey(workspace, params); + + const queryFn: QueryFunction>> = ({ signal }) => + ironSwarmListJobs(workspace, params, signal); + + return { queryKey, queryFn, ...queryOptions } as UseSuspenseQueryOptions< + Awaited>, + TError, + TData + > & { queryKey: DataTag }; +}; + +export type IronSwarmListJobsSuspenseQueryResult = NonNullable< + Awaited> +>; +export type IronSwarmListJobsSuspenseQueryError = ErrorType; + +export function useIronSwarmListJobsSuspense< + TData = Awaited>, + TError = ErrorType, +>( + workspace: string, + params: undefined | IronSwarmListJobsParams, + options: { + query: Partial< + UseSuspenseQueryOptions>, TError, TData> + >; + }, + queryClient?: QueryClient +): UseSuspenseQueryResult & { queryKey: DataTag }; +export function useIronSwarmListJobsSuspense< + TData = Awaited>, + TError = ErrorType, +>( + workspace: string, + params?: IronSwarmListJobsParams, + options?: { + query?: Partial< + UseSuspenseQueryOptions>, TError, TData> + >; + }, + queryClient?: QueryClient +): UseSuspenseQueryResult & { queryKey: DataTag }; +export function useIronSwarmListJobsSuspense< + TData = Awaited>, + TError = ErrorType, +>( + workspace: string, + params?: IronSwarmListJobsParams, + options?: { + query?: Partial< + UseSuspenseQueryOptions>, TError, TData> + >; + }, + queryClient?: QueryClient +): UseSuspenseQueryResult & { queryKey: DataTag }; +/** + * @summary List Jobs + */ + +export function useIronSwarmListJobsSuspense< + TData = Awaited>, + TError = ErrorType, +>( + workspace: string, + params?: IronSwarmListJobsParams, + options?: { + query?: Partial< + UseSuspenseQueryOptions>, TError, TData> + >; + }, + queryClient?: QueryClient +): UseSuspenseQueryResult & { queryKey: DataTag } { + const queryOptions = getIronSwarmListJobsSuspenseQueryOptions(workspace, params, options); + + const query = useSuspenseQuery(queryOptions, queryClient) as UseSuspenseQueryResult< + TData, + TError + > & { queryKey: DataTag }; + + return withQueryKey(query, queryOptions.queryKey); +} + +/** + * @summary Get Job Result + */ +export const ironSwarmGetJobResult = ( + workspace: string, + job: string, + name: string, + signal?: AbortSignal +) => { + return customFetch({ + url: `/apis/iron-swarm/v2/workspaces/${encodeURIComponent(String(workspace))}/jobs/${encodeURIComponent(String(job))}/results/${encodeURIComponent(String(name))}`, + method: 'GET', + signal, + }); +}; + +export const getIronSwarmGetJobResultQueryKey = (workspace: string, job: string, name: string) => { + return [`/apis/iron-swarm/v2/workspaces/${workspace}/jobs/${job}/results/${name}`] as const; +}; + +export const getIronSwarmGetJobResultQueryOptions = < + TData = Awaited>, + TError = ErrorType, +>( + workspace: string, + job: string, + name: string, + options?: { + query?: Partial< + UseQueryOptions>, TError, TData> + >; + } +) => { + const { query: queryOptions } = options ?? {}; + + const queryKey = queryOptions?.queryKey ?? getIronSwarmGetJobResultQueryKey(workspace, job, name); + + const queryFn: QueryFunction>> = ({ signal }) => + ironSwarmGetJobResult(workspace, job, name, signal); + + return { + queryKey, + queryFn, + enabled: + workspace !== null && + workspace !== undefined && + job !== null && + job !== undefined && + name !== null && + name !== undefined, + ...queryOptions, + } as UseQueryOptions>, TError, TData> & { + queryKey: DataTag; + }; +}; + +export type IronSwarmGetJobResultQueryResult = NonNullable< + Awaited> +>; +export type IronSwarmGetJobResultQueryError = ErrorType; + +export function useIronSwarmGetJobResult< + TData = Awaited>, + TError = ErrorType, +>( + workspace: string, + job: string, + name: string, + options: { + query: Partial< + UseQueryOptions>, TError, TData> + > & + Pick< + DefinedInitialDataOptions< + Awaited>, + TError, + Awaited> + >, + 'initialData' + >; + }, + queryClient?: QueryClient +): DefinedUseQueryResult & { queryKey: DataTag }; +export function useIronSwarmGetJobResult< + TData = Awaited>, + TError = ErrorType, +>( + workspace: string, + job: string, + name: string, + options?: { + query?: Partial< + UseQueryOptions>, TError, TData> + > & + Pick< + UndefinedInitialDataOptions< + Awaited>, + TError, + Awaited> + >, + 'initialData' + >; + }, + queryClient?: QueryClient +): UseQueryResult & { queryKey: DataTag }; +export function useIronSwarmGetJobResult< + TData = Awaited>, + TError = ErrorType, +>( + workspace: string, + job: string, + name: string, + options?: { + query?: Partial< + UseQueryOptions>, TError, TData> + >; + }, + queryClient?: QueryClient +): UseQueryResult & { queryKey: DataTag }; +/** + * @summary Get Job Result + */ + +export function useIronSwarmGetJobResult< + TData = Awaited>, + TError = ErrorType, +>( + workspace: string, + job: string, + name: string, + options?: { + query?: Partial< + UseQueryOptions>, TError, TData> + >; + }, + queryClient?: QueryClient +): UseQueryResult & { queryKey: DataTag } { + const queryOptions = getIronSwarmGetJobResultQueryOptions(workspace, job, name, options); + + const query = useQuery(queryOptions, queryClient) as UseQueryResult & { + queryKey: DataTag; + }; + + return withQueryKey(query, queryOptions.queryKey); +} + +export const getIronSwarmGetJobResultSuspenseQueryOptions = < + TData = Awaited>, + TError = ErrorType, +>( + workspace: string, + job: string, + name: string, + options?: { + query?: Partial< + UseSuspenseQueryOptions>, TError, TData> + >; + } +) => { + const { query: queryOptions } = options ?? {}; + + const queryKey = queryOptions?.queryKey ?? getIronSwarmGetJobResultQueryKey(workspace, job, name); + + const queryFn: QueryFunction>> = ({ signal }) => + ironSwarmGetJobResult(workspace, job, name, signal); + + return { queryKey, queryFn, ...queryOptions } as UseSuspenseQueryOptions< + Awaited>, + TError, + TData + > & { queryKey: DataTag }; +}; + +export type IronSwarmGetJobResultSuspenseQueryResult = NonNullable< + Awaited> +>; +export type IronSwarmGetJobResultSuspenseQueryError = ErrorType; + +export function useIronSwarmGetJobResultSuspense< + TData = Awaited>, + TError = ErrorType, +>( + workspace: string, + job: string, + name: string, + options: { + query: Partial< + UseSuspenseQueryOptions>, TError, TData> + >; + }, + queryClient?: QueryClient +): UseSuspenseQueryResult & { queryKey: DataTag }; +export function useIronSwarmGetJobResultSuspense< + TData = Awaited>, + TError = ErrorType, +>( + workspace: string, + job: string, + name: string, + options?: { + query?: Partial< + UseSuspenseQueryOptions>, TError, TData> + >; + }, + queryClient?: QueryClient +): UseSuspenseQueryResult & { queryKey: DataTag }; +export function useIronSwarmGetJobResultSuspense< + TData = Awaited>, + TError = ErrorType, +>( + workspace: string, + job: string, + name: string, + options?: { + query?: Partial< + UseSuspenseQueryOptions>, TError, TData> + >; + }, + queryClient?: QueryClient +): UseSuspenseQueryResult & { queryKey: DataTag }; +/** + * @summary Get Job Result + */ + +export function useIronSwarmGetJobResultSuspense< + TData = Awaited>, + TError = ErrorType, +>( + workspace: string, + job: string, + name: string, + options?: { + query?: Partial< + UseSuspenseQueryOptions>, TError, TData> + >; + }, + queryClient?: QueryClient +): UseSuspenseQueryResult & { queryKey: DataTag } { + const queryOptions = getIronSwarmGetJobResultSuspenseQueryOptions(workspace, job, name, options); + + const query = useSuspenseQuery(queryOptions, queryClient) as UseSuspenseQueryResult< + TData, + TError + > & { queryKey: DataTag }; + + return withQueryKey(query, queryOptions.queryKey); +} + +/** + * @summary Download Job Result + */ +export const ironSwarmDownloadJobResult = ( + workspace: string, + job: string, + name: string, + signal?: AbortSignal +) => { + return customFetch({ + url: `/apis/iron-swarm/v2/workspaces/${encodeURIComponent(String(workspace))}/jobs/${encodeURIComponent(String(job))}/results/${encodeURIComponent(String(name))}/download`, + method: 'GET', + responseType: 'blob', + signal, + }); +}; + +export const getIronSwarmDownloadJobResultQueryKey = ( + workspace: string, + job: string, + name: string +) => { + return [ + `/apis/iron-swarm/v2/workspaces/${workspace}/jobs/${job}/results/${name}/download`, + ] as const; +}; + +export const getIronSwarmDownloadJobResultQueryOptions = < + TData = Awaited>, + TError = ErrorType, +>( + workspace: string, + job: string, + name: string, + options?: { + query?: Partial< + UseQueryOptions>, TError, TData> + >; + } +) => { + const { query: queryOptions } = options ?? {}; + + const queryKey = + queryOptions?.queryKey ?? getIronSwarmDownloadJobResultQueryKey(workspace, job, name); + + const queryFn: QueryFunction>> = ({ + signal, + }) => ironSwarmDownloadJobResult(workspace, job, name, signal); + + return { + queryKey, + queryFn, + enabled: + workspace !== null && + workspace !== undefined && + job !== null && + job !== undefined && + name !== null && + name !== undefined, + ...queryOptions, + } as UseQueryOptions>, TError, TData> & { + queryKey: DataTag; + }; +}; + +export type IronSwarmDownloadJobResultQueryResult = NonNullable< + Awaited> +>; +export type IronSwarmDownloadJobResultQueryError = ErrorType; + +export function useIronSwarmDownloadJobResult< + TData = Awaited>, + TError = ErrorType, +>( + workspace: string, + job: string, + name: string, + options: { + query: Partial< + UseQueryOptions>, TError, TData> + > & + Pick< + DefinedInitialDataOptions< + Awaited>, + TError, + Awaited> + >, + 'initialData' + >; + }, + queryClient?: QueryClient +): DefinedUseQueryResult & { queryKey: DataTag }; +export function useIronSwarmDownloadJobResult< + TData = Awaited>, + TError = ErrorType, +>( + workspace: string, + job: string, + name: string, + options?: { + query?: Partial< + UseQueryOptions>, TError, TData> + > & + Pick< + UndefinedInitialDataOptions< + Awaited>, + TError, + Awaited> + >, + 'initialData' + >; + }, + queryClient?: QueryClient +): UseQueryResult & { queryKey: DataTag }; +export function useIronSwarmDownloadJobResult< + TData = Awaited>, + TError = ErrorType, +>( + workspace: string, + job: string, + name: string, + options?: { + query?: Partial< + UseQueryOptions>, TError, TData> + >; + }, + queryClient?: QueryClient +): UseQueryResult & { queryKey: DataTag }; +/** + * @summary Download Job Result + */ + +export function useIronSwarmDownloadJobResult< + TData = Awaited>, + TError = ErrorType, +>( + workspace: string, + job: string, + name: string, + options?: { + query?: Partial< + UseQueryOptions>, TError, TData> + >; + }, + queryClient?: QueryClient +): UseQueryResult & { queryKey: DataTag } { + const queryOptions = getIronSwarmDownloadJobResultQueryOptions(workspace, job, name, options); + + const query = useQuery(queryOptions, queryClient) as UseQueryResult & { + queryKey: DataTag; + }; + + return withQueryKey(query, queryOptions.queryKey); +} + +export const getIronSwarmDownloadJobResultSuspenseQueryOptions = < + TData = Awaited>, + TError = ErrorType, +>( + workspace: string, + job: string, + name: string, + options?: { + query?: Partial< + UseSuspenseQueryOptions>, TError, TData> + >; + } +) => { + const { query: queryOptions } = options ?? {}; + + const queryKey = + queryOptions?.queryKey ?? getIronSwarmDownloadJobResultQueryKey(workspace, job, name); + + const queryFn: QueryFunction>> = ({ + signal, + }) => ironSwarmDownloadJobResult(workspace, job, name, signal); + + return { queryKey, queryFn, ...queryOptions } as UseSuspenseQueryOptions< + Awaited>, + TError, + TData + > & { queryKey: DataTag }; +}; + +export type IronSwarmDownloadJobResultSuspenseQueryResult = NonNullable< + Awaited> +>; +export type IronSwarmDownloadJobResultSuspenseQueryError = ErrorType; + +export function useIronSwarmDownloadJobResultSuspense< + TData = Awaited>, + TError = ErrorType, +>( + workspace: string, + job: string, + name: string, + options: { + query: Partial< + UseSuspenseQueryOptions>, TError, TData> + >; + }, + queryClient?: QueryClient +): UseSuspenseQueryResult & { queryKey: DataTag }; +export function useIronSwarmDownloadJobResultSuspense< + TData = Awaited>, + TError = ErrorType, +>( + workspace: string, + job: string, + name: string, + options?: { + query?: Partial< + UseSuspenseQueryOptions>, TError, TData> + >; + }, + queryClient?: QueryClient +): UseSuspenseQueryResult & { queryKey: DataTag }; +export function useIronSwarmDownloadJobResultSuspense< + TData = Awaited>, + TError = ErrorType, +>( + workspace: string, + job: string, + name: string, + options?: { + query?: Partial< + UseSuspenseQueryOptions>, TError, TData> + >; + }, + queryClient?: QueryClient +): UseSuspenseQueryResult & { queryKey: DataTag }; +/** + * @summary Download Job Result + */ + +export function useIronSwarmDownloadJobResultSuspense< + TData = Awaited>, + TError = ErrorType, +>( + workspace: string, + job: string, + name: string, + options?: { + query?: Partial< + UseSuspenseQueryOptions>, TError, TData> + >; + }, + queryClient?: QueryClient +): UseSuspenseQueryResult & { queryKey: DataTag } { + const queryOptions = getIronSwarmDownloadJobResultSuspenseQueryOptions( + workspace, + job, + name, + options + ); + + const query = useSuspenseQuery(queryOptions, queryClient) as UseSuspenseQueryResult< + TData, + TError + > & { queryKey: DataTag }; + + return withQueryKey(query, queryOptions.queryKey); +} + +/** + * @summary Get Job + */ +export const ironSwarmGetJob = (workspace: string, name: string, signal?: AbortSignal) => { + return customFetch({ + url: `/apis/iron-swarm/v2/workspaces/${encodeURIComponent(String(workspace))}/jobs/${encodeURIComponent(String(name))}`, + method: 'GET', + signal, + }); +}; + +export const getIronSwarmGetJobQueryKey = (workspace: string, name: string) => { + return [`/apis/iron-swarm/v2/workspaces/${workspace}/jobs/${name}`] as const; +}; + +export const getIronSwarmGetJobQueryOptions = < + TData = Awaited>, + TError = ErrorType, +>( + workspace: string, + name: string, + options?: { + query?: Partial>, TError, TData>>; + } +) => { + const { query: queryOptions } = options ?? {}; + + const queryKey = queryOptions?.queryKey ?? getIronSwarmGetJobQueryKey(workspace, name); + + const queryFn: QueryFunction>> = ({ signal }) => + ironSwarmGetJob(workspace, name, signal); + + return { + queryKey, + queryFn, + enabled: workspace !== null && workspace !== undefined && name !== null && name !== undefined, + ...queryOptions, + } as UseQueryOptions>, TError, TData> & { + queryKey: DataTag; + }; +}; + +export type IronSwarmGetJobQueryResult = NonNullable>>; +export type IronSwarmGetJobQueryError = ErrorType; + +export function useIronSwarmGetJob< + TData = Awaited>, + TError = ErrorType, +>( + workspace: string, + name: string, + options: { + query: Partial>, TError, TData>> & + Pick< + DefinedInitialDataOptions< + Awaited>, + TError, + Awaited> + >, + 'initialData' + >; + }, + queryClient?: QueryClient +): DefinedUseQueryResult & { queryKey: DataTag }; +export function useIronSwarmGetJob< + TData = Awaited>, + TError = ErrorType, +>( + workspace: string, + name: string, + options?: { + query?: Partial>, TError, TData>> & + Pick< + UndefinedInitialDataOptions< + Awaited>, + TError, + Awaited> + >, + 'initialData' + >; + }, + queryClient?: QueryClient +): UseQueryResult & { queryKey: DataTag }; +export function useIronSwarmGetJob< + TData = Awaited>, + TError = ErrorType, +>( + workspace: string, + name: string, + options?: { + query?: Partial>, TError, TData>>; + }, + queryClient?: QueryClient +): UseQueryResult & { queryKey: DataTag }; +/** + * @summary Get Job + */ + +export function useIronSwarmGetJob< + TData = Awaited>, + TError = ErrorType, +>( + workspace: string, + name: string, + options?: { + query?: Partial>, TError, TData>>; + }, + queryClient?: QueryClient +): UseQueryResult & { queryKey: DataTag } { + const queryOptions = getIronSwarmGetJobQueryOptions(workspace, name, options); + + const query = useQuery(queryOptions, queryClient) as UseQueryResult & { + queryKey: DataTag; + }; + + return withQueryKey(query, queryOptions.queryKey); +} + +export const getIronSwarmGetJobSuspenseQueryOptions = < + TData = Awaited>, + TError = ErrorType, +>( + workspace: string, + name: string, + options?: { + query?: Partial< + UseSuspenseQueryOptions>, TError, TData> + >; + } +) => { + const { query: queryOptions } = options ?? {}; + + const queryKey = queryOptions?.queryKey ?? getIronSwarmGetJobQueryKey(workspace, name); + + const queryFn: QueryFunction>> = ({ signal }) => + ironSwarmGetJob(workspace, name, signal); + + return { queryKey, queryFn, ...queryOptions } as UseSuspenseQueryOptions< + Awaited>, + TError, + TData + > & { queryKey: DataTag }; +}; + +export type IronSwarmGetJobSuspenseQueryResult = NonNullable< + Awaited> +>; +export type IronSwarmGetJobSuspenseQueryError = ErrorType; + +export function useIronSwarmGetJobSuspense< + TData = Awaited>, + TError = ErrorType, +>( + workspace: string, + name: string, + options: { + query: Partial< + UseSuspenseQueryOptions>, TError, TData> + >; + }, + queryClient?: QueryClient +): UseSuspenseQueryResult & { queryKey: DataTag }; +export function useIronSwarmGetJobSuspense< + TData = Awaited>, + TError = ErrorType, +>( + workspace: string, + name: string, + options?: { + query?: Partial< + UseSuspenseQueryOptions>, TError, TData> + >; + }, + queryClient?: QueryClient +): UseSuspenseQueryResult & { queryKey: DataTag }; +export function useIronSwarmGetJobSuspense< + TData = Awaited>, + TError = ErrorType, +>( + workspace: string, + name: string, + options?: { + query?: Partial< + UseSuspenseQueryOptions>, TError, TData> + >; + }, + queryClient?: QueryClient +): UseSuspenseQueryResult & { queryKey: DataTag }; +/** + * @summary Get Job + */ + +export function useIronSwarmGetJobSuspense< + TData = Awaited>, + TError = ErrorType, +>( + workspace: string, + name: string, + options?: { + query?: Partial< + UseSuspenseQueryOptions>, TError, TData> + >; + }, + queryClient?: QueryClient +): UseSuspenseQueryResult & { queryKey: DataTag } { + const queryOptions = getIronSwarmGetJobSuspenseQueryOptions(workspace, name, options); + + const query = useSuspenseQuery(queryOptions, queryClient) as UseSuspenseQueryResult< + TData, + TError + > & { queryKey: DataTag }; + + return withQueryKey(query, queryOptions.queryKey); +} + +/** + * @summary Delete Job + */ +export const ironSwarmDeleteJob = (workspace: string, name: string, signal?: AbortSignal) => { + return customFetch({ + url: `/apis/iron-swarm/v2/workspaces/${encodeURIComponent(String(workspace))}/jobs/${encodeURIComponent(String(name))}`, + method: 'DELETE', + signal, + }); +}; + +export const getIronSwarmDeleteJobMutationOptions = < + TError = ErrorType, + TContext = unknown, +>(options?: { + mutation?: UseMutationOptions< + Awaited>, + TError, + { workspace: string; name: string }, + TContext + >; +}): UseMutationOptions< + Awaited>, + TError, + { workspace: string; name: string }, + TContext +> => { + const mutationKey = ['ironSwarmDeleteJob']; + const { mutation: mutationOptions } = options + ? options.mutation && 'mutationKey' in options.mutation && options.mutation.mutationKey + ? options + : { ...options, mutation: { ...options.mutation, mutationKey } } + : { mutation: { mutationKey } }; + + const mutationFn: MutationFunction< + Awaited>, + { workspace: string; name: string } + > = (props) => { + const { workspace, name } = props ?? {}; + + return ironSwarmDeleteJob(workspace, name); + }; + + return { mutationFn, ...mutationOptions }; +}; + +export type IronSwarmDeleteJobMutationResult = NonNullable< + Awaited> +>; + +export type IronSwarmDeleteJobMutationError = ErrorType; + +/** + * @summary Delete Job + */ +export const useIronSwarmDeleteJob = , TContext = unknown>( + options?: { + mutation?: UseMutationOptions< + Awaited>, + TError, + { workspace: string; name: string }, + TContext + >; + }, + queryClient?: QueryClient +): UseMutationResult< + Awaited>, + TError, + { workspace: string; name: string }, + TContext +> => { + return useMutation(getIronSwarmDeleteJobMutationOptions(options), queryClient); +}; + +/** + * @summary Cancel Job + */ +export const ironSwarmCancelJob = (workspace: string, name: string, signal?: AbortSignal) => { + return customFetch({ + url: `/apis/iron-swarm/v2/workspaces/${encodeURIComponent(String(workspace))}/jobs/${encodeURIComponent(String(name))}/cancel`, + method: 'POST', + signal, + }); +}; + +export const getIronSwarmCancelJobMutationOptions = < + TError = ErrorType, + TContext = unknown, +>(options?: { + mutation?: UseMutationOptions< + Awaited>, + TError, + { workspace: string; name: string }, + TContext + >; +}): UseMutationOptions< + Awaited>, + TError, + { workspace: string; name: string }, + TContext +> => { + const mutationKey = ['ironSwarmCancelJob']; + const { mutation: mutationOptions } = options + ? options.mutation && 'mutationKey' in options.mutation && options.mutation.mutationKey + ? options + : { ...options, mutation: { ...options.mutation, mutationKey } } + : { mutation: { mutationKey } }; + + const mutationFn: MutationFunction< + Awaited>, + { workspace: string; name: string } + > = (props) => { + const { workspace, name } = props ?? {}; + + return ironSwarmCancelJob(workspace, name); + }; + + return { mutationFn, ...mutationOptions }; +}; + +export type IronSwarmCancelJobMutationResult = NonNullable< + Awaited> +>; + +export type IronSwarmCancelJobMutationError = ErrorType; + +/** + * @summary Cancel Job + */ +export const useIronSwarmCancelJob = , TContext = unknown>( + options?: { + mutation?: UseMutationOptions< + Awaited>, + TError, + { workspace: string; name: string }, + TContext + >; + }, + queryClient?: QueryClient +): UseMutationResult< + Awaited>, + TError, + { workspace: string; name: string }, + TContext +> => { + return useMutation(getIronSwarmCancelJobMutationOptions(options), queryClient); +}; + +/** + * @summary Get Job Logs + */ +export const ironSwarmGetJobLogs = ( + workspace: string, + name: string, + params?: IronSwarmGetJobLogsParams, + signal?: AbortSignal +) => { + return customFetch({ + url: `/apis/iron-swarm/v2/workspaces/${encodeURIComponent(String(workspace))}/jobs/${encodeURIComponent(String(name))}/logs`, + method: 'GET', + params, + signal, + }); +}; + +export const getIronSwarmGetJobLogsQueryKey = ( + workspace: string, + name: string, + params?: IronSwarmGetJobLogsParams +) => { + return [ + `/apis/iron-swarm/v2/workspaces/${workspace}/jobs/${name}/logs`, + ...(params ? [params] : []), + ] as const; +}; + +export const getIronSwarmGetJobLogsQueryOptions = < + TData = Awaited>, + TError = ErrorType, +>( + workspace: string, + name: string, + params?: IronSwarmGetJobLogsParams, + options?: { + query?: Partial< + UseQueryOptions>, TError, TData> + >; + } +) => { + const { query: queryOptions } = options ?? {}; + + const queryKey = + queryOptions?.queryKey ?? getIronSwarmGetJobLogsQueryKey(workspace, name, params); + + const queryFn: QueryFunction>> = ({ signal }) => + ironSwarmGetJobLogs(workspace, name, params, signal); + + return { + queryKey, + queryFn, + enabled: workspace !== null && workspace !== undefined && name !== null && name !== undefined, + ...queryOptions, + } as UseQueryOptions>, TError, TData> & { + queryKey: DataTag; + }; +}; + +export type IronSwarmGetJobLogsQueryResult = NonNullable< + Awaited> +>; +export type IronSwarmGetJobLogsQueryError = ErrorType; + +export function useIronSwarmGetJobLogs< + TData = Awaited>, + TError = ErrorType, +>( + workspace: string, + name: string, + params: undefined | IronSwarmGetJobLogsParams, + options: { + query: Partial< + UseQueryOptions>, TError, TData> + > & + Pick< + DefinedInitialDataOptions< + Awaited>, + TError, + Awaited> + >, + 'initialData' + >; + }, + queryClient?: QueryClient +): DefinedUseQueryResult & { queryKey: DataTag }; +export function useIronSwarmGetJobLogs< + TData = Awaited>, + TError = ErrorType, +>( + workspace: string, + name: string, + params?: IronSwarmGetJobLogsParams, + options?: { + query?: Partial< + UseQueryOptions>, TError, TData> + > & + Pick< + UndefinedInitialDataOptions< + Awaited>, + TError, + Awaited> + >, + 'initialData' + >; + }, + queryClient?: QueryClient +): UseQueryResult & { queryKey: DataTag }; +export function useIronSwarmGetJobLogs< + TData = Awaited>, + TError = ErrorType, +>( + workspace: string, + name: string, + params?: IronSwarmGetJobLogsParams, + options?: { + query?: Partial< + UseQueryOptions>, TError, TData> + >; + }, + queryClient?: QueryClient +): UseQueryResult & { queryKey: DataTag }; +/** + * @summary Get Job Logs + */ + +export function useIronSwarmGetJobLogs< + TData = Awaited>, + TError = ErrorType, +>( + workspace: string, + name: string, + params?: IronSwarmGetJobLogsParams, + options?: { + query?: Partial< + UseQueryOptions>, TError, TData> + >; + }, + queryClient?: QueryClient +): UseQueryResult & { queryKey: DataTag } { + const queryOptions = getIronSwarmGetJobLogsQueryOptions(workspace, name, params, options); + + const query = useQuery(queryOptions, queryClient) as UseQueryResult & { + queryKey: DataTag; + }; + + return withQueryKey(query, queryOptions.queryKey); +} + +export const getIronSwarmGetJobLogsSuspenseQueryOptions = < + TData = Awaited>, + TError = ErrorType, +>( + workspace: string, + name: string, + params?: IronSwarmGetJobLogsParams, + options?: { + query?: Partial< + UseSuspenseQueryOptions>, TError, TData> + >; + } +) => { + const { query: queryOptions } = options ?? {}; + + const queryKey = + queryOptions?.queryKey ?? getIronSwarmGetJobLogsQueryKey(workspace, name, params); + + const queryFn: QueryFunction>> = ({ signal }) => + ironSwarmGetJobLogs(workspace, name, params, signal); + + return { queryKey, queryFn, ...queryOptions } as UseSuspenseQueryOptions< + Awaited>, + TError, + TData + > & { queryKey: DataTag }; +}; + +export type IronSwarmGetJobLogsSuspenseQueryResult = NonNullable< + Awaited> +>; +export type IronSwarmGetJobLogsSuspenseQueryError = ErrorType; + +export function useIronSwarmGetJobLogsSuspense< + TData = Awaited>, + TError = ErrorType, +>( + workspace: string, + name: string, + params: undefined | IronSwarmGetJobLogsParams, + options: { + query: Partial< + UseSuspenseQueryOptions>, TError, TData> + >; + }, + queryClient?: QueryClient +): UseSuspenseQueryResult & { queryKey: DataTag }; +export function useIronSwarmGetJobLogsSuspense< + TData = Awaited>, + TError = ErrorType, +>( + workspace: string, + name: string, + params?: IronSwarmGetJobLogsParams, + options?: { + query?: Partial< + UseSuspenseQueryOptions>, TError, TData> + >; + }, + queryClient?: QueryClient +): UseSuspenseQueryResult & { queryKey: DataTag }; +export function useIronSwarmGetJobLogsSuspense< + TData = Awaited>, + TError = ErrorType, +>( + workspace: string, + name: string, + params?: IronSwarmGetJobLogsParams, + options?: { + query?: Partial< + UseSuspenseQueryOptions>, TError, TData> + >; + }, + queryClient?: QueryClient +): UseSuspenseQueryResult & { queryKey: DataTag }; +/** + * @summary Get Job Logs + */ + +export function useIronSwarmGetJobLogsSuspense< + TData = Awaited>, + TError = ErrorType, +>( + workspace: string, + name: string, + params?: IronSwarmGetJobLogsParams, + options?: { + query?: Partial< + UseSuspenseQueryOptions>, TError, TData> + >; + }, + queryClient?: QueryClient +): UseSuspenseQueryResult & { queryKey: DataTag } { + const queryOptions = getIronSwarmGetJobLogsSuspenseQueryOptions(workspace, name, params, options); + + const query = useSuspenseQuery(queryOptions, queryClient) as UseSuspenseQueryResult< + TData, + TError + > & { queryKey: DataTag }; + + return withQueryKey(query, queryOptions.queryKey); +} + +/** + * @summary List Job Results + */ +export const ironSwarmListJobResults = (workspace: string, name: string, signal?: AbortSignal) => { + return customFetch({ + url: `/apis/iron-swarm/v2/workspaces/${encodeURIComponent(String(workspace))}/jobs/${encodeURIComponent(String(name))}/results`, + method: 'GET', + signal, + }); +}; + +export const getIronSwarmListJobResultsQueryKey = (workspace: string, name: string) => { + return [`/apis/iron-swarm/v2/workspaces/${workspace}/jobs/${name}/results`] as const; +}; + +export const getIronSwarmListJobResultsQueryOptions = < + TData = Awaited>, + TError = ErrorType, +>( + workspace: string, + name: string, + options?: { + query?: Partial< + UseQueryOptions>, TError, TData> + >; + } +) => { + const { query: queryOptions } = options ?? {}; + + const queryKey = queryOptions?.queryKey ?? getIronSwarmListJobResultsQueryKey(workspace, name); + + const queryFn: QueryFunction>> = ({ + signal, + }) => ironSwarmListJobResults(workspace, name, signal); + + return { + queryKey, + queryFn, + enabled: workspace !== null && workspace !== undefined && name !== null && name !== undefined, + ...queryOptions, + } as UseQueryOptions>, TError, TData> & { + queryKey: DataTag; + }; +}; + +export type IronSwarmListJobResultsQueryResult = NonNullable< + Awaited> +>; +export type IronSwarmListJobResultsQueryError = ErrorType; + +export function useIronSwarmListJobResults< + TData = Awaited>, + TError = ErrorType, +>( + workspace: string, + name: string, + options: { + query: Partial< + UseQueryOptions>, TError, TData> + > & + Pick< + DefinedInitialDataOptions< + Awaited>, + TError, + Awaited> + >, + 'initialData' + >; + }, + queryClient?: QueryClient +): DefinedUseQueryResult & { queryKey: DataTag }; +export function useIronSwarmListJobResults< + TData = Awaited>, + TError = ErrorType, +>( + workspace: string, + name: string, + options?: { + query?: Partial< + UseQueryOptions>, TError, TData> + > & + Pick< + UndefinedInitialDataOptions< + Awaited>, + TError, + Awaited> + >, + 'initialData' + >; + }, + queryClient?: QueryClient +): UseQueryResult & { queryKey: DataTag }; +export function useIronSwarmListJobResults< + TData = Awaited>, + TError = ErrorType, +>( + workspace: string, + name: string, + options?: { + query?: Partial< + UseQueryOptions>, TError, TData> + >; + }, + queryClient?: QueryClient +): UseQueryResult & { queryKey: DataTag }; +/** + * @summary List Job Results + */ + +export function useIronSwarmListJobResults< + TData = Awaited>, + TError = ErrorType, +>( + workspace: string, + name: string, + options?: { + query?: Partial< + UseQueryOptions>, TError, TData> + >; + }, + queryClient?: QueryClient +): UseQueryResult & { queryKey: DataTag } { + const queryOptions = getIronSwarmListJobResultsQueryOptions(workspace, name, options); + + const query = useQuery(queryOptions, queryClient) as UseQueryResult & { + queryKey: DataTag; + }; + + return withQueryKey(query, queryOptions.queryKey); +} + +export const getIronSwarmListJobResultsSuspenseQueryOptions = < + TData = Awaited>, + TError = ErrorType, +>( + workspace: string, + name: string, + options?: { + query?: Partial< + UseSuspenseQueryOptions>, TError, TData> + >; + } +) => { + const { query: queryOptions } = options ?? {}; + + const queryKey = queryOptions?.queryKey ?? getIronSwarmListJobResultsQueryKey(workspace, name); + + const queryFn: QueryFunction>> = ({ + signal, + }) => ironSwarmListJobResults(workspace, name, signal); + + return { queryKey, queryFn, ...queryOptions } as UseSuspenseQueryOptions< + Awaited>, + TError, + TData + > & { queryKey: DataTag }; +}; + +export type IronSwarmListJobResultsSuspenseQueryResult = NonNullable< + Awaited> +>; +export type IronSwarmListJobResultsSuspenseQueryError = ErrorType; + +export function useIronSwarmListJobResultsSuspense< + TData = Awaited>, + TError = ErrorType, +>( + workspace: string, + name: string, + options: { + query: Partial< + UseSuspenseQueryOptions>, TError, TData> + >; + }, + queryClient?: QueryClient +): UseSuspenseQueryResult & { queryKey: DataTag }; +export function useIronSwarmListJobResultsSuspense< + TData = Awaited>, + TError = ErrorType, +>( + workspace: string, + name: string, + options?: { + query?: Partial< + UseSuspenseQueryOptions>, TError, TData> + >; + }, + queryClient?: QueryClient +): UseSuspenseQueryResult & { queryKey: DataTag }; +export function useIronSwarmListJobResultsSuspense< + TData = Awaited>, + TError = ErrorType, +>( + workspace: string, + name: string, + options?: { + query?: Partial< + UseSuspenseQueryOptions>, TError, TData> + >; + }, + queryClient?: QueryClient +): UseSuspenseQueryResult & { queryKey: DataTag }; +/** + * @summary List Job Results + */ + +export function useIronSwarmListJobResultsSuspense< + TData = Awaited>, + TError = ErrorType, +>( + workspace: string, + name: string, + options?: { + query?: Partial< + UseSuspenseQueryOptions>, TError, TData> + >; + }, + queryClient?: QueryClient +): UseSuspenseQueryResult & { queryKey: DataTag } { + const queryOptions = getIronSwarmListJobResultsSuspenseQueryOptions(workspace, name, options); + + const query = useSuspenseQuery(queryOptions, queryClient) as UseSuspenseQueryResult< + TData, + TError + > & { queryKey: DataTag }; + + return withQueryKey(query, queryOptions.queryKey); +} + +/** + * @summary Get Job Status + */ +export const ironSwarmGetJobStatus = (workspace: string, name: string, signal?: AbortSignal) => { + return customFetch({ + url: `/apis/iron-swarm/v2/workspaces/${encodeURIComponent(String(workspace))}/jobs/${encodeURIComponent(String(name))}/status`, + method: 'GET', + signal, + }); +}; + +export const getIronSwarmGetJobStatusQueryKey = (workspace: string, name: string) => { + return [`/apis/iron-swarm/v2/workspaces/${workspace}/jobs/${name}/status`] as const; +}; + +export const getIronSwarmGetJobStatusQueryOptions = < + TData = Awaited>, + TError = ErrorType, +>( + workspace: string, + name: string, + options?: { + query?: Partial< + UseQueryOptions>, TError, TData> + >; + } +) => { + const { query: queryOptions } = options ?? {}; + + const queryKey = queryOptions?.queryKey ?? getIronSwarmGetJobStatusQueryKey(workspace, name); + + const queryFn: QueryFunction>> = ({ signal }) => + ironSwarmGetJobStatus(workspace, name, signal); + + return { + queryKey, + queryFn, + enabled: workspace !== null && workspace !== undefined && name !== null && name !== undefined, + ...queryOptions, + } as UseQueryOptions>, TError, TData> & { + queryKey: DataTag; + }; +}; + +export type IronSwarmGetJobStatusQueryResult = NonNullable< + Awaited> +>; +export type IronSwarmGetJobStatusQueryError = ErrorType; + +export function useIronSwarmGetJobStatus< + TData = Awaited>, + TError = ErrorType, +>( + workspace: string, + name: string, + options: { + query: Partial< + UseQueryOptions>, TError, TData> + > & + Pick< + DefinedInitialDataOptions< + Awaited>, + TError, + Awaited> + >, + 'initialData' + >; + }, + queryClient?: QueryClient +): DefinedUseQueryResult & { queryKey: DataTag }; +export function useIronSwarmGetJobStatus< + TData = Awaited>, + TError = ErrorType, +>( + workspace: string, + name: string, + options?: { + query?: Partial< + UseQueryOptions>, TError, TData> + > & + Pick< + UndefinedInitialDataOptions< + Awaited>, + TError, + Awaited> + >, + 'initialData' + >; + }, + queryClient?: QueryClient +): UseQueryResult & { queryKey: DataTag }; +export function useIronSwarmGetJobStatus< + TData = Awaited>, + TError = ErrorType, +>( + workspace: string, + name: string, + options?: { + query?: Partial< + UseQueryOptions>, TError, TData> + >; + }, + queryClient?: QueryClient +): UseQueryResult & { queryKey: DataTag }; +/** + * @summary Get Job Status + */ + +export function useIronSwarmGetJobStatus< + TData = Awaited>, + TError = ErrorType, +>( + workspace: string, + name: string, + options?: { + query?: Partial< + UseQueryOptions>, TError, TData> + >; + }, + queryClient?: QueryClient +): UseQueryResult & { queryKey: DataTag } { + const queryOptions = getIronSwarmGetJobStatusQueryOptions(workspace, name, options); + + const query = useQuery(queryOptions, queryClient) as UseQueryResult & { + queryKey: DataTag; + }; + + return withQueryKey(query, queryOptions.queryKey); +} + +export const getIronSwarmGetJobStatusSuspenseQueryOptions = < + TData = Awaited>, + TError = ErrorType, +>( + workspace: string, + name: string, + options?: { + query?: Partial< + UseSuspenseQueryOptions>, TError, TData> + >; + } +) => { + const { query: queryOptions } = options ?? {}; + + const queryKey = queryOptions?.queryKey ?? getIronSwarmGetJobStatusQueryKey(workspace, name); + + const queryFn: QueryFunction>> = ({ signal }) => + ironSwarmGetJobStatus(workspace, name, signal); + + return { queryKey, queryFn, ...queryOptions } as UseSuspenseQueryOptions< + Awaited>, + TError, + TData + > & { queryKey: DataTag }; +}; + +export type IronSwarmGetJobStatusSuspenseQueryResult = NonNullable< + Awaited> +>; +export type IronSwarmGetJobStatusSuspenseQueryError = ErrorType; + +export function useIronSwarmGetJobStatusSuspense< + TData = Awaited>, + TError = ErrorType, +>( + workspace: string, + name: string, + options: { + query: Partial< + UseSuspenseQueryOptions>, TError, TData> + >; + }, + queryClient?: QueryClient +): UseSuspenseQueryResult & { queryKey: DataTag }; +export function useIronSwarmGetJobStatusSuspense< + TData = Awaited>, + TError = ErrorType, +>( + workspace: string, + name: string, + options?: { + query?: Partial< + UseSuspenseQueryOptions>, TError, TData> + >; + }, + queryClient?: QueryClient +): UseSuspenseQueryResult & { queryKey: DataTag }; +export function useIronSwarmGetJobStatusSuspense< + TData = Awaited>, + TError = ErrorType, +>( + workspace: string, + name: string, + options?: { + query?: Partial< + UseSuspenseQueryOptions>, TError, TData> + >; + }, + queryClient?: QueryClient +): UseSuspenseQueryResult & { queryKey: DataTag }; +/** + * @summary Get Job Status + */ + +export function useIronSwarmGetJobStatusSuspense< + TData = Awaited>, + TError = ErrorType, +>( + workspace: string, + name: string, + options?: { + query?: Partial< + UseSuspenseQueryOptions>, TError, TData> + >; + }, + queryClient?: QueryClient +): UseSuspenseQueryResult & { queryKey: DataTag } { + const queryOptions = getIronSwarmGetJobStatusSuspenseQueryOptions(workspace, name, options); + + const query = useSuspenseQuery(queryOptions, queryClient) as UseSuspenseQueryResult< + TData, + TError + > & { queryKey: DataTag }; + + return withQueryKey(query, queryOptions.queryKey); +} + +/** + * List saved manifests in the workspace, with pagination and an ``agent``/``source_type`` filter. + * @summary List Manifests + */ +export const ironSwarmListManifests = ( + workspace: string, + params?: IronSwarmListManifestsParams, + signal?: AbortSignal +) => { + return customFetch({ + url: `/apis/iron-swarm/v2/workspaces/${encodeURIComponent(String(workspace))}/manifests`, + method: 'GET', + params, + signal, + }); +}; + +export const getIronSwarmListManifestsQueryKey = ( + workspace: string, + params?: IronSwarmListManifestsParams +) => { + return [ + `/apis/iron-swarm/v2/workspaces/${workspace}/manifests`, + ...(params ? [params] : []), + ] as const; +}; + +export const getIronSwarmListManifestsQueryOptions = < + TData = Awaited>, + TError = ErrorType, +>( + workspace: string, + params?: IronSwarmListManifestsParams, + options?: { + query?: Partial< + UseQueryOptions>, TError, TData> + >; + } +) => { + const { query: queryOptions } = options ?? {}; + + const queryKey = queryOptions?.queryKey ?? getIronSwarmListManifestsQueryKey(workspace, params); + + const queryFn: QueryFunction>> = ({ signal }) => + ironSwarmListManifests(workspace, params, signal); + + return { + queryKey, + queryFn, + enabled: workspace !== null && workspace !== undefined, + ...queryOptions, + } as UseQueryOptions>, TError, TData> & { + queryKey: DataTag; + }; +}; + +export type IronSwarmListManifestsQueryResult = NonNullable< + Awaited> +>; +export type IronSwarmListManifestsQueryError = ErrorType; + +export function useIronSwarmListManifests< + TData = Awaited>, + TError = ErrorType, +>( + workspace: string, + params: undefined | IronSwarmListManifestsParams, + options: { + query: Partial< + UseQueryOptions>, TError, TData> + > & + Pick< + DefinedInitialDataOptions< + Awaited>, + TError, + Awaited> + >, + 'initialData' + >; + }, + queryClient?: QueryClient +): DefinedUseQueryResult & { queryKey: DataTag }; +export function useIronSwarmListManifests< + TData = Awaited>, + TError = ErrorType, +>( + workspace: string, + params?: IronSwarmListManifestsParams, + options?: { + query?: Partial< + UseQueryOptions>, TError, TData> + > & + Pick< + UndefinedInitialDataOptions< + Awaited>, + TError, + Awaited> + >, + 'initialData' + >; + }, + queryClient?: QueryClient +): UseQueryResult & { queryKey: DataTag }; +export function useIronSwarmListManifests< + TData = Awaited>, + TError = ErrorType, +>( + workspace: string, + params?: IronSwarmListManifestsParams, + options?: { + query?: Partial< + UseQueryOptions>, TError, TData> + >; + }, + queryClient?: QueryClient +): UseQueryResult & { queryKey: DataTag }; +/** + * @summary List Manifests + */ + +export function useIronSwarmListManifests< + TData = Awaited>, + TError = ErrorType, +>( + workspace: string, + params?: IronSwarmListManifestsParams, + options?: { + query?: Partial< + UseQueryOptions>, TError, TData> + >; + }, + queryClient?: QueryClient +): UseQueryResult & { queryKey: DataTag } { + const queryOptions = getIronSwarmListManifestsQueryOptions(workspace, params, options); + + const query = useQuery(queryOptions, queryClient) as UseQueryResult & { + queryKey: DataTag; + }; + + return withQueryKey(query, queryOptions.queryKey); +} + +export const getIronSwarmListManifestsSuspenseQueryOptions = < + TData = Awaited>, + TError = ErrorType, +>( + workspace: string, + params?: IronSwarmListManifestsParams, + options?: { + query?: Partial< + UseSuspenseQueryOptions>, TError, TData> + >; + } +) => { + const { query: queryOptions } = options ?? {}; + + const queryKey = queryOptions?.queryKey ?? getIronSwarmListManifestsQueryKey(workspace, params); + + const queryFn: QueryFunction>> = ({ signal }) => + ironSwarmListManifests(workspace, params, signal); + + return { queryKey, queryFn, ...queryOptions } as UseSuspenseQueryOptions< + Awaited>, + TError, + TData + > & { queryKey: DataTag }; +}; + +export type IronSwarmListManifestsSuspenseQueryResult = NonNullable< + Awaited> +>; +export type IronSwarmListManifestsSuspenseQueryError = ErrorType; + +export function useIronSwarmListManifestsSuspense< + TData = Awaited>, + TError = ErrorType, +>( + workspace: string, + params: undefined | IronSwarmListManifestsParams, + options: { + query: Partial< + UseSuspenseQueryOptions>, TError, TData> + >; + }, + queryClient?: QueryClient +): UseSuspenseQueryResult & { queryKey: DataTag }; +export function useIronSwarmListManifestsSuspense< + TData = Awaited>, + TError = ErrorType, +>( + workspace: string, + params?: IronSwarmListManifestsParams, + options?: { + query?: Partial< + UseSuspenseQueryOptions>, TError, TData> + >; + }, + queryClient?: QueryClient +): UseSuspenseQueryResult & { queryKey: DataTag }; +export function useIronSwarmListManifestsSuspense< + TData = Awaited>, + TError = ErrorType, +>( + workspace: string, + params?: IronSwarmListManifestsParams, + options?: { + query?: Partial< + UseSuspenseQueryOptions>, TError, TData> + >; + }, + queryClient?: QueryClient +): UseSuspenseQueryResult & { queryKey: DataTag }; +/** + * @summary List Manifests + */ + +export function useIronSwarmListManifestsSuspense< + TData = Awaited>, + TError = ErrorType, +>( + workspace: string, + params?: IronSwarmListManifestsParams, + options?: { + query?: Partial< + UseSuspenseQueryOptions>, TError, TData> + >; + }, + queryClient?: QueryClient +): UseSuspenseQueryResult & { queryKey: DataTag } { + const queryOptions = getIronSwarmListManifestsSuspenseQueryOptions(workspace, params, options); + + const query = useSuspenseQuery(queryOptions, queryClient) as UseSuspenseQueryResult< + TData, + TError + > & { queryKey: DataTag }; + + return withQueryKey(query, queryOptions.queryKey); +} + +/** + * `init`: build a manifest (from a deployed agent or an uploaded project) and persist it by ``name``. + * @summary Create Manifest + */ +export const ironSwarmCreateManifest = ( + workspace: string, + manifestInit: ManifestInit, + signal?: AbortSignal +) => { + return customFetch({ + url: `/apis/iron-swarm/v2/workspaces/${encodeURIComponent(String(workspace))}/manifests`, + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + data: manifestInit, + signal, + }); +}; + +export const getIronSwarmCreateManifestMutationOptions = < + TError = ErrorType, + TContext = unknown, +>(options?: { + mutation?: UseMutationOptions< + Awaited>, + TError, + { workspace: string; data: ManifestInit }, + TContext + >; +}): UseMutationOptions< + Awaited>, + TError, + { workspace: string; data: ManifestInit }, + TContext +> => { + const mutationKey = ['ironSwarmCreateManifest']; + const { mutation: mutationOptions } = options + ? options.mutation && 'mutationKey' in options.mutation && options.mutation.mutationKey + ? options + : { ...options, mutation: { ...options.mutation, mutationKey } } + : { mutation: { mutationKey } }; + + const mutationFn: MutationFunction< + Awaited>, + { workspace: string; data: ManifestInit } + > = (props) => { + const { workspace, data } = props ?? {}; + + return ironSwarmCreateManifest(workspace, data); + }; + + return { mutationFn, ...mutationOptions }; +}; + +export type IronSwarmCreateManifestMutationResult = NonNullable< + Awaited> +>; +export type IronSwarmCreateManifestMutationBody = ManifestInit; +export type IronSwarmCreateManifestMutationError = ErrorType; + +/** + * @summary Create Manifest + */ +export const useIronSwarmCreateManifest = < + TError = ErrorType, + TContext = unknown, +>( + options?: { + mutation?: UseMutationOptions< + Awaited>, + TError, + { workspace: string; data: ManifestInit }, + TContext + >; + }, + queryClient?: QueryClient +): UseMutationResult< + Awaited>, + TError, + { workspace: string; data: ManifestInit }, + TContext +> => { + return useMutation(getIronSwarmCreateManifestMutationOptions(options), queryClient); +}; + +/** + * Detect an uploaded NAT project's layout (`iron-swarm inspect`) to pre-fill the create wizard. + * + * Downloads the project bundle, expands it, and runs the read-only, offline detector — no code is + * executed. Returns the discovered workflows, launch mode, name, secrets, and egress as defaults. + * @summary Inspect Project + */ +export const ironSwarmInspectProject = ( + workspace: string, + inspectProjectRequest: InspectProjectRequest, + signal?: AbortSignal +) => { + return customFetch({ + url: `/apis/iron-swarm/v2/workspaces/${encodeURIComponent(String(workspace))}/manifests/inspect`, + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + data: inspectProjectRequest, + signal, + }); +}; + +export const getIronSwarmInspectProjectMutationOptions = < + TError = ErrorType, + TContext = unknown, +>(options?: { + mutation?: UseMutationOptions< + Awaited>, + TError, + { workspace: string; data: InspectProjectRequest }, + TContext + >; +}): UseMutationOptions< + Awaited>, + TError, + { workspace: string; data: InspectProjectRequest }, + TContext +> => { + const mutationKey = ['ironSwarmInspectProject']; + const { mutation: mutationOptions } = options + ? options.mutation && 'mutationKey' in options.mutation && options.mutation.mutationKey + ? options + : { ...options, mutation: { ...options.mutation, mutationKey } } + : { mutation: { mutationKey } }; + + const mutationFn: MutationFunction< + Awaited>, + { workspace: string; data: InspectProjectRequest } + > = (props) => { + const { workspace, data } = props ?? {}; + + return ironSwarmInspectProject(workspace, data); + }; + + return { mutationFn, ...mutationOptions }; +}; + +export type IronSwarmInspectProjectMutationResult = NonNullable< + Awaited> +>; +export type IronSwarmInspectProjectMutationBody = InspectProjectRequest; +export type IronSwarmInspectProjectMutationError = ErrorType; + +/** + * @summary Inspect Project + */ +export const useIronSwarmInspectProject = < + TError = ErrorType, + TContext = unknown, +>( + options?: { + mutation?: UseMutationOptions< + Awaited>, + TError, + { workspace: string; data: InspectProjectRequest }, + TContext + >; + }, + queryClient?: QueryClient +): UseMutationResult< + Awaited>, + TError, + { workspace: string; data: InspectProjectRequest }, + TContext +> => { + return useMutation(getIronSwarmInspectProjectMutationOptions(options), queryClient); +}; + +/** + * Derive the deployed-agent create-form defaults (victim port + secret names) for pre-fill. + * + * Read-only: fetches the stored agent config and its running deployment; nothing is materialized. + * @summary Inspect Agent Endpoint + */ +export const ironSwarmInspectManifestsInspectAgentEndpoint = ( + workspace: string, + inspectAgentRequest: InspectAgentRequest, + signal?: AbortSignal +) => { + return customFetch({ + url: `/apis/iron-swarm/v2/workspaces/${encodeURIComponent(String(workspace))}/manifests/inspect-agent`, + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + data: inspectAgentRequest, + signal, + }); +}; + +export const getIronSwarmInspectManifestsInspectAgentEndpointMutationOptions = < + TError = ErrorType, + TContext = unknown, +>(options?: { + mutation?: UseMutationOptions< + Awaited>, + TError, + { workspace: string; data: InspectAgentRequest }, + TContext + >; +}): UseMutationOptions< + Awaited>, + TError, + { workspace: string; data: InspectAgentRequest }, + TContext +> => { + const mutationKey = ['ironSwarmInspectManifestsInspectAgentEndpoint']; + const { mutation: mutationOptions } = options + ? options.mutation && 'mutationKey' in options.mutation && options.mutation.mutationKey + ? options + : { ...options, mutation: { ...options.mutation, mutationKey } } + : { mutation: { mutationKey } }; + + const mutationFn: MutationFunction< + Awaited>, + { workspace: string; data: InspectAgentRequest } + > = (props) => { + const { workspace, data } = props ?? {}; + + return ironSwarmInspectManifestsInspectAgentEndpoint(workspace, data); + }; + + return { mutationFn, ...mutationOptions }; +}; + +export type IronSwarmInspectManifestsInspectAgentEndpointMutationResult = NonNullable< + Awaited> +>; +export type IronSwarmInspectManifestsInspectAgentEndpointMutationBody = InspectAgentRequest; +export type IronSwarmInspectManifestsInspectAgentEndpointMutationError = + ErrorType; + +/** + * @summary Inspect Agent Endpoint + */ +export const useIronSwarmInspectManifestsInspectAgentEndpoint = < + TError = ErrorType, + TContext = unknown, +>( + options?: { + mutation?: UseMutationOptions< + Awaited>, + TError, + { workspace: string; data: InspectAgentRequest }, + TContext + >; + }, + queryClient?: QueryClient +): UseMutationResult< + Awaited>, + TError, + { workspace: string; data: InspectAgentRequest }, + TContext +> => { + return useMutation( + getIronSwarmInspectManifestsInspectAgentEndpointMutationOptions(options), + queryClient + ); +}; + +/** + * Get a single manifest by name. + * @summary Get Manifest + */ +export const ironSwarmGetManifest = (workspace: string, name: string, signal?: AbortSignal) => { + return customFetch({ + url: `/apis/iron-swarm/v2/workspaces/${encodeURIComponent(String(workspace))}/manifests/${encodeURIComponent(String(name))}`, + method: 'GET', + signal, + }); +}; + +export const getIronSwarmGetManifestQueryKey = (workspace: string, name: string) => { + return [`/apis/iron-swarm/v2/workspaces/${workspace}/manifests/${name}`] as const; +}; + +export const getIronSwarmGetManifestQueryOptions = < + TData = Awaited>, + TError = ErrorType, +>( + workspace: string, + name: string, + options?: { + query?: Partial< + UseQueryOptions>, TError, TData> + >; + } +) => { + const { query: queryOptions } = options ?? {}; + + const queryKey = queryOptions?.queryKey ?? getIronSwarmGetManifestQueryKey(workspace, name); + + const queryFn: QueryFunction>> = ({ signal }) => + ironSwarmGetManifest(workspace, name, signal); + + return { + queryKey, + queryFn, + enabled: workspace !== null && workspace !== undefined && name !== null && name !== undefined, + ...queryOptions, + } as UseQueryOptions>, TError, TData> & { + queryKey: DataTag; + }; +}; + +export type IronSwarmGetManifestQueryResult = NonNullable< + Awaited> +>; +export type IronSwarmGetManifestQueryError = ErrorType; + +export function useIronSwarmGetManifest< + TData = Awaited>, + TError = ErrorType, +>( + workspace: string, + name: string, + options: { + query: Partial< + UseQueryOptions>, TError, TData> + > & + Pick< + DefinedInitialDataOptions< + Awaited>, + TError, + Awaited> + >, + 'initialData' + >; + }, + queryClient?: QueryClient +): DefinedUseQueryResult & { queryKey: DataTag }; +export function useIronSwarmGetManifest< + TData = Awaited>, + TError = ErrorType, +>( + workspace: string, + name: string, + options?: { + query?: Partial< + UseQueryOptions>, TError, TData> + > & + Pick< + UndefinedInitialDataOptions< + Awaited>, + TError, + Awaited> + >, + 'initialData' + >; + }, + queryClient?: QueryClient +): UseQueryResult & { queryKey: DataTag }; +export function useIronSwarmGetManifest< + TData = Awaited>, + TError = ErrorType, +>( + workspace: string, + name: string, + options?: { + query?: Partial< + UseQueryOptions>, TError, TData> + >; + }, + queryClient?: QueryClient +): UseQueryResult & { queryKey: DataTag }; +/** + * @summary Get Manifest + */ + +export function useIronSwarmGetManifest< + TData = Awaited>, + TError = ErrorType, +>( + workspace: string, + name: string, + options?: { + query?: Partial< + UseQueryOptions>, TError, TData> + >; + }, + queryClient?: QueryClient +): UseQueryResult & { queryKey: DataTag } { + const queryOptions = getIronSwarmGetManifestQueryOptions(workspace, name, options); + + const query = useQuery(queryOptions, queryClient) as UseQueryResult & { + queryKey: DataTag; + }; + + return withQueryKey(query, queryOptions.queryKey); +} + +export const getIronSwarmGetManifestSuspenseQueryOptions = < + TData = Awaited>, + TError = ErrorType, +>( + workspace: string, + name: string, + options?: { + query?: Partial< + UseSuspenseQueryOptions>, TError, TData> + >; + } +) => { + const { query: queryOptions } = options ?? {}; + + const queryKey = queryOptions?.queryKey ?? getIronSwarmGetManifestQueryKey(workspace, name); + + const queryFn: QueryFunction>> = ({ signal }) => + ironSwarmGetManifest(workspace, name, signal); + + return { queryKey, queryFn, ...queryOptions } as UseSuspenseQueryOptions< + Awaited>, + TError, + TData + > & { queryKey: DataTag }; +}; + +export type IronSwarmGetManifestSuspenseQueryResult = NonNullable< + Awaited> +>; +export type IronSwarmGetManifestSuspenseQueryError = ErrorType; + +export function useIronSwarmGetManifestSuspense< + TData = Awaited>, + TError = ErrorType, +>( + workspace: string, + name: string, + options: { + query: Partial< + UseSuspenseQueryOptions>, TError, TData> + >; + }, + queryClient?: QueryClient +): UseSuspenseQueryResult & { queryKey: DataTag }; +export function useIronSwarmGetManifestSuspense< + TData = Awaited>, + TError = ErrorType, +>( + workspace: string, + name: string, + options?: { + query?: Partial< + UseSuspenseQueryOptions>, TError, TData> + >; + }, + queryClient?: QueryClient +): UseSuspenseQueryResult & { queryKey: DataTag }; +export function useIronSwarmGetManifestSuspense< + TData = Awaited>, + TError = ErrorType, +>( + workspace: string, + name: string, + options?: { + query?: Partial< + UseSuspenseQueryOptions>, TError, TData> + >; + }, + queryClient?: QueryClient +): UseSuspenseQueryResult & { queryKey: DataTag }; +/** + * @summary Get Manifest + */ + +export function useIronSwarmGetManifestSuspense< + TData = Awaited>, + TError = ErrorType, +>( + workspace: string, + name: string, + options?: { + query?: Partial< + UseSuspenseQueryOptions>, TError, TData> + >; + }, + queryClient?: QueryClient +): UseSuspenseQueryResult & { queryKey: DataTag } { + const queryOptions = getIronSwarmGetManifestSuspenseQueryOptions(workspace, name, options); + + const query = useSuspenseQuery(queryOptions, queryClient) as UseSuspenseQueryResult< + TData, + TError + > & { queryKey: DataTag }; + + return withQueryKey(query, queryOptions.queryKey); +} + +/** + * Edit a manifest's cached benign suite and/or victim port (the agent source is immutable). + * @summary Update Manifest + */ +export const ironSwarmUpdateManifest = ( + workspace: string, + name: string, + manifestUpdate: ManifestUpdate, + signal?: AbortSignal +) => { + return customFetch({ + url: `/apis/iron-swarm/v2/workspaces/${encodeURIComponent(String(workspace))}/manifests/${encodeURIComponent(String(name))}`, + method: 'PATCH', + headers: { 'Content-Type': 'application/json' }, + data: manifestUpdate, + signal, + }); +}; + +export const getIronSwarmUpdateManifestMutationOptions = < + TError = ErrorType, + TContext = unknown, +>(options?: { + mutation?: UseMutationOptions< + Awaited>, + TError, + { workspace: string; name: string; data: ManifestUpdate }, + TContext + >; +}): UseMutationOptions< + Awaited>, + TError, + { workspace: string; name: string; data: ManifestUpdate }, + TContext +> => { + const mutationKey = ['ironSwarmUpdateManifest']; + const { mutation: mutationOptions } = options + ? options.mutation && 'mutationKey' in options.mutation && options.mutation.mutationKey + ? options + : { ...options, mutation: { ...options.mutation, mutationKey } } + : { mutation: { mutationKey } }; + + const mutationFn: MutationFunction< + Awaited>, + { workspace: string; name: string; data: ManifestUpdate } + > = (props) => { + const { workspace, name, data } = props ?? {}; + + return ironSwarmUpdateManifest(workspace, name, data); + }; + + return { mutationFn, ...mutationOptions }; +}; + +export type IronSwarmUpdateManifestMutationResult = NonNullable< + Awaited> +>; +export type IronSwarmUpdateManifestMutationBody = ManifestUpdate; +export type IronSwarmUpdateManifestMutationError = ErrorType; + +/** + * @summary Update Manifest + */ +export const useIronSwarmUpdateManifest = < + TError = ErrorType, + TContext = unknown, +>( + options?: { + mutation?: UseMutationOptions< + Awaited>, + TError, + { workspace: string; name: string; data: ManifestUpdate }, + TContext + >; + }, + queryClient?: QueryClient +): UseMutationResult< + Awaited>, + TError, + { workspace: string; name: string; data: ManifestUpdate }, + TContext +> => { + return useMutation(getIronSwarmUpdateManifestMutationOptions(options), queryClient); +}; + +/** + * Delete a saved manifest by name. + * @summary Delete Manifest + */ +export const ironSwarmDeleteManifest = (workspace: string, name: string, signal?: AbortSignal) => { + return customFetch({ + url: `/apis/iron-swarm/v2/workspaces/${encodeURIComponent(String(workspace))}/manifests/${encodeURIComponent(String(name))}`, + method: 'DELETE', + signal, + }); +}; + +export const getIronSwarmDeleteManifestMutationOptions = < + TError = ErrorType, + TContext = unknown, +>(options?: { + mutation?: UseMutationOptions< + Awaited>, + TError, + { workspace: string; name: string }, + TContext + >; +}): UseMutationOptions< + Awaited>, + TError, + { workspace: string; name: string }, + TContext +> => { + const mutationKey = ['ironSwarmDeleteManifest']; + const { mutation: mutationOptions } = options + ? options.mutation && 'mutationKey' in options.mutation && options.mutation.mutationKey + ? options + : { ...options, mutation: { ...options.mutation, mutationKey } } + : { mutation: { mutationKey } }; + + const mutationFn: MutationFunction< + Awaited>, + { workspace: string; name: string } + > = (props) => { + const { workspace, name } = props ?? {}; + + return ironSwarmDeleteManifest(workspace, name); + }; + + return { mutationFn, ...mutationOptions }; +}; + +export type IronSwarmDeleteManifestMutationResult = NonNullable< + Awaited> +>; + +export type IronSwarmDeleteManifestMutationError = ErrorType; + +/** + * @summary Delete Manifest + */ +export const useIronSwarmDeleteManifest = < + TError = ErrorType, + TContext = unknown, +>( + options?: { + mutation?: UseMutationOptions< + Awaited>, + TError, + { workspace: string; name: string }, + TContext + >; + }, + queryClient?: QueryClient +): UseMutationResult< + Awaited>, + TError, + { workspace: string; name: string }, + TContext +> => { + return useMutation(getIronSwarmDeleteManifestMutationOptions(options), queryClient); +}; + +/** + * The built-in per-group model defaults (attack/analysis) the create/run forms pre-fill. + * @summary Get Model Config Defaults + */ +export const ironSwarmGetModelConfigDefaults = (workspace: string, signal?: AbortSignal) => { + return customFetch({ + url: `/apis/iron-swarm/v2/workspaces/${encodeURIComponent(String(workspace))}/model-config-defaults`, + method: 'GET', + signal, + }); +}; + +export const getIronSwarmGetModelConfigDefaultsQueryKey = (workspace: string) => { + return [`/apis/iron-swarm/v2/workspaces/${workspace}/model-config-defaults`] as const; +}; + +export const getIronSwarmGetModelConfigDefaultsQueryOptions = < + TData = Awaited>, + TError = ErrorType, +>( + workspace: string, + options?: { + query?: Partial< + UseQueryOptions>, TError, TData> + >; + } +) => { + const { query: queryOptions } = options ?? {}; + + const queryKey = queryOptions?.queryKey ?? getIronSwarmGetModelConfigDefaultsQueryKey(workspace); + + const queryFn: QueryFunction>> = ({ + signal, + }) => ironSwarmGetModelConfigDefaults(workspace, signal); + + return { + queryKey, + queryFn, + enabled: workspace !== null && workspace !== undefined, + ...queryOptions, + } as UseQueryOptions< + Awaited>, + TError, + TData + > & { queryKey: DataTag }; +}; + +export type IronSwarmGetModelConfigDefaultsQueryResult = NonNullable< + Awaited> +>; +export type IronSwarmGetModelConfigDefaultsQueryError = ErrorType; + +export function useIronSwarmGetModelConfigDefaults< + TData = Awaited>, + TError = ErrorType, +>( + workspace: string, + options: { + query: Partial< + UseQueryOptions>, TError, TData> + > & + Pick< + DefinedInitialDataOptions< + Awaited>, + TError, + Awaited> + >, + 'initialData' + >; + }, + queryClient?: QueryClient +): DefinedUseQueryResult & { queryKey: DataTag }; +export function useIronSwarmGetModelConfigDefaults< + TData = Awaited>, + TError = ErrorType, +>( + workspace: string, + options?: { + query?: Partial< + UseQueryOptions>, TError, TData> + > & + Pick< + UndefinedInitialDataOptions< + Awaited>, + TError, + Awaited> + >, + 'initialData' + >; + }, + queryClient?: QueryClient +): UseQueryResult & { queryKey: DataTag }; +export function useIronSwarmGetModelConfigDefaults< + TData = Awaited>, + TError = ErrorType, +>( + workspace: string, + options?: { + query?: Partial< + UseQueryOptions>, TError, TData> + >; + }, + queryClient?: QueryClient +): UseQueryResult & { queryKey: DataTag }; +/** + * @summary Get Model Config Defaults + */ + +export function useIronSwarmGetModelConfigDefaults< + TData = Awaited>, + TError = ErrorType, +>( + workspace: string, + options?: { + query?: Partial< + UseQueryOptions>, TError, TData> + >; + }, + queryClient?: QueryClient +): UseQueryResult & { queryKey: DataTag } { + const queryOptions = getIronSwarmGetModelConfigDefaultsQueryOptions(workspace, options); + + const query = useQuery(queryOptions, queryClient) as UseQueryResult & { + queryKey: DataTag; + }; + + return withQueryKey(query, queryOptions.queryKey); +} + +export const getIronSwarmGetModelConfigDefaultsSuspenseQueryOptions = < + TData = Awaited>, + TError = ErrorType, +>( + workspace: string, + options?: { + query?: Partial< + UseSuspenseQueryOptions< + Awaited>, + TError, + TData + > + >; + } +) => { + const { query: queryOptions } = options ?? {}; + + const queryKey = queryOptions?.queryKey ?? getIronSwarmGetModelConfigDefaultsQueryKey(workspace); + + const queryFn: QueryFunction>> = ({ + signal, + }) => ironSwarmGetModelConfigDefaults(workspace, signal); + + return { queryKey, queryFn, ...queryOptions } as UseSuspenseQueryOptions< + Awaited>, + TError, + TData + > & { queryKey: DataTag }; +}; + +export type IronSwarmGetModelConfigDefaultsSuspenseQueryResult = NonNullable< + Awaited> +>; +export type IronSwarmGetModelConfigDefaultsSuspenseQueryError = ErrorType; + +export function useIronSwarmGetModelConfigDefaultsSuspense< + TData = Awaited>, + TError = ErrorType, +>( + workspace: string, + options: { + query: Partial< + UseSuspenseQueryOptions< + Awaited>, + TError, + TData + > + >; + }, + queryClient?: QueryClient +): UseSuspenseQueryResult & { queryKey: DataTag }; +export function useIronSwarmGetModelConfigDefaultsSuspense< + TData = Awaited>, + TError = ErrorType, +>( + workspace: string, + options?: { + query?: Partial< + UseSuspenseQueryOptions< + Awaited>, + TError, + TData + > + >; + }, + queryClient?: QueryClient +): UseSuspenseQueryResult & { queryKey: DataTag }; +export function useIronSwarmGetModelConfigDefaultsSuspense< + TData = Awaited>, + TError = ErrorType, +>( + workspace: string, + options?: { + query?: Partial< + UseSuspenseQueryOptions< + Awaited>, + TError, + TData + > + >; + }, + queryClient?: QueryClient +): UseSuspenseQueryResult & { queryKey: DataTag }; +/** + * @summary Get Model Config Defaults + */ + +export function useIronSwarmGetModelConfigDefaultsSuspense< + TData = Awaited>, + TError = ErrorType, +>( + workspace: string, + options?: { + query?: Partial< + UseSuspenseQueryOptions< + Awaited>, + TError, + TData + > + >; + }, + queryClient?: QueryClient +): UseSuspenseQueryResult & { queryKey: DataTag } { + const queryOptions = getIronSwarmGetModelConfigDefaultsSuspenseQueryOptions(workspace, options); + + const query = useSuspenseQuery(queryOptions, queryClient) as UseSuspenseQueryResult< + TData, + TError + > & { queryKey: DataTag }; + + return withQueryKey(query, queryOptions.queryKey); +} + +/** + * Probe a model choice's endpoint/key (the "Test connection" affordance) and list reachable models. + * + * Resolves the chosen Secret to its value (if any) and lists ``{base_url}/models``. Never leaks the key — + * only the boolean verdict + the reachable model ids come back, so the UI can offer real options. + * @summary Validate Model Config + */ +export const ironSwarmValidateModelConfig = ( + workspace: string, + validateModelRequest: ValidateModelRequest, + signal?: AbortSignal +) => { + return customFetch({ + url: `/apis/iron-swarm/v2/workspaces/${encodeURIComponent(String(workspace))}/model-config/validate`, + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + data: validateModelRequest, + signal, + }); +}; + +export const getIronSwarmValidateModelConfigMutationOptions = < + TError = ErrorType, + TContext = unknown, +>(options?: { + mutation?: UseMutationOptions< + Awaited>, + TError, + { workspace: string; data: ValidateModelRequest }, + TContext + >; +}): UseMutationOptions< + Awaited>, + TError, + { workspace: string; data: ValidateModelRequest }, + TContext +> => { + const mutationKey = ['ironSwarmValidateModelConfig']; + const { mutation: mutationOptions } = options + ? options.mutation && 'mutationKey' in options.mutation && options.mutation.mutationKey + ? options + : { ...options, mutation: { ...options.mutation, mutationKey } } + : { mutation: { mutationKey } }; + + const mutationFn: MutationFunction< + Awaited>, + { workspace: string; data: ValidateModelRequest } + > = (props) => { + const { workspace, data } = props ?? {}; + + return ironSwarmValidateModelConfig(workspace, data); + }; + + return { mutationFn, ...mutationOptions }; +}; + +export type IronSwarmValidateModelConfigMutationResult = NonNullable< + Awaited> +>; +export type IronSwarmValidateModelConfigMutationBody = ValidateModelRequest; +export type IronSwarmValidateModelConfigMutationError = ErrorType; + +/** + * @summary Validate Model Config + */ +export const useIronSwarmValidateModelConfig = < + TError = ErrorType, + TContext = unknown, +>( + options?: { + mutation?: UseMutationOptions< + Awaited>, + TError, + { workspace: string; data: ValidateModelRequest }, + TContext + >; + }, + queryClient?: QueryClient +): UseMutationResult< + Awaited>, + TError, + { workspace: string; data: ValidateModelRequest }, + TContext +> => { + return useMutation(getIronSwarmValidateModelConfigMutationOptions(options), queryClient); +}; + +/** + * List war-game runs in the workspace, with pagination and an ``agent``/``status`` filter. + * @summary List Runs + */ +export const ironSwarmListRuns = ( + workspace: string, + params?: IronSwarmListRunsParams, + signal?: AbortSignal +) => { + return customFetch({ + url: `/apis/iron-swarm/v2/workspaces/${encodeURIComponent(String(workspace))}/runs`, + method: 'GET', + params, + signal, + }); +}; + +export const getIronSwarmListRunsQueryKey = ( + workspace: string, + params?: IronSwarmListRunsParams +) => { + return [`/apis/iron-swarm/v2/workspaces/${workspace}/runs`, ...(params ? [params] : [])] as const; +}; + +export const getIronSwarmListRunsQueryOptions = < + TData = Awaited>, + TError = ErrorType, +>( + workspace: string, + params?: IronSwarmListRunsParams, + options?: { + query?: Partial>, TError, TData>>; + } +) => { + const { query: queryOptions } = options ?? {}; + + const queryKey = queryOptions?.queryKey ?? getIronSwarmListRunsQueryKey(workspace, params); + + const queryFn: QueryFunction>> = ({ signal }) => + ironSwarmListRuns(workspace, params, signal); + + return { + queryKey, + queryFn, + enabled: workspace !== null && workspace !== undefined, + ...queryOptions, + } as UseQueryOptions>, TError, TData> & { + queryKey: DataTag; + }; +}; + +export type IronSwarmListRunsQueryResult = NonNullable< + Awaited> +>; +export type IronSwarmListRunsQueryError = ErrorType; + +export function useIronSwarmListRuns< + TData = Awaited>, + TError = ErrorType, +>( + workspace: string, + params: undefined | IronSwarmListRunsParams, + options: { + query: Partial>, TError, TData>> & + Pick< + DefinedInitialDataOptions< + Awaited>, + TError, + Awaited> + >, + 'initialData' + >; + }, + queryClient?: QueryClient +): DefinedUseQueryResult & { queryKey: DataTag }; +export function useIronSwarmListRuns< + TData = Awaited>, + TError = ErrorType, +>( + workspace: string, + params?: IronSwarmListRunsParams, + options?: { + query?: Partial>, TError, TData>> & + Pick< + UndefinedInitialDataOptions< + Awaited>, + TError, + Awaited> + >, + 'initialData' + >; + }, + queryClient?: QueryClient +): UseQueryResult & { queryKey: DataTag }; +export function useIronSwarmListRuns< + TData = Awaited>, + TError = ErrorType, +>( + workspace: string, + params?: IronSwarmListRunsParams, + options?: { + query?: Partial>, TError, TData>>; + }, + queryClient?: QueryClient +): UseQueryResult & { queryKey: DataTag }; +/** + * @summary List Runs + */ + +export function useIronSwarmListRuns< + TData = Awaited>, + TError = ErrorType, +>( + workspace: string, + params?: IronSwarmListRunsParams, + options?: { + query?: Partial>, TError, TData>>; + }, + queryClient?: QueryClient +): UseQueryResult & { queryKey: DataTag } { + const queryOptions = getIronSwarmListRunsQueryOptions(workspace, params, options); + + const query = useQuery(queryOptions, queryClient) as UseQueryResult & { + queryKey: DataTag; + }; + + return withQueryKey(query, queryOptions.queryKey); +} + +export const getIronSwarmListRunsSuspenseQueryOptions = < + TData = Awaited>, + TError = ErrorType, +>( + workspace: string, + params?: IronSwarmListRunsParams, + options?: { + query?: Partial< + UseSuspenseQueryOptions>, TError, TData> + >; + } +) => { + const { query: queryOptions } = options ?? {}; + + const queryKey = queryOptions?.queryKey ?? getIronSwarmListRunsQueryKey(workspace, params); + + const queryFn: QueryFunction>> = ({ signal }) => + ironSwarmListRuns(workspace, params, signal); + + return { queryKey, queryFn, ...queryOptions } as UseSuspenseQueryOptions< + Awaited>, + TError, + TData + > & { queryKey: DataTag }; +}; + +export type IronSwarmListRunsSuspenseQueryResult = NonNullable< + Awaited> +>; +export type IronSwarmListRunsSuspenseQueryError = ErrorType; + +export function useIronSwarmListRunsSuspense< + TData = Awaited>, + TError = ErrorType, +>( + workspace: string, + params: undefined | IronSwarmListRunsParams, + options: { + query: Partial< + UseSuspenseQueryOptions>, TError, TData> + >; + }, + queryClient?: QueryClient +): UseSuspenseQueryResult & { queryKey: DataTag }; +export function useIronSwarmListRunsSuspense< + TData = Awaited>, + TError = ErrorType, +>( + workspace: string, + params?: IronSwarmListRunsParams, + options?: { + query?: Partial< + UseSuspenseQueryOptions>, TError, TData> + >; + }, + queryClient?: QueryClient +): UseSuspenseQueryResult & { queryKey: DataTag }; +export function useIronSwarmListRunsSuspense< + TData = Awaited>, + TError = ErrorType, +>( + workspace: string, + params?: IronSwarmListRunsParams, + options?: { + query?: Partial< + UseSuspenseQueryOptions>, TError, TData> + >; + }, + queryClient?: QueryClient +): UseSuspenseQueryResult & { queryKey: DataTag }; +/** + * @summary List Runs + */ + +export function useIronSwarmListRunsSuspense< + TData = Awaited>, + TError = ErrorType, +>( + workspace: string, + params?: IronSwarmListRunsParams, + options?: { + query?: Partial< + UseSuspenseQueryOptions>, TError, TData> + >; + }, + queryClient?: QueryClient +): UseSuspenseQueryResult & { queryKey: DataTag } { + const queryOptions = getIronSwarmListRunsSuspenseQueryOptions(workspace, params, options); + + const query = useSuspenseQuery(queryOptions, queryClient) as UseSuspenseQueryResult< + TData, + TError + > & { queryKey: DataTag }; + + return withQueryKey(query, queryOptions.queryKey); +} + +/** + * Get a single war-game run by name. + * @summary Get Run + */ +export const ironSwarmGetRun = (workspace: string, name: string, signal?: AbortSignal) => { + return customFetch({ + url: `/apis/iron-swarm/v2/workspaces/${encodeURIComponent(String(workspace))}/runs/${encodeURIComponent(String(name))}`, + method: 'GET', + signal, + }); +}; + +export const getIronSwarmGetRunQueryKey = (workspace: string, name: string) => { + return [`/apis/iron-swarm/v2/workspaces/${workspace}/runs/${name}`] as const; +}; + +export const getIronSwarmGetRunQueryOptions = < + TData = Awaited>, + TError = ErrorType, +>( + workspace: string, + name: string, + options?: { + query?: Partial>, TError, TData>>; + } +) => { + const { query: queryOptions } = options ?? {}; + + const queryKey = queryOptions?.queryKey ?? getIronSwarmGetRunQueryKey(workspace, name); + + const queryFn: QueryFunction>> = ({ signal }) => + ironSwarmGetRun(workspace, name, signal); + + return { + queryKey, + queryFn, + enabled: workspace !== null && workspace !== undefined && name !== null && name !== undefined, + ...queryOptions, + } as UseQueryOptions>, TError, TData> & { + queryKey: DataTag; + }; +}; + +export type IronSwarmGetRunQueryResult = NonNullable>>; +export type IronSwarmGetRunQueryError = ErrorType; + +export function useIronSwarmGetRun< + TData = Awaited>, + TError = ErrorType, +>( + workspace: string, + name: string, + options: { + query: Partial>, TError, TData>> & + Pick< + DefinedInitialDataOptions< + Awaited>, + TError, + Awaited> + >, + 'initialData' + >; + }, + queryClient?: QueryClient +): DefinedUseQueryResult & { queryKey: DataTag }; +export function useIronSwarmGetRun< + TData = Awaited>, + TError = ErrorType, +>( + workspace: string, + name: string, + options?: { + query?: Partial>, TError, TData>> & + Pick< + UndefinedInitialDataOptions< + Awaited>, + TError, + Awaited> + >, + 'initialData' + >; + }, + queryClient?: QueryClient +): UseQueryResult & { queryKey: DataTag }; +export function useIronSwarmGetRun< + TData = Awaited>, + TError = ErrorType, +>( + workspace: string, + name: string, + options?: { + query?: Partial>, TError, TData>>; + }, + queryClient?: QueryClient +): UseQueryResult & { queryKey: DataTag }; +/** + * @summary Get Run + */ + +export function useIronSwarmGetRun< + TData = Awaited>, + TError = ErrorType, +>( + workspace: string, + name: string, + options?: { + query?: Partial>, TError, TData>>; + }, + queryClient?: QueryClient +): UseQueryResult & { queryKey: DataTag } { + const queryOptions = getIronSwarmGetRunQueryOptions(workspace, name, options); + + const query = useQuery(queryOptions, queryClient) as UseQueryResult & { + queryKey: DataTag; + }; + + return withQueryKey(query, queryOptions.queryKey); +} + +export const getIronSwarmGetRunSuspenseQueryOptions = < + TData = Awaited>, + TError = ErrorType, +>( + workspace: string, + name: string, + options?: { + query?: Partial< + UseSuspenseQueryOptions>, TError, TData> + >; + } +) => { + const { query: queryOptions } = options ?? {}; + + const queryKey = queryOptions?.queryKey ?? getIronSwarmGetRunQueryKey(workspace, name); + + const queryFn: QueryFunction>> = ({ signal }) => + ironSwarmGetRun(workspace, name, signal); + + return { queryKey, queryFn, ...queryOptions } as UseSuspenseQueryOptions< + Awaited>, + TError, + TData + > & { queryKey: DataTag }; +}; + +export type IronSwarmGetRunSuspenseQueryResult = NonNullable< + Awaited> +>; +export type IronSwarmGetRunSuspenseQueryError = ErrorType; + +export function useIronSwarmGetRunSuspense< + TData = Awaited>, + TError = ErrorType, +>( + workspace: string, + name: string, + options: { + query: Partial< + UseSuspenseQueryOptions>, TError, TData> + >; + }, + queryClient?: QueryClient +): UseSuspenseQueryResult & { queryKey: DataTag }; +export function useIronSwarmGetRunSuspense< + TData = Awaited>, + TError = ErrorType, +>( + workspace: string, + name: string, + options?: { + query?: Partial< + UseSuspenseQueryOptions>, TError, TData> + >; + }, + queryClient?: QueryClient +): UseSuspenseQueryResult & { queryKey: DataTag }; +export function useIronSwarmGetRunSuspense< + TData = Awaited>, + TError = ErrorType, +>( + workspace: string, + name: string, + options?: { + query?: Partial< + UseSuspenseQueryOptions>, TError, TData> + >; + }, + queryClient?: QueryClient +): UseSuspenseQueryResult & { queryKey: DataTag }; +/** + * @summary Get Run + */ + +export function useIronSwarmGetRunSuspense< + TData = Awaited>, + TError = ErrorType, +>( + workspace: string, + name: string, + options?: { + query?: Partial< + UseSuspenseQueryOptions>, TError, TData> + >; + }, + queryClient?: QueryClient +): UseSuspenseQueryResult & { queryKey: DataTag } { + const queryOptions = getIronSwarmGetRunSuspenseQueryOptions(workspace, name, options); + + const query = useSuspenseQuery(queryOptions, queryClient) as UseSuspenseQueryResult< + TData, + TError + > & { queryKey: DataTag }; + + return withQueryKey(query, queryOptions.queryKey); +} + +/** + * Delete a war-game run record. The underlying platform job is cancelled/deleted separately. + * @summary Delete Run + */ +export const ironSwarmDeleteRun = (workspace: string, name: string, signal?: AbortSignal) => { + return customFetch({ + url: `/apis/iron-swarm/v2/workspaces/${encodeURIComponent(String(workspace))}/runs/${encodeURIComponent(String(name))}`, + method: 'DELETE', + signal, + }); +}; + +export const getIronSwarmDeleteRunMutationOptions = < + TError = ErrorType, + TContext = unknown, +>(options?: { + mutation?: UseMutationOptions< + Awaited>, + TError, + { workspace: string; name: string }, + TContext + >; +}): UseMutationOptions< + Awaited>, + TError, + { workspace: string; name: string }, + TContext +> => { + const mutationKey = ['ironSwarmDeleteRun']; + const { mutation: mutationOptions } = options + ? options.mutation && 'mutationKey' in options.mutation && options.mutation.mutationKey + ? options + : { ...options, mutation: { ...options.mutation, mutationKey } } + : { mutation: { mutationKey } }; + + const mutationFn: MutationFunction< + Awaited>, + { workspace: string; name: string } + > = (props) => { + const { workspace, name } = props ?? {}; + + return ironSwarmDeleteRun(workspace, name); + }; + + return { mutationFn, ...mutationOptions }; +}; + +export type IronSwarmDeleteRunMutationResult = NonNullable< + Awaited> +>; + +export type IronSwarmDeleteRunMutationError = ErrorType; + +/** + * @summary Delete Run + */ +export const useIronSwarmDeleteRun = , TContext = unknown>( + options?: { + mutation?: UseMutationOptions< + Awaited>, + TError, + { workspace: string; name: string }, + TContext + >; + }, + queryClient?: QueryClient +): UseMutationResult< + Awaited>, + TError, + { workspace: string; name: string }, + TContext +> => { + return useMutation(getIronSwarmDeleteRunMutationOptions(options), queryClient); +}; + +/** + * Adopt a run's hardened workflow: write it onto the run's target agent config (no redeploy). + * + * Reverses the Inference-Gateway injection so the stored config stays deployment-neutral, then updates + * the ``Agent`` entity in place. The user must redeploy the agent for the guardrails to take effect. + * @summary Apply Mitigation + */ +export const ironSwarmApplyMitigation = ( + workspace: string, + name: string, + applyMitigationRequest: ApplyMitigationRequest, + signal?: AbortSignal +) => { + return customFetch({ + url: `/apis/iron-swarm/v2/workspaces/${encodeURIComponent(String(workspace))}/runs/${encodeURIComponent(String(name))}/apply-mitigation`, + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + data: applyMitigationRequest, + signal, + }); +}; + +export const getIronSwarmApplyMitigationMutationOptions = < + TError = ErrorType, + TContext = unknown, +>(options?: { + mutation?: UseMutationOptions< + Awaited>, + TError, + { workspace: string; name: string; data: ApplyMitigationRequest }, + TContext + >; +}): UseMutationOptions< + Awaited>, + TError, + { workspace: string; name: string; data: ApplyMitigationRequest }, + TContext +> => { + const mutationKey = ['ironSwarmApplyMitigation']; + const { mutation: mutationOptions } = options + ? options.mutation && 'mutationKey' in options.mutation && options.mutation.mutationKey + ? options + : { ...options, mutation: { ...options.mutation, mutationKey } } + : { mutation: { mutationKey } }; + + const mutationFn: MutationFunction< + Awaited>, + { workspace: string; name: string; data: ApplyMitigationRequest } + > = (props) => { + const { workspace, name, data } = props ?? {}; + + return ironSwarmApplyMitigation(workspace, name, data); + }; + + return { mutationFn, ...mutationOptions }; +}; + +export type IronSwarmApplyMitigationMutationResult = NonNullable< + Awaited> +>; +export type IronSwarmApplyMitigationMutationBody = ApplyMitigationRequest; +export type IronSwarmApplyMitigationMutationError = ErrorType; + +/** + * @summary Apply Mitigation + */ +export const useIronSwarmApplyMitigation = < + TError = ErrorType, + TContext = unknown, +>( + options?: { + mutation?: UseMutationOptions< + Awaited>, + TError, + { workspace: string; name: string; data: ApplyMitigationRequest }, + TContext + >; + }, + queryClient?: QueryClient +): UseMutationResult< + Awaited>, + TError, + { workspace: string; name: string; data: ApplyMitigationRequest }, + TContext +> => { + return useMutation(getIronSwarmApplyMitigationMutationOptions(options), queryClient); +}; + +/** + * Compose a chosen subset of a run's recommended defenses into deployable workflow + policy YAML. + * + * Keeps only the selected guardrails in the workflow and picks the hardened-vs-baseline policy. Powers + * the harden flow's live preview and feeds the composed YAMLs to a sanity-check (validate-only) run. + * @summary Compose Defense Route + */ +export const ironSwarmComposeDefenseRoute = ( + workspace: string, + name: string, + composeDefenseRequest: ComposeDefenseRequest, + signal?: AbortSignal +) => { + return customFetch({ + url: `/apis/iron-swarm/v2/workspaces/${encodeURIComponent(String(workspace))}/runs/${encodeURIComponent(String(name))}/compose-defense`, + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + data: composeDefenseRequest, + signal, + }); +}; + +export const getIronSwarmComposeDefenseRouteMutationOptions = < + TError = ErrorType, + TContext = unknown, +>(options?: { + mutation?: UseMutationOptions< + Awaited>, + TError, + { workspace: string; name: string; data: ComposeDefenseRequest }, + TContext + >; +}): UseMutationOptions< + Awaited>, + TError, + { workspace: string; name: string; data: ComposeDefenseRequest }, + TContext +> => { + const mutationKey = ['ironSwarmComposeDefenseRoute']; + const { mutation: mutationOptions } = options + ? options.mutation && 'mutationKey' in options.mutation && options.mutation.mutationKey + ? options + : { ...options, mutation: { ...options.mutation, mutationKey } } + : { mutation: { mutationKey } }; + + const mutationFn: MutationFunction< + Awaited>, + { workspace: string; name: string; data: ComposeDefenseRequest } + > = (props) => { + const { workspace, name, data } = props ?? {}; + + return ironSwarmComposeDefenseRoute(workspace, name, data); + }; + + return { mutationFn, ...mutationOptions }; +}; + +export type IronSwarmComposeDefenseRouteMutationResult = NonNullable< + Awaited> +>; +export type IronSwarmComposeDefenseRouteMutationBody = ComposeDefenseRequest; +export type IronSwarmComposeDefenseRouteMutationError = ErrorType; + +/** + * @summary Compose Defense Route + */ +export const useIronSwarmComposeDefenseRoute = < + TError = ErrorType, + TContext = unknown, +>( + options?: { + mutation?: UseMutationOptions< + Awaited>, + TError, + { workspace: string; name: string; data: ComposeDefenseRequest }, + TContext + >; + }, + queryClient?: QueryClient +): UseMutationResult< + Awaited>, + TError, + { workspace: string; name: string; data: ComposeDefenseRequest }, + TContext +> => { + return useMutation(getIronSwarmComposeDefenseRouteMutationOptions(options), queryClient); +}; + +/** + * Ingest one run event (the run's EventBus POSTs here). + * @summary Ingest Event + */ +export const ironSwarmIngestEvent = ( + workspace: string, + name: string, + eventIn: EventIn, + signal?: AbortSignal +) => { + return customFetch({ + url: `/apis/iron-swarm/v2/workspaces/${encodeURIComponent(String(workspace))}/runs/${encodeURIComponent(String(name))}/events`, + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + data: eventIn, + signal, + }); +}; + +export const getIronSwarmIngestEventMutationOptions = < + TError = ErrorType, + TContext = unknown, +>(options?: { + mutation?: UseMutationOptions< + Awaited>, + TError, + { workspace: string; name: string; data: EventIn }, + TContext + >; +}): UseMutationOptions< + Awaited>, + TError, + { workspace: string; name: string; data: EventIn }, + TContext +> => { + const mutationKey = ['ironSwarmIngestEvent']; + const { mutation: mutationOptions } = options + ? options.mutation && 'mutationKey' in options.mutation && options.mutation.mutationKey + ? options + : { ...options, mutation: { ...options.mutation, mutationKey } } + : { mutation: { mutationKey } }; + + const mutationFn: MutationFunction< + Awaited>, + { workspace: string; name: string; data: EventIn } + > = (props) => { + const { workspace, name, data } = props ?? {}; + + return ironSwarmIngestEvent(workspace, name, data); + }; + + return { mutationFn, ...mutationOptions }; +}; + +export type IronSwarmIngestEventMutationResult = NonNullable< + Awaited> +>; +export type IronSwarmIngestEventMutationBody = EventIn; +export type IronSwarmIngestEventMutationError = ErrorType; + +/** + * @summary Ingest Event + */ +export const useIronSwarmIngestEvent = < + TError = ErrorType, + TContext = unknown, +>( + options?: { + mutation?: UseMutationOptions< + Awaited>, + TError, + { workspace: string; name: string; data: EventIn }, + TContext + >; + }, + queryClient?: QueryClient +): UseMutationResult< + Awaited>, + TError, + { workspace: string; name: string; data: EventIn }, + TContext +> => { + return useMutation(getIronSwarmIngestEventMutationOptions(options), queryClient); +}; + +/** + * Return all persisted run events with sequence id greater than *after*. + * @summary Get Events + */ +export const ironSwarmGetRunEvents = ( + workspace: string, + name: string, + params?: IronSwarmGetRunEventsParams, + signal?: AbortSignal +) => { + return customFetch({ + url: `/apis/iron-swarm/v2/workspaces/${encodeURIComponent(String(workspace))}/runs/${encodeURIComponent(String(name))}/events`, + method: 'GET', + params, + signal, + }); +}; + +export const getIronSwarmGetRunEventsQueryKey = ( + workspace: string, + name: string, + params?: IronSwarmGetRunEventsParams +) => { + return [ + `/apis/iron-swarm/v2/workspaces/${workspace}/runs/${name}/events`, + ...(params ? [params] : []), + ] as const; +}; + +export const getIronSwarmGetRunEventsQueryOptions = < + TData = Awaited>, + TError = ErrorType, +>( + workspace: string, + name: string, + params?: IronSwarmGetRunEventsParams, + options?: { + query?: Partial< + UseQueryOptions>, TError, TData> + >; + } +) => { + const { query: queryOptions } = options ?? {}; + + const queryKey = + queryOptions?.queryKey ?? getIronSwarmGetRunEventsQueryKey(workspace, name, params); + + const queryFn: QueryFunction>> = ({ signal }) => + ironSwarmGetRunEvents(workspace, name, params, signal); + + return { + queryKey, + queryFn, + enabled: workspace !== null && workspace !== undefined && name !== null && name !== undefined, + ...queryOptions, + } as UseQueryOptions>, TError, TData> & { + queryKey: DataTag; + }; +}; + +export type IronSwarmGetRunEventsQueryResult = NonNullable< + Awaited> +>; +export type IronSwarmGetRunEventsQueryError = ErrorType; + +export function useIronSwarmGetRunEvents< + TData = Awaited>, + TError = ErrorType, +>( + workspace: string, + name: string, + params: undefined | IronSwarmGetRunEventsParams, + options: { + query: Partial< + UseQueryOptions>, TError, TData> + > & + Pick< + DefinedInitialDataOptions< + Awaited>, + TError, + Awaited> + >, + 'initialData' + >; + }, + queryClient?: QueryClient +): DefinedUseQueryResult & { queryKey: DataTag }; +export function useIronSwarmGetRunEvents< + TData = Awaited>, + TError = ErrorType, +>( + workspace: string, + name: string, + params?: IronSwarmGetRunEventsParams, + options?: { + query?: Partial< + UseQueryOptions>, TError, TData> + > & + Pick< + UndefinedInitialDataOptions< + Awaited>, + TError, + Awaited> + >, + 'initialData' + >; + }, + queryClient?: QueryClient +): UseQueryResult & { queryKey: DataTag }; +export function useIronSwarmGetRunEvents< + TData = Awaited>, + TError = ErrorType, +>( + workspace: string, + name: string, + params?: IronSwarmGetRunEventsParams, + options?: { + query?: Partial< + UseQueryOptions>, TError, TData> + >; + }, + queryClient?: QueryClient +): UseQueryResult & { queryKey: DataTag }; +/** + * @summary Get Events + */ + +export function useIronSwarmGetRunEvents< + TData = Awaited>, + TError = ErrorType, +>( + workspace: string, + name: string, + params?: IronSwarmGetRunEventsParams, + options?: { + query?: Partial< + UseQueryOptions>, TError, TData> + >; + }, + queryClient?: QueryClient +): UseQueryResult & { queryKey: DataTag } { + const queryOptions = getIronSwarmGetRunEventsQueryOptions(workspace, name, params, options); + + const query = useQuery(queryOptions, queryClient) as UseQueryResult & { + queryKey: DataTag; + }; + + return withQueryKey(query, queryOptions.queryKey); +} + +export const getIronSwarmGetRunEventsSuspenseQueryOptions = < + TData = Awaited>, + TError = ErrorType, +>( + workspace: string, + name: string, + params?: IronSwarmGetRunEventsParams, + options?: { + query?: Partial< + UseSuspenseQueryOptions>, TError, TData> + >; + } +) => { + const { query: queryOptions } = options ?? {}; + + const queryKey = + queryOptions?.queryKey ?? getIronSwarmGetRunEventsQueryKey(workspace, name, params); + + const queryFn: QueryFunction>> = ({ signal }) => + ironSwarmGetRunEvents(workspace, name, params, signal); + + return { queryKey, queryFn, ...queryOptions } as UseSuspenseQueryOptions< + Awaited>, + TError, + TData + > & { queryKey: DataTag }; +}; + +export type IronSwarmGetRunEventsSuspenseQueryResult = NonNullable< + Awaited> +>; +export type IronSwarmGetRunEventsSuspenseQueryError = ErrorType; + +export function useIronSwarmGetRunEventsSuspense< + TData = Awaited>, + TError = ErrorType, +>( + workspace: string, + name: string, + params: undefined | IronSwarmGetRunEventsParams, + options: { + query: Partial< + UseSuspenseQueryOptions>, TError, TData> + >; + }, + queryClient?: QueryClient +): UseSuspenseQueryResult & { queryKey: DataTag }; +export function useIronSwarmGetRunEventsSuspense< + TData = Awaited>, + TError = ErrorType, +>( + workspace: string, + name: string, + params?: IronSwarmGetRunEventsParams, + options?: { + query?: Partial< + UseSuspenseQueryOptions>, TError, TData> + >; + }, + queryClient?: QueryClient +): UseSuspenseQueryResult & { queryKey: DataTag }; +export function useIronSwarmGetRunEventsSuspense< + TData = Awaited>, + TError = ErrorType, +>( + workspace: string, + name: string, + params?: IronSwarmGetRunEventsParams, + options?: { + query?: Partial< + UseSuspenseQueryOptions>, TError, TData> + >; + }, + queryClient?: QueryClient +): UseSuspenseQueryResult & { queryKey: DataTag }; +/** + * @summary Get Events + */ + +export function useIronSwarmGetRunEventsSuspense< + TData = Awaited>, + TError = ErrorType, +>( + workspace: string, + name: string, + params?: IronSwarmGetRunEventsParams, + options?: { + query?: Partial< + UseSuspenseQueryOptions>, TError, TData> + >; + }, + queryClient?: QueryClient +): UseSuspenseQueryResult & { queryKey: DataTag } { + const queryOptions = getIronSwarmGetRunEventsSuspenseQueryOptions( + workspace, + name, + params, + options + ); + + const query = useSuspenseQuery(queryOptions, queryClient) as UseSuspenseQueryResult< + TData, + TError + > & { queryKey: DataTag }; + + return withQueryKey(query, queryOptions.queryKey); +} diff --git a/web/packages/studio/src/components/ironSwarm/swarm/useSwarmEvents.ts b/web/packages/studio/src/components/ironSwarm/swarm/useSwarmEvents.ts new file mode 100644 index 0000000000..1e705e5a46 --- /dev/null +++ b/web/packages/studio/src/components/ironSwarm/swarm/useSwarmEvents.ts @@ -0,0 +1,57 @@ +// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { getIronSwarmGetRunEventsQueryKey } from '@nemo/sdk/generated/iron-swarm/api'; +import type { SwarmEvent } from '@studio/components/ironSwarm/eventTypes'; +import { PLATFORM_BASE_URL } from '@studio/constants/environment'; +import { logger } from '@studio/util/logger'; +import { streamSse } from '@studio/util/sseStream'; +import { useEffect, useState } from 'react'; +import { useAuth } from 'react-oidc-context'; + +const MAX_EVENTS = 500; + +// One SSE subscription to a run's live EventBus, relayed by the plugin. Both the swarm graph and the +// message feed read this single ordered stream (Last-Event-ID resume is handled by streamSse). +export const useSwarmEvents = (workspace: string, runName: string): SwarmEvent[] => { + const accessToken = useAuth()?.user?.access_token; + const [events, setEvents] = useState([]); + + useEffect(() => { + if (!runName) return undefined; + setEvents([]); + const url = `${PLATFORM_BASE_URL}${getIronSwarmGetRunEventsQueryKey(workspace, runName)[0]}`; + const controller = new AbortController(); + void streamSse(url, { + signal: controller.signal, + headers: accessToken ? { Authorization: `Bearer ${accessToken}` } : undefined, + onEvent: (evt) => { + try { + const parsed = JSON.parse(evt.data) as { + event: string; + payload: Record; + }; + const next: SwarmEvent = { + id: evt.id ? Number(evt.id) : Date.now(), + event: parsed.event, + payload: parsed.payload ?? {}, + ts: Date.now(), + }; + setEvents((prev) => { + const appended = [...prev, next]; + return appended.length > MAX_EVENTS + ? appended.slice(appended.length - MAX_EVENTS) + : appended; + }); + } catch { + // ignore malformed frames + } + }, + onError: (err) => + logger.warn(`Iron Swarm event stream interrupted for ${runName}; retrying`, err), + }); + return () => controller.abort(); + }, [workspace, runName, accessToken]); + + return events; +}; From 17724710699aa3f522979c3e1dcf496aab05e414 Mon Sep 17 00:00:00 2001 From: Koral Chapnik Verbun Date: Tue, 21 Jul 2026 14:43:52 +0300 Subject: [PATCH 05/55] replace SSE with polling in useSwarmEvents Signed-off-by: Koral Chapnik Verbun --- .../ironSwarm/swarm/useSwarmEvents.ts | 71 +++++++------------ 1 file changed, 27 insertions(+), 44 deletions(-) diff --git a/web/packages/studio/src/components/ironSwarm/swarm/useSwarmEvents.ts b/web/packages/studio/src/components/ironSwarm/swarm/useSwarmEvents.ts index 1e705e5a46..e870ff7103 100644 --- a/web/packages/studio/src/components/ironSwarm/swarm/useSwarmEvents.ts +++ b/web/packages/studio/src/components/ironSwarm/swarm/useSwarmEvents.ts @@ -1,57 +1,40 @@ // SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. // SPDX-License-Identifier: Apache-2.0 -import { getIronSwarmGetRunEventsQueryKey } from '@nemo/sdk/generated/iron-swarm/api'; +import { useIronSwarmGetRunEvents } from '@nemo/sdk/generated/iron-swarm/api'; import type { SwarmEvent } from '@studio/components/ironSwarm/eventTypes'; -import { PLATFORM_BASE_URL } from '@studio/constants/environment'; -import { logger } from '@studio/util/logger'; -import { streamSse } from '@studio/util/sseStream'; import { useEffect, useState } from 'react'; -import { useAuth } from 'react-oidc-context'; +const POLL_INTERVAL_MS = 1000; const MAX_EVENTS = 500; -// One SSE subscription to a run's live EventBus, relayed by the plugin. Both the swarm graph and the -// message feed read this single ordered stream (Last-Event-ID resume is handled by streamSse). export const useSwarmEvents = (workspace: string, runName: string): SwarmEvent[] => { - const accessToken = useAuth()?.user?.access_token; - const [events, setEvents] = useState([]); + const [afterId, setAfterId] = useState(0); + const [allEvents, setAllEvents] = useState([]); + + const { data } = useIronSwarmGetRunEvents(workspace, runName, { after: afterId }, { + query: { + enabled: Boolean(runName), + refetchInterval: POLL_INTERVAL_MS, + }, + }); + + useEffect(() => { + if (!data?.events?.length) return; + const next: SwarmEvent[] = data.events.map((e) => ({ + id: typeof e['id'] === 'number' ? e['id'] : Date.now(), + event: typeof e['event'] === 'string' ? e['event'] : '', + payload: (e['payload'] ?? {}) as Record, + ts: Date.now(), + })); + setAllEvents((prev) => [...prev, ...next].slice(-MAX_EVENTS)); + setAfterId(next[next.length - 1].id); + }, [data]); useEffect(() => { - if (!runName) return undefined; - setEvents([]); - const url = `${PLATFORM_BASE_URL}${getIronSwarmGetRunEventsQueryKey(workspace, runName)[0]}`; - const controller = new AbortController(); - void streamSse(url, { - signal: controller.signal, - headers: accessToken ? { Authorization: `Bearer ${accessToken}` } : undefined, - onEvent: (evt) => { - try { - const parsed = JSON.parse(evt.data) as { - event: string; - payload: Record; - }; - const next: SwarmEvent = { - id: evt.id ? Number(evt.id) : Date.now(), - event: parsed.event, - payload: parsed.payload ?? {}, - ts: Date.now(), - }; - setEvents((prev) => { - const appended = [...prev, next]; - return appended.length > MAX_EVENTS - ? appended.slice(appended.length - MAX_EVENTS) - : appended; - }); - } catch { - // ignore malformed frames - } - }, - onError: (err) => - logger.warn(`Iron Swarm event stream interrupted for ${runName}; retrying`, err), - }); - return () => controller.abort(); - }, [workspace, runName, accessToken]); + setAllEvents([]); + setAfterId(0); + }, [runName]); - return events; + return allEvents; }; From 228fb8eaca9247df623037fea44bf64cf101590f Mon Sep 17 00:00:00 2001 From: Koral Chapnik Verbun Date: Tue, 21 Jul 2026 14:46:30 +0300 Subject: [PATCH 06/55] fix workspace dep in useSwarmEvents reset effect Signed-off-by: Koral Chapnik Verbun --- .../studio/src/components/ironSwarm/swarm/useSwarmEvents.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/web/packages/studio/src/components/ironSwarm/swarm/useSwarmEvents.ts b/web/packages/studio/src/components/ironSwarm/swarm/useSwarmEvents.ts index e870ff7103..09e0eb8c51 100644 --- a/web/packages/studio/src/components/ironSwarm/swarm/useSwarmEvents.ts +++ b/web/packages/studio/src/components/ironSwarm/swarm/useSwarmEvents.ts @@ -34,7 +34,7 @@ export const useSwarmEvents = (workspace: string, runName: string): SwarmEvent[] useEffect(() => { setAllEvents([]); setAfterId(0); - }, [runName]); + }, [workspace, runName]); return allEvents; }; From e9df12de585e8c6cef78c8ee7a74496e644095d0 Mon Sep 17 00:00:00 2001 From: Koral Chapnik Verbun Date: Tue, 21 Jul 2026 15:25:28 +0300 Subject: [PATCH 07/55] add events_fileset field to IronSwarmRun Signed-off-by: Koral Chapnik Verbun --- .../src/nemo_iron_swarm_plugin/entities.py | 113 +++++++++++ .../nemo_iron_swarm_plugin/jobs/records.py | 191 ++++++++++++++++++ .../tests/unit/test_run_record.py | 62 ++++++ 3 files changed, 366 insertions(+) create mode 100644 plugins/nemo-iron-swarm/src/nemo_iron_swarm_plugin/entities.py create mode 100644 plugins/nemo-iron-swarm/src/nemo_iron_swarm_plugin/jobs/records.py create mode 100644 plugins/nemo-iron-swarm/tests/unit/test_run_record.py diff --git a/plugins/nemo-iron-swarm/src/nemo_iron_swarm_plugin/entities.py b/plugins/nemo-iron-swarm/src/nemo_iron_swarm_plugin/entities.py new file mode 100644 index 0000000000..2f583c99f1 --- /dev/null +++ b/plugins/nemo-iron-swarm/src/nemo_iron_swarm_plugin/entities.py @@ -0,0 +1,113 @@ +# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +"""Entity definitions for the Iron Swarm plugin — stored in the NeMo Platform entity store. + +A :class:`IronSwarmRun` records one war-game run (agent targeted, victim port, manifest, outcome); a +:class:`IronSwarmManifest` is a named, reusable war-game target scaffolded from a deployed agent or an +uploaded NAT project. ``name``/``workspace``/``created_at``/``id`` are inherited from the base and +managed by the store; only domain fields are declared here. The ``IRON_SWARM_*_TYPE`` constants are +the canonical entity-type strings used at every call site. +""" + +from __future__ import annotations + +from typing import Literal + +from nemo_iron_swarm_plugin.model_config import WarGameModels +from nemo_platform_plugin.entity import NemoEntity +from pydantic import Field + +IRON_SWARM_RUN_TYPE = "iron_swarm_run" +IRON_SWARM_MANIFEST_TYPE = "iron_swarm_manifest" + +RunStatus = Literal["running", "completed", "failed"] +ManifestSource = Literal["agent", "project"] + + +class IronSwarmRun(NemoEntity, entity_type=IRON_SWARM_RUN_TYPE): + """A record of one Iron Swarm war-game run.""" + + agent: str = Field(default="", description="Targeted agent reference (workspace/name).") + job_id: str = Field(default="", description="Platform job that drove this run (for live status/HITL).") + port: int = Field(default=0, description="Victim port the war-game attacked.") + manifest: str = Field(default="", description="Path to the iron-swarm.yaml manifest used.") + manifest_id: str = Field(default="", description="Manifest this run belongs to (scopes 'replay last run').") + status: RunStatus = Field(default="failed", description="Final run status.") + returncode: int = Field(default=-1, description="Exit code from `iron-swarm run`.") + summary: str = Field(default="", description="Short human-readable outcome summary.") + error_category: str = Field( + default="", + description="Classified failure category when status is 'failed' (e.g. sandbox, missing_credential, " + "manifest, network); empty for a successful run.", + ) + error_message: str = Field(default="", description="Operator-facing failure message when the run failed.") + error_remediation: str = Field( + default="", description="Suggested next step to resolve the failure; empty for a successful run." + ) + hitlog_fileset: str = Field( + default="", + description="Fileset ref of the garak hitlog this run produced, if any; replay a later run from it.", + ) + events_fileset: str = Field( + default="", + description="Fileset ref of the run's events.jsonl, uploaded at completion for durable history.", + ) + source_run: str = Field( + default="", + description="For a validate-only sanity-check run, the name of the harden run it was launched from; " + "lets the Harden tab re-attach the scorecard on reload. Empty for normal war-game runs.", + ) + + +class IronSwarmManifest(NemoEntity, entity_type=IRON_SWARM_MANIFEST_TYPE): + """A named, reusable war-game target scaffolded via `init` (its ``name`` is the user-defined id). + + Two sources: ``agent`` re-materializes the manifest from a deployed agent ref (no bundle persisted); + ``project`` war-games an uploaded NAT project — its files are stored as ``project_fileset`` and the + run re-downloads them so custom-tool agents (unregistrable as config-only agents) can be targeted. + """ + + agent: str = Field(default="", description="Deployed agent reference (workspace/name) this manifest targets.") + source_type: ManifestSource = Field(default="agent", description="How the manifest was built ('agent'|'project').") + project_fileset: str = Field( + default="", + description="Fileset ref holding the uploaded NAT project bundle (source_type 'project'); the run " + "re-downloads it to a project_dir before launching the victim.", + ) + workflow: str = Field(default="", description="Chosen workflow path within the project (project source, display).") + launch_mode: str = Field(default="", description="Victim launch mode ('workflow'|'byo'; project source).") + manifest_yaml: str = Field(default="", description="The resolved iron-swarm.yaml content (for display).") + port: int = Field(default=0, description="Victim port the war-game will target.") + secrets: list[str] = Field(default_factory=list, description="Secret names the victim agent requires.") + warnings: list[str] = Field(default_factory=list, description="Non-fatal notes from scaffolding.") + benign_suite: list[dict[str, str]] = Field( + default_factory=list, + description="Cached, reviewed benign test suite (tool,payload,label,rationale,persona rows); " + "generated on the first run and reused/edited thereafter. Empty until generated.", + ) + benign_interview: list[dict[str, str]] = Field( + default_factory=list, + description="Interview Q&A (gap,question,answer rows) captured during the last benign-suite " + "generation, kept for display. Empty until generated.", + ) + defenders: list[str] = Field( + default_factory=list, + description="Enabled defender keys ('guardrails','openshell'); empty means iron-swarm's defaults " + "(all applicable). Materialized into the manifest's overrides.defenders at run time.", + ) + attack_intensity: Literal["light", "standard", "thorough"] = Field( + default="standard", + description="Attacker (garak) effort preset, materialized into the manifest's garak block at run time.", + ) + rounds: int = Field( + default=1, + ge=1, + description="Number of iterative attack/defend/validate hardening rounds; passed to iron-swarm's " + "`run --rounds` at run time.", + ) + models: WarGameModels = Field( + default_factory=WarGameModels, + description="Stored default model selection (attack/analysis/agent groups); an unset group uses " + "iron-swarm's built-in default. A run may override these per-launch.", + ) diff --git a/plugins/nemo-iron-swarm/src/nemo_iron_swarm_plugin/jobs/records.py b/plugins/nemo-iron-swarm/src/nemo_iron_swarm_plugin/jobs/records.py new file mode 100644 index 0000000000..ed45771579 --- /dev/null +++ b/plugins/nemo-iron-swarm/src/nemo_iron_swarm_plugin/jobs/records.py @@ -0,0 +1,191 @@ +# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +"""Read/write the war-game's entity-store records. + +IronSwarmRun rows (create/pre-create/update + the data payload) and the manifest-entity reads the +run depends on (configured rounds, cached benign suite, persisting a reviewed suite). All best-effort: +recording never fails the war-game itself. +""" + +from __future__ import annotations + +import logging +from typing import Any + +from nemo_iron_swarm_plugin.entities import ( + IRON_SWARM_MANIFEST_TYPE, + IRON_SWARM_RUN_TYPE, + IronSwarmManifest, + IronSwarmRun, +) +from nemo_iron_swarm_plugin.jobs.errors import RunFailure +from nemo_platform_plugin.entity_client import NemoEntitiesClient +from nemo_platform_plugin.job_context import JobContext + +logger = logging.getLogger(__name__) + + +def _run_data( + agent: str, + port: int, + manifest: str, + status: str, + returncode: int, + job_id: str = "", + hitlog_fileset: str = "", + manifest_id: str = "", + source_run: str = "", + failure: RunFailure | None = None, + events_fileset: str = "", +) -> dict[str, Any]: + """The IronSwarmRun data payload (whole record, since updates replace it). + + When *failure* is given (a failed run), its classified category/message/remediation are recorded and + folded into the summary so the cause is visible even where only the summary is shown. + """ + if status == "running": + summary = f"running against {agent or 'agent'}" + elif failure is not None: + summary = f"failed ({failure.category}) against {agent or 'agent'}: {failure.message}" + else: + summary = f"{status} (exit {returncode}) against {agent or 'agent'}" + return { + "agent": agent, + "job_id": job_id, + "port": port, + "manifest": manifest, + "manifest_id": manifest_id, + "status": status, + "returncode": returncode, + "summary": summary, + "hitlog_fileset": hitlog_fileset, + "events_fileset": events_fileset, + "source_run": source_run, + "error_category": failure.category if failure else "", + "error_message": failure.message if failure else "", + "error_remediation": failure.remediation if failure else "", + } + + +def _create_run(sdk: Any, *, workspace: str, data: dict[str, Any]) -> str | None: + """Persist a new IronSwarmRun record; never fail the run on error. Returns its name.""" + if sdk is None or not hasattr(sdk, "entities"): + return None + try: + entity = sdk.entities.create(IRON_SWARM_RUN_TYPE, workspace=workspace, data=data) + return getattr(entity, "name", None) + except Exception: # recording is best-effort, not part of the war-game + logger.warning("failed to persist IronSwarmRun record", exc_info=True) + return None + + +async def _precreate_run( + entity_client: NemoEntitiesClient, *, workspace: str, manifest_id: str, job_id: str, source_run: str = "" +) -> str | None: + """Create the run record at submit time so Studio can open its live view immediately. + + Reads the agent/port straight off the manifest entity (no sandbox/materialization) and records a + ``running`` row linked to the job. The worker reuses this record instead of creating its own. Best-effort: + on any failure we return ``None`` and the worker falls back to creating the record when it starts. + """ + try: + manifest = await entity_client.get(IronSwarmManifest, name=manifest_id, workspace=workspace) + # Project-source manifests have no agent ref; label the run by the manifest name instead. + label = manifest.agent or manifest.name + run = IronSwarmRun( + workspace=workspace, + agent=manifest.agent, + port=manifest.port, + job_id=job_id, + manifest_id=manifest_id, + status="running", + returncode=-1, + summary=f"running against {label}", + source_run=source_run, + ) + return (await entity_client.create(run)).name + except Exception: # pre-creation is an optimization; never block job submission on it + logger.warning("failed to pre-create IronSwarmRun for job %s", job_id, exc_info=True) + return None + + +def _update_run(sdk: Any, *, workspace: str, name: str, data: dict[str, Any]) -> None: + """Overwrite an existing IronSwarmRun record (e.g. running -> completed); best-effort.""" + if sdk is None or not hasattr(sdk, "entities"): + return + try: + sdk.entities.update_entity_by_name(name=name, entity_type=IRON_SWARM_RUN_TYPE, workspace=workspace, data=data) + except Exception: # recording is best-effort, not part of the war-game + logger.warning("failed to update IronSwarmRun record", exc_info=True) + + +def _manifest_rounds(sdk: Any, manifest_id: str, ctx: JobContext) -> int: + """The manifest's configured number of hardening rounds (>=1); 1 if unset/unavailable (best-effort).""" + if sdk is None or not hasattr(sdk, "entities"): + return 1 + try: + record = sdk.entities.get_entity_by_name( + name=manifest_id, entity_type=IRON_SWARM_MANIFEST_TYPE, workspace=ctx.workspace + ) + rounds = (getattr(record, "data", {}) or {}).get("rounds") + return rounds if isinstance(rounds, int) and rounds >= 1 else 1 + except Exception: # reading config is best-effort; default to a single round + logger.warning("failed to read rounds for manifest %s", manifest_id, exc_info=True) + return 1 + + +def _manifest_models(sdk: Any, manifest_id: str, ctx: JobContext) -> dict[str, Any]: + """The manifest's stored default model selection (attack/analysis/agent), or ``{}`` (best-effort).""" + if sdk is None or not hasattr(sdk, "entities"): + return {} + try: + record = sdk.entities.get_entity_by_name( + name=manifest_id, entity_type=IRON_SWARM_MANIFEST_TYPE, workspace=ctx.workspace + ) + models = (getattr(record, "data", {}) or {}).get("models") + return models if isinstance(models, dict) else {} + except Exception: # reading config is best-effort; fall back to iron-swarm's built-in model defaults + logger.warning("failed to read models for manifest %s", manifest_id, exc_info=True) + return {} + + +def _cached_benign_suite(sdk: Any, manifest_id: str, ctx: JobContext) -> list[dict[str, str]]: + """The manifest's cached benign-suite rows, or ``[]`` when none/unavailable (best-effort).""" + if sdk is None or not hasattr(sdk, "entities"): + return [] + try: + record = sdk.entities.get_entity_by_name( + name=manifest_id, entity_type=IRON_SWARM_MANIFEST_TYPE, workspace=ctx.workspace + ) + suite = (getattr(record, "data", {}) or {}).get("benign_suite") or [] + return [row for row in suite if isinstance(row, dict)] + except Exception: # reading the cache is best-effort; a miss just re-generates + logger.warning("failed to read cached benign suite for manifest %s", manifest_id, exc_info=True) + return [] + + +def _persist_benign_suite( + sdk: Any, + *, + workspace: str, + manifest_id: str, + suite: list[dict[str, str]], + interview: list[dict[str, Any]] | None = None, +) -> None: + """Cache the reviewed benign suite (and the interview Q&A behind it) on the manifest; best-effort.""" + if sdk is None or not hasattr(sdk, "entities") or not suite: + return + try: + record = sdk.entities.get_entity_by_name( + name=manifest_id, entity_type=IRON_SWARM_MANIFEST_TYPE, workspace=workspace + ) + data = dict(getattr(record, "data", {}) or {}) + data["benign_suite"] = suite + if interview: + data["benign_interview"] = interview + sdk.entities.update_entity_by_name( + name=manifest_id, entity_type=IRON_SWARM_MANIFEST_TYPE, workspace=workspace, data=data + ) + except Exception: # caching is best-effort, not part of the war-game + logger.warning("failed to cache benign suite on manifest %s", manifest_id, exc_info=True) diff --git a/plugins/nemo-iron-swarm/tests/unit/test_run_record.py b/plugins/nemo-iron-swarm/tests/unit/test_run_record.py new file mode 100644 index 0000000000..cb1d482cc2 --- /dev/null +++ b/plugins/nemo-iron-swarm/tests/unit/test_run_record.py @@ -0,0 +1,62 @@ +# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +"""Unit tests for run-record helpers (manifest fact extraction + entity flattening). + +These import plugin modules that depend on nemo-platform packages, so they run under the +workspace test environment (``make test-package PACKAGE=nemo_iron_swarm_plugin``). +""" + +from __future__ import annotations + +import types + +import yaml +from nemo_iron_swarm_plugin.jobs.run import _manifest_facts, _run_data +from nemo_iron_swarm_plugin.sdk import _run_to_dict + + +def test_manifest_facts_reads_agent_name_and_port(tmp_path): + manifest = tmp_path / "iron-swarm.yaml" + manifest.write_text(yaml.safe_dump({"agent": {"name": "calc", "port": 9123}, "backends": []}), encoding="utf-8") + assert _manifest_facts(str(manifest)) == ("calc", 9123) + + +def test_manifest_facts_tolerates_missing_file(): + assert _manifest_facts("/no/such/manifest.yaml") == ("", 0) + + +def test_run_data_carries_source_run_for_sanity_check(): + # A validate-only sanity check records the harden run it came from, so the Harden tab re-attaches its + # scorecard on reload; a normal run leaves it empty. + linked = _run_data("default/scout", 8000, "m.yaml", "failed", 1, source_run="iron-swarm-run-abc") + assert linked["source_run"] == "iron-swarm-run-abc" + assert _run_data("default/scout", 8000, "m.yaml", "running", -1)["source_run"] == "" + + +def test_run_data_includes_events_fileset(): + from nemo_iron_swarm_plugin.jobs.records import _run_data + + data = _run_data( + agent="test-agent", + port=0, + manifest="manifest-1", + manifest_id="mid-1", + status="completed", + returncode=0, + events_fileset="default/my-events-fileset", + ) + assert data["events_fileset"] == "default/my-events-fileset" + + +def test_run_to_dict_flattens_entity_data_name_and_created_at(): + entity = types.SimpleNamespace( + data={"agent": "default/calc", "status": "completed", "returncode": 0}, + name="iron-swarm-run-abc", + created_at="2026-06-28T10:00:00", + ) + flat = _run_to_dict(entity) + assert flat["agent"] == "default/calc" + assert flat["status"] == "completed" + assert flat["name"] == "iron-swarm-run-abc" + assert flat["created_at"] == "2026-06-28T10:00:00" From 9316f2a68123fbdf769df1364e4a0c172a7fb495 Mon Sep 17 00:00:00 2001 From: Koral Chapnik Verbun Date: Tue, 21 Jul 2026 15:30:45 +0300 Subject: [PATCH 08/55] upload events.jsonl to fileset at run completion Signed-off-by: Koral Chapnik Verbun --- .../nemo_iron_swarm_plugin/jobs/artifacts.py | 147 +++++++ .../src/nemo_iron_swarm_plugin/jobs/run.py | 411 ++++++++++++++++++ .../tests/unit/test_artifacts.py | 67 +++ 3 files changed, 625 insertions(+) create mode 100644 plugins/nemo-iron-swarm/src/nemo_iron_swarm_plugin/jobs/artifacts.py create mode 100644 plugins/nemo-iron-swarm/src/nemo_iron_swarm_plugin/jobs/run.py create mode 100644 plugins/nemo-iron-swarm/tests/unit/test_artifacts.py diff --git a/plugins/nemo-iron-swarm/src/nemo_iron_swarm_plugin/jobs/artifacts.py b/plugins/nemo-iron-swarm/src/nemo_iron_swarm_plugin/jobs/artifacts.py new file mode 100644 index 0000000000..ac3683dc70 --- /dev/null +++ b/plugins/nemo-iron-swarm/src/nemo_iron_swarm_plugin/jobs/artifacts.py @@ -0,0 +1,147 @@ +# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +"""Job-result artifacts and fileset round-trips for the war-game. + +Saves the run's Studio-facing results (mitigations, validation scorecard, composed workflow) and moves +suites/hitlogs through platform filesets (download an uploaded replay hitlog or benign suite; persist the +run's produced hitlog for later replay). All best-effort — capturing an artifact never fails the run. +""" + +from __future__ import annotations + +import logging +from typing import Any + +from nemo_iron_swarm_plugin.api.v2.events import _events_path +from nemo_iron_swarm_plugin.filesets import download_fileset, upload_file_to_fileset +from nemo_iron_swarm_plugin.jobs.errors import CATEGORY_FILESET, IronSwarmRunError +from nemo_platform_plugin.job_context import JobContext + +logger = logging.getLogger(__name__) + + +def _download_fileset(sdk: Any, ref: str, dest: Any, *, what: str) -> Any: + """Download a fileset, classifying any transport/download failure as a :class:`fileset `.""" + try: + return download_fileset(sdk, ref, dest) + except IronSwarmRunError: + raise + except Exception as exc: + raise IronSwarmRunError(CATEGORY_FILESET, f"could not download the {what} fileset {ref!r}: {exc}") from exc + + +def _replay_args(replay_hitlog_fileset: str | None, sdk: Any, ctx: JobContext) -> list[str]: + """Resolve replay mode to `iron-swarm run` args: download the hitlog fileset and point `--replay` at it. + + Returns ``[]`` when not replaying. iron-swarm's ``--replay `` skips the live garak attack and + replays the recorded hits against the (defended) victim. + """ + if not replay_hitlog_fileset: + return [] + dest = _download_fileset(sdk, replay_hitlog_fileset, ctx.storage.persistent / "replay-hitlog", what="replay hitlog") + hitlog = next((p for p in sorted(dest.rglob("*")) if p.is_file()), None) + if hitlog is None: + raise IronSwarmRunError(CATEGORY_FILESET, f"Replay hitlog fileset {replay_hitlog_fileset!r} contained no file.") + return ["--replay", str(hitlog)] + + +def _uploaded_benign_suite(benign_suite_fileset: str | None, sdk: Any, ctx: JobContext) -> str | None: + """Download an uploaded benign-suite fileset and return its local CSV path, or ``None`` if not set.""" + if not benign_suite_fileset: + return None + dest = _download_fileset( + sdk, benign_suite_fileset, ctx.storage.persistent / "benign-suite-upload", what="benign suite" + ) + csv_file = next((p for p in sorted(dest.rglob("*")) if p.is_file()), None) + if csv_file is None: + raise IronSwarmRunError(CATEGORY_FILESET, f"Benign suite fileset {benign_suite_fileset!r} contained no file.") + return str(csv_file) + + +def _save_mitigations(ctx: JobContext) -> None: + """Save the run's ``mitigations.json`` (before/after policy + workflow) as a job result for Studio. + + iron-swarm writes it under ``.iron-swarm/run-logs//`` at the end of a hardening run; the Studio + Mitigations view fetches it via the results API. Best-effort — never fail the run over it. + """ + try: + candidates = sorted( + (ctx.storage.persistent / ".iron-swarm" / "run-logs").glob("*/mitigations.json"), + key=lambda p: p.stat().st_mtime, + reverse=True, + ) + if candidates: + ctx.results.save("mitigations", candidates[0]) + except Exception: # capturing the artifact is best-effort, not part of the war-game + logger.warning("failed to save mitigations result", exc_info=True) + + +def _save_validation(ctx: JobContext) -> None: + """Save the run's ``validation.json`` (per-item attack/benign results) as a job result for Studio. + + iron-swarm writes it under ``.iron-swarm/run-logs//`` for any run that ran validators — including + the frozen validate-only sanity check. Drives the Studio scorecard. Best-effort — never fail the run. + """ + try: + candidates = sorted( + (ctx.storage.persistent / ".iron-swarm" / "run-logs").glob("*/validation.json"), + key=lambda p: p.stat().st_mtime, + reverse=True, + ) + if candidates: + ctx.results.save("validation", candidates[0]) + except Exception: # capturing the artifact is best-effort, not part of the war-game + logger.warning("failed to save validation result", exc_info=True) + + +def _save_composed_workflow(ctx: JobContext, defense_workflow: str | None) -> None: + """Persist the validated composed workflow YAML as a ``composed-workflow`` job result (best-effort). + + Lets the Harden tab recover the exact workflow a sanity check validated after a page reload, so + "Apply to Agent" stays available without re-running the check. + """ + if not defense_workflow: + return + try: + path = ctx.storage.persistent / ".iron-swarm" / "composed-workflow.yaml" + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(defense_workflow, encoding="utf-8") + ctx.results.save("composed-workflow", path) + except Exception: # capturing the artifact is best-effort, not part of the war-game + logger.warning("failed to save composed workflow result", exc_info=True) + + +def _save_events_fileset(sdk: Any, *, workspace: str, run_name: str) -> str: + """Upload the run's events.jsonl to a fileset; return its ref or '' on any failure.""" + path = _events_path(workspace, run_name) + if not path.exists(): + return "" + try: + return upload_file_to_fileset(sdk, path, workspace=workspace) + except Exception: + logger.warning("Failed to upload events.jsonl for run %r; history will not survive pod restart", run_name) + return "" + + +def _save_hitlog_fileset(sdk: Any, ctx: JobContext, workspace: str) -> str: + """Upload the run's produced garak hitlog to a fileset so a later run can replay it; return its ref. + + iron-swarm's attacker writes ``*.hitlog.jsonl`` run-scoped under ``.iron-swarm/run-logs//…/garak/``. + Persistent job storage is per-job, so we persist the newest hitlog as a fileset and record its ref on the + run entity. Best-effort — returns ``""`` on any failure (a run with no attack has no hitlog to save). + """ + if sdk is None: + return "" + try: + hitlogs = sorted( + (ctx.storage.persistent / ".iron-swarm" / "run-logs").rglob("*.hitlog.jsonl"), + key=lambda p: p.stat().st_mtime, + reverse=True, + ) + if not hitlogs: + return "" + return upload_file_to_fileset(sdk, hitlogs[0], workspace=workspace) + except Exception: # persisting the hitlog is best-effort, not part of the war-game + logger.warning("failed to save hitlog fileset", exc_info=True) + return "" diff --git a/plugins/nemo-iron-swarm/src/nemo_iron_swarm_plugin/jobs/run.py b/plugins/nemo-iron-swarm/src/nemo_iron_swarm_plugin/jobs/run.py new file mode 100644 index 0000000000..0e62df2123 --- /dev/null +++ b/plugins/nemo-iron-swarm/src/nemo_iron_swarm_plugin/jobs/run.py @@ -0,0 +1,411 @@ +# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +"""``iron-swarm.war-game`` job — registered under ``nemo.jobs``. + +Orchestrates one attack/defend/validate war-game against a deployed NAT agent by shelling out to +iron-swarm's own CLI (its own venv; iron-swarm is never imported). This module holds only the job +class: ``compile`` builds the platform job spec (pre-creating the run record for Studio's live view) +and ``run`` sequences the phases. The mechanics live in sibling modules: +:mod:`~nemo_iron_swarm_plugin.jobs.manifest` (materialize/seed the on-host manifest), +:mod:`~nemo_iron_swarm_plugin.jobs.records` (entity-store rows), +:mod:`~nemo_iron_swarm_plugin.jobs.artifacts` (results + filesets), and +:mod:`~nemo_iron_swarm_plugin.jobs.execution` (the subprocess invocation paths). +""" + +from __future__ import annotations + +import logging +import os +from pathlib import Path +from typing import Any, ClassVar, cast + +from nemo_iron_swarm_plugin.config import IronSwarmConfig +from nemo_iron_swarm_plugin.jobs import _common +from nemo_iron_swarm_plugin.jobs.artifacts import ( + _replay_args, + _save_composed_workflow, + _save_events_fileset, + _save_hitlog_fileset, + _save_mitigations, + _save_validation, + _uploaded_benign_suite, +) +from nemo_iron_swarm_plugin.jobs.errors import ( + CATEGORY_MODEL_UNAVAILABLE, + IronSwarmRunError, + RunFailure, + classify_exception, +) +from nemo_iron_swarm_plugin.jobs.execution import _run_one_shot, _run_service_driven +from nemo_iron_swarm_plugin.jobs.manifest import _manifest_facts, _materialize_manifest, _seed_validation_manifest +from nemo_iron_swarm_plugin.jobs.records import ( + _cached_benign_suite, + _create_run, + _manifest_models, + _manifest_rounds, + _precreate_run, + _run_data, + _update_run, +) +from nemo_iron_swarm_plugin.jobs.spec import WarGameSpec +from nemo_iron_swarm_plugin.model_config import ( + ANALYSIS_DEFAULT_BASE_URL, + ATTACK_DEFAULT_BASE_URL, + ModelChoice, + WarGameModels, +) +from nemo_iron_swarm_plugin.model_preflight import validate_choice +from nemo_platform_plugin.entity_client import NemoEntitiesClient +from nemo_platform_plugin.job import NemoJob +from nemo_platform_plugin.job_context import JobContext +from nemo_platform_plugin.jobs.api_factory import ( + EnvironmentVariable, + PlatformJobSpec, + PlatformJobStep, + SubprocessExecutionProviderSpec, +) +from nemo_platform_plugin.jobs.constants import DEFAULT_JOB_STORAGE_PATH, PERSISTENT_JOB_STORAGE_PATH_ENVVAR +from pydantic import BaseModel + +logger = logging.getLogger(__name__) + +_LOG_TAIL = 4000 + + +def _merge_choice(default: ModelChoice | None, override: ModelChoice | None) -> ModelChoice | None: + """Field-level merge of one model group: the per-run override wins per field, else the stored default.""" + if default is None and override is None: + return None + default = default or ModelChoice() + override = override or ModelChoice() + merged = ModelChoice( + model=override.model or default.model, + base_url=override.base_url or default.base_url, + api_key_secret=override.api_key_secret or default.api_key_secret, + ) + return merged if (merged.model or merged.base_url or merged.api_key_secret) else None + + +def _effective_models(sdk: Any, config: dict, ctx: JobContext) -> WarGameModels | None: + """Resolve the run's effective model selection: the manifest's stored default merged with the override. + + Reads the stored default from the manifest record (Studio path) and merges the per-run ``models`` from + the spec over it, field by field. Returns ``None`` when neither side selects anything (so iron-swarm's + built-in defaults stay in force and nothing is injected). + """ + stored_raw = _manifest_models(sdk, str(config["manifest_id"]), ctx) if config.get("manifest_id") else {} + stored = WarGameModels.model_validate(stored_raw) + override = WarGameModels.model_validate(config.get("models") or {}) + merged = WarGameModels( + attack=_merge_choice(stored.attack, override.attack), + analysis=_merge_choice(stored.analysis, override.analysis), + agent=_merge_choice(stored.agent, override.agent), + ) + return merged if (merged.attack or merged.analysis or merged.agent) else None + + +def _preflight_models(models: WarGameModels | None, *, sdk: Any, workspace: str, default_key: str | None) -> None: + """Fail fast (before the sandbox spins up) if a user-chosen model/endpoint/key can't be reached. + + Only groups the user explicitly configured (a model name and/or a custom ``base_url``) are probed — + the built-in defaults are known-good and left untouched. On a bad credential or a wrong model name we + raise a classified :class:`IronSwarmRunError` whose message lists the models those credentials *can* + reach, so the user can correct the choice instead of guessing. The victim ("agent") group routes + through the Inference Gateway and is validated interactively in Studio, not here. + """ + if models is None: + return + groups = ( + ("attack", models.attack, ATTACK_DEFAULT_BASE_URL), + ("analysis", models.analysis, ANALYSIS_DEFAULT_BASE_URL), + ) + for label, choice, default_base_url in groups: + if choice is None or not (choice.model or choice.base_url): + continue + key = _common._resolve_secret(sdk, choice.api_key_secret, workspace) if choice.api_key_secret else default_key + verdict = validate_choice(choice.model, choice.base_url or default_base_url, key) + if verdict.ok: + continue + raise IronSwarmRunError(CATEGORY_MODEL_UNAVAILABLE, _preflight_message(label, choice, verdict)) + + +def _preflight_message(label: str, choice: ModelChoice, verdict: Any) -> str: + """Compose the operator-facing message for a failed model preflight (lists reachable models).""" + endpoint = choice.base_url or "the default endpoint" + if verdict.reason == "auth": + return f"The {label} model credentials were rejected by {endpoint} ({verdict.detail or 'unauthorized'})." + if verdict.reason == "unreachable": + return f"Could not reach the {label} model endpoint {endpoint} ({verdict.detail or 'no response'})." + available = ", ".join(verdict.available[:20]) or "none" + return ( + f"The {label} model {choice.model!r} is not available at {endpoint}. " + f"Models reachable with these credentials: {available}." + ) + + +class IronSwarmRunJob(NemoJob): + """Run the attack/defend/validate war-game against the configured agent.""" + + name = "war-game" # CLI: `nemo iron-swarm war-game ...`; keeps `run` free for the wrapper command + description = "Run the Iron Swarm war-game against a deployed NAT agent." + container = "cpu-tasks" + spec_schema: ClassVar[type[BaseModel] | None] = WarGameSpec + + @classmethod + async def compile( + cls, + *, + workspace: str, + spec: BaseModel, # WarGameSpec + entity_client: object, + job_name: str | None, + async_sdk: object, + profile: str | None = None, + options: dict | None = None, + ) -> PlatformJobSpec: + """Single subprocess step running the war-game on the host where `nemo iron-swarm setup` provisioned it. + + Subprocess (not container) executor: the war-game shells out to iron-swarm's CLI + garak venv and + launches the Docker victim sandbox, all of which live on the provisioned host today. A Docker-capable + container image (`CPUExecutionProviderSpec(container=...)`) is the Phase-2 swap — `run()` is unchanged. + """ + war_game = cast(WarGameSpec, spec) + + # Pre-create the run record now (a Studio war-game submits a manifest_id + service driver) so the UI + # can open its live view immediately; the worker reuses this record via `run_name` in the step config. + run_name: str | None = None + if war_game.driver == "service" and war_game.manifest_id and job_name and not war_game.stop_after_synth: + run_name = await _precreate_run( + cast(NemoEntitiesClient, entity_client), + workspace=workspace, + manifest_id=war_game.manifest_id, + job_id=job_name, + source_run=war_game.source_run or "", + ) + + environment = [ + EnvironmentVariable(name=PERSISTENT_JOB_STORAGE_PATH_ENVVAR, value=DEFAULT_JOB_STORAGE_PATH), + ] + # The subprocess executor forwards only PATH/VIRTUAL_ENV, but the war-game's openshell sandbox reads + # its gateway registration from $HOME/.config/openshell and reaches Docker via $DOCKER_HOST. Forward + # them explicitly from the provisioned host this subprocess runs on (see the executor note above). + for name in ("HOME", "DOCKER_HOST", "XDG_CONFIG_HOME"): + value = os.environ.get(name) + if value: + environment.append(EnvironmentVariable(name=name, value=value)) + return PlatformJobSpec( + steps=[ + PlatformJobStep( + name="war-game", + executor=SubprocessExecutionProviderSpec( + provider="subprocess", + command=["python", "-m", "nemo_iron_swarm_plugin.tasks.war_game"], + ), + config={**war_game.model_dump(mode="json"), **({"run_name": run_name} if run_name else {})}, + environment=environment, + ), + ], + ) + + def run(self, config: dict, *, ctx: JobContext, sdk: Any = None, **_: Any) -> dict: + """Run the war-game, classifying and surfacing any failure that affects the run's results. + + The whole run is wrapped in one error boundary: a classified :class:`IronSwarmRunError` (or any + other exception) is turned into a :class:`RunFailure`, recorded on the run entity (so the + pre-created ``running`` row is finalized to ``failed`` with a cause, never orphaned) and logged, + then re-surfaced as a ``failed`` result so the process exits non-zero and the platform job errors. + """ + try: + return self._execute(config, ctx=ctx, sdk=sdk) + except Exception as exc: + failure = classify_exception(exc) + logger.exception("iron-swarm war-game failed [%s]: %s", failure.category, failure.message) + self._record_failure(ctx, sdk, config, failure) + return { + "status": "failed", + "returncode": 1, + "error": { + "category": failure.category, + "message": failure.message, + "remediation": failure.remediation, + }, + } + + def _record_failure(self, ctx: JobContext, sdk: Any, config: dict, failure: RunFailure) -> None: + """Finalize the run record as ``failed`` with the classified error, on every channel the user sees. + + Reuses the pre-created record (``run_name``) when present so its live view resolves to the failure + instead of a perpetual ``running``; otherwise creates a failed record now. Also reports terminal + ``failed`` progress with the error details. Recording stays best-effort — it must not mask the cause. + """ + data = _run_data( + "", + 0, + str(config.get("config") or ""), + "failed", + 1, + ctx.job_id or "", + manifest_id=str(config.get("manifest_id") or ""), + source_run=str(config.get("source_run") or ""), + failure=failure, + ) + prepared = config.get("run_name") + if prepared: + _update_run(sdk, workspace=ctx.workspace, name=str(prepared), data=data) + else: + _create_run(sdk, workspace=ctx.workspace, data=data) + self.report_progress(ctx, work_done=0, work_total=1, status="failed", details=failure.as_error_details()) + + def _execute(self, config: dict, *, ctx: JobContext, sdk: Any = None) -> dict: + plugin_config = IronSwarmConfig.get() + _common.require_provisioned(plugin_config) + + # Studio submits a saved manifest_id (materialized here from the stored agent ref); the CLI + # passes a ready manifest path via `config`. + manifest_id: str | None = None + cached_suite: list[dict[str, str]] = [] + rounds = 1 + # Per-run config overrides from the launch dialog: apply over the manifest without persisting. + config_overrides = { + key: config[key] for key in ("port", "defenders", "attack_intensity") if config.get(key) is not None + } + # Effective model selection = the manifest's stored default merged with the per-run override. Drives + # both the victim-LLM rewrite (agent group, threaded via the manifest) and the subprocess env knobs + # (attack/analysis groups). Resolved once here so materialize + env stay consistent. + models = _effective_models(sdk, config, ctx) + if models is not None: + config_overrides["models"] = models.model_dump(mode="json") + model_env = _common.build_model_env(models, sdk=sdk, workspace=ctx.workspace) + # Preflight user-chosen models against their endpoint before the (minutes-long) sandbox spin-up, so + # a wrong model name / key fails in seconds with the list of models the credentials can actually reach. + _preflight_models( + models, + sdk=sdk, + workspace=ctx.workspace, + default_key=_common.build_subprocess_env(plugin_config).get("INFERENCE_API_KEY"), + ) + validate_only = bool(config.get("validate_only")) + if config.get("manifest_id"): + manifest_id = str(config["manifest_id"]) + manifest = _materialize_manifest(sdk, manifest_id, ctx, config_overrides) + cached_suite = _cached_benign_suite(sdk, manifest_id, ctx) + rounds = int(config["rounds"]) if config.get("rounds") else _manifest_rounds(sdk, manifest_id, ctx) + elif config.get("config"): + manifest = str(config["config"]) + else: + raise ValueError("iron-swarm war-game requires a 'manifest_id' or a 'config' manifest path in the spec.") + + # Frozen sanity check: seed the chosen composed defenses as the victim baseline and force zero + # defenders, so the replay measures the fixed defense without generating new mitigations. Always a + # single round (validation, not iterative hardening). + if validate_only: + _seed_validation_manifest(manifest, config.get("defense_workflow"), config.get("defense_policy"), ctx) + rounds = 1 + # Studio submits no env_file; iron-swarm reads victim creds from a project dotenv, so synthesize + # one from the operator env (which carries the provisioned INFERENCE_API_KEY) for the manifest's secrets. + env_file = config.get("env_file") + if not env_file: + env_file = _common.materialize_victim_env_file( + manifest, _common.build_subprocess_env(plugin_config), Path(manifest).parent + ) + agent_name, port = _manifest_facts(manifest) + + # Replay mode: skip the live garak attack and replay a recorded hitlog (uploaded, or a prior run's + # saved hitlog) against the defended victim. The fileset is downloaded here and passed as `--replay `. + replay_fileset = config.get("replay_hitlog_fileset") or None + replay_args = _replay_args(replay_fileset, sdk, ctx) + + # Benign suite: an uploaded suite (if supplied) overrides the manifest's cached suite for this run. + benign_override = _uploaded_benign_suite(config.get("benign_suite_fileset") or None, sdk, ctx) + + # `driver: "service"` (Studio) drives the interview/review HITL via the serve service; otherwise the + # default one-shot `iron-swarm run` (TTY interview when interactive). + if config.get("driver") == "service": + outcome = _run_service_driven( + manifest, + env_file, + plugin_config, + ctx, + sdk, + agent_name, + port, + manifest_id=manifest_id, + cached_suite=cached_suite, + stop_after_synth=bool(config.get("stop_after_synth")), + prepared_run_name=config.get("run_name") or None, + rounds=rounds, + replay_args=replay_args, + benign_suite_override=benign_override, + source_run=str(config.get("source_run") or ""), + model_env=model_env, + ) + else: + outcome = _run_one_shot( + manifest, env_file, plugin_config, ctx, replay_args, benign_suite=benign_override, model_env=model_env + ) + + # A validate-only run generates no mitigations (defenders: []); it produces the sanity-check + # validation.json instead. A normal hardening run produces the mitigations artifact. + if validate_only: + _save_validation(ctx) + # Also persist the exact composed workflow that was validated, so the Harden tab can recover it + # after a reload and keep "Apply to Agent" enabled without re-running the check. + _save_composed_workflow(ctx, config.get("defense_workflow")) + elif not config.get("stop_after_synth"): + _save_mitigations(ctx) + + # Record the run's garak hitlog so a later run (e.g. the Harden-tab sanity check) can replay it; + # per-job storage doesn't survive across runs. A live attack produces a new hitlog we persist to a + # fileset; a replay produces no new hits but carries forward the hitlog it replayed, so replay runs + # stay sanity-checkable too. Generation-only runs (`--stop-after-synth`) have no attack hits. + hitlog_fileset = "" + if config.get("stop_after_synth"): + pass + elif replay_args: + hitlog_fileset = replay_fileset or "" + else: + hitlog_fileset = _save_hitlog_fileset(sdk, ctx, ctx.workspace) + + events_fileset = _save_events_fileset(sdk, workspace=ctx.workspace, run_name=outcome.record_name or "") + + # Finalize the up-front record (service path) or create one now (one-shot). Preserve source_run from + # the config so a validate-only sanity check stays linked to its harden run (the update replaces the + # whole record, which would otherwise drop the pre-created link). + data = _run_data( + agent_name, + port, + manifest, + outcome.status, + outcome.returncode, + ctx.job_id or "", + hitlog_fileset, + manifest_id or "", + source_run=str(config.get("source_run") or ""), + failure=outcome.failure, + events_fileset=events_fileset, + ) + if outcome.record_name: + _update_run(sdk, workspace=ctx.workspace, name=outcome.record_name, data=data) + record_name = outcome.record_name + else: + record_name = _create_run(sdk, workspace=ctx.workspace, data=data) + + details = {"returncode": str(outcome.returncode)} + if outcome.failure is not None: + details.update(outcome.failure.as_error_details()) + self.report_progress(ctx, work_done=1, work_total=1, status=outcome.status, details=details) + result = { + "status": outcome.status, + "returncode": outcome.returncode, + "log_tail": outcome.log_text[-_LOG_TAIL:], + "results": {"iron-swarm-log": outcome.log_ref.model_dump()} if outcome.log_ref else {}, + "run_record": record_name, + } + if outcome.failure is not None: # a subprocess-classified failure surfaces its cause here too + result["error"] = { + "category": outcome.failure.category, + "message": outcome.failure.message, + "remediation": outcome.failure.remediation, + } + return result diff --git a/plugins/nemo-iron-swarm/tests/unit/test_artifacts.py b/plugins/nemo-iron-swarm/tests/unit/test_artifacts.py new file mode 100644 index 0000000000..19308e4f1a --- /dev/null +++ b/plugins/nemo-iron-swarm/tests/unit/test_artifacts.py @@ -0,0 +1,67 @@ +# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +"""Tests for job artifact helpers: events fileset upload.""" + +from __future__ import annotations + +from pathlib import Path +from unittest.mock import MagicMock, patch + + +def test_save_events_fileset_uploads_file(tmp_path: Path) -> None: + from nemo_iron_swarm_plugin.jobs.artifacts import _save_events_fileset + + events_file = tmp_path / "events.jsonl" + events_file.write_text('{"event":"test","payload":{}}\n') + + sdk = MagicMock() + with ( + patch( + "nemo_iron_swarm_plugin.jobs.artifacts._events_path", + return_value=events_file, + ), + patch( + "nemo_iron_swarm_plugin.jobs.artifacts.upload_file_to_fileset", + return_value="default/events-abc123", + ) as mock_upload, + ): + result = _save_events_fileset(sdk, workspace="default", run_name="my-run") + + mock_upload.assert_called_once_with(sdk, events_file, workspace="default") + assert result == "default/events-abc123" + + +def test_save_events_fileset_returns_empty_when_file_missing(tmp_path: Path) -> None: + from nemo_iron_swarm_plugin.jobs.artifacts import _save_events_fileset + + sdk = MagicMock() + with patch( + "nemo_iron_swarm_plugin.jobs.artifacts._events_path", + return_value=tmp_path / "nonexistent.jsonl", + ): + result = _save_events_fileset(sdk, workspace="default", run_name="my-run") + + assert result == "" + + +def test_save_events_fileset_returns_empty_on_upload_error(tmp_path: Path) -> None: + from nemo_iron_swarm_plugin.jobs.artifacts import _save_events_fileset + + events_file = tmp_path / "events.jsonl" + events_file.write_text('{"event":"test","payload":{}}\n') + + sdk = MagicMock() + with ( + patch( + "nemo_iron_swarm_plugin.jobs.artifacts._events_path", + return_value=events_file, + ), + patch( + "nemo_iron_swarm_plugin.jobs.artifacts.upload_file_to_fileset", + side_effect=Exception("network error"), + ), + ): + result = _save_events_fileset(sdk, workspace="default", run_name="my-run") + + assert result == "" From a3feeb56b758161349a2abe2104c393a038fea24 Mon Sep 17 00:00:00 2001 From: Koral Chapnik Verbun Date: Tue, 21 Jul 2026 15:32:09 +0300 Subject: [PATCH 09/55] fall back to fileset when events.jsonl missing in GET endpoint Signed-off-by: Koral Chapnik Verbun --- .../nemo_iron_swarm_plugin/api/v2/events.py | 35 ++++++++-- .../nemo-iron-swarm/tests/unit/test_events.py | 65 +++++++++++++++++++ 2 files changed, 96 insertions(+), 4 deletions(-) diff --git a/plugins/nemo-iron-swarm/src/nemo_iron_swarm_plugin/api/v2/events.py b/plugins/nemo-iron-swarm/src/nemo_iron_swarm_plugin/api/v2/events.py index 082ba0185c..c41cd8d326 100644 --- a/plugins/nemo-iron-swarm/src/nemo_iron_swarm_plugin/api/v2/events.py +++ b/plugins/nemo-iron-swarm/src/nemo_iron_swarm_plugin/api/v2/events.py @@ -21,12 +21,20 @@ from nemo_iron_swarm_plugin._perms import IronSwarmRunPerms from nemo_iron_swarm_plugin.authz import scope from nemo_iron_swarm_plugin.config import IronSwarmConfig +from nemo_iron_swarm_plugin.entities import IRON_SWARM_RUN_TYPE, IronSwarmRun +from nemo_iron_swarm_plugin.filesets import download_fileset from nemo_platform_plugin.authz import CallerKind, path_rule from pydantic import BaseModel logger = logging.getLogger(__name__) +def _get_sdk() -> Any: + from nemo_platform_plugin.sdk import get_sdk + + return get_sdk() + + def _events_path(workspace: str, run_name: str) -> Path: """Durable per-run events log: ``/run-events//.jsonl``.""" safe = "".join(ch if ch.isalnum() or ch in "-._" else "_" for ch in run_name) or "run" @@ -120,7 +128,26 @@ class EventsResponse(BaseModel): @scope.read @path_rule(callers=[CallerKind.PRINCIPAL], permissions=[IronSwarmRunPerms.EVENTS_READ]) async def get_events(workspace: str, name: str, after: int = 0) -> EventsResponse: - """Return all persisted run events with sequence id greater than *after*.""" - return EventsResponse( - events=[{"id": seq, **event} for seq, event in hub.stream(workspace, name).history(after_id=after)] - ) + """Return all persisted run events with sequence id greater than *after*. + + Falls back to downloading from the run's ``events_fileset`` when the local + file is absent (e.g. after a pod restart). + """ + stream = hub.stream(workspace, name) + result = stream.history(after_id=after) + + if not result and not stream._path.exists(): + try: + sdk = _get_sdk() + run: IronSwarmRun = sdk.entities.get_entity_by_name( + name=name, + entity_type=IRON_SWARM_RUN_TYPE, + workspace=workspace, + ) + if run.events_fileset: + download_fileset(sdk, run.events_fileset, stream._path.parent) + result = stream.history(after_id=after) + except Exception: + logger.warning("Fileset fallback failed for run %r events; returning empty", name) + + return EventsResponse(events=[{"id": seq, **event} for seq, event in result]) diff --git a/plugins/nemo-iron-swarm/tests/unit/test_events.py b/plugins/nemo-iron-swarm/tests/unit/test_events.py index ffc4f0acc9..ae6153f218 100644 --- a/plugins/nemo-iron-swarm/tests/unit/test_events.py +++ b/plugins/nemo-iron-swarm/tests/unit/test_events.py @@ -7,6 +7,7 @@ import json from pathlib import Path +from unittest.mock import MagicMock, patch import pytest from fastapi import FastAPI @@ -14,6 +15,13 @@ from starlette.testclient import TestClient +def _write_events(path: Path, event_list: list[dict]) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + with path.open("w") as f: + for e in event_list: + f.write(json.dumps(e) + "\n") + + def test_history_filters_by_last_seen_id(tmp_path: Path) -> None: stream = events._RunStream(tmp_path / "events.jsonl") for i in range(3): @@ -74,3 +82,60 @@ def test_get_events_endpoint_returns_events_after_id(tmp_path: Path, monkeypatch assert [e["id"] for e in body["events"]] == [2, 3] assert body["events"][0]["event"] == "e1" assert body["events"][1]["event"] == "e2" + + +def test_get_events_falls_back_to_fileset_when_local_missing(tmp_path: Path) -> None: + """When local events.jsonl is absent but run entity has events_fileset, download and serve.""" + events_file = tmp_path / "source" / "events.jsonl" + _write_events(events_file, [{"event": "run_started", "payload": {}}]) + + missing_path = tmp_path / "missing" / "events.jsonl" + + mock_sdk = MagicMock() + mock_run = MagicMock() + mock_run.events_fileset = "default/my-events-fs" + mock_sdk.entities.get_entity_by_name.return_value = mock_run + + def fake_download(sdk, ref, dest): + dest.mkdir(parents=True, exist_ok=True) + (dest / "events.jsonl").write_text(events_file.read_text()) + return dest + + with ( + patch.object(events, "hub", events.EventHub()), + patch("nemo_iron_swarm_plugin.api.v2.events._events_path", return_value=missing_path), + patch("nemo_iron_swarm_plugin.api.v2.events._get_sdk", return_value=mock_sdk), + patch("nemo_iron_swarm_plugin.api.v2.events.download_fileset", side_effect=fake_download), + ): + app = FastAPI() + app.include_router(events.router, prefix="/v2/workspaces/{workspace}") + client = TestClient(app) + resp = client.get("/v2/workspaces/default/runs/my-run/events?after=0") + + assert resp.status_code == 200 + data = resp.json() + assert len(data["events"]) == 1 + assert data["events"][0]["event"] == "run_started" + + +def test_get_events_returns_empty_when_no_local_and_no_fileset(tmp_path: Path) -> None: + """When local file is missing and no fileset ref exists, return empty list.""" + missing_path = tmp_path / "missing" / "events.jsonl" + + mock_sdk = MagicMock() + mock_run = MagicMock() + mock_run.events_fileset = "" + mock_sdk.entities.get_entity_by_name.return_value = mock_run + + with ( + patch.object(events, "hub", events.EventHub()), + patch("nemo_iron_swarm_plugin.api.v2.events._events_path", return_value=missing_path), + patch("nemo_iron_swarm_plugin.api.v2.events._get_sdk", return_value=mock_sdk), + ): + app = FastAPI() + app.include_router(events.router, prefix="/v2/workspaces/{workspace}") + client = TestClient(app) + resp = client.get("/v2/workspaces/default/runs/my-run/events?after=0") + + assert resp.status_code == 200 + assert resp.json() == {"events": []} From ce0d74bc8fc7bc935a754978aac1483d6b545100 Mon Sep 17 00:00:00 2001 From: Koral Chapnik Verbun Date: Tue, 21 Jul 2026 15:33:37 +0300 Subject: [PATCH 10/55] =?UTF-8?q?fix=20=5Fget=5Fsdk=20=E2=80=94=20use=20ge?= =?UTF-8?q?t=5Fplatform=5Fsdk=20not=20nonexistent=20get=5Fsdk?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Koral Chapnik Verbun --- .../src/nemo_iron_swarm_plugin/api/v2/events.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/plugins/nemo-iron-swarm/src/nemo_iron_swarm_plugin/api/v2/events.py b/plugins/nemo-iron-swarm/src/nemo_iron_swarm_plugin/api/v2/events.py index c41cd8d326..917f74f218 100644 --- a/plugins/nemo-iron-swarm/src/nemo_iron_swarm_plugin/api/v2/events.py +++ b/plugins/nemo-iron-swarm/src/nemo_iron_swarm_plugin/api/v2/events.py @@ -30,9 +30,9 @@ def _get_sdk() -> Any: - from nemo_platform_plugin.sdk import get_sdk + from nemo_platform_plugin.sdk_provider import get_platform_sdk - return get_sdk() + return get_platform_sdk(as_service="iron-swarm", internal=True) def _events_path(workspace: str, run_name: str) -> Path: From 61a6c7ef28f0bb4d44148ea3d4def724c8495aa9 Mon Sep 17 00:00:00 2001 From: Koral Chapnik Verbun Date: Tue, 21 Jul 2026 15:35:50 +0300 Subject: [PATCH 11/55] add exc_info=True to events fileset upload warning Signed-off-by: Koral Chapnik Verbun --- .../src/nemo_iron_swarm_plugin/jobs/artifacts.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/plugins/nemo-iron-swarm/src/nemo_iron_swarm_plugin/jobs/artifacts.py b/plugins/nemo-iron-swarm/src/nemo_iron_swarm_plugin/jobs/artifacts.py index ac3683dc70..0d16918985 100644 --- a/plugins/nemo-iron-swarm/src/nemo_iron_swarm_plugin/jobs/artifacts.py +++ b/plugins/nemo-iron-swarm/src/nemo_iron_swarm_plugin/jobs/artifacts.py @@ -120,7 +120,7 @@ def _save_events_fileset(sdk: Any, *, workspace: str, run_name: str) -> str: try: return upload_file_to_fileset(sdk, path, workspace=workspace) except Exception: - logger.warning("Failed to upload events.jsonl for run %r; history will not survive pod restart", run_name) + logger.warning("Failed to upload events.jsonl for run %r; history will not survive pod restart", run_name, exc_info=True) return "" From ba3608039821ac00276d430996593b9645f9d516 Mon Sep 17 00:00:00 2001 From: Koral Chapnik Verbun Date: Mon, 27 Jul 2026 21:16:42 +0300 Subject: [PATCH 12/55] add nemo-iron-swarm plugin Red-team and harden deployed NAT agents via iron-swarm, which runs in its own venv and is invoked by subprocess (never imported). Adds the CLI, v2 API, war-game and synth jobs, entity model, and unit tests. Signed-off-by: Koral Chapnik Verbun --- plugins/nemo-iron-swarm/.gitignore | 2 + plugins/nemo-iron-swarm/README.md | 292 ++ plugins/nemo-iron-swarm/openapi/openapi.yaml | 2983 +++++++++++++++++ plugins/nemo-iron-swarm/pyproject.toml | 64 + .../src/nemo_iron_swarm_plugin/_perms.py | 30 + .../nemo_iron_swarm_plugin/agent_resolver.py | 393 +++ .../nemo_iron_swarm_plugin/api/v2/_filters.py | 33 + .../nemo_iron_swarm_plugin/api/v2/events.py | 40 +- .../src/nemo_iron_swarm_plugin/api/v2/jobs.py | 78 + .../api/v2/manifests.py | 455 +++ .../src/nemo_iron_swarm_plugin/api/v2/runs.py | 207 ++ .../nemo_iron_swarm_plugin/api/v2/schemas.py | 203 ++ .../src/nemo_iron_swarm_plugin/authz.py | 14 + .../src/nemo_iron_swarm_plugin/cli/checks.py | 141 + .../src/nemo_iron_swarm_plugin/cli/client.py | 20 + .../nemo_iron_swarm_plugin/cli/credentials.py | 78 + .../src/nemo_iron_swarm_plugin/cli/main.py | 348 ++ .../cli/provisioning.py | 94 + .../src/nemo_iron_swarm_plugin/config.py | 202 ++ .../src/nemo_iron_swarm_plugin/entities.py | 30 + .../src/nemo_iron_swarm_plugin/filesets.py | 106 + .../nemo_iron_swarm_plugin/jobs/_common.py | 195 ++ .../nemo_iron_swarm_plugin/jobs/artifacts.py | 4 +- .../jobs/benign_suite.py | 53 + .../nemo_iron_swarm_plugin/jobs/defenses.py | 115 + .../src/nemo_iron_swarm_plugin/jobs/errors.py | 218 ++ .../nemo_iron_swarm_plugin/jobs/execution.py | 393 +++ .../src/nemo_iron_swarm_plugin/jobs/hitl.py | 119 + .../nemo_iron_swarm_plugin/jobs/manifest.py | 213 ++ .../nemo_iron_swarm_plugin/jobs/records.py | 39 + .../src/nemo_iron_swarm_plugin/jobs/run.py | 27 +- .../src/nemo_iron_swarm_plugin/jobs/spec.py | 51 + .../jobs/synth_benign.py | 224 ++ .../jobs/synth_client.py | 117 + .../nemo_iron_swarm_plugin/model_config.py | 75 + .../nemo_iron_swarm_plugin/model_preflight.py | 100 + .../src/nemo_iron_swarm_plugin/sdk.py | 288 ++ .../src/nemo_iron_swarm_plugin/service.py | 87 + .../src/nemo_iron_swarm_plugin/skills.py | 9 + .../skills/iron-swarm/SKILL.md | 62 + .../tasks/synth_benign/__main__.py | 43 + .../tasks/war_game/__main__.py | 42 + .../nemo-iron-swarm/tests/unit/_doubles.py | 62 + .../tests/unit/test_agent_resolver.py | 228 ++ .../tests/unit/test_api_manifests.py | 365 ++ .../tests/unit/test_api_runs.py | 135 + .../tests/unit/test_apply_mitigation.py | 110 + .../tests/unit/test_benign_suite.py | 36 + .../tests/unit/test_compose_defense.py | 106 + .../nemo-iron-swarm/tests/unit/test_errors.py | 168 + .../nemo-iron-swarm/tests/unit/test_events.py | 9 +- .../tests/unit/test_filesets.py | 90 + .../tests/unit/test_garak_provision.py | 118 + .../tests/unit/test_model_config.py | 89 + .../tests/unit/test_model_preflight.py | 100 + .../tests/unit/test_operator_env.py | 272 ++ .../tests/unit/test_preflight.py | 85 + .../tests/unit/test_run_cli.py | 143 + .../tests/unit/test_run_service.py | 736 ++++ .../tests/unit/test_sanity_check_cli.py | 155 + .../tests/unit/test_sdk_resources.py | 91 + .../tests/unit/test_service.py | 64 + .../tests/unit/test_synth_benign.py | 483 +++ .../tests/unit/test_synth_hitl.py | 90 + 64 files changed, 11999 insertions(+), 23 deletions(-) create mode 100644 plugins/nemo-iron-swarm/.gitignore create mode 100644 plugins/nemo-iron-swarm/README.md create mode 100644 plugins/nemo-iron-swarm/openapi/openapi.yaml create mode 100644 plugins/nemo-iron-swarm/pyproject.toml create mode 100644 plugins/nemo-iron-swarm/src/nemo_iron_swarm_plugin/_perms.py create mode 100644 plugins/nemo-iron-swarm/src/nemo_iron_swarm_plugin/agent_resolver.py create mode 100644 plugins/nemo-iron-swarm/src/nemo_iron_swarm_plugin/api/v2/_filters.py create mode 100644 plugins/nemo-iron-swarm/src/nemo_iron_swarm_plugin/api/v2/jobs.py create mode 100644 plugins/nemo-iron-swarm/src/nemo_iron_swarm_plugin/api/v2/manifests.py create mode 100644 plugins/nemo-iron-swarm/src/nemo_iron_swarm_plugin/api/v2/runs.py create mode 100644 plugins/nemo-iron-swarm/src/nemo_iron_swarm_plugin/api/v2/schemas.py create mode 100644 plugins/nemo-iron-swarm/src/nemo_iron_swarm_plugin/authz.py create mode 100644 plugins/nemo-iron-swarm/src/nemo_iron_swarm_plugin/cli/checks.py create mode 100644 plugins/nemo-iron-swarm/src/nemo_iron_swarm_plugin/cli/client.py create mode 100644 plugins/nemo-iron-swarm/src/nemo_iron_swarm_plugin/cli/credentials.py create mode 100644 plugins/nemo-iron-swarm/src/nemo_iron_swarm_plugin/cli/main.py create mode 100644 plugins/nemo-iron-swarm/src/nemo_iron_swarm_plugin/cli/provisioning.py create mode 100644 plugins/nemo-iron-swarm/src/nemo_iron_swarm_plugin/config.py create mode 100644 plugins/nemo-iron-swarm/src/nemo_iron_swarm_plugin/filesets.py create mode 100644 plugins/nemo-iron-swarm/src/nemo_iron_swarm_plugin/jobs/_common.py create mode 100644 plugins/nemo-iron-swarm/src/nemo_iron_swarm_plugin/jobs/benign_suite.py create mode 100644 plugins/nemo-iron-swarm/src/nemo_iron_swarm_plugin/jobs/defenses.py create mode 100644 plugins/nemo-iron-swarm/src/nemo_iron_swarm_plugin/jobs/errors.py create mode 100644 plugins/nemo-iron-swarm/src/nemo_iron_swarm_plugin/jobs/execution.py create mode 100644 plugins/nemo-iron-swarm/src/nemo_iron_swarm_plugin/jobs/hitl.py create mode 100644 plugins/nemo-iron-swarm/src/nemo_iron_swarm_plugin/jobs/manifest.py create mode 100644 plugins/nemo-iron-swarm/src/nemo_iron_swarm_plugin/jobs/spec.py create mode 100644 plugins/nemo-iron-swarm/src/nemo_iron_swarm_plugin/jobs/synth_benign.py create mode 100644 plugins/nemo-iron-swarm/src/nemo_iron_swarm_plugin/jobs/synth_client.py create mode 100644 plugins/nemo-iron-swarm/src/nemo_iron_swarm_plugin/model_config.py create mode 100644 plugins/nemo-iron-swarm/src/nemo_iron_swarm_plugin/model_preflight.py create mode 100644 plugins/nemo-iron-swarm/src/nemo_iron_swarm_plugin/sdk.py create mode 100644 plugins/nemo-iron-swarm/src/nemo_iron_swarm_plugin/service.py create mode 100644 plugins/nemo-iron-swarm/src/nemo_iron_swarm_plugin/skills.py create mode 100644 plugins/nemo-iron-swarm/src/nemo_iron_swarm_plugin/skills/iron-swarm/SKILL.md create mode 100644 plugins/nemo-iron-swarm/src/nemo_iron_swarm_plugin/tasks/synth_benign/__main__.py create mode 100644 plugins/nemo-iron-swarm/src/nemo_iron_swarm_plugin/tasks/war_game/__main__.py create mode 100644 plugins/nemo-iron-swarm/tests/unit/_doubles.py create mode 100644 plugins/nemo-iron-swarm/tests/unit/test_agent_resolver.py create mode 100644 plugins/nemo-iron-swarm/tests/unit/test_api_manifests.py create mode 100644 plugins/nemo-iron-swarm/tests/unit/test_api_runs.py create mode 100644 plugins/nemo-iron-swarm/tests/unit/test_apply_mitigation.py create mode 100644 plugins/nemo-iron-swarm/tests/unit/test_benign_suite.py create mode 100644 plugins/nemo-iron-swarm/tests/unit/test_compose_defense.py create mode 100644 plugins/nemo-iron-swarm/tests/unit/test_errors.py create mode 100644 plugins/nemo-iron-swarm/tests/unit/test_filesets.py create mode 100644 plugins/nemo-iron-swarm/tests/unit/test_garak_provision.py create mode 100644 plugins/nemo-iron-swarm/tests/unit/test_model_config.py create mode 100644 plugins/nemo-iron-swarm/tests/unit/test_model_preflight.py create mode 100644 plugins/nemo-iron-swarm/tests/unit/test_operator_env.py create mode 100644 plugins/nemo-iron-swarm/tests/unit/test_preflight.py create mode 100644 plugins/nemo-iron-swarm/tests/unit/test_run_cli.py create mode 100644 plugins/nemo-iron-swarm/tests/unit/test_run_service.py create mode 100644 plugins/nemo-iron-swarm/tests/unit/test_sanity_check_cli.py create mode 100644 plugins/nemo-iron-swarm/tests/unit/test_sdk_resources.py create mode 100644 plugins/nemo-iron-swarm/tests/unit/test_service.py create mode 100644 plugins/nemo-iron-swarm/tests/unit/test_synth_benign.py create mode 100644 plugins/nemo-iron-swarm/tests/unit/test_synth_hitl.py diff --git a/plugins/nemo-iron-swarm/.gitignore b/plugins/nemo-iron-swarm/.gitignore new file mode 100644 index 0000000000..ff0886fd85 --- /dev/null +++ b/plugins/nemo-iron-swarm/.gitignore @@ -0,0 +1,2 @@ +# Local developer helper scripts, not part of the plugin distribution +scripts/ diff --git a/plugins/nemo-iron-swarm/README.md b/plugins/nemo-iron-swarm/README.md new file mode 100644 index 0000000000..773c6b104b --- /dev/null +++ b/plugins/nemo-iron-swarm/README.md @@ -0,0 +1,292 @@ +# nemo-iron-swarm plugin + +Red-team and harden a **deployed NeMo Platform NAT agent** with Iron Swarm. The plugin resolves an +agent already registered in NeMo Platform into an Iron Swarm manifest, then runs the +attack → defend → validate war-game against it. + +iron-swarm (and garak) run in their own venvs and are invoked by subprocess — never imported — +because their pins (`litellm → httpx>=0.28`, `torch`) conflict with the platform's deps. + +--- + +## Step 0 — Get iron-swarm + +The plugin drives **iron-swarm**, a separate red-teaming tool that ships as its own package and runs +in its own venv (never imported — see the note above). iron-swarm is currently an NVIDIA-internal +package and is not yet published publicly; point the plugin at your iron-swarm checkout or package +via `NEMO_IRON_SWARM_IRON_SWARM_SPEC` (see Step 3). Everything else in this guide runs from this +nemo-platform repo. + +--- + +## Step 1 — System prerequisites + +Install these once on your machine. + +### Always needed + +```bash +# uv (Python package manager) +curl -LsSf https://astral.sh/uv/install.sh | sh + +# just (task runner used by iron-swarm) +brew install just # macOS +# or: cargo install just + +# git, curl, Python 3.11 — assumed present +``` + +### For the real war-game (Docker + OpenShell) + +```bash +# Docker — use Docker Desktop or Colima on macOS +brew install colima docker +colima start +``` + +**OpenShell — use the native installer, not `uv tool install`.** +`uv tool install openshell` installs the CLI only; it does not install or start the local gateway +service. The native installer installs both. + +```bash +curl -LsSf https://raw.githubusercontent.com/NVIDIA/OpenShell/main/install.sh | sh +openshell status # should say "Status: Connected" +``` + +On macOS, configure the Docker compute driver so sandboxes can reach the host: + +```bash +DOCKER_SOCK=$(docker context inspect --format '{{.Endpoints.docker.Host}}') +brew services stop openshell +launchctl setenv OPENSHELL_DRIVERS docker +launchctl setenv DOCKER_HOST "$DOCKER_SOCK" +brew services restart openshell +openshell status # should say "Status: Connected" +``` + +The `auto-defender` gateway registration is handled automatically by `nemo iron-swarm setup` (Step 4). + +Optional: `jq` for artifact inspection. + +### For the Studio UI only + +```bash +# Node 22 + pnpm +brew install node@22 pnpm +# or use nvm: nvm install 22 && nvm use 22 + +# Install the web dependencies (creates web/node_modules). +# open-studio.sh does this automatically on first run; run it manually to build the FastAPI assets too. +make bootstrap-studio +``` + +--- + +## Quickstart + +Already have Steps 0–1 done and the platform bootstrapped? Three env vars and you're off: + +```bash +export NMP_BASE_URL=http://localhost:8080 +export NEMO_IRON_SWARM_IRON_SWARM_SPEC=/path/to/iron-swarm # your local checkout +export INFERENCE_API_KEY= +export HTTPS=0 + +# Studio UI — easiest path (handles platform, venvs, provider, agent, browser): +cd nemo-platform +./plugins/nemo-iron-swarm/scripts/open-studio.sh [--agent ] + +# CLI war-game: +uv run nemo iron-swarm setup && uv run nemo iron-swarm doctor +uv run nemo iron-swarm init --agent +uv run nemo iron-swarm run --config iron-swarm.yaml +``` + +First time? Continue from Step 2 below. + +--- + +## Step 2 — Bootstrap the platform + +```bash +cd /path/to/nemo-platform +make bootstrap-python # creates .venv and runs uv sync --frozen --all-packages +``` + +--- + +## Step 3 — Set up your environment + +Set these in your shell (or add to `.zshrc` / `.envrc`): + +```bash +export NMP_BASE_URL=http://localhost:8080 +export NEMO_IRON_SWARM_IRON_SWARM_SPEC=/path/to/iron-swarm # until iron-swarm is on PyPI +export INFERENCE_API_KEY= +``` + +Create a `.env` file in your iron-swarm checkout with your inference key — the war-game subprocess reads it: + +```bash +echo "INFERENCE_API_KEY=sk-..." > /path/to/iron-swarm/.env +``` + +--- + +## Step 4 — Provision iron-swarm venvs + +```bash +uv run nemo iron-swarm setup # creates ~/.iron-swarm/venv (iron-swarm) and + # ~/.iron-swarm/garak-venv (garak attacker) +uv run nemo iron-swarm doctor # read-only preflight — expect all green before a real run +``` + +Neither command needs the platform running. + +--- + +## Step 5 — Start the platform + +```bash +# --controllers models,jobs is required: +# models → reconciler that discovers served models (without it providers show 0 models and 404) +# jobs → job executor that runs the war-game task +# --host 0.0.0.0 so the OpenShell sandbox can reach the Inference Gateway via host.docker.internal +uv run nemo services run --service-group all --controllers models,jobs \ + --host 0.0.0.0 --port 8080 & + +until curl -sf http://localhost:8080/health/ready >/dev/null; do sleep 2; done +echo "platform ready" +``` + +Create the inference provider (idempotent — safe to re-run): + +```bash +printf '%s' "$INFERENCE_API_KEY" | \ + uv run nemo secrets create nvidia-inference-key --from-file - --workspace default + +uv run nemo inference providers create nvidia-inference --workspace default \ + --host-url "https://inference-api.nvidia.com/v1" \ + --api-key-secret-name nvidia-inference-key +``` + +Wait a few seconds for the model controller to discover served models: + +```bash +uv run nemo inference providers get nvidia-inference --workspace default \ + --output-format json | jq '.served_models | length' # should be > 0 +``` + +--- + +## Run the CLI war-game + +```bash +# Register a NAT agent in the platform (skip if you already have one). +# Run from the nemo_platform repo root — agents/clockbot.yml is a relative path. +uv run nemo agents create --name clockbot --agent-config agents/clockbot.yml + +# Resolve it into iron-swarm.yaml + scaffold: +uv run nemo iron-swarm init --agent clockbot + +# Run the attack → defend → validate war-game: +uv run nemo iron-swarm run --config iron-swarm.yaml + +# Optionally pass a benign suite to check false positives: +uv run nemo iron-swarm run --config iron-swarm.yaml --benign-suite requests.csv + +# Show recent runs: +uv run nemo iron-swarm status +``` + +`init` only needs the agent **registered** (never invokes the model), so a config-only agent is +enough — no deploy, no active inference key. Add `--project-dir` only for agents with custom +components. + +--- + +## Open the Studio UI + +`scripts/open-studio.sh` handles everything from Step 4 onward automatically and opens the browser. + +```bash +export IRON_SWARM_REPO=/path/to/iron-swarm # where your local iron-swarm checkout lives +export INFERENCE_API_KEY= # read from $IRON_SWARM_REPO/.env if not set + +./plugins/nemo-iron-swarm/scripts/open-studio.sh [--agent ] +``` + +The `--agent` flag sets which agent to register and open in Studio (default: `clockbot`). It expects +`.yml` to exist next to the script (`plugins/nemo-iron-swarm/scripts/`); a `clockbot.yml` ships +there. Point elsewhere with `AGENT_CONFIG=/path/to/agent.yml`. Also settable via `AGENT=` env var. + +What the script does, in order: + +1. Reinstalls iron-swarm (editable) from `IRON_SWARM_REPO` into `~/.iron-swarm/venv` so local + code changes are picked up without a manual reinstall. +2. Starts the platform on `:8080` if it isn't already running. +3. Runs `nemo iron-swarm setup` (venvs + inference credential). +4. Creates the `nvidia-inference` provider (idempotent). +5. Re-creates the clockbot agent (`RESET_AGENT=1` by default, giving a clean slate each run). +6. Clears any leftover OpenShell sandboxes from a previous crashed run. +7. Installs `web/node_modules` if missing (first run only). +8. Starts the Studio dev server on `:5173` with `VITE_FF_IRON_SWARM_ENABLED=true`. +9. Opens your browser at `https://localhost:5173/workspaces/default/iron-swarm`. + +Key options (set as env vars before running): + +| Variable | Default | Effect | +|---|---|---| +| `HTTPS` | `1` | Set to `0` to skip mkcert / the sudo password prompt (plain http) | +| `RESET_AGENT` | `1` | Set to `0` to keep the existing agent registration | +| `REINSTALL_IRON_SWARM` | `1` | Set to `0` to skip the editable reinstall (faster) | +| `STUDIO_PORT` | `5173` | Vite dev server port | + +`Ctrl-C` stops Studio. The platform keeps running — stop it with `uv run nemo services stop`. + +--- + +## Environment variables reference + +| Variable | Default | Purpose | +|---|---|---| +| `NEMO_IRON_SWARM_IRON_SWARM_SPEC` | — | PyPI spec or local path used by `setup` to install iron-swarm | +| `NEMO_IRON_SWARM_VENV_PATH` | `~/.iron-swarm/venv` | iron-swarm venv location | +| `NEMO_IRON_SWARM_GARAK_VENV_PATH` | `~/.iron-swarm/garak-venv` | garak venv location | +| `NEMO_IRON_SWARM_DEFAULT_WORKSPACE` | `default` | Platform workspace used by CLI commands | +| `NEMO_IRON_SWARM_REQUIRE_SANDBOX` | `true` | Fail `run` if Docker/OpenShell are not ready | +| `NEMO_IRON_SWARM_OPERATOR_ENV_FILE` | `$IRON_SWARM_REPO/.env` | `.env` file the war-game subprocess reads | + +All can also be set via Helm `platformConfig.iron_swarm.*`. + +--- + +## Troubleshooting + +**OpenShell installed via `uv tool install` — gateway missing.** +`uv tool install openshell` only installs the CLI binary. Uninstall it (`uv tool uninstall openshell`) +and re-install with the native curl installer above. + +**`openshell status` says "connection refused" or "no compute driver" on macOS.** +The gateway is running but no compute driver is configured. Follow the Docker driver setup in +Step 1 above (`launchctl setenv OPENSHELL_DRIVERS docker …`). Once fixed, re-run +`nemo iron-swarm setup` — it re-registers the `auto-defender` gateway automatically. + +**Provider shows 0 served models / inference calls 404.** +The model controller isn't running. Make sure you started the platform with `--controllers models` +(and `--controllers jobs` for the job executor). + +**`INFERENCE_API_KEY` missing in the war-game subprocess.** +The subprocess reads `NEMO_IRON_SWARM_OPERATOR_ENV_FILE` (default: `$IRON_SWARM_REPO/.env`). +Confirm the file exists and contains `INFERENCE_API_KEY=...`. + +--- + +## Appendix: venvs and internals + +- `~/.iron-swarm/venv` — iron-swarm + deps, installed from `NEMO_IRON_SWARM_IRON_SWARM_SPEC`; + invoked as `bin/iron-swarm` by subprocess. +- `~/.iron-swarm/garak-venv` — garak's `agent_breaker` attacker, isolated because of + `litellm → httpx>=0.28` + `torch` conflicts. `setup` delegates to `iron-swarm setup`. +- The plugin itself never imports iron-swarm or garak — all communication is via subprocess + + filesystem artifacts (YAML manifests, JSON hitlogs, event logs). diff --git a/plugins/nemo-iron-swarm/openapi/openapi.yaml b/plugins/nemo-iron-swarm/openapi/openapi.yaml new file mode 100644 index 0000000000..7656a7f54a --- /dev/null +++ b/plugins/nemo-iron-swarm/openapi/openapi.yaml @@ -0,0 +1,2983 @@ +openapi: 3.1.0 +info: + title: iron-swarm (plugin) + version: 0.0.0 +paths: + /apis/iron-swarm/v1/healthz: + get: + tags: + - Iron Swarm Plugin + summary: Healthz + operationId: healthz_apis_iron_swarm_v1_healthz_get + responses: + '200': + description: Successful Response + content: + application/json: + schema: + additionalProperties: true + type: object + title: Response Healthz Apis Iron Swarm V1 Healthz Get + /apis/iron-swarm/v2/workspaces/{workspace}/jobs: + post: + tags: + - Iron Swarm Jobs + summary: Create Job + operationId: create_job_apis_iron_swarm_v2_workspaces__workspace__jobs_post + parameters: + - name: workspace + in: path + required: true + schema: + type: string + title: Workspace + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/WarGameJobRequest' + responses: + '201': + description: Successful Response + content: + application/json: + schema: + $ref: '#/components/schemas/WarGameJob' + '422': + description: Validation Error + content: + application/json: + schema: + $ref: '#/components/schemas/HTTPValidationError' + get: + tags: + - Iron Swarm Jobs + summary: List Jobs + operationId: list_jobs_apis_iron_swarm_v2_workspaces__workspace__jobs_get + parameters: + - name: workspace + in: path + required: true + schema: + type: string + title: Workspace + - name: page + in: query + required: false + schema: + type: integer + exclusiveMinimum: 0 + description: Page number. + default: 1 + title: Page + description: Page number. + - name: page_size + in: query + required: false + schema: + type: integer + exclusiveMinimum: 0 + description: Page size. + default: 10 + title: Page Size + description: Page size. + - name: sort + in: query + required: false + schema: + allOf: + - $ref: '#/components/schemas/WarGameJobsSortField' + description: The field to sort by. To sort in decreasing order, use `-` + in front of the field name. + default: -created_at + description: The field to sort by. To sort in decreasing order, use `-` in + front of the field name. + - in: query + name: filter + style: deepObject + required: false + explode: true + schema: + $ref: '#/components/schemas/WarGameJobsListFilter' + description: Filter jobs on various criteria. + responses: + '200': + description: Successful Response + content: + application/json: + schema: + $ref: '#/components/schemas/WarGameJobsPage' + '422': + description: Validation Error + content: + application/json: + schema: + $ref: '#/components/schemas/HTTPValidationError' + /apis/iron-swarm/v2/workspaces/{workspace}/jobs/{job}/results/{name}: + get: + tags: + - Iron Swarm Jobs + summary: Get Job Result + operationId: get_job_result_apis_iron_swarm_v2_workspaces__workspace__jobs__job__results__name__get + parameters: + - name: workspace + in: path + required: true + schema: + type: string + title: Workspace + - name: job + in: path + required: true + schema: + type: string + title: Job + - name: name + in: path + required: true + schema: + type: string + title: Name + responses: + '200': + description: Successful Response + content: + application/json: + schema: + $ref: '#/components/schemas/PlatformJobResultResponse' + '422': + description: Validation Error + content: + application/json: + schema: + $ref: '#/components/schemas/HTTPValidationError' + /apis/iron-swarm/v2/workspaces/{workspace}/jobs/{job}/results/{name}/download: + get: + tags: + - Iron Swarm Jobs + summary: Download Job Result + operationId: download_job_result_apis_iron_swarm_v2_workspaces__workspace__jobs__job__results__name__download_get + parameters: + - name: workspace + in: path + required: true + schema: + type: string + title: Workspace + - name: job + in: path + required: true + schema: + type: string + title: Job + - name: name + in: path + required: true + schema: + type: string + title: Name + responses: + '200': + description: Successful Response + content: + application/octet-stream: + schema: + type: string + format: binary + '404': + description: Not Found + '422': + description: Validation Error + content: + application/json: + schema: + $ref: '#/components/schemas/HTTPValidationError' + /apis/iron-swarm/v2/workspaces/{workspace}/jobs/{name}: + get: + tags: + - Iron Swarm Jobs + summary: Get Job + operationId: get_job_apis_iron_swarm_v2_workspaces__workspace__jobs__name__get + parameters: + - name: workspace + in: path + required: true + schema: + type: string + title: Workspace + - name: name + in: path + required: true + schema: + type: string + title: Name + responses: + '200': + description: Successful Response + content: + application/json: + schema: + $ref: '#/components/schemas/WarGameJob' + '422': + description: Validation Error + content: + application/json: + schema: + $ref: '#/components/schemas/HTTPValidationError' + delete: + tags: + - Iron Swarm Jobs + summary: Delete Job + operationId: delete_job_apis_iron_swarm_v2_workspaces__workspace__jobs__name__delete + parameters: + - name: workspace + in: path + required: true + schema: + type: string + title: Workspace + - name: name + in: path + required: true + schema: + type: string + title: Name + responses: + '204': + description: Successful Response + '422': + description: Validation Error + content: + application/json: + schema: + $ref: '#/components/schemas/HTTPValidationError' + /apis/iron-swarm/v2/workspaces/{workspace}/jobs/{name}/cancel: + post: + tags: + - Iron Swarm Jobs + summary: Cancel Job + operationId: cancel_job_apis_iron_swarm_v2_workspaces__workspace__jobs__name__cancel_post + parameters: + - name: workspace + in: path + required: true + schema: + type: string + title: Workspace + - name: name + in: path + required: true + schema: + type: string + title: Name + responses: + '200': + description: Successful Response + content: + application/json: + schema: + $ref: '#/components/schemas/WarGameJob' + '422': + description: Validation Error + content: + application/json: + schema: + $ref: '#/components/schemas/HTTPValidationError' + /apis/iron-swarm/v2/workspaces/{workspace}/jobs/{name}/logs: + get: + tags: + - Iron Swarm Jobs + summary: Get Job Logs + operationId: get_job_logs_apis_iron_swarm_v2_workspaces__workspace__jobs__name__logs_get + parameters: + - name: workspace + in: path + required: true + schema: + type: string + title: Workspace + - name: name + in: path + required: true + schema: + type: string + title: Name + - name: limit + in: query + required: false + schema: + title: Limit + type: integer + - name: page_cursor + in: query + required: false + schema: + title: Page Cursor + type: string + responses: + '200': + description: Successful Response + content: + application/json: + schema: + $ref: '#/components/schemas/PlatformJobLogPage' + '422': + description: Validation Error + content: + application/json: + schema: + $ref: '#/components/schemas/HTTPValidationError' + /apis/iron-swarm/v2/workspaces/{workspace}/jobs/{name}/results: + get: + tags: + - Iron Swarm Jobs + summary: List Job Results + operationId: list_job_results_apis_iron_swarm_v2_workspaces__workspace__jobs__name__results_get + parameters: + - name: workspace + in: path + required: true + schema: + type: string + title: Workspace + - name: name + in: path + required: true + schema: + type: string + title: Name + responses: + '200': + description: Successful Response + content: + application/json: + schema: + $ref: '#/components/schemas/PlatformJobListResultResponse' + '422': + description: Validation Error + content: + application/json: + schema: + $ref: '#/components/schemas/HTTPValidationError' + /apis/iron-swarm/v2/workspaces/{workspace}/jobs/{name}/status: + get: + tags: + - Iron Swarm Jobs + summary: Get Job Status + operationId: get_job_status_apis_iron_swarm_v2_workspaces__workspace__jobs__name__status_get + parameters: + - name: workspace + in: path + required: true + schema: + type: string + title: Workspace + - name: name + in: path + required: true + schema: + type: string + title: Name + responses: + '200': + description: Successful Response + content: + application/json: + schema: + $ref: '#/components/schemas/PlatformJobStatusResponse' + '422': + description: Validation Error + content: + application/json: + schema: + $ref: '#/components/schemas/HTTPValidationError' + /apis/iron-swarm/v2/workspaces/{workspace}/manifests: + get: + tags: + - Iron Swarm Manifests + summary: List Manifests + description: List saved manifests in the workspace, with pagination and an ``agent``/``source_type`` + filter. + operationId: list_manifests_apis_iron_swarm_v2_workspaces__workspace__manifests_get + parameters: + - name: workspace + in: path + required: true + schema: + type: string + title: Workspace + - name: page + in: query + required: false + schema: + type: integer + minimum: 1 + default: 1 + title: Page + - name: page_size + in: query + required: false + schema: + type: integer + maximum: 100 + minimum: 1 + default: 20 + title: Page Size + - name: sort + in: query + required: false + schema: + type: string + default: -created_at + title: Sort + - in: query + name: filter + style: deepObject + required: false + explode: true + schema: + $ref: '#/components/schemas/ManifestFilter' + description: 'Filter results on various criteria. Supports bracket notation + (?filter[field][$op]=value), JSON ({"field":{"$op":"value"}}), and text + syntax (field:"value"). Operators: $eq (exact match), $like (substring), + $lt, $lte, $gt, $gte (comparison), $in, $nin (set membership), $and, $or, + $not (logical). Default operator is $eq.' + responses: + '200': + description: Successful Response + content: + application/json: + schema: + type: object + additionalProperties: true + title: Response List Manifests Apis Iron Swarm V2 Workspaces Workspace Manifests + Get + '422': + description: Validation Error + content: + application/json: + schema: + $ref: '#/components/schemas/HTTPValidationError' + post: + tags: + - Iron Swarm Manifests + summary: Create Manifest + description: '`init`: build a manifest (from a deployed agent or an uploaded + project) and persist it by ``name``.' + operationId: create_manifest_apis_iron_swarm_v2_workspaces__workspace__manifests_post + parameters: + - name: workspace + in: path + required: true + schema: + type: string + title: Workspace + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/ManifestInit' + responses: + '201': + description: Successful Response + content: + application/json: + schema: + $ref: '#/components/schemas/IronSwarmManifest' + '422': + description: Validation Error + content: + application/json: + schema: + $ref: '#/components/schemas/HTTPValidationError' + /apis/iron-swarm/v2/workspaces/{workspace}/manifests/inspect: + post: + tags: + - Iron Swarm Manifests + summary: Inspect Project + description: "Detect an uploaded NAT project's layout (`iron-swarm inspect`)\ + \ to pre-fill the create wizard.\n\nDownloads the project bundle, expands\ + \ it, and runs the read-only, offline detector \u2014 no code is\nexecuted.\ + \ Returns the discovered workflows, launch mode, name, secrets, and egress\ + \ as defaults." + operationId: inspect_project_apis_iron_swarm_v2_workspaces__workspace__manifests_inspect_post + parameters: + - name: workspace + in: path + required: true + schema: + type: string + title: Workspace + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/InspectProjectRequest' + responses: + '200': + description: Successful Response + content: + application/json: + schema: + $ref: '#/components/schemas/InspectProjectResponse' + '422': + description: Validation Error + content: + application/json: + schema: + $ref: '#/components/schemas/HTTPValidationError' + /apis/iron-swarm/v2/workspaces/{workspace}/manifests/inspect-agent: + post: + tags: + - Iron Swarm Manifests + summary: Inspect Agent Endpoint + description: 'Derive the deployed-agent create-form defaults (victim port + + secret names) for pre-fill. + + + Read-only: fetches the stored agent config and its running deployment; nothing + is materialized.' + operationId: inspect_agent_endpoint_apis_iron_swarm_v2_workspaces__workspace__manifests_inspect_agent_post + parameters: + - name: workspace + in: path + required: true + schema: + type: string + title: Workspace + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/InspectAgentRequest' + responses: + '200': + description: Successful Response + content: + application/json: + schema: + $ref: '#/components/schemas/InspectAgentResponse' + '422': + description: Validation Error + content: + application/json: + schema: + $ref: '#/components/schemas/HTTPValidationError' + /apis/iron-swarm/v2/workspaces/{workspace}/manifests/{name}: + get: + tags: + - Iron Swarm Manifests + summary: Get Manifest + description: Get a single manifest by name. + operationId: get_manifest_apis_iron_swarm_v2_workspaces__workspace__manifests__name__get + parameters: + - name: workspace + in: path + required: true + schema: + type: string + title: Workspace + - name: name + in: path + required: true + schema: + type: string + title: Name + responses: + '200': + description: Successful Response + content: + application/json: + schema: + $ref: '#/components/schemas/IronSwarmManifest' + '422': + description: Validation Error + content: + application/json: + schema: + $ref: '#/components/schemas/HTTPValidationError' + patch: + tags: + - Iron Swarm Manifests + summary: Update Manifest + description: Edit a manifest's cached benign suite and/or victim port (the agent + source is immutable). + operationId: update_manifest_apis_iron_swarm_v2_workspaces__workspace__manifests__name__patch + parameters: + - name: workspace + in: path + required: true + schema: + type: string + title: Workspace + - name: name + in: path + required: true + schema: + type: string + title: Name + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/ManifestUpdate' + responses: + '200': + description: Successful Response + content: + application/json: + schema: + $ref: '#/components/schemas/IronSwarmManifest' + '422': + description: Validation Error + content: + application/json: + schema: + $ref: '#/components/schemas/HTTPValidationError' + delete: + tags: + - Iron Swarm Manifests + summary: Delete Manifest + description: Delete a saved manifest by name. + operationId: delete_manifest_apis_iron_swarm_v2_workspaces__workspace__manifests__name__delete + parameters: + - name: workspace + in: path + required: true + schema: + type: string + title: Workspace + - name: name + in: path + required: true + schema: + type: string + title: Name + responses: + '204': + description: Successful Response + '422': + description: Validation Error + content: + application/json: + schema: + $ref: '#/components/schemas/HTTPValidationError' + /apis/iron-swarm/v2/workspaces/{workspace}/model-config-defaults: + get: + tags: + - Iron Swarm Manifests + summary: Get Model Config Defaults + description: The built-in per-group model defaults (attack/analysis) the create/run + forms pre-fill. + operationId: get_model_config_defaults_apis_iron_swarm_v2_workspaces__workspace__model_config_defaults_get + parameters: + - name: workspace + in: path + required: true + schema: + type: string + title: Workspace + responses: + '200': + description: Successful Response + content: + application/json: + schema: + $ref: '#/components/schemas/ModelConfigDefaults' + '422': + description: Validation Error + content: + application/json: + schema: + $ref: '#/components/schemas/HTTPValidationError' + /apis/iron-swarm/v2/workspaces/{workspace}/model-config/validate: + post: + tags: + - Iron Swarm Manifests + summary: Validate Model Config + description: "Probe a model choice's endpoint/key (the \"Test connection\" affordance)\ + \ and list reachable models.\n\nResolves the chosen Secret to its value (if\ + \ any) and lists ``{base_url}/models``. Never leaks the key \u2014\nonly the\ + \ boolean verdict + the reachable model ids come back, so the UI can offer\ + \ real options." + operationId: validate_model_config_apis_iron_swarm_v2_workspaces__workspace__model_config_validate_post + parameters: + - name: workspace + in: path + required: true + schema: + type: string + title: Workspace + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/ValidateModelRequest' + responses: + '200': + description: Successful Response + content: + application/json: + schema: + $ref: '#/components/schemas/ValidateModelResponse' + '422': + description: Validation Error + content: + application/json: + schema: + $ref: '#/components/schemas/HTTPValidationError' + /apis/iron-swarm/v2/workspaces/{workspace}/runs: + get: + tags: + - Iron Swarm Runs + summary: List Runs + description: List war-game runs in the workspace, with pagination and an ``agent``/``status`` + filter. + operationId: list_runs_apis_iron_swarm_v2_workspaces__workspace__runs_get + parameters: + - name: workspace + in: path + required: true + schema: + type: string + title: Workspace + - name: page + in: query + required: false + schema: + type: integer + minimum: 1 + default: 1 + title: Page + - name: page_size + in: query + required: false + schema: + type: integer + maximum: 100 + minimum: 1 + default: 20 + title: Page Size + - name: sort + in: query + required: false + schema: + type: string + default: -created_at + title: Sort + - in: query + name: filter + style: deepObject + required: false + explode: true + schema: + $ref: '#/components/schemas/RunFilter' + description: 'Filter results on various criteria. Supports bracket notation + (?filter[field][$op]=value), JSON ({"field":{"$op":"value"}}), and text + syntax (field:"value"). Operators: $eq (exact match), $like (substring), + $lt, $lte, $gt, $gte (comparison), $in, $nin (set membership), $and, $or, + $not (logical). Default operator is $eq.' + responses: + '200': + description: Successful Response + content: + application/json: + schema: + type: object + additionalProperties: true + title: Response List Runs Apis Iron Swarm V2 Workspaces Workspace Runs + Get + '422': + description: Validation Error + content: + application/json: + schema: + $ref: '#/components/schemas/HTTPValidationError' + /apis/iron-swarm/v2/workspaces/{workspace}/runs/{name}: + get: + tags: + - Iron Swarm Runs + summary: Get Run + description: Get a single war-game run by name. + operationId: get_run_apis_iron_swarm_v2_workspaces__workspace__runs__name__get + parameters: + - name: workspace + in: path + required: true + schema: + type: string + title: Workspace + - name: name + in: path + required: true + schema: + type: string + title: Name + responses: + '200': + description: Successful Response + content: + application/json: + schema: + $ref: '#/components/schemas/IronSwarmRun' + '422': + description: Validation Error + content: + application/json: + schema: + $ref: '#/components/schemas/HTTPValidationError' + delete: + tags: + - Iron Swarm Runs + summary: Delete Run + description: Delete a war-game run record. The underlying platform job is cancelled/deleted + separately. + operationId: delete_run_apis_iron_swarm_v2_workspaces__workspace__runs__name__delete + parameters: + - name: workspace + in: path + required: true + schema: + type: string + title: Workspace + - name: name + in: path + required: true + schema: + type: string + title: Name + responses: + '204': + description: Successful Response + '422': + description: Validation Error + content: + application/json: + schema: + $ref: '#/components/schemas/HTTPValidationError' + /apis/iron-swarm/v2/workspaces/{workspace}/runs/{name}/apply-mitigation: + post: + tags: + - Iron Swarm Runs + summary: Apply Mitigation + description: 'Adopt a run''s hardened workflow: write it onto the run''s target + agent config (no redeploy). + + + Reverses the Inference-Gateway injection so the stored config stays deployment-neutral, + then updates + + the ``Agent`` entity in place. The user must redeploy the agent for the guardrails + to take effect.' + operationId: apply_mitigation_apis_iron_swarm_v2_workspaces__workspace__runs__name__apply_mitigation_post + parameters: + - name: workspace + in: path + required: true + schema: + type: string + title: Workspace + - name: name + in: path + required: true + schema: + type: string + title: Name + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/ApplyMitigationRequest' + responses: + '200': + description: Successful Response + content: + application/json: + schema: + $ref: '#/components/schemas/ApplyMitigationResponse' + '422': + description: Validation Error + content: + application/json: + schema: + $ref: '#/components/schemas/HTTPValidationError' + /apis/iron-swarm/v2/workspaces/{workspace}/runs/{name}/compose-defense: + post: + tags: + - Iron Swarm Runs + summary: Compose Defense Route + description: 'Compose a chosen subset of a run''s recommended defenses into + deployable workflow + policy YAML. + + + Keeps only the selected guardrails in the workflow and picks the hardened-vs-baseline + policy. Powers + + the harden flow''s live preview and feeds the composed YAMLs to a sanity-check + (validate-only) run.' + operationId: compose_defense_route_apis_iron_swarm_v2_workspaces__workspace__runs__name__compose_defense_post + parameters: + - name: workspace + in: path + required: true + schema: + type: string + title: Workspace + - name: name + in: path + required: true + schema: + type: string + title: Name + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/ComposeDefenseRequest' + responses: + '200': + description: Successful Response + content: + application/json: + schema: + $ref: '#/components/schemas/ComposeDefenseResponse' + '422': + description: Validation Error + content: + application/json: + schema: + $ref: '#/components/schemas/HTTPValidationError' + /apis/iron-swarm/v2/workspaces/{workspace}/runs/{name}/events: + post: + tags: + - Iron Swarm Events + summary: Ingest Event + description: Ingest one run event (the run's EventBus POSTs here). + operationId: ingest_event_apis_iron_swarm_v2_workspaces__workspace__runs__name__events_post + parameters: + - name: workspace + in: path + required: true + schema: + type: string + title: Workspace + - name: name + in: path + required: true + schema: + type: string + title: Name + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/EventIn' + responses: + '204': + description: Successful Response + '422': + description: Validation Error + content: + application/json: + schema: + $ref: '#/components/schemas/HTTPValidationError' + get: + tags: + - Iron Swarm Events + summary: Get Events + description: 'Return all persisted run events with sequence id greater than + *after*. + + + Falls back to downloading from the run''s ``events_fileset`` when the local + + file is absent (e.g. after a pod restart).' + operationId: get_events_apis_iron_swarm_v2_workspaces__workspace__runs__name__events_get + parameters: + - name: workspace + in: path + required: true + schema: + type: string + title: Workspace + - name: name + in: path + required: true + schema: + type: string + title: Name + - name: after + in: query + required: false + schema: + type: integer + default: 0 + title: After + responses: + '200': + description: Successful Response + content: + application/json: + schema: + $ref: '#/components/schemas/EventsResponse' + '422': + description: Validation Error + content: + application/json: + schema: + $ref: '#/components/schemas/HTTPValidationError' + /apis/iron-swarm/v2/workspaces/{workspace}/synth-benign/jobs: + post: + tags: + - Iron Swarm Synth Jobs + summary: Create Job + operationId: create_job_apis_iron_swarm_v2_workspaces__workspace__synth_benign_jobs_post + parameters: + - name: workspace + in: path + required: true + schema: + type: string + title: Workspace + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/SynthBenignJobRequest' + responses: + '201': + description: Successful Response + content: + application/json: + schema: + $ref: '#/components/schemas/SynthBenignJob' + '422': + description: Validation Error + content: + application/json: + schema: + $ref: '#/components/schemas/HTTPValidationError' + get: + tags: + - Iron Swarm Synth Jobs + summary: List Jobs + operationId: list_jobs_apis_iron_swarm_v2_workspaces__workspace__synth_benign_jobs_get + parameters: + - name: workspace + in: path + required: true + schema: + type: string + title: Workspace + - name: page + in: query + required: false + schema: + type: integer + exclusiveMinimum: 0 + description: Page number. + default: 1 + title: Page + description: Page number. + - name: page_size + in: query + required: false + schema: + type: integer + exclusiveMinimum: 0 + description: Page size. + default: 10 + title: Page Size + description: Page size. + - name: sort + in: query + required: false + schema: + allOf: + - $ref: '#/components/schemas/SynthBenignJobsSortField' + description: The field to sort by. To sort in decreasing order, use `-` + in front of the field name. + default: -created_at + description: The field to sort by. To sort in decreasing order, use `-` in + front of the field name. + - in: query + name: filter + style: deepObject + required: false + explode: true + schema: + $ref: '#/components/schemas/SynthBenignJobsListFilter' + description: Filter jobs on various criteria. + responses: + '200': + description: Successful Response + content: + application/json: + schema: + $ref: '#/components/schemas/SynthBenignJobsPage' + '422': + description: Validation Error + content: + application/json: + schema: + $ref: '#/components/schemas/HTTPValidationError' + /apis/iron-swarm/v2/workspaces/{workspace}/synth-benign/jobs/{job}/results/{name}: + get: + tags: + - Iron Swarm Synth Jobs + summary: Get Job Result + operationId: get_job_result_apis_iron_swarm_v2_workspaces__workspace__synth_benign_jobs__job__results__name__get + parameters: + - name: workspace + in: path + required: true + schema: + type: string + title: Workspace + - name: job + in: path + required: true + schema: + type: string + title: Job + - name: name + in: path + required: true + schema: + type: string + title: Name + responses: + '200': + description: Successful Response + content: + application/json: + schema: + $ref: '#/components/schemas/PlatformJobResultResponse' + '422': + description: Validation Error + content: + application/json: + schema: + $ref: '#/components/schemas/HTTPValidationError' + /apis/iron-swarm/v2/workspaces/{workspace}/synth-benign/jobs/{job}/results/{name}/download: + get: + tags: + - Iron Swarm Synth Jobs + summary: Download Job Result + operationId: download_job_result_apis_iron_swarm_v2_workspaces__workspace__synth_benign_jobs__job__results__name__download_get + parameters: + - name: workspace + in: path + required: true + schema: + type: string + title: Workspace + - name: job + in: path + required: true + schema: + type: string + title: Job + - name: name + in: path + required: true + schema: + type: string + title: Name + responses: + '200': + description: Successful Response + content: + application/octet-stream: + schema: + type: string + format: binary + '404': + description: Not Found + '422': + description: Validation Error + content: + application/json: + schema: + $ref: '#/components/schemas/HTTPValidationError' + /apis/iron-swarm/v2/workspaces/{workspace}/synth-benign/jobs/{name}: + get: + tags: + - Iron Swarm Synth Jobs + summary: Get Job + operationId: get_job_apis_iron_swarm_v2_workspaces__workspace__synth_benign_jobs__name__get + parameters: + - name: workspace + in: path + required: true + schema: + type: string + title: Workspace + - name: name + in: path + required: true + schema: + type: string + title: Name + responses: + '200': + description: Successful Response + content: + application/json: + schema: + $ref: '#/components/schemas/SynthBenignJob' + '422': + description: Validation Error + content: + application/json: + schema: + $ref: '#/components/schemas/HTTPValidationError' + delete: + tags: + - Iron Swarm Synth Jobs + summary: Delete Job + operationId: delete_job_apis_iron_swarm_v2_workspaces__workspace__synth_benign_jobs__name__delete + parameters: + - name: workspace + in: path + required: true + schema: + type: string + title: Workspace + - name: name + in: path + required: true + schema: + type: string + title: Name + responses: + '204': + description: Successful Response + '422': + description: Validation Error + content: + application/json: + schema: + $ref: '#/components/schemas/HTTPValidationError' + /apis/iron-swarm/v2/workspaces/{workspace}/synth-benign/jobs/{name}/cancel: + post: + tags: + - Iron Swarm Synth Jobs + summary: Cancel Job + operationId: cancel_job_apis_iron_swarm_v2_workspaces__workspace__synth_benign_jobs__name__cancel_post + parameters: + - name: workspace + in: path + required: true + schema: + type: string + title: Workspace + - name: name + in: path + required: true + schema: + type: string + title: Name + responses: + '200': + description: Successful Response + content: + application/json: + schema: + $ref: '#/components/schemas/SynthBenignJob' + '422': + description: Validation Error + content: + application/json: + schema: + $ref: '#/components/schemas/HTTPValidationError' + /apis/iron-swarm/v2/workspaces/{workspace}/synth-benign/jobs/{name}/logs: + get: + tags: + - Iron Swarm Synth Jobs + summary: Get Job Logs + operationId: get_job_logs_apis_iron_swarm_v2_workspaces__workspace__synth_benign_jobs__name__logs_get + parameters: + - name: workspace + in: path + required: true + schema: + type: string + title: Workspace + - name: name + in: path + required: true + schema: + type: string + title: Name + - name: limit + in: query + required: false + schema: + title: Limit + type: integer + - name: page_cursor + in: query + required: false + schema: + title: Page Cursor + type: string + responses: + '200': + description: Successful Response + content: + application/json: + schema: + $ref: '#/components/schemas/PlatformJobLogPage' + '422': + description: Validation Error + content: + application/json: + schema: + $ref: '#/components/schemas/HTTPValidationError' + /apis/iron-swarm/v2/workspaces/{workspace}/synth-benign/jobs/{name}/results: + get: + tags: + - Iron Swarm Synth Jobs + summary: List Job Results + operationId: list_job_results_apis_iron_swarm_v2_workspaces__workspace__synth_benign_jobs__name__results_get + parameters: + - name: workspace + in: path + required: true + schema: + type: string + title: Workspace + - name: name + in: path + required: true + schema: + type: string + title: Name + responses: + '200': + description: Successful Response + content: + application/json: + schema: + $ref: '#/components/schemas/PlatformJobListResultResponse' + '422': + description: Validation Error + content: + application/json: + schema: + $ref: '#/components/schemas/HTTPValidationError' + /apis/iron-swarm/v2/workspaces/{workspace}/synth-benign/jobs/{name}/status: + get: + tags: + - Iron Swarm Synth Jobs + summary: Get Job Status + operationId: get_job_status_apis_iron_swarm_v2_workspaces__workspace__synth_benign_jobs__name__status_get + parameters: + - name: workspace + in: path + required: true + schema: + type: string + title: Workspace + - name: name + in: path + required: true + schema: + type: string + title: Name + responses: + '200': + description: Successful Response + content: + application/json: + schema: + $ref: '#/components/schemas/PlatformJobStatusResponse' + '422': + description: Validation Error + content: + application/json: + schema: + $ref: '#/components/schemas/HTTPValidationError' +components: + schemas: + ApplyMitigationRequest: + properties: + workflow_yaml: + type: string + title: Workflow Yaml + description: Hardened NAT workflow YAML (the mitigations 'after' document). + type: object + required: + - workflow_yaml + title: ApplyMitigationRequest + description: "Body for ``POST /v2/workspaces/{workspace}/runs/{name}/apply-mitigation``\ + \ \u2014 adopt the hardened workflow.\n\nThe client passes the hardened workflow\ + \ YAML from the run's mitigations artifact. The endpoint reverses\nthe Inference-Gateway\ + \ injection and writes it onto the run's target agent config (no redeploy)." + ApplyMitigationResponse: + properties: + applied: + type: boolean + title: Applied + description: True when the agent config was updated. + agent: + type: string + title: Agent + description: Name of the agent whose config was updated. + detail: + type: string + title: Detail + description: Human-readable note (e.g. a reminder to redeploy). + type: object + required: + - applied + - agent + - detail + title: ApplyMitigationResponse + description: Result of applying a hardened workflow to an agent. + ComposeDefenseRequest: + properties: + mitigations: + additionalProperties: true + type: object + title: Mitigations + description: The run's mitigations artifact (its 'defenses'/workflow/policy). + selected_defense_ids: + items: + type: string + type: array + title: Selected Defense Ids + description: Ids of the defenses to keep (guardrail ids and/or 'openshell_policy'). + type: object + required: + - mitigations + title: ComposeDefenseRequest + description: "Body for ``POST /v2/workspaces/{workspace}/runs/{name}/compose-defense``\ + \ \u2014 build a chosen defense subset.\n\nThe client passes the run's ``mitigations``\ + \ artifact (which it already fetched for the recommendations\nview) plus the\ + \ ids of the defenses to keep. The endpoint composes the workflow with only\ + \ the selected\nguardrails and picks the hardened-vs-baseline policy, for\ + \ live preview and to feed a sanity-check run." + ComposeDefenseResponse: + properties: + workflow_yaml: + title: Workflow Yaml + description: Workflow with only the selected guardrails, or null. + type: string + policy_yaml: + title: Policy Yaml + description: Hardened policy if selected, else the baseline, or null. + type: string + type: object + title: ComposeDefenseResponse + description: The composed workflow + policy for the selected defenses. + DatetimeFilter: + additionalProperties: false + properties: + $gte: + description: Filter for results greater than or equal to this datetime. + title: $Gte + format: date-time + type: string + $lte: + description: Filter for results less than or equal to this datetime. + title: $Lte + format: date-time + type: string + title: DatetimeFilter + type: object + EventIn: + properties: + event: + type: string + title: Event + payload: + additionalProperties: true + type: object + title: Payload + default: {} + type: object + required: + - event + title: EventIn + description: "Body for ``POST /runs/{name}/events`` \u2014 one event emitted\ + \ by the run's EventBus." + EventsResponse: + properties: + events: + items: + additionalProperties: true + type: object + type: array + title: Events + type: object + required: + - events + title: EventsResponse + description: "Response for GET /runs/{name}/events \u2014 events after the given\ + \ sequence id." + FileStorageType: + type: string + enum: + - fileset + title: FileStorageType + HTTPValidationError: + properties: + detail: + items: + $ref: '#/components/schemas/ValidationError' + type: array + title: Detail + type: object + title: HTTPValidationError + InspectAgentRequest: + properties: + agent: + type: string + title: Agent + description: Deployed agent reference (``workspace/name`` or ``name``). + type: object + required: + - agent + title: InspectAgentRequest + description: "Body for ``POST /v2/workspaces/{workspace}/manifests/inspect-agent``\ + \ \u2014 a deployed agent ref." + InspectAgentResponse: + properties: + agent: + type: string + title: Agent + description: Resolved ``workspace/name`` of the agent. + port: + type: integer + title: Port + description: Victim port derived from the running deployment (else the default). + secrets: + items: + type: string + type: array + title: Secrets + description: Secret names derived from the agent config. + warnings: + items: + type: string + type: array + title: Warnings + description: Non-fatal notes (e.g. no running deployment). + type: object + required: + - agent + - port + title: InspectAgentResponse + description: Auto-derived defaults for the deployed-agent create form (port + + secret names, editable). + InspectProjectRequest: + properties: + project_fileset: + type: string + title: Project Fileset + description: Fileset ref of the uploaded NAT project bundle to inspect. + type: object + required: + - project_fileset + title: InspectProjectRequest + description: "Body for ``POST /v2/workspaces/{workspace}/manifests/inspect``\ + \ \u2014 detect an uploaded project." + InspectProjectResponse: + properties: + project_dir: + type: string + title: Project Dir + description: Detected installable project root (relative to the bundle). + default: '' + workflows: + items: + type: string + type: array + title: Workflows + description: Discovered workflow paths (project-relative). + dockerfiles: + items: + type: string + type: array + title: Dockerfiles + description: Discovered Dockerfile paths (project-relative). + suggested_launch_mode: + type: string + title: Suggested Launch Mode + description: '''workflow'' or ''byo''.' + default: workflow + default_agent_name: + type: string + title: Default Agent Name + description: Suggested agent name. + default: '' + default_port: + type: integer + title: Default Port + description: Suggested victim port. + default: 8000 + secrets_file: + type: string + title: Secrets File + description: Detected dotenv path (project-relative), or empty. + default: '' + secret_names: + items: + type: string + type: array + title: Secret Names + description: Secret names found in the dotenv file. + egress: + items: + type: string + type: array + title: Egress + description: External hosts the agent reaches (allow-list). + backend_ports: + items: + type: integer + type: array + title: Backend Ports + description: Local host-backend ports detected in the workflow (localhost:PORT + the tools call). + type: object + title: InspectProjectResponse + description: Detection facts + defaults for the upload wizard (the parsed ``iron-swarm + inspect --json`` output). + IronSwarmManifest: + properties: + name: + type: string + title: Name + description: Entity name within the workspace + default: '' + workspace: + type: string + pattern: ^[\w\-\+.@:]+$ + title: Workspace + description: Workspace identifier + project: + title: Project + description: The name of the project associated with this entity. + type: string + agent: + type: string + title: Agent + description: Deployed agent reference (workspace/name) this manifest targets. + default: '' + source_type: + type: string + enum: + - agent + - project + title: Source Type + description: How the manifest was built ('agent'|'project'). + default: agent + project_fileset: + type: string + title: Project Fileset + description: Fileset ref holding the uploaded NAT project bundle (source_type + 'project'); the run re-downloads it to a project_dir before launching + the victim. + default: '' + workflow: + type: string + title: Workflow + description: Chosen workflow path within the project (project source, display). + default: '' + launch_mode: + type: string + title: Launch Mode + description: Victim launch mode ('workflow'|'byo'; project source). + default: '' + manifest_yaml: + type: string + title: Manifest Yaml + description: The resolved iron-swarm.yaml content (for display). + default: '' + port: + type: integer + title: Port + description: Victim port the war-game will target. + default: 0 + secrets: + items: + type: string + type: array + title: Secrets + description: Secret names the victim agent requires. + warnings: + items: + type: string + type: array + title: Warnings + description: Non-fatal notes from scaffolding. + benign_suite: + items: + additionalProperties: + type: string + type: object + type: array + title: Benign Suite + description: Cached, reviewed benign test suite (tool,payload,label,rationale,persona + rows); generated on the first run and reused/edited thereafter. Empty + until generated. + benign_interview: + items: + additionalProperties: + type: string + type: object + type: array + title: Benign Interview + description: Interview Q&A (gap,question,answer rows) captured during the + last benign-suite generation, kept for display. Empty until generated. + defenders: + items: + type: string + type: array + title: Defenders + description: Enabled defender keys ('guardrails','openshell'); empty means + iron-swarm's defaults (all applicable). Materialized into the manifest's + overrides.defenders at run time. + attack_intensity: + type: string + enum: + - light + - standard + - thorough + title: Attack Intensity + description: Attacker (garak) effort preset, materialized into the manifest's + garak block at run time. + default: standard + rounds: + type: integer + minimum: 1.0 + title: Rounds + description: Number of iterative attack/defend/validate hardening rounds; + passed to iron-swarm's `run --rounds` at run time. + default: 1 + models: + allOf: + - $ref: '#/components/schemas/WarGameModels' + description: Stored default model selection (attack/analysis/agent groups); + an unset group uses iron-swarm's built-in default. A run may override + these per-launch. + id: + type: string + title: Id + readOnly: true + created_at: + title: Created At + readOnly: true + type: string + format: date-time + created_by: + title: Created By + readOnly: true + nullable: true + type: string + updated_at: + title: Updated At + readOnly: true + type: string + format: date-time + updated_by: + title: Updated By + readOnly: true + nullable: true + type: string + entity_id: + type: string + title: Entity Id + description: Alias for id for backwards compatibility. + readOnly: true + parent: + title: Parent + description: Parent entity ID for nested entities. + readOnly: true + type: string + type: object + required: + - workspace + - id + - created_at + - created_by + - updated_at + - updated_by + - entity_id + - parent + title: IronSwarmManifest + description: "A named, reusable war-game target scaffolded via `init` (its ``name``\ + \ is the user-defined id).\n\nTwo sources: ``agent`` re-materializes the manifest\ + \ from a deployed agent ref (no bundle persisted);\n``project`` war-games\ + \ an uploaded NAT project \u2014 its files are stored as ``project_fileset``\ + \ and the\nrun re-downloads them so custom-tool agents (unregistrable as config-only\ + \ agents) can be targeted." + IronSwarmRun: + properties: + name: + type: string + title: Name + description: Entity name within the workspace + default: '' + workspace: + type: string + pattern: ^[\w\-\+.@:]+$ + title: Workspace + description: Workspace identifier + project: + title: Project + description: The name of the project associated with this entity. + type: string + agent: + type: string + title: Agent + description: Targeted agent reference (workspace/name). + default: '' + job_id: + type: string + title: Job Id + description: Platform job that drove this run (for live status/HITL). + default: '' + port: + type: integer + title: Port + description: Victim port the war-game attacked. + default: 0 + manifest: + type: string + title: Manifest + description: Path to the iron-swarm.yaml manifest used. + default: '' + manifest_id: + type: string + title: Manifest Id + description: Manifest this run belongs to (scopes 'replay last run'). + default: '' + status: + type: string + enum: + - running + - completed + - failed + title: Status + description: Final run status. + default: failed + returncode: + type: integer + title: Returncode + description: Exit code from `iron-swarm run`. + default: -1 + summary: + type: string + title: Summary + description: Short human-readable outcome summary. + default: '' + error_category: + type: string + title: Error Category + description: Classified failure category when status is 'failed' (e.g. sandbox, + missing_credential, manifest, network); empty for a successful run. + default: '' + error_message: + type: string + title: Error Message + description: Operator-facing failure message when the run failed. + default: '' + error_remediation: + type: string + title: Error Remediation + description: Suggested next step to resolve the failure; empty for a successful + run. + default: '' + hitlog_fileset: + type: string + title: Hitlog Fileset + description: Fileset ref of the garak hitlog this run produced, if any; + replay a later run from it. + default: '' + events_fileset: + type: string + title: Events Fileset + description: Fileset ref of the run's events.jsonl, uploaded at completion + for durable history. + default: '' + source_run: + type: string + title: Source Run + description: For a validate-only sanity-check run, the name of the harden + run it was launched from; lets the Harden tab re-attach the scorecard + on reload. Empty for normal war-game runs. + default: '' + id: + type: string + title: Id + readOnly: true + created_at: + title: Created At + readOnly: true + type: string + format: date-time + created_by: + title: Created By + readOnly: true + nullable: true + type: string + updated_at: + title: Updated At + readOnly: true + type: string + format: date-time + updated_by: + title: Updated By + readOnly: true + nullable: true + type: string + entity_id: + type: string + title: Entity Id + description: Alias for id for backwards compatibility. + readOnly: true + parent: + title: Parent + description: Parent entity ID for nested entities. + readOnly: true + type: string + type: object + required: + - workspace + - id + - created_at + - created_by + - updated_at + - updated_by + - entity_id + - parent + title: IronSwarmRun + description: A record of one Iron Swarm war-game run. + ManifestFilter: + additionalProperties: false + description: Query filter for ``GET /v2/workspaces/{workspace}/manifests``. + properties: + agent: + description: Filter to manifests for this agent reference. + title: Agent + type: string + source_type: + description: Filter by source ('agent' or 'project'). + title: Source Type + type: string + title: ManifestFilter + type: object + ManifestInit: + properties: + name: + type: string + title: Name + description: User-defined manifest id (unique within the workspace). + source_type: + type: string + enum: + - agent + - project + title: Source Type + description: Scaffold source ('agent' or 'project'). + default: agent + agent: + title: Agent + description: Agent reference (required when source_type='agent'). + type: string + project_fileset: + title: Project Fileset + description: Fileset ref of the uploaded NAT project bundle. + type: string + workflow: + title: Workflow + description: Chosen workflow path within the project (project-relative). + type: string + launch_mode: + title: Launch Mode + description: Victim launch mode ('workflow'; BYO is Phase 2). + type: string + port: + title: Port + description: Victim port (defaults to 8000). + type: integer + secrets: + title: Secrets + description: Secret names the victim requires. + items: + type: string + type: array + secrets_file: + title: Secrets File + description: Dotenv path within the project holding the secrets. + type: string + egress: + title: Egress + description: Allow-listed egress host[:port] entries the victim may reach + (external hosts the agent calls, e.g. inference-api.nvidia.com); baked + into the manifest by `init --egress`. + items: + type: string + type: array + backends: + title: Backends + description: Route-only host backends the agent's tools call, each 'NAME:PORT[,PORT2]' + (e.g. 'finance:8086'). Rewrites the agent's localhost:PORT to host.docker.internal:PORT + and opens the sandbox->host route; passed to `init --backend`. + items: + type: string + type: array + models: + allOf: + - $ref: '#/components/schemas/WarGameModels' + description: Stored default model selection (attack/analysis/agent groups); + omit to use iron-swarm's built-in defaults. + type: object + required: + - name + title: ManifestInit + description: "Body for ``POST /v2/workspaces/{workspace}/manifests`` \u2014\ + \ scaffold a named manifest.\n\n``agent`` resolves a deployed agent; ``project``\ + \ builds the manifest from an uploaded NAT project\n(``project_fileset`` +\ + \ the confirmed detection answers) by shelling ``iron-swarm init --yes``." + ManifestUpdate: + properties: + benign_suite: + title: Benign Suite + description: Replace the cached benign suite (tool,payload,label,rationale,persona + rows). + items: + additionalProperties: + type: string + type: object + type: array + port: + title: Port + description: Victim port the war-game will target. + type: integer + defenders: + title: Defenders + description: Enabled defender keys ('guardrails','openshell'); empty means + iron-swarm defaults. + items: + type: string + type: array + attack_intensity: + title: Attack Intensity + description: Attacker (garak) effort preset. + type: string + enum: + - light + - standard + - thorough + rounds: + title: Rounds + description: Number of iterative hardening rounds (iron-swarm `run --rounds`). + type: integer + minimum: 1.0 + models: + allOf: + - $ref: '#/components/schemas/WarGameModels' + description: Replace the stored default model selection (attack/analysis/agent + groups). + type: object + title: ManifestUpdate + description: "Body for ``PATCH /v2/workspaces/{workspace}/manifests/{name}``\ + \ \u2014 edit an existing manifest.\n\nOnly editable fields; omitted fields\ + \ are left unchanged. The agent source is immutable (delete +\nrecreate to\ + \ retarget)." + ModelChoice: + properties: + model: + title: Model + description: Model name/URN; null uses the group default. + type: string + base_url: + title: Base Url + description: Custom OpenAI-compatible endpoint; null uses the default. + type: string + api_key_secret: + title: Api Key Secret + description: Name of a NeMo Secret holding the provider API key for a custom + endpoint; null uses the platform's provisioned iron-swarm inference key. + type: string + type: object + title: ModelChoice + description: "One group's model selection. Every field is optional; ``None``\ + \ \u2192 the group's built-in default." + ModelConfigDefaults: + properties: + attack: + $ref: '#/components/schemas/ModelGroupDefault' + analysis: + $ref: '#/components/schemas/ModelGroupDefault' + type: object + required: + - attack + - analysis + title: ModelConfigDefaults + description: Defaults surfaced to the UI so pickers pre-fill without hardcoding + iron-swarm's literals. + ModelGroupDefault: + properties: + model: + type: string + title: Model + base_url: + type: string + title: Base Url + type: object + required: + - model + - base_url + title: ModelGroupDefault + description: The default model + endpoint the UI shows for one group. + PaginationData: + properties: + page: + type: integer + title: Page + description: The current page number. + page_size: + type: integer + title: Page Size + description: The page size used for the query. + current_page_size: + type: integer + title: Current Page Size + description: The size for the current page. + total_pages: + type: integer + title: Total Pages + description: The total number of pages. + total_results: + type: integer + title: Total Results + description: The total number of results. + type: object + required: + - page + - page_size + - current_page_size + - total_pages + - total_results + title: PaginationData + PlatformJobListResultResponse: + properties: + data: + items: + $ref: '#/components/schemas/PlatformJobResultResponse' + type: array + title: Data + type: object + required: + - data + title: PlatformJobListResultResponse + PlatformJobLog: + properties: + timestamp: + type: string + format: date-time + title: Timestamp + job: + type: string + title: Job + job_step: + type: string + title: Job Step + job_task: + type: string + title: Job Task + message: + type: string + title: Message + type: object + required: + - timestamp + - job + - job_step + - job_task + - message + title: PlatformJobLog + PlatformJobLogPage: + properties: + data: + items: + $ref: '#/components/schemas/PlatformJobLog' + type: array + title: Data + total: + type: integer + title: Total + next_page: + title: Next Page + type: string + prev_page: + title: Prev Page + type: string + type: object + required: + - data + - total + - next_page + - prev_page + title: PlatformJobLogPage + PlatformJobResultResponse: + properties: + name: + type: string + title: Name + job: + type: string + title: Job + workspace: + type: string + title: Workspace + project: + title: Project + type: string + created_at: + type: string + format: date-time + title: Created At + updated_at: + type: string + format: date-time + title: Updated At + artifact_url: + type: string + title: Artifact Url + artifact_storage_type: + $ref: '#/components/schemas/FileStorageType' + download_url: + title: Download Url + type: string + type: object + required: + - name + - job + - workspace + - artifact_url + - artifact_storage_type + title: PlatformJobResultResponse + PlatformJobStatus: + type: string + enum: + - created + - pending + - active + - cancelled + - cancelling + - error + - completed + - paused + - pausing + - resuming + title: PlatformJobStatus + description: 'Enumeration of possible job statuses. + + + This enum represents the various states a job can be in during its lifecycle, + + from creation to a terminal state.' + PlatformJobStatusResponse: + properties: + id: + type: string + title: Id + name: + type: string + title: Name + status: + $ref: '#/components/schemas/PlatformJobStatus' + status_details: + additionalProperties: true + type: object + title: Status Details + error_details: + title: Error Details + additionalProperties: true + type: object + steps: + items: + $ref: '#/components/schemas/PlatformJobStepStatusResponse' + type: array + title: Steps + created_at: + type: string + format: date-time + title: Created At + updated_at: + type: string + format: date-time + title: Updated At + type: object + required: + - id + - name + - status + - status_details + - error_details + - steps + - created_at + - updated_at + title: PlatformJobStatusResponse + PlatformJobStepStatusResponse: + properties: + id: + type: string + title: Id + name: + type: string + title: Name + status: + $ref: '#/components/schemas/PlatformJobStatus' + status_details: + additionalProperties: true + type: object + title: Status Details + error_details: + title: Error Details + additionalProperties: true + type: object + tasks: + items: + $ref: '#/components/schemas/PlatformJobTaskStatusResponse' + type: array + title: Tasks + created_at: + type: string + format: date-time + title: Created At + updated_at: + type: string + format: date-time + title: Updated At + type: object + required: + - id + - name + - status + - status_details + - error_details + - tasks + - created_at + - updated_at + title: PlatformJobStepStatusResponse + PlatformJobTaskStatusResponse: + properties: + id: + type: string + title: Id + name: + type: string + title: Name + status: + $ref: '#/components/schemas/PlatformJobStatus' + status_details: + additionalProperties: true + type: object + title: Status Details + error_details: + title: Error Details + additionalProperties: true + type: object + error_stack: + title: Error Stack + type: string + created_at: + type: string + format: date-time + title: Created At + updated_at: + type: string + format: date-time + title: Updated At + type: object + required: + - id + - name + - status + - status_details + - error_details + - error_stack + - created_at + - updated_at + title: PlatformJobTaskStatusResponse + RunFilter: + additionalProperties: false + description: Query filter for ``GET /v2/workspaces/{workspace}/runs``. + properties: + agent: + description: Filter to runs targeting this agent reference (workspace/name). + title: Agent + type: string + manifest_id: + description: Filter to runs launched from this manifest (scopes 'replay + last run'). + title: Manifest Id + type: string + status: + description: Filter to runs with this status ('running', 'completed', or + 'failed'). + title: Status + type: string + title: RunFilter + type: object + StringFilter: + additionalProperties: false + properties: + $eq: + description: Filter for results equal to this value. + title: $Eq + type: string + $like: + description: Filter for results matching this pattern. + title: $Like + type: string + $in: + description: Filter for results in this list of values. + title: $In + items: + type: string + type: array + $nin: + description: Filter for results not in this list of values. + title: $Nin + items: + type: string + type: array + title: StringFilter + type: object + SynthBenignJob: + properties: + id: + title: Id + type: string + name: + type: string + title: Name + description: + title: Description + type: string + project: + title: Project + type: string + workspace: + title: Workspace + type: string + created_at: + title: Created At + type: string + format: date-time + updated_at: + title: Updated At + type: string + format: date-time + spec: + $ref: '#/components/schemas/SynthBenignSpec' + status: + $ref: '#/components/schemas/PlatformJobStatus' + status_details: + title: Status Details + additionalProperties: true + type: object + error_details: + title: Error Details + additionalProperties: true + type: object + ownership: + title: Ownership + additionalProperties: true + type: object + custom_fields: + title: Custom Fields + additionalProperties: true + type: object + type: object + required: + - name + - spec + title: SynthBenignJob + SynthBenignJobRequest: + properties: + name: + title: Name + type: string + description: + title: Description + type: string + project: + title: Project + type: string + spec: + $ref: '#/components/schemas/SynthBenignSpec' + ownership: + title: Ownership + additionalProperties: true + type: object + custom_fields: + title: Custom Fields + additionalProperties: true + type: object + type: object + required: + - spec + title: SynthBenignJobRequest + SynthBenignJobsListFilter: + additionalProperties: false + properties: + created_at: + allOf: + - $ref: '#/components/schemas/DatetimeFilter' + description: Jobs created at 'gte' datetime or 'lte' datetime. + name: + anyOf: + - $ref: '#/components/schemas/StringFilter' + - type: string + description: Name of the job. + title: Name + workspace: + description: Workspace of the job. + title: Workspace + type: string + project: + description: Project containing the job. + title: Project + type: string + status: + allOf: + - $ref: '#/components/schemas/PlatformJobStatus' + description: The current status. + updated_at: + allOf: + - $ref: '#/components/schemas/DatetimeFilter' + description: Jobs updated at 'gte' datetime or 'lte' datetime. + title: SynthBenignJobsListFilter + type: object + SynthBenignJobsPage: + properties: + data: + items: + $ref: '#/components/schemas/SynthBenignJob' + type: array + title: Data + pagination: + allOf: + - $ref: '#/components/schemas/PaginationData' + description: Pagination information. + sort: + title: Sort + description: The field on which the results are sorted. + type: string + filter: + title: Filter + description: Filtering information. + additionalProperties: true + type: object + type: object + required: + - data + title: SynthBenignJobsPage + SynthBenignJobsSortField: + type: string + enum: + - created_at + - -created_at + - updated_at + - -updated_at + title: SynthBenignJobsSortField + SynthBenignSpec: + properties: + manifest_id: + type: string + title: Manifest Id + driver: + type: string + title: Driver + default: native + env_file: + title: Env File + type: string + interview: + type: string + title: Interview + default: interactive + run_name: + title: Run Name + type: string + source_run: + title: Source Run + type: string + type: object + required: + - manifest_id + title: SynthBenignSpec + description: Inputs for the benign-suite synthesis phase (the shape ``run()``/``compile()`` + see). + ValidateModelRequest: + properties: + model: + title: Model + description: Model name to verify against the endpoint's model list. + type: string + base_url: + type: string + title: Base Url + description: OpenAI-compatible endpoint to probe (`GET {base_url}/models`). + api_key_secret: + title: Api Key Secret + description: Secret name holding the provider key; omitted probes without + auth. + type: string + type: object + required: + - base_url + title: ValidateModelRequest + description: "Body for ``POST /v2/workspaces/{workspace}/model-config/validate``\ + \ \u2014 probe a model choice." + ValidateModelResponse: + properties: + ok: + type: boolean + title: Ok + description: True when the endpoint is reachable, authorized, and serves + the model. + reason: + type: string + title: Reason + description: ''''' | ''auth'' | ''unreachable'' | ''unknown_model''.' + default: '' + available: + items: + type: string + type: array + title: Available + description: Model ids the credentials can reach. + detail: + type: string + title: Detail + description: Human-readable diagnostic (status code / transport error). + default: '' + type: object + required: + - ok + title: ValidateModelResponse + description: Verdict for a model choice; ``available`` lists what the credentials + can reach so the UI offers real options. + ValidationError: + properties: + loc: + items: + anyOf: + - type: string + - type: integer + type: array + title: Location + msg: + type: string + title: Message + type: + type: string + title: Error Type + input: + title: Input + ctx: + type: object + title: Context + additionalProperties: true + type: object + required: + - loc + - msg + - type + title: ValidationError + WarGameJob: + properties: + id: + title: Id + type: string + name: + type: string + title: Name + description: + title: Description + type: string + project: + title: Project + type: string + workspace: + title: Workspace + type: string + created_at: + title: Created At + type: string + format: date-time + updated_at: + title: Updated At + type: string + format: date-time + spec: + $ref: '#/components/schemas/WarGameSpecOutput' + status: + $ref: '#/components/schemas/PlatformJobStatus' + status_details: + title: Status Details + additionalProperties: true + type: object + error_details: + title: Error Details + additionalProperties: true + type: object + ownership: + title: Ownership + additionalProperties: true + type: object + custom_fields: + title: Custom Fields + additionalProperties: true + type: object + type: object + required: + - name + - spec + title: WarGameJob + WarGameJobRequest: + properties: + name: + title: Name + type: string + description: + title: Description + type: string + project: + title: Project + type: string + spec: + $ref: '#/components/schemas/WarGameSpecInput' + ownership: + title: Ownership + additionalProperties: true + type: object + custom_fields: + title: Custom Fields + additionalProperties: true + type: object + type: object + required: + - spec + title: WarGameJobRequest + WarGameJobsListFilter: + additionalProperties: false + properties: + created_at: + allOf: + - $ref: '#/components/schemas/DatetimeFilter' + description: Jobs created at 'gte' datetime or 'lte' datetime. + name: + anyOf: + - $ref: '#/components/schemas/StringFilter' + - type: string + description: Name of the job. + title: Name + workspace: + description: Workspace of the job. + title: Workspace + type: string + project: + description: Project containing the job. + title: Project + type: string + status: + allOf: + - $ref: '#/components/schemas/PlatformJobStatus' + description: The current status. + updated_at: + allOf: + - $ref: '#/components/schemas/DatetimeFilter' + description: Jobs updated at 'gte' datetime or 'lte' datetime. + title: WarGameJobsListFilter + type: object + WarGameJobsPage: + properties: + data: + items: + $ref: '#/components/schemas/WarGameJob' + type: array + title: Data + pagination: + allOf: + - $ref: '#/components/schemas/PaginationData' + description: Pagination information. + sort: + title: Sort + description: The field on which the results are sorted. + type: string + filter: + title: Filter + description: Filtering information. + additionalProperties: true + type: object + type: object + required: + - data + title: WarGameJobsPage + WarGameJobsSortField: + type: string + enum: + - created_at + - -created_at + - updated_at + - -updated_at + title: WarGameJobsSortField + WarGameModels: + properties: + attack: + allOf: + - $ref: '#/components/schemas/ModelChoice' + description: garak red-team + detector model. + analysis: + allOf: + - $ref: '#/components/schemas/ModelChoice' + description: Defenders + benign validator (synth suite-generation + judge) + model. + agent: + allOf: + - $ref: '#/components/schemas/ModelChoice' + description: Victim agent LLM override (model only). + type: object + title: WarGameModels + description: The three model groups for a war-game. An unset group uses iron-swarm's + built-in default. + WarGameSpecInput: + properties: + config: + title: Config + type: string + manifest_id: + title: Manifest Id + type: string + env_file: + title: Env File + type: string + driver: + title: Driver + type: string + stop_after_synth: + type: boolean + title: Stop After Synth + default: false + replay_hitlog_fileset: + title: Replay Hitlog Fileset + type: string + benign_suite_fileset: + title: Benign Suite Fileset + type: string + port: + title: Port + type: integer + defenders: + title: Defenders + items: + type: string + type: array + attack_intensity: + title: Attack Intensity + type: string + rounds: + title: Rounds + type: integer + validate_only: + type: boolean + title: Validate Only + default: false + defense_workflow: + title: Defense Workflow + type: string + defense_policy: + title: Defense Policy + type: string + models: + $ref: '#/components/schemas/WarGameModels' + source_run: + title: Source Run + type: string + type: object + title: WarGameSpecInput + description: "Canonical war-game inputs \u2014 the shape ``run()`` and ``compile()``\ + \ see.\n\nSupply either a saved ``manifest_id`` (the Studio path \u2014 materialized\ + \ on the host from the stored\nagent ref) or a ready ``config`` manifest path\ + \ (the CLI path)." + WarGameSpecOutput: + properties: + config: + title: Config + type: string + manifest_id: + title: Manifest Id + type: string + env_file: + title: Env File + type: string + driver: + title: Driver + type: string + stop_after_synth: + type: boolean + title: Stop After Synth + default: false + replay_hitlog_fileset: + title: Replay Hitlog Fileset + type: string + benign_suite_fileset: + title: Benign Suite Fileset + type: string + port: + title: Port + type: integer + defenders: + title: Defenders + items: + type: string + type: array + attack_intensity: + title: Attack Intensity + type: string + rounds: + title: Rounds + type: integer + validate_only: + type: boolean + title: Validate Only + default: false + defense_workflow: + title: Defense Workflow + type: string + defense_policy: + title: Defense Policy + type: string + models: + $ref: '#/components/schemas/WarGameModels' + source_run: + title: Source Run + type: string + type: object + title: WarGameSpecOutput + description: "Canonical war-game inputs \u2014 the shape ``run()`` and ``compile()``\ + \ see.\n\nSupply either a saved ``manifest_id`` (the Studio path \u2014 materialized\ + \ on the host from the stored\nagent ref) or a ready ``config`` manifest path\ + \ (the CLI path)." diff --git a/plugins/nemo-iron-swarm/pyproject.toml b/plugins/nemo-iron-swarm/pyproject.toml new file mode 100644 index 0000000000..6fe2550f1c --- /dev/null +++ b/plugins/nemo-iron-swarm/pyproject.toml @@ -0,0 +1,64 @@ +[project] +name = "nemo-iron-swarm-plugin" +version = "0.1.0" +description = "Iron Swarm plugin for NeMo Platform — red-team and harden deployed NAT agents." +requires-python = ">=3.11,<3.15" +# NOTE: iron-swarm is intentionally NOT a dependency here. It pins garak==0.15.1 and +# requires-python <3.13, and only resolves with a root-level httpx override — embedding it +# would constrain the whole monorepo. Instead it is installed into its own venv by +# `nemo iron-swarm setup` and invoked by subprocess. See the plan/memory for the spike verdict. +dependencies = [ + "nemo-platform-plugin", + "nemo-platform", + # For the Agent entity, so a run's hardened workflow can be adopted into the agent config + # (POST /runs/{name}/apply-mitigation). Same-process entity-store update — no cross-service call. + "nemo-agents-plugin", + "pydantic>=2.10.6", + "pyyaml>=6.0.2", + "typer>=0.20.0", + "httpx>=0.27", +] + +[project.entry-points."nemo.services"] +iron-swarm = "nemo_iron_swarm_plugin.service:IronSwarmPluginService" + +[project.entry-points."nemo.cli"] +iron-swarm = "nemo_iron_swarm_plugin.cli.main:IronSwarmCLI" + +[project.entry-points."nemo.jobs"] +# Named "war-game" so the auto job group doesn't shadow the hand-written `iron-swarm run --config`. +"iron-swarm.war-game" = "nemo_iron_swarm_plugin.jobs.run:IronSwarmRunJob" +# Named "synth" so the auto job group doesn't shadow the hand-written `iron-swarm synth-benign`. +"iron-swarm.synth" = "nemo_iron_swarm_plugin.jobs.synth_benign:IronSwarmSynthBenignJob" + +[project.entry-points."nemo.sdk"] +# Underscore: the nemo.sdk key is the client attribute name (`client.iron_swarm`); a hyphen never matches. +iron_swarm = "nemo_iron_swarm_plugin.sdk:iron_swarm_sdk_resources" + +[project.entry-points."nemo.skills"] +iron-swarm = "nemo_iron_swarm_plugin.skills:get_skills_path" + +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[tool.hatch.build.targets.wheel] +packages = ["src/nemo_iron_swarm_plugin"] + +[dependency-groups] +dev = [ + "pytest>=8.3.4", + "pytest-asyncio>=0.25.3", + "ruff>=0.11.8", +] + +[tool.pytest.ini_options] +asyncio_mode = "auto" +pythonpath = ["src"] +testpaths = ["tests"] + +[tool.pyright] +extraPaths = ["src"] + +# Opt this plugin into OpenAPI spec generation +[tool.nemo.openapi] diff --git a/plugins/nemo-iron-swarm/src/nemo_iron_swarm_plugin/_perms.py b/plugins/nemo-iron-swarm/src/nemo_iron_swarm_plugin/_perms.py new file mode 100644 index 0000000000..f03699fcb3 --- /dev/null +++ b/plugins/nemo-iron-swarm/src/nemo_iron_swarm_plugin/_perms.py @@ -0,0 +1,30 @@ +# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +"""Typed permission vocabulary for the Iron Swarm plugin routes. + +Each ``perm(...)`` member mints a :class:`Permission` whose id is ``.`` +(or ``.`` when given). Referenced from ``@path_rule(permissions=[...])`` on the +route handlers — never as bare strings. +""" + +from __future__ import annotations + +from nemo_platform_plugin.authz import PermissionSet, perm + + +class IronSwarmRunPerms(PermissionSet, namespace="iron-swarm.runs"): + LIST = perm("List Iron Swarm runs") + READ = perm("Read an Iron Swarm run") + DELETE = perm("Delete an Iron Swarm run record") + APPLY = perm("Apply a run's hardened workflow to its agent") + COMPOSE = perm("Compose a chosen subset of a run's recommended defenses") + EVENTS_READ = perm("Stream an Iron Swarm run's live events", suffix="events.read") + EVENTS_WRITE = perm("Ingest an Iron Swarm run's live events", suffix="events.write") + + +class IronSwarmManifestPerms(PermissionSet, namespace="iron-swarm.manifests"): + LIST = perm("List Iron Swarm manifests") + READ = perm("Read an Iron Swarm manifest") + WRITE = perm("Create, update, or delete an Iron Swarm manifest") + INSPECT = perm("Inspect projects/agents and validate model config for the create wizard") diff --git a/plugins/nemo-iron-swarm/src/nemo_iron_swarm_plugin/agent_resolver.py b/plugins/nemo-iron-swarm/src/nemo_iron_swarm_plugin/agent_resolver.py new file mode 100644 index 0000000000..2d5f86f1f0 --- /dev/null +++ b/plugins/nemo-iron-swarm/src/nemo_iron_swarm_plugin/agent_resolver.py @@ -0,0 +1,393 @@ +# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +"""Resolve a deployed NeMo Platform agent into an Iron Swarm manifest. + +This is the centerpiece that makes ``nemo iron-swarm init`` trivial: instead of pointing +iron-swarm at a NAT project and answering discovery prompts, the user names an agent already +deployed in NeMo Platform and we derive the manifest from the agent registry. + +Design constraints (both deliberate): + +- **No ``iron_swarm`` import.** iron-swarm runs from its own venv, driven by subprocess: its + garak-based attacker pulls a dependency closure (``litellm``/``torch``) that conflicts with the + platform's, so it stays out of our lockfile. We therefore build the manifest *dict* matching + iron-swarm's ``AgentManifest``/``AgentSpec`` schema and let ``iron-swarm run`` validate it. The + schema authority is ``iron_swarm/manifest.py`` (``AgentSpec`` fields: name, project_dir, + workflow, port, secrets, secrets_file, egress). +- **Read the agent over HTTP.** We fetch it via the platform SDK (``client.agents.get`` / + ``client.agents.deployments.list``), which returns plain dicts, so resolution needs no + ``nemo_agents_plugin`` entity classes. The ~10-line IGW injection below is a local copy of + ``nemo_agents_plugin.utils.inject_gateway_url``. That copy originally existed to avoid a + cross-plugin dependency; that rationale is obsolete (``nemo-agents-plugin`` is now a declared + dependency and ``api/v2/runs.py`` imports ``Agent`` from it), so the copy is free to drift from + upstream — it has already grown a ``model_override`` parameter the original lacks. + +Models resolve through the Inference Gateway (the platform standard): the victim workflow's +OpenAI/NIM LLMs get the IGW ``base_url`` injected, so no raw model keys are needed. +""" + +from __future__ import annotations + +import copy +import logging +from dataclasses import dataclass, field +from pathlib import Path +from typing import Any +from urllib.parse import urlsplit + +import yaml + +logger = logging.getLogger(__name__) + +# Scaffold dir (relative to the manifest location) holding the materialized workflow + project. +SCAFFOLD_ROOT = ".iron-swarm-agents" +WORKFLOW_FILENAME = "workflow.yaml" +# NAT LLM _types whose base_url should point at the Inference Gateway. +_IGW_LLM_TYPES = frozenset({"openai", "nim"}) + + +class AgentResolutionError(Exception): + """Raised when an agent reference cannot be resolved into a usable manifest.""" + + +@dataclass +class ResolvedManifest: + """Result of resolving an agent reference into an Iron Swarm manifest.""" + + manifest: dict[str, Any] + workflow_path: Path + project_dir: Path + workspace: str + agent_name: str + port: int + secrets: list[str] + warnings: list[str] = field(default_factory=list) + + +# --------------------------------------------------------------------------- # +# Pure helpers (unit-testable without a live platform) +# --------------------------------------------------------------------------- # +def parse_agent_ref(ref: str, default_workspace: str) -> tuple[str, str]: + """Split an agent reference into ``(workspace, name)``. + + Accepts ``"name"`` or ``"workspace/name"``. A URL (anything containing ``"://"``) is + rejected — ``init --agent`` targets a platform-managed agent, not an arbitrary endpoint. + """ + if "://" in ref: + raise AgentResolutionError(f"--agent expects a deployed agent name or workspace/name, not a URL: {ref!r}") + ref = ref.strip().strip("/") + if not ref: + raise AgentResolutionError("agent reference is empty") + if "/" in ref: + workspace, name = ref.split("/", 1) + return workspace or default_workspace, name + return default_workspace, ref + + +def inject_gateway_url( + config: dict[str, Any], workspace: str, base_url: str, model_override: str | None = None +) -> dict[str, Any]: + """Deep-copy *config* and point OpenAI/NIM LLMs at the Inference Gateway. + + A local copy of ``nemo_agents_plugin.utils.inject_gateway_url`` (see the module docstring — the + copy predates the plugin taking a dependency on ``nemo-agents-plugin``). Uses ``setdefault`` so + explicit values in the config are preserved. When ``model_override`` is set (the user's "agent" + model choice), it *replaces* the model on every openai/nim LLM — including one that kept its own + explicit ``base_url`` and so is not actually gateway-bound. + """ + base = base_url.rstrip("/") + gateway_url = f"{base}/apis/inference-gateway/v2/workspaces/{workspace}/openai/-/v1" + config = copy.deepcopy(config) + for llm_cfg in config.get("llms", {}).values(): + if isinstance(llm_cfg, dict) and llm_cfg.get("_type") in _IGW_LLM_TYPES: + llm_cfg.setdefault("base_url", gateway_url) + llm_cfg.setdefault("api_key", "not-used") + if model_override: + llm_cfg["model"] = model_override + return config + + +def strip_gateway_url(config: dict[str, Any]) -> dict[str, Any]: + """Reverse :func:`inject_gateway_url`: drop the Inference-Gateway ``base_url``/``api_key`` we injected. + + The hardened workflow iron-swarm hands back is gateway-bound (that is how the sandboxed victim reached + the IGW). Before writing it onto the stored agent config we undo that binding, so the agent stays + deployment-neutral — its next deploy re-injects the gateway. Only the values we add are removed: + an IGW ``base_url`` and the ``"not-used"`` placeholder ``api_key``; anything the author set stays. + """ + config = copy.deepcopy(config) + for llm_cfg in config.get("llms", {}).values(): + if not isinstance(llm_cfg, dict) or llm_cfg.get("_type") not in _IGW_LLM_TYPES: + continue + base_url = llm_cfg.get("base_url") + if isinstance(base_url, str) and "/apis/inference-gateway/" in base_url: + llm_cfg.pop("base_url", None) + if llm_cfg.get("api_key") == "not-used": + llm_cfg.pop("api_key", None) + return config + + +def detect_custom_components(agent_config: dict[str, Any]) -> list[str]: + """Return ``_type`` values that look like custom (non-packaged) NAT components. + + Heuristic: a ``_type`` containing a dot or colon (e.g. ``my_pkg.tools:search``) signals a + user-defined component whose source is not in the agent's stored config — so the OpenShell + generic victim build needs the real project. Returns an empty list for config-only agents. + """ + custom: list[str] = [] + entries: list[Any] = [] + # functions/tools are mappings of named component dicts; workflow is a single component dict. + for section in ("functions", "tools"): + block = agent_config.get(section) + if isinstance(block, dict): + entries.extend(block.values()) + workflow = agent_config.get("workflow") + if isinstance(workflow, dict): + entries.append(workflow) + for entry in entries: + if isinstance(entry, dict): + type_name = entry.get("_type", "") + if isinstance(type_name, str) and ("." in type_name or ":" in type_name): + custom.append(type_name) + return sorted(set(custom)) + + +def derive_secret_names(agent_config: dict[str, Any], extra: list[str] | None = None) -> list[str]: + """Collect env-var secret names the victim build needs (non-model creds). + + Scans the config for ``${ENV_VAR}`` references and obvious ``*_token`` / ``*_api_key`` keys. + Model credentials are intentionally excluded — those resolve through the IGW. When the scan finds + nothing at all, falls back to ``["INFERENCE_API_KEY"]``; note this is a fallback, not an addition — + a config that declares its own secrets returns only those. + """ + found: set[str] = set(extra or []) + + def walk(node: Any) -> None: + if isinstance(node, dict): + for key, value in node.items(): + if isinstance(value, str) and value.startswith("${") and value.endswith("}"): + found.add(value[2:-1]) + if isinstance(key, str) and key.lower().endswith(("_token", "_api_key")) and isinstance(value, str): + # value like "GITHUB_TOKEN" or "${GITHUB_TOKEN}" + candidate = value.strip("${}") + if candidate.isupper(): + found.add(candidate) + walk(value) + elif isinstance(node, list): + for item in node: + walk(item) + + walk(agent_config) + names = sorted(found) or ["INFERENCE_API_KEY"] + return names + + +def gateway_backend(base_url: str) -> dict[str, Any] | None: + """Route-only backend so the sandboxed victim can reach a *local* Inference Gateway. + + iron-swarm then rewrites ``localhost:`` -> ``host.docker.internal:`` and opens the + egress route. Remote gateways are reachable directly (via egress discovery), so skip them. + """ + parts = urlsplit(base_url) + if parts.hostname not in ("localhost", "127.0.0.1"): + return None + port = parts.port or (443 if parts.scheme == "https" else 80) + return {"name": "nemo-inference-gateway", "ports": [port]} + + +def build_manifest_dict( + *, + agent_name: str, + project_dir: str, + workflow: str, + port: int, + secrets: list[str], + secrets_file: str = ".env", + egress: list[str] | None = None, + backends: list[dict[str, Any]] | None = None, +) -> dict[str, Any]: + """Build the ``iron-swarm.yaml`` mapping (mirrors ``iron_swarm.cli.build_manifest``).""" + agent: dict[str, Any] = { + "name": agent_name, + "project_dir": project_dir, + "workflow": workflow, + "port": port, + "secrets": secrets, + "secrets_file": secrets_file, + } + if egress: + agent["egress"] = egress + return {"agent": agent, "backends": backends or []} + + +# --------------------------------------------------------------------------- # +# Filesystem materialization +# --------------------------------------------------------------------------- # +def materialize_workflow(workflow_config: dict[str, Any], project_path: Path) -> Path: + """Write the (IGW-injected) NAT workflow config to ``project_path/workflow.yaml``.""" + project_path.mkdir(parents=True, exist_ok=True) + workflow_path = project_path / WORKFLOW_FILENAME + workflow_path.write_text(yaml.safe_dump(workflow_config, sort_keys=False), encoding="utf-8") + return workflow_path + + +def scaffold_project(project_path: Path, agent_name: str) -> None: + """Write a minimal installable NAT project for a config-only agent. + + OpenShell's generic victim build needs an installable project (``uv pip install`` then + ``nat serve``). For agents that reference only packaged NAT components, a tiny pyproject + depending on ``nvidia-nat`` is enough to serve the materialized workflow. + """ + project_path.mkdir(parents=True, exist_ok=True) + pyproject = project_path / "pyproject.toml" + if not pyproject.exists(): + # A config-only victim has no Python package, so hatchling's default file selection fails + # ("no directory matches the project name"). Ship just the workflow via an explicit + # only-include so `uv pip install .` builds inside the sandbox. + pyproject.write_text( + "[project]\n" + f'name = "iron-swarm-victim-{agent_name}"\n' + 'version = "0.0.0"\n' + 'requires-python = ">=3.11,<3.13"\n' + # Pin to nvidia-nat 1.7.x: the guardrails defender writes `_type: pre_tool_verifier` + # middlewares, which live in ``nat.middleware.defense`` — present in 1.7.x but removed + # in 1.8.0. On 1.8.0 the hardened victim fails config validation ("middleware type + # pre_tool_verifier not found") and never serves, so the replay/benign validators all + # get "Server disconnected". Revisit when iron-swarm targets the 1.8+ defense API. + 'dependencies = ["nvidia-nat[langchain]>=1.7.0,<1.8"]\n' + "\n[build-system]\n" + 'requires = ["hatchling"]\n' + 'build-backend = "hatchling.build"\n' + "\n[tool.hatch.build.targets.wheel]\n" + f'only-include = ["{WORKFLOW_FILENAME}"]\n', + encoding="utf-8", + ) + + +# --------------------------------------------------------------------------- # +# Orchestrator +# --------------------------------------------------------------------------- # +def _fetch_agent_config(sdk: Any, workspace: str, name: str) -> dict[str, Any]: + """Fetch the agent's stored NAT workflow config, raising a clean error if unusable.""" + try: + agent = sdk.agents.get(name, workspace=workspace) + except Exception as exc: # any SDK/transport failure → one clean, actionable error + raise AgentResolutionError( + f"agent {workspace}/{name!r} not found. Deploy it first (nemo agents create + nemo agents deploy)." + ) from exc + agent_config = agent.get("config") or {} + if not agent_config: + raise AgentResolutionError(f"agent {workspace}/{name!r} has an empty config; nothing to build a victim from.") + return agent_config + + +def _resolve_victim_port(sdk: Any, workspace: str, name: str) -> tuple[int, list[str]]: + """Return the running deployment's port (else iron-swarm's default 8000) plus any warnings.""" + try: + resp = sdk.agents.deployments.list(workspace=workspace) + except Exception: # transport error → fall back to the default port, but surface why (not a silent miss) + logger.warning( + "could not list deployments for %s/%s; defaulting victim port to 8000", workspace, name, exc_info=True + ) + return 8000, [f"could not reach the deployments API for {workspace}/{name!r}; defaulting victim port to 8000."] + # The deployments API returns {"data": [...], "pagination": {...}}; normalize to the list of + # deployment dicts (tolerating a bare list too, for robustness). + deployments = resp.get("data", []) if isinstance(resp, dict) else (resp or []) + running = [ + d for d in deployments if isinstance(d, dict) and d.get("agent") == name and d.get("status") == "running" + ] + if running and running[0].get("port"): + return int(running[0]["port"]), [] + return 8000, [f"no running deployment for {workspace}/{name!r}; defaulting victim port to 8000."] + + +def inspect_agent(ref: str, *, sdk: Any, default_workspace: str) -> tuple[str, int, list[str], list[str]]: + """Derive the create-form defaults for a deployed agent without materializing anything. + + Returns ``(qualified_ref, port, secrets, warnings)``: the victim port from the running deployment + (else iron-swarm's default) and the secret names scanned from the stored config. Cheap read-only + counterpart to :func:`resolve_agent_to_manifest`, used to pre-fill (and let the operator override) + the port/secret fields before creating the manifest. + """ + workspace, name = parse_agent_ref(ref, default_workspace) + agent_config = _fetch_agent_config(sdk, workspace, name) + port, warnings = _resolve_victim_port(sdk, workspace, name) + secrets = derive_secret_names(agent_config) + return f"{workspace}/{name}", port, secrets, warnings + + +def resolve_agent_to_manifest( + ref: str, + *, + sdk: Any, + base_url: str, + default_workspace: str, + manifest_dir: Path, + project_dir: str | None = None, + egress: list[str] | None = None, + port: int | None = None, + secrets: list[str] | None = None, + model_override: str | None = None, +) -> ResolvedManifest: + """Resolve a deployed-agent reference into a ready Iron Swarm manifest. + + ``sdk`` is a ``nemo_platform.NeMoPlatform`` client. ``manifest_dir`` is where + ``iron-swarm.yaml`` will be written (paths in the manifest are relative to it). + + Pipeline: parse ref → fetch ``Agent`` config → resolve the victim port from a running + deployment → IGW-inject the workflow → materialize it under a scaffold/project dir → build + the manifest dict. For custom-code agents, ``project_dir`` must be supplied (the stored config + lacks the component source); config-only agents get a generated scaffold. + + ``egress`` allow-lists external hosts the victim may reach (needed for config-only agents, + whose tool hosts live in packaged code and so aren't found by egress discovery). ``port`` and + ``secrets`` override the auto-derived victim port / secret names; leave them unset to derive. + """ + workspace, name = parse_agent_ref(ref, default_workspace) + agent_config = _fetch_agent_config(sdk, workspace, name) + resolved_port, warnings = _resolve_victim_port(sdk, workspace, name) + port = port or resolved_port + + # Custom-code detection gates whether we can scaffold a project automatically. + custom = detect_custom_components(agent_config) + if custom and project_dir is None: + raise AgentResolutionError( + f"agent {workspace}/{name!r} references custom components {custom} whose source is not " + "in the stored config. Re-run with --project-dir pointing at the agent's NAT project." + ) + + injected = inject_gateway_url(agent_config, workspace, base_url, model_override) + + if project_dir is not None: + project_path = Path(project_dir) + rel_project = project_dir + else: + rel_project = str(Path(SCAFFOLD_ROOT) / name) + project_path = manifest_dir / rel_project + scaffold_project(project_path, name) + + workflow_path = materialize_workflow(injected, project_path) + secrets = secrets or derive_secret_names(agent_config) + + gw_backend = gateway_backend(base_url) + manifest = build_manifest_dict( + agent_name=name, + project_dir=rel_project, + workflow=WORKFLOW_FILENAME, + port=port, + secrets=secrets, + egress=egress, + backends=[gw_backend] if gw_backend else [], + ) + + return ResolvedManifest( + manifest=manifest, + workflow_path=workflow_path, + project_dir=project_path, + workspace=workspace, + agent_name=name, + port=port, + secrets=secrets, + warnings=warnings, + ) diff --git a/plugins/nemo-iron-swarm/src/nemo_iron_swarm_plugin/api/v2/_filters.py b/plugins/nemo-iron-swarm/src/nemo_iron_swarm_plugin/api/v2/_filters.py new file mode 100644 index 0000000000..6bdf776509 --- /dev/null +++ b/plugins/nemo-iron-swarm/src/nemo_iron_swarm_plugin/api/v2/_filters.py @@ -0,0 +1,33 @@ +# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +"""Filter dependency helper for iron-swarm list endpoints. + +Wraps :func:`nemo_platform_plugin.api.filters.make_filter_obj_dep` so an unknown +``filter[field]=value`` key (``NemoFilter`` is ``extra="forbid"``) fails with a +422 instead of the raw ``ValidationError`` FastAPI would otherwise surface as a +500 — typos must fail loudly, not be silently swallowed. +""" + +from __future__ import annotations + +from collections.abc import Awaitable, Callable +from typing import cast + +from fastapi import HTTPException +from nemo_platform_plugin.api.filters import make_filter_obj_dep +from pydantic import BaseModel, ValidationError +from starlette.requests import Request + + +def make_filter_dep(filter_model: type[BaseModel]) -> Callable[[Request], object]: + """Build a FastAPI dependency that validates filter params and 422s on typos.""" + inner = make_filter_obj_dep(filter_model) + + async def _dep(request: Request) -> object: + try: + return await cast(Awaitable[object], inner(request)) + except ValidationError as exc: + raise HTTPException(status_code=422, detail=exc.errors(include_url=False)) from exc + + return _dep diff --git a/plugins/nemo-iron-swarm/src/nemo_iron_swarm_plugin/api/v2/events.py b/plugins/nemo-iron-swarm/src/nemo_iron_swarm_plugin/api/v2/events.py index 917f74f218..31a8f5c2aa 100644 --- a/plugins/nemo-iron-swarm/src/nemo_iron_swarm_plugin/api/v2/events.py +++ b/plugins/nemo-iron-swarm/src/nemo_iron_swarm_plugin/api/v2/events.py @@ -21,10 +21,11 @@ from nemo_iron_swarm_plugin._perms import IronSwarmRunPerms from nemo_iron_swarm_plugin.authz import scope from nemo_iron_swarm_plugin.config import IronSwarmConfig -from nemo_iron_swarm_plugin.entities import IRON_SWARM_RUN_TYPE, IronSwarmRun +from nemo_iron_swarm_plugin.entities import IRON_SWARM_RUN_TYPE from nemo_iron_swarm_plugin.filesets import download_fileset from nemo_platform_plugin.authz import CallerKind, path_rule from pydantic import BaseModel +from starlette.concurrency import run_in_threadpool logger = logging.getLogger(__name__) @@ -137,17 +138,30 @@ async def get_events(workspace: str, name: str, after: int = 0) -> EventsRespons result = stream.history(after_id=after) if not result and not stream._path.exists(): - try: - sdk = _get_sdk() - run: IronSwarmRun = sdk.entities.get_entity_by_name( - name=name, - entity_type=IRON_SWARM_RUN_TYPE, - workspace=workspace, - ) - if run.events_fileset: - download_fileset(sdk, run.events_fileset, stream._path.parent) - result = stream.history(after_id=after) - except Exception: - logger.warning("Fileset fallback failed for run %r events; returning empty", name) + # The fallback does blocking sync I/O (SDK entity lookup + fileset download) that calls back into + # this same platform. Running it on the event loop self-deadlocks — the server can't answer its own + # lookup, so the SDK retries for ~181s while every other request (incl. the inference gateway) is + # frozen. Offload to a worker thread so the loop stays free and the lookup resolves promptly. + result = await run_in_threadpool(_fileset_fallback, workspace, name, stream, after) return EventsResponse(events=[{"id": seq, **event} for seq, event in result]) + + +def _fileset_fallback(workspace: str, name: str, stream: Any, after: int) -> list[tuple[int, dict[str, Any]]]: + """Blocking: fetch the run's ``events_fileset`` and re-read history. Must run off the event loop.""" + try: + sdk = _get_sdk() + # get_entity_by_name returns a generic Entity — its domain fields live under `.data` + # (same access pattern as sdk.py::_run_to_dict), not as top-level attributes. + run = sdk.entities.get_entity_by_name( + name=name, + entity_type=IRON_SWARM_RUN_TYPE, + workspace=workspace, + ) + fileset_ref = (getattr(run, "data", None) or {}).get("events_fileset") + if fileset_ref: + download_fileset(sdk, fileset_ref, stream._path.parent) + return stream.history(after_id=after) + except Exception: + logger.warning("Fileset fallback failed for run %r events; returning empty", name, exc_info=True) + return [] diff --git a/plugins/nemo-iron-swarm/src/nemo_iron_swarm_plugin/api/v2/jobs.py b/plugins/nemo-iron-swarm/src/nemo_iron_swarm_plugin/api/v2/jobs.py new file mode 100644 index 0000000000..5440f2442d --- /dev/null +++ b/plugins/nemo-iron-swarm/src/nemo_iron_swarm_plugin/api/v2/jobs.py @@ -0,0 +1,78 @@ +# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +"""Job-submission routes for the Iron Swarm service. + +Mounts the platform's standard job-collection endpoints (POST/GET/DELETE + status/cancel/logs) via +:func:`job_route_factory` for two jobs: the ``iron-swarm.war-game`` job (``/jobs``) and the +``iron-swarm.synth`` benign-suite job (``/synth-benign/jobs``). Each compiler delegates to the job's own +``compile``, so the submitted (Studio) path and the local (CLI) path share one spec. +""" + +from __future__ import annotations + +from nemo_iron_swarm_plugin.authz import scope +from nemo_iron_swarm_plugin.jobs.run import IronSwarmRunJob +from nemo_iron_swarm_plugin.jobs.spec import WarGameSpec +from nemo_iron_swarm_plugin.jobs.synth_benign import IronSwarmSynthBenignJob, SynthBenignSpec +from nemo_platform import AsyncNeMoPlatform +from nemo_platform_plugin.entities import EntityClient +from nemo_platform_plugin.jobs.api_factory import PlatformJobSpec, job_route_factory + + +async def _compile_war_game( + workspace: str, + original_spec: WarGameSpec, + transformed_spec: WarGameSpec, + entity_client: EntityClient, + job_name: str | None, + sdk: AsyncNeMoPlatform, +) -> PlatformJobSpec: + """Compile a war-game submission into a platform job (delegates to the job's own compile).""" + del original_spec + return await IronSwarmRunJob.compile( + workspace=workspace, + spec=transformed_spec, + entity_client=entity_client, + job_name=job_name, + async_sdk=sdk, + ) + + +router = job_route_factory( + service_name="iron-swarm", + job_type="WarGame", + job_input=WarGameSpec, + platform_job_config_compiler=_compile_war_game, + authz=scope.child("jobs"), +) + + +async def _compile_synth_benign( + workspace: str, + original_spec: SynthBenignSpec, + transformed_spec: SynthBenignSpec, + entity_client: EntityClient, + job_name: str | None, + sdk: AsyncNeMoPlatform, +) -> PlatformJobSpec: + """Compile a benign-suite synthesis submission into a platform job (delegates to the job's own compile).""" + del original_spec + return await IronSwarmSynthBenignJob.compile( + workspace=workspace, + spec=transformed_spec, + entity_client=entity_client, + job_name=job_name, + async_sdk=sdk, + ) + + +# Mounted under a distinct ``/synth-benign`` prefix (see service.py) so its ``/jobs`` paths don't collide +# with the war-game router's. +synth_router = job_route_factory( + service_name="iron-swarm", + job_type="SynthBenign", + job_input=SynthBenignSpec, + platform_job_config_compiler=_compile_synth_benign, + authz=scope.child("jobs"), +) diff --git a/plugins/nemo-iron-swarm/src/nemo_iron_swarm_plugin/api/v2/manifests.py b/plugins/nemo-iron-swarm/src/nemo_iron_swarm_plugin/api/v2/manifests.py new file mode 100644 index 0000000000..f742ff9995 --- /dev/null +++ b/plugins/nemo-iron-swarm/src/nemo_iron_swarm_plugin/api/v2/manifests.py @@ -0,0 +1,455 @@ +# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +"""Routes over the ``IronSwarmManifest`` entity — named, reusable war-game targets. + +Mounted at ``/apis/iron-swarm/v2/workspaces/{workspace}``. ``POST /manifests`` runs `init` and persists a +named record the operator later selects to run against; list/get/delete mirror the runs routes. Two +sources: ``agent`` resolves a deployed agent (the run re-materializes from the ref, no bundle stored); +``project`` builds from an uploaded NAT project via ``iron-swarm init --yes`` (``POST /manifests/inspect`` +detects its layout first) and stores the bundle as a fileset the run re-downloads. +""" + +from __future__ import annotations + +import json +import logging +import subprocess +import tempfile +from pathlib import Path + +import yaml +from fastapi import APIRouter, Depends, HTTPException, Query +from nemo_iron_swarm_plugin._perms import IronSwarmManifestPerms +from nemo_iron_swarm_plugin.agent_resolver import ( + AgentResolutionError, + ResolvedManifest, + inspect_agent, + resolve_agent_to_manifest, +) +from nemo_iron_swarm_plugin.api.v2._filters import make_filter_dep +from nemo_iron_swarm_plugin.api.v2.schemas import ( + InspectAgentRequest, + InspectAgentResponse, + InspectProjectRequest, + InspectProjectResponse, + ManifestFilter, + ManifestInit, + ManifestUpdate, + ValidateModelRequest, + ValidateModelResponse, +) +from nemo_iron_swarm_plugin.authz import scope +from nemo_iron_swarm_plugin.cli.client import base_url +from nemo_iron_swarm_plugin.config import IronSwarmConfig +from nemo_iron_swarm_plugin.entities import IronSwarmManifest +from nemo_iron_swarm_plugin.filesets import download_and_extract_project +from nemo_iron_swarm_plugin.model_config import ModelConfigDefaults, WarGameModels, model_config_defaults +from nemo_iron_swarm_plugin.model_preflight import validate_choice +from nemo_platform_plugin.authz import CallerKind, path_rule +from nemo_platform_plugin.entity_client import ( + NemoEntitiesClient, + NemoEntityConflictError, + NemoEntityNotFoundError, + get_entity_client, +) +from nemo_platform_plugin.jobs.openapi_utils import generate_openapi_extra_params +from nemo_platform_plugin.sdk_provider import get_platform_sdk +from starlette.concurrency import run_in_threadpool + +logger = logging.getLogger(__name__) + +router = APIRouter() + + +class _SubprocessError(Exception): + """A non-zero ``iron-swarm inspect``/``init`` exit (carries the stderr tail for the API detail).""" + + +# These run inside a request, on a threadpool worker. Unbounded, a wedged subprocess pins its worker +# for the process's lifetime and enough of them starve the pool — so every call gets a ceiling. +_SUBPROCESS_TIMEOUT_SECONDS = 120 + + +class _SubprocessTimeout(Exception): + """``iron-swarm inspect``/``init`` exceeded :data:`_SUBPROCESS_TIMEOUT_SECONDS`.""" + + +def _run_iron_swarm(cmd: list[str], cwd: str, action: str) -> subprocess.CompletedProcess[str]: + """Run an ``iron-swarm`` subcommand with a bounded runtime, raising on timeout or non-zero exit.""" + try: + result = subprocess.run( + cmd, capture_output=True, text=True, cwd=cwd, check=False, timeout=_SUBPROCESS_TIMEOUT_SECONDS + ) + except subprocess.TimeoutExpired as exc: + raise _SubprocessTimeout(f"{action} timed out after {_SUBPROCESS_TIMEOUT_SECONDS}s.") from exc + if result.returncode != 0: + raise _SubprocessError((result.stderr or result.stdout).strip()[-500:] or f"{action} returned no output.") + return result + + +_manifest_filter_dep = make_filter_dep(ManifestFilter) + + +@router.get( + "/manifests", + tags=["Iron Swarm Manifests"], + openapi_extra=generate_openapi_extra_params(filter_schema=ManifestFilter), +) +@scope.read +@path_rule(callers=[CallerKind.PRINCIPAL], permissions=[IronSwarmManifestPerms.LIST]) +async def list_manifests( + workspace: str, + page: int = Query(default=1, ge=1), + page_size: int = Query(default=20, ge=1, le=100), + sort: str = Query(default="-created_at"), + filter: ManifestFilter = Depends(_manifest_filter_dep), + entity_client: NemoEntitiesClient = Depends(get_entity_client), +) -> dict: + """List saved manifests in the workspace, with pagination and an ``agent``/``source_type`` filter.""" + filter_dict = filter if isinstance(filter, dict) else filter.model_dump(exclude_none=True) + try: + result = await entity_client.list( + IronSwarmManifest, + workspace=workspace, + page=page, + page_size=page_size, + sort=sort, + filter_obj=filter_dict or None, + ) + except Exception as exc: + logger.exception("Failed to list iron-swarm manifests in workspace '%s'", workspace) + raise HTTPException(status_code=500, detail="Failed to list iron-swarm manifests.") from exc + return { + "data": [manifest.model_dump(mode="json") for manifest in result.data], + "pagination": result.pagination.model_dump() if result.pagination else None, + "sort": sort, + "filter": filter or None, + } + + +@router.get("/manifests/{name}", response_model=IronSwarmManifest, tags=["Iron Swarm Manifests"]) +@scope.read +@path_rule(callers=[CallerKind.PRINCIPAL], permissions=[IronSwarmManifestPerms.READ]) +async def get_manifest( + workspace: str, + name: str, + entity_client: NemoEntitiesClient = Depends(get_entity_client), +) -> IronSwarmManifest: + """Get a single manifest by name.""" + try: + return await entity_client.get(IronSwarmManifest, name=name, workspace=workspace) + except NemoEntityNotFoundError as exc: + raise HTTPException( + status_code=404, detail=f"IronSwarmManifest '{name}' not found in workspace '{workspace}'." + ) from exc + except Exception as exc: + logger.exception("Failed to get iron-swarm manifest '%s'", name) + raise HTTPException(status_code=500, detail="Failed to get iron-swarm manifest.") from exc + + +@router.get("/model-config-defaults", response_model=ModelConfigDefaults, tags=["Iron Swarm Manifests"]) +@scope.read +@path_rule(callers=[CallerKind.PRINCIPAL], permissions=[IronSwarmManifestPerms.INSPECT]) +async def get_model_config_defaults(workspace: str) -> ModelConfigDefaults: + """The built-in per-group model defaults (attack/analysis) the create/run forms pre-fill.""" + return model_config_defaults() + + +@router.post("/model-config/validate", response_model=ValidateModelResponse, tags=["Iron Swarm Manifests"]) +@scope.read +@path_rule(callers=[CallerKind.PRINCIPAL], permissions=[IronSwarmManifestPerms.INSPECT]) +async def validate_model_config(workspace: str, body: ValidateModelRequest) -> ValidateModelResponse: + """Probe a model choice's endpoint/key (the "Test connection" affordance) and list reachable models. + + Resolves the chosen Secret to its value (if any) and lists ``{base_url}/models``. Never leaks the key — + only the boolean verdict + the reachable model ids come back, so the UI can offer real options. + """ + sdk = get_platform_sdk(as_service="iron-swarm", internal=True) + + def _validate() -> ValidateModelResponse: + api_key: str | None = None + if body.api_key_secret: + secret = sdk.secrets.access(body.api_key_secret, workspace=workspace) + api_key = getattr(secret, "value", None) + verdict = validate_choice(body.model, body.base_url, api_key) + return ValidateModelResponse( + ok=verdict.ok, reason=verdict.reason, available=verdict.available, detail=verdict.detail + ) + + return await run_in_threadpool(_validate) + + +@router.post("/manifests/inspect", response_model=InspectProjectResponse, tags=["Iron Swarm Manifests"]) +@scope.read +@path_rule(callers=[CallerKind.PRINCIPAL], permissions=[IronSwarmManifestPerms.INSPECT]) +async def inspect_project( + workspace: str, + body: InspectProjectRequest, +) -> InspectProjectResponse: + """Detect an uploaded NAT project's layout (`iron-swarm inspect`) to pre-fill the create wizard. + + Downloads the project bundle, expands it, and runs the read-only, offline detector — no code is + executed. Returns the discovered workflows, launch mode, name, secrets, and egress as defaults. + """ + sdk = get_platform_sdk(as_service="iron-swarm", internal=True) + bin_path = IronSwarmConfig.get().iron_swarm_bin + + def _inspect() -> dict: + with tempfile.TemporaryDirectory() as tmp: + project_dir = download_and_extract_project(sdk, body.project_fileset, Path(tmp)) + result = _run_iron_swarm( + [str(bin_path), "inspect", "--project-dir", str(project_dir), "--json"], + cwd=str(project_dir), + action="inspect", + ) + return json.loads(result.stdout) + + try: + detected = await run_in_threadpool(_inspect) + except _SubprocessTimeout as exc: + raise HTTPException(status_code=504, detail=f"Failed to inspect project: {exc}") from exc + except _SubprocessError as exc: + raise HTTPException(status_code=400, detail=f"Failed to inspect project: {exc}") from exc + except (ValueError, json.JSONDecodeError) as exc: + raise HTTPException(status_code=400, detail=f"Could not read the uploaded project: {exc}") from exc + return InspectProjectResponse(**detected) + + +@router.post("/manifests/inspect-agent", response_model=InspectAgentResponse, tags=["Iron Swarm Manifests"]) +@scope.read +@path_rule(callers=[CallerKind.PRINCIPAL], permissions=[IronSwarmManifestPerms.INSPECT]) +async def inspect_agent_endpoint(workspace: str, body: InspectAgentRequest) -> InspectAgentResponse: + """Derive the deployed-agent create-form defaults (victim port + secret names) for pre-fill. + + Read-only: fetches the stored agent config and its running deployment; nothing is materialized. + """ + sdk = get_platform_sdk(as_service="iron-swarm", internal=True) + + def _inspect() -> tuple[str, int, list[str], list[str]]: + return inspect_agent(body.agent, sdk=sdk, default_workspace=workspace) + + try: + ref, port, secrets, warnings = await run_in_threadpool(_inspect) + except AgentResolutionError as exc: + raise HTTPException(status_code=400, detail=str(exc)) from exc + return InspectAgentResponse(agent=ref, port=port, secrets=secrets, warnings=warnings) + + +@router.post("/manifests", response_model=IronSwarmManifest, status_code=201, tags=["Iron Swarm Manifests"]) +@scope.write +@path_rule(callers=[CallerKind.PRINCIPAL], permissions=[IronSwarmManifestPerms.WRITE]) +async def create_manifest( + workspace: str, + body: ManifestInit, + entity_client: NemoEntitiesClient = Depends(get_entity_client), +) -> IronSwarmManifest: + """`init`: build a manifest (from a deployed agent or an uploaded project) and persist it by ``name``.""" + if body.source_type == "project": + manifest = await _build_project_manifest(workspace, body) + else: + manifest = await _build_agent_manifest(workspace, body) + try: + return await entity_client.create(manifest) + except NemoEntityConflictError as exc: + raise HTTPException( + status_code=409, detail=f"Manifest '{body.name}' already exists in workspace '{workspace}'." + ) from exc + except Exception as exc: + logger.exception("Failed to persist iron-swarm manifest '%s'", body.name) + raise HTTPException(status_code=500, detail="Failed to create iron-swarm manifest.") from exc + + +async def _build_agent_manifest(workspace: str, body: ManifestInit) -> IronSwarmManifest: + """Resolve a deployed agent into a manifest (the run re-materializes from the stored agent ref).""" + if not body.agent: + raise HTTPException(status_code=422, detail="source_type 'agent' requires an 'agent' reference.") + + agent_ref = body.agent + sdk = get_platform_sdk(as_service="iron-swarm", internal=True) + + # resolve_agent_to_manifest is sync + network-bound (sdk.agents.get); keep it off the event loop. + # The scaffold dir is only for validation/preview here — the run re-materializes from the agent ref. + def _resolve() -> ResolvedManifest: + with tempfile.TemporaryDirectory() as tmp: + return resolve_agent_to_manifest( + agent_ref, + sdk=sdk, + base_url=base_url(), + default_workspace=workspace, + manifest_dir=Path(tmp), + egress=body.egress, + port=body.port, + secrets=body.secrets, + ) + + try: + resolved = await run_in_threadpool(_resolve) + except AgentResolutionError as exc: + raise HTTPException(status_code=400, detail=str(exc)) from exc + + return IronSwarmManifest.from_agent_resolution( + name=body.name, + workspace=workspace, + agent_ref=f"{resolved.workspace}/{resolved.agent_name}", + manifest_yaml=yaml.safe_dump(resolved.manifest, sort_keys=False), + port=resolved.port, + secrets=resolved.secrets, + warnings=resolved.warnings, + models=body.models or WarGameModels(), + ) + + +async def _build_project_manifest(workspace: str, body: ManifestInit) -> IronSwarmManifest: + """Build a manifest from an uploaded NAT project by shelling ``iron-swarm init --yes``. + + The bundle is expanded to a temp dir and ``init`` runs there (so ``project_dir`` resolves to ``.``); + the war-game re-downloads the bundle and repoints ``project_dir`` at the restored copy. + """ + fileset = body.project_fileset + if not fileset: + raise HTTPException(status_code=422, detail="source_type 'project' requires a 'project_fileset'.") + if body.launch_mode and body.launch_mode != "workflow": + raise HTTPException(status_code=422, detail="Only the 'workflow' launch mode is supported (BYO is Phase 2).") + + sdk = get_platform_sdk(as_service="iron-swarm", internal=True) + bin_path = IronSwarmConfig.get().iron_swarm_bin + port = body.port or 8000 + + def _init() -> str: + with tempfile.TemporaryDirectory() as tmp: + project_dir = download_and_extract_project(sdk, fileset, Path(tmp)) + output = Path(tmp) / "iron-swarm.yaml" + cmd = [ + str(bin_path), + "init", + "--yes", + "--force", + "--project-dir", + ".", + "--name", + body.name, + "--port", + str(port), + "-o", + str(output), + ] + if body.workflow: + cmd += ["--workflow", body.workflow] + if body.secrets: + cmd += ["--secrets", ",".join(body.secrets)] + if body.secrets_file: + cmd += ["--secrets-file", body.secrets_file] + for host in body.egress or []: + cmd += ["--egress", host] + for spec in body.backends or []: + cmd += ["--backend", spec] + _run_iron_swarm(cmd, cwd=str(project_dir), action="init") + return output.read_text(encoding="utf-8") + + try: + manifest_yaml = await run_in_threadpool(_init) + except _SubprocessTimeout as exc: + raise HTTPException(status_code=504, detail=f"Failed to build manifest from project: {exc}") from exc + except _SubprocessError as exc: + raise HTTPException(status_code=400, detail=f"Failed to build manifest from project: {exc}") from exc + except ValueError as exc: + raise HTTPException(status_code=400, detail=f"Could not read the uploaded project: {exc}") from exc + + # The persisted manifest can't hold the temp project path; the run repoints it. Force project_dir='.'. + manifest_yaml = _with_project_dir_dot(manifest_yaml) + return IronSwarmManifest( + name=body.name, + workspace=workspace, + source_type="project", + project_fileset=fileset, + workflow=body.workflow or "", + launch_mode=body.launch_mode or "workflow", + manifest_yaml=manifest_yaml, + port=port, + secrets=body.secrets or [], + models=body.models or WarGameModels(), + ) + + +def _with_project_dir_dot(manifest_yaml: str) -> str: + """Return *manifest_yaml* with ``agent.project_dir`` normalized to ``.`` (unchanged if unparseable).""" + try: + data = yaml.safe_load(manifest_yaml) or {} + except yaml.YAMLError: + return manifest_yaml + if isinstance(data, dict) and isinstance(data.get("agent"), dict): + data["agent"]["project_dir"] = "." + return yaml.safe_dump(data, sort_keys=False) + return manifest_yaml + + +def _yaml_with_port(manifest_yaml: str, port: int) -> str: + """Return *manifest_yaml* with ``agent.port`` set to *port* (unchanged if it can't be parsed).""" + try: + data = yaml.safe_load(manifest_yaml) or {} + except yaml.YAMLError: + return manifest_yaml + if isinstance(data, dict) and isinstance(data.get("agent"), dict): + data["agent"]["port"] = port + return yaml.safe_dump(data, sort_keys=False) + return manifest_yaml + + +@router.patch("/manifests/{name}", response_model=IronSwarmManifest, tags=["Iron Swarm Manifests"]) +@scope.write +@path_rule(callers=[CallerKind.PRINCIPAL], permissions=[IronSwarmManifestPerms.WRITE]) +async def update_manifest( + workspace: str, + name: str, + body: ManifestUpdate, + entity_client: NemoEntitiesClient = Depends(get_entity_client), +) -> IronSwarmManifest: + """Edit a manifest's cached benign suite and/or victim port (the agent source is immutable).""" + try: + existing = await entity_client.get(IronSwarmManifest, name=name, workspace=workspace) + except NemoEntityNotFoundError as exc: + raise HTTPException( + status_code=404, detail=f"IronSwarmManifest '{name}' not found in workspace '{workspace}'." + ) from exc + if body.benign_suite is not None: + existing.benign_suite = body.benign_suite + if body.defenders is not None: + existing.defenders = body.defenders + if body.attack_intensity is not None: + existing.attack_intensity = body.attack_intensity + if body.rounds is not None: + existing.rounds = body.rounds + if body.models is not None: + existing.models = body.models + if body.port is not None: + existing.port = body.port + existing.manifest_yaml = _yaml_with_port(existing.manifest_yaml, body.port) + try: + return await entity_client.update(existing) + except NemoEntityNotFoundError as exc: + raise HTTPException( + status_code=404, detail=f"IronSwarmManifest '{name}' not found in workspace '{workspace}'." + ) from exc + except NemoEntityConflictError as exc: + raise HTTPException(status_code=409, detail=f"Manifest '{name}' was modified concurrently.") from exc + + +@router.delete("/manifests/{name}", status_code=204, tags=["Iron Swarm Manifests"]) +@scope.write +@path_rule(callers=[CallerKind.PRINCIPAL], permissions=[IronSwarmManifestPerms.WRITE]) +async def delete_manifest( + workspace: str, + name: str, + entity_client: NemoEntitiesClient = Depends(get_entity_client), +) -> None: + """Delete a saved manifest by name.""" + try: + await entity_client.delete(IronSwarmManifest, name=name, workspace=workspace) + except NemoEntityNotFoundError as exc: + raise HTTPException( + status_code=404, detail=f"IronSwarmManifest '{name}' not found in workspace '{workspace}'." + ) from exc + except Exception as exc: + logger.exception("Failed to delete iron-swarm manifest '%s'", name) + raise HTTPException(status_code=500, detail="Failed to delete iron-swarm manifest.") from exc diff --git a/plugins/nemo-iron-swarm/src/nemo_iron_swarm_plugin/api/v2/runs.py b/plugins/nemo-iron-swarm/src/nemo_iron_swarm_plugin/api/v2/runs.py new file mode 100644 index 0000000000..20ad133b58 --- /dev/null +++ b/plugins/nemo-iron-swarm/src/nemo_iron_swarm_plugin/api/v2/runs.py @@ -0,0 +1,207 @@ +# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +"""Read-only routes over the ``IronSwarmRun`` entity. + +Mounted by the plugin service at ``/apis/iron-swarm/v2/workspaces/{workspace}``. War-game +runs are created by the job (``client.entities.create``), so the plugin only exposes reads: +list the agent's runs (Studio's Hardening tab) and fetch one. The entity is the same shape +on the wire as at rest, so it is returned directly. +""" + +from __future__ import annotations + +import logging + +import yaml +from fastapi import APIRouter, Depends, HTTPException, Query +from nemo_agents_plugin.entities import Agent +from nemo_iron_swarm_plugin._perms import IronSwarmRunPerms +from nemo_iron_swarm_plugin.agent_resolver import parse_agent_ref, strip_gateway_url +from nemo_iron_swarm_plugin.api.v2._filters import make_filter_dep +from nemo_iron_swarm_plugin.api.v2.schemas import ( + ApplyMitigationRequest, + ApplyMitigationResponse, + ComposeDefenseRequest, + ComposeDefenseResponse, + RunFilter, +) +from nemo_iron_swarm_plugin.authz import scope +from nemo_iron_swarm_plugin.entities import IronSwarmRun +from nemo_iron_swarm_plugin.jobs.defenses import compose_defense +from nemo_platform_plugin.authz import CallerKind, path_rule +from nemo_platform_plugin.entity_client import ( + NemoEntitiesClient, + NemoEntityNotFoundError, + get_entity_client, +) +from nemo_platform_plugin.jobs.openapi_utils import generate_openapi_extra_params + +logger = logging.getLogger(__name__) + +router = APIRouter() + +_run_filter_dep = make_filter_dep(RunFilter) + + +@router.get( + "/runs", + tags=["Iron Swarm Runs"], + openapi_extra=generate_openapi_extra_params(filter_schema=RunFilter), +) +@scope.read +@path_rule(callers=[CallerKind.PRINCIPAL], permissions=[IronSwarmRunPerms.LIST]) +async def list_runs( + workspace: str, + page: int = Query(default=1, ge=1), + page_size: int = Query(default=20, ge=1, le=100), + sort: str = Query(default="-created_at"), + filter: RunFilter = Depends(_run_filter_dep), + entity_client: NemoEntitiesClient = Depends(get_entity_client), +) -> dict: + """List war-game runs in the workspace, with pagination and an ``agent``/``status`` filter.""" + filter_dict = filter if isinstance(filter, dict) else filter.model_dump(exclude_none=True) + try: + result = await entity_client.list( + IronSwarmRun, + workspace=workspace, + page=page, + page_size=page_size, + sort=sort, + filter_obj=filter_dict or None, + ) + except Exception as exc: + logger.exception("Failed to list iron-swarm runs in workspace '%s'", workspace) + raise HTTPException(status_code=500, detail="Failed to list iron-swarm runs.") from exc + return { + "data": [run.model_dump(mode="json") for run in result.data], + "pagination": result.pagination.model_dump() if result.pagination else None, + "sort": sort, + "filter": filter or None, + } + + +@router.get("/runs/{name}", response_model=IronSwarmRun, tags=["Iron Swarm Runs"]) +@scope.read +@path_rule(callers=[CallerKind.PRINCIPAL], permissions=[IronSwarmRunPerms.READ]) +async def get_run( + workspace: str, + name: str, + entity_client: NemoEntitiesClient = Depends(get_entity_client), +) -> IronSwarmRun: + """Get a single war-game run by name.""" + try: + return await entity_client.get(IronSwarmRun, name=name, workspace=workspace) + except NemoEntityNotFoundError as exc: + raise HTTPException( + status_code=404, + detail=f"IronSwarmRun '{name}' not found in workspace '{workspace}'.", + ) from exc + except Exception as exc: + logger.exception("Failed to get iron-swarm run '%s'", name) + raise HTTPException(status_code=500, detail="Failed to get iron-swarm run.") from exc + + +@router.post( + "/runs/{name}/apply-mitigation", + response_model=ApplyMitigationResponse, + tags=["Iron Swarm Runs"], +) +@scope.write +# NOTE: this writes another plugin's entity (`Agent.config`) while holding only +# `iron-swarm.runs.apply`. Requiring `agents.agents.create` alongside is not possible — the platform +# fail-closes on permission ids outside a service's own namespace — so `iron-swarm.runs.apply` is +# effectively an agent-write grant. Treat it as such when assigning it. +@path_rule(callers=[CallerKind.PRINCIPAL], permissions=[IronSwarmRunPerms.APPLY]) +async def apply_mitigation( + workspace: str, + name: str, + body: ApplyMitigationRequest, + entity_client: NemoEntitiesClient = Depends(get_entity_client), +) -> ApplyMitigationResponse: + """Adopt a run's hardened workflow: write it onto the run's target agent config (no redeploy). + + Reverses the Inference-Gateway injection so the stored config stays deployment-neutral, then updates + the ``Agent`` entity in place. The user must redeploy the agent for the guardrails to take effect. + """ + try: + config = yaml.safe_load(body.workflow_yaml) + except yaml.YAMLError as exc: + raise HTTPException(status_code=422, detail=f"workflow_yaml is not valid YAML: {exc}") from exc + if not isinstance(config, dict): + raise HTTPException(status_code=422, detail="workflow_yaml must be a NAT workflow mapping.") + + try: + run = await entity_client.get(IronSwarmRun, name=name, workspace=workspace) + except NemoEntityNotFoundError as exc: + raise HTTPException( + status_code=404, detail=f"IronSwarmRun '{name}' not found in workspace '{workspace}'." + ) from exc + + if not run.agent: + raise HTTPException(status_code=409, detail=f"Run '{name}' has no target agent to update.") + agent_ws, agent_name = parse_agent_ref(run.agent, workspace) + + try: + agent = await entity_client.get(Agent, name=agent_name, workspace=agent_ws) + except NemoEntityNotFoundError as exc: + raise HTTPException( + status_code=404, detail=f"Agent '{agent_name}' not found in workspace '{agent_ws}'." + ) from exc + + agent.config = strip_gateway_url(config) + try: + await entity_client.update(agent) + except Exception as exc: + logger.exception("Failed to apply mitigation to agent '%s'", agent_name) + raise HTTPException(status_code=500, detail="Failed to update the agent config.") from exc + + return ApplyMitigationResponse( + applied=True, + agent=agent_name, + detail=f"Updated '{agent_name}' with the hardened workflow. Redeploy the agent to activate the guardrails.", + ) + + +@router.post( + "/runs/{name}/compose-defense", + response_model=ComposeDefenseResponse, + tags=["Iron Swarm Runs"], +) +@scope.read +@path_rule(callers=[CallerKind.PRINCIPAL], permissions=[IronSwarmRunPerms.COMPOSE]) +async def compose_defense_route( + workspace: str, + name: str, + body: ComposeDefenseRequest, +) -> ComposeDefenseResponse: + """Compose a chosen subset of a run's recommended defenses into deployable workflow + policy YAML. + + Keeps only the selected guardrails in the workflow and picks the hardened-vs-baseline policy. Powers + the harden flow's live preview and feeds the composed YAMLs to a sanity-check (validate-only) run. + """ + try: + workflow_yaml, policy_yaml = compose_defense(body.mitigations, body.selected_defense_ids) + except Exception as exc: + raise HTTPException(status_code=422, detail=f"Failed to compose the selected defenses: {exc}") from exc + return ComposeDefenseResponse(workflow_yaml=workflow_yaml, policy_yaml=policy_yaml) + + +@router.delete("/runs/{name}", status_code=204, tags=["Iron Swarm Runs"]) +@scope.write +@path_rule(callers=[CallerKind.PRINCIPAL], permissions=[IronSwarmRunPerms.DELETE]) +async def delete_run( + workspace: str, + name: str, + entity_client: NemoEntitiesClient = Depends(get_entity_client), +) -> None: + """Delete a war-game run record. The underlying platform job is cancelled/deleted separately.""" + try: + await entity_client.delete(IronSwarmRun, name=name, workspace=workspace) + except NemoEntityNotFoundError as exc: + raise HTTPException( + status_code=404, detail=f"IronSwarmRun '{name}' not found in workspace '{workspace}'." + ) from exc + except Exception as exc: + logger.exception("Failed to delete iron-swarm run '%s'", name) + raise HTTPException(status_code=500, detail="Failed to delete iron-swarm run.") from exc diff --git a/plugins/nemo-iron-swarm/src/nemo_iron_swarm_plugin/api/v2/schemas.py b/plugins/nemo-iron-swarm/src/nemo_iron_swarm_plugin/api/v2/schemas.py new file mode 100644 index 0000000000..715ada6f02 --- /dev/null +++ b/plugins/nemo-iron-swarm/src/nemo_iron_swarm_plugin/api/v2/schemas.py @@ -0,0 +1,203 @@ +# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +"""Iron Swarm plugin API request/query schemas. + +The persisted entities are the same shape on the wire as at rest, so the read routes return them +directly. This module holds the list-endpoint query filters (extending ``NemoFilter``, +``extra="forbid"`` so a misspelled key 422s) plus the ``POST /manifests`` init request body. +""" + +from __future__ import annotations + +from typing import Any, Literal + +from nemo_iron_swarm_plugin.entities import ManifestSource +from nemo_iron_swarm_plugin.model_config import WarGameModels +from nemo_platform_plugin.schema import NemoFilter +from pydantic import BaseModel, Field + + +class RunFilter(NemoFilter): + """Query filter for ``GET /v2/workspaces/{workspace}/runs``.""" + + agent: str | None = Field( + default=None, + description="Filter to runs targeting this agent reference (workspace/name).", + ) + manifest_id: str | None = Field( + default=None, + description="Filter to runs launched from this manifest (scopes 'replay last run').", + ) + status: str | None = Field( + default=None, + description="Filter to runs with this status ('running', 'completed', or 'failed').", + ) + + +class ManifestFilter(NemoFilter): + """Query filter for ``GET /v2/workspaces/{workspace}/manifests``.""" + + agent: str | None = Field(default=None, description="Filter to manifests for this agent reference.") + source_type: str | None = Field(default=None, description="Filter by source ('agent' or 'project').") + + +class ManifestInit(BaseModel): + """Body for ``POST /v2/workspaces/{workspace}/manifests`` — scaffold a named manifest. + + ``agent`` resolves a deployed agent; ``project`` builds the manifest from an uploaded NAT project + (``project_fileset`` + the confirmed detection answers) by shelling ``iron-swarm init --yes``. + """ + + name: str = Field(description="User-defined manifest id (unique within the workspace).") + source_type: ManifestSource = Field(default="agent", description="Scaffold source ('agent' or 'project').") + agent: str | None = Field(default=None, description="Agent reference (required when source_type='agent').") + # Project source (source_type='project') — the confirmed answers from the inspect step. + project_fileset: str | None = Field(default=None, description="Fileset ref of the uploaded NAT project bundle.") + workflow: str | None = Field( + default=None, description="Chosen workflow path within the project (project-relative)." + ) + launch_mode: str | None = Field(default=None, description="Victim launch mode ('workflow'; BYO is Phase 2).") + port: int | None = Field(default=None, description="Victim port (defaults to 8000).") + secrets: list[str] | None = Field(default=None, description="Secret names the victim requires.") + secrets_file: str | None = Field(default=None, description="Dotenv path within the project holding the secrets.") + egress: list[str] | None = Field( + default=None, + description="Allow-listed egress host[:port] entries the victim may reach (external hosts the agent " + "calls, e.g. inference-api.nvidia.com); baked into the manifest by `init --egress`.", + ) + backends: list[str] | None = Field( + default=None, + description="Route-only host backends the agent's tools call, each 'NAME:PORT[,PORT2]' (e.g. " + "'finance:8086'). Rewrites the agent's localhost:PORT to host.docker.internal:PORT and opens the " + "sandbox->host route; passed to `init --backend`.", + ) + models: WarGameModels | None = Field( + default=None, + description="Stored default model selection (attack/analysis/agent groups); omit to use iron-swarm's " + "built-in defaults.", + ) + + +class ValidateModelRequest(BaseModel): + """Body for ``POST /v2/workspaces/{workspace}/model-config/validate`` — probe a model choice.""" + + model: str | None = Field(default=None, description="Model name to verify against the endpoint's model list.") + base_url: str = Field(description="OpenAI-compatible endpoint to probe (`GET {base_url}/models`).") + api_key_secret: str | None = Field( + default=None, description="Secret name holding the provider key; omitted probes without auth." + ) + + +class ValidateModelResponse(BaseModel): + """Verdict for a model choice; ``available`` lists what the credentials can reach so the UI offers real options.""" + + ok: bool = Field(description="True when the endpoint is reachable, authorized, and serves the model.") + reason: str = Field(default="", description="'' | 'auth' | 'unreachable' | 'unknown_model'.") + available: list[str] = Field(default_factory=list, description="Model ids the credentials can reach.") + detail: str = Field(default="", description="Human-readable diagnostic (status code / transport error).") + + +class InspectProjectRequest(BaseModel): + """Body for ``POST /v2/workspaces/{workspace}/manifests/inspect`` — detect an uploaded project.""" + + project_fileset: str = Field(description="Fileset ref of the uploaded NAT project bundle to inspect.") + + +class InspectProjectResponse(BaseModel): + """Detection facts + defaults for the upload wizard (the parsed ``iron-swarm inspect --json`` output).""" + + project_dir: str = Field(default="", description="Detected installable project root (relative to the bundle).") + workflows: list[str] = Field(default_factory=list, description="Discovered workflow paths (project-relative).") + dockerfiles: list[str] = Field(default_factory=list, description="Discovered Dockerfile paths (project-relative).") + suggested_launch_mode: str = Field(default="workflow", description="'workflow' or 'byo'.") + default_agent_name: str = Field(default="", description="Suggested agent name.") + default_port: int = Field(default=8000, description="Suggested victim port.") + secrets_file: str = Field(default="", description="Detected dotenv path (project-relative), or empty.") + secret_names: list[str] = Field(default_factory=list, description="Secret names found in the dotenv file.") + egress: list[str] = Field(default_factory=list, description="External hosts the agent reaches (allow-list).") + backend_ports: list[int] = Field( + default_factory=list, + description="Local host-backend ports detected in the workflow (localhost:PORT the tools call).", + ) + + +class InspectAgentRequest(BaseModel): + """Body for ``POST /v2/workspaces/{workspace}/manifests/inspect-agent`` — a deployed agent ref.""" + + agent: str = Field(description="Deployed agent reference (``workspace/name`` or ``name``).") + + +class InspectAgentResponse(BaseModel): + """Auto-derived defaults for the deployed-agent create form (port + secret names, editable).""" + + agent: str = Field(description="Resolved ``workspace/name`` of the agent.") + port: int = Field(description="Victim port derived from the running deployment (else the default).") + secrets: list[str] = Field(default_factory=list, description="Secret names derived from the agent config.") + warnings: list[str] = Field(default_factory=list, description="Non-fatal notes (e.g. no running deployment).") + + +class ManifestUpdate(BaseModel): + """Body for ``PATCH /v2/workspaces/{workspace}/manifests/{name}`` — edit an existing manifest. + + Only editable fields; omitted fields are left unchanged. The agent source is immutable (delete + + recreate to retarget). + """ + + benign_suite: list[dict[str, str]] | None = Field( + default=None, description="Replace the cached benign suite (tool,payload,label,rationale,persona rows)." + ) + port: int | None = Field(default=None, description="Victim port the war-game will target.") + defenders: list[str] | None = Field( + default=None, description="Enabled defender keys ('guardrails','openshell'); empty means iron-swarm defaults." + ) + attack_intensity: Literal["light", "standard", "thorough"] | None = Field( + default=None, description="Attacker (garak) effort preset." + ) + rounds: int | None = Field( + default=None, ge=1, description="Number of iterative hardening rounds (iron-swarm `run --rounds`)." + ) + models: WarGameModels | None = Field( + default=None, description="Replace the stored default model selection (attack/analysis/agent groups)." + ) + + +class ApplyMitigationRequest(BaseModel): + """Body for ``POST /v2/workspaces/{workspace}/runs/{name}/apply-mitigation`` — adopt the hardened workflow. + + The client passes the hardened workflow YAML from the run's mitigations artifact. The endpoint reverses + the Inference-Gateway injection and writes it onto the run's target agent config (no redeploy). + """ + + workflow_yaml: str = Field(description="Hardened NAT workflow YAML (the mitigations 'after' document).") + + +class ApplyMitigationResponse(BaseModel): + """Result of applying a hardened workflow to an agent.""" + + applied: bool = Field(description="True when the agent config was updated.") + agent: str = Field(description="Name of the agent whose config was updated.") + detail: str = Field(description="Human-readable note (e.g. a reminder to redeploy).") + + +class ComposeDefenseRequest(BaseModel): + """Body for ``POST /v2/workspaces/{workspace}/runs/{name}/compose-defense`` — build a chosen defense subset. + + The client passes the run's ``mitigations`` artifact (which it already fetched for the recommendations + view) plus the ids of the defenses to keep. The endpoint composes the workflow with only the selected + guardrails and picks the hardened-vs-baseline policy, for live preview and to feed a sanity-check run. + """ + + mitigations: dict[str, Any] = Field(description="The run's mitigations artifact (its 'defenses'/workflow/policy).") + selected_defense_ids: list[str] = Field( + default_factory=list, description="Ids of the defenses to keep (guardrail ids and/or 'openshell_policy')." + ) + + +class ComposeDefenseResponse(BaseModel): + """The composed workflow + policy for the selected defenses.""" + + workflow_yaml: str | None = Field(default=None, description="Workflow with only the selected guardrails, or null.") + policy_yaml: str | None = Field( + default=None, description="Hardened policy if selected, else the baseline, or null." + ) diff --git a/plugins/nemo-iron-swarm/src/nemo_iron_swarm_plugin/authz.py b/plugins/nemo-iron-swarm/src/nemo_iron_swarm_plugin/authz.py new file mode 100644 index 0000000000..40f6b4a8c4 --- /dev/null +++ b/plugins/nemo-iron-swarm/src/nemo_iron_swarm_plugin/authz.py @@ -0,0 +1,14 @@ +# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +"""The one OAuth scope the Iron Swarm plugin owns. + +Kept in its own module so the service and every route module share a single ``AuthzScope("iron-swarm")`` +without an import cycle. Reads carry ``@scope.read``; mutating routes carry ``@scope.write``. +""" + +from __future__ import annotations + +from nemo_platform_plugin.authz import AuthzScope + +scope = AuthzScope("iron-swarm") diff --git a/plugins/nemo-iron-swarm/src/nemo_iron_swarm_plugin/cli/checks.py b/plugins/nemo-iron-swarm/src/nemo_iron_swarm_plugin/cli/checks.py new file mode 100644 index 0000000000..bb70b05db8 --- /dev/null +++ b/plugins/nemo-iron-swarm/src/nemo_iron_swarm_plugin/cli/checks.py @@ -0,0 +1,141 @@ +# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +"""Preflight checks. Each ``*_ok`` returns ``(ok, detail)``; :func:`run_checks` labels them.""" + +from __future__ import annotations + +import os +import re +import shutil +import subprocess +from typing import NamedTuple + +import typer +from nemo_iron_swarm_plugin.config import INFERENCE_API_KEY_ENVVAR, IronSwarmConfig, read_env_file + +# The OpenShell gateway iron-swarm's `scripts/setup.sh` registers for the defender control plane. +OPENSHELL_GATEWAY = "auto-defender" + +# Every mutating command gates on these probes, so the timeout is a ceiling for a *wedged* daemon, +# not a budget for a healthy one — both answer in well under a second when up. +PROBE_TIMEOUT_SECONDS = 5 + +_ANSI = re.compile(r"\x1b\[[0-9;]*m") +# `openshell status` prints an aligned " Status: Connected" row (color-coded, no JSON mode). +_STATUS_ROW = re.compile(r"^\s*Status:\s*(?P.+?)\s*$", re.MULTILINE) + + +class CheckResult(NamedTuple): + """One preflight row. A NamedTuple so callers can unpack it or use named fields.""" + + label: str + ok: bool + detail: str + + +def docker_ok() -> tuple[bool, str]: + """True if the Docker CLI is present and the daemon is reachable.""" + if shutil.which("docker") is None: + return False, "docker CLI not found — install Docker: https://docs.docker.com/engine/install/" + try: + proc = subprocess.run( + ["docker", "info"], capture_output=True, text=True, timeout=PROBE_TIMEOUT_SECONDS, check=False + ) + except subprocess.TimeoutExpired: + return False, f"`docker info` timed out after {PROBE_TIMEOUT_SECONDS}s — the daemon looks wedged." + except (OSError, subprocess.SubprocessError) as exc: + return False, f"could not run `docker info`: {exc}" + if proc.returncode != 0: + return False, "Docker daemon not reachable — start Docker, then retry." + return True, "Docker daemon reachable." + + +def openshell_gateway_ok() -> tuple[bool, str]: + """True if the OpenShell CLI reports the auto-defender gateway as Connected.""" + if shutil.which("openshell") is None: + return False, ( + "openshell CLI not found — install it: " + "curl -LsSf https://raw.githubusercontent.com/NVIDIA/OpenShell/main/install.sh | sh" + ) + try: + proc = subprocess.run( + ["openshell", "status", "--gateway", OPENSHELL_GATEWAY], + capture_output=True, + text=True, + timeout=PROBE_TIMEOUT_SECONDS, + check=False, + ) + except subprocess.TimeoutExpired: + return False, f"`openshell status` timed out after {PROBE_TIMEOUT_SECONDS}s — the gateway is unresponsive." + except (OSError, subprocess.SubprocessError) as exc: + return False, f"could not run `openshell status`: {exc}" + status = gateway_status(proc.stdout) + if proc.returncode == 0 and status.casefold() == "connected": + return True, f"OpenShell gateway '{OPENSHELL_GATEWAY}' connected." + reported = f" (reported: {status})" if status else "" + return False, f"OpenShell gateway '{OPENSHELL_GATEWAY}' not connected{reported} — run `nemo iron-swarm setup`." + + +def gateway_status(stdout: str) -> str: + """The value of ``openshell status``'s ``Status:`` row, ANSI stripped; ``""`` if absent. + + Matching the whole field rather than substring-searching for "Connected" — otherwise + ``Not Connected`` and ``Last Connected: ...`` both read as healthy. + """ + match = _STATUS_ROW.search(_ANSI.sub("", stdout)) + return match.group("value").strip() if match else "" + + +def venv_ok(config: IronSwarmConfig) -> tuple[bool, str]: + """True if iron-swarm's dedicated venv has been provisioned.""" + if config.iron_swarm_bin.exists(): + return True, f"iron-swarm venv present at {config.venv_path}." + return False, (f"iron-swarm venv missing at {config.venv_path} — run `nemo iron-swarm setup`.") + + +def garak_venv_ok(config: IronSwarmConfig) -> tuple[bool, str]: + """True if the dedicated garak venv (used by iron-swarm's agent_breaker) is provisioned.""" + if config.garak_python.exists(): + return True, f"garak venv present at {config.garak_venv_path}." + return False, (f"garak venv missing at {config.garak_venv_path} — run `nemo iron-swarm setup`.") + + +def operator_env_ok(config: IronSwarmConfig) -> tuple[bool, str]: + """True if iron-swarm's own inference credential is resolvable (env or operator dotenv).""" + if os.environ.get(INFERENCE_API_KEY_ENVVAR): + return True, f"{INFERENCE_API_KEY_ENVVAR} set in the environment." + if read_env_file(config.operator_env_file).get(INFERENCE_API_KEY_ENVVAR): + return True, f"{INFERENCE_API_KEY_ENVVAR} present in {config.operator_env_file}." + return False, ( + f"{INFERENCE_API_KEY_ENVVAR} not found — run `nemo iron-swarm setup` (or export {INFERENCE_API_KEY_ENVVAR})." + ) + + +def run_checks(config: IronSwarmConfig) -> list[CheckResult]: + """Run all preflight checks.""" + return [ + CheckResult("iron-swarm venv", *venv_ok(config)), + CheckResult("garak venv", *garak_venv_ok(config)), + CheckResult("inference credential", *operator_env_ok(config)), + CheckResult("docker", *docker_ok()), + CheckResult("openshell gateway", *openshell_gateway_ok()), + ] + + +def print_checks(checks: list[CheckResult]) -> None: + for check in checks: + mark = typer.style("✓", fg="green") if check.ok else typer.style("✗", fg="red") + typer.echo(f" {mark} {check.label}: {check.detail}") + + +def require_preflight(config: IronSwarmConfig) -> None: + """Gate init/run on preflight when sandbox is required.""" + if not config.require_sandbox: + return + checks = run_checks(config) + if not all(check.ok for check in checks): + typer.secho("Preflight failed:", fg="red") + print_checks(checks) + typer.secho("\nRun `nemo iron-swarm setup` first.", fg="yellow") + raise typer.Exit(code=1) diff --git a/plugins/nemo-iron-swarm/src/nemo_iron_swarm_plugin/cli/client.py b/plugins/nemo-iron-swarm/src/nemo_iron_swarm_plugin/cli/client.py new file mode 100644 index 0000000000..606b97f280 --- /dev/null +++ b/plugins/nemo-iron-swarm/src/nemo_iron_swarm_plugin/cli/client.py @@ -0,0 +1,20 @@ +# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +"""Platform-access helpers shared by the CLI commands and credential resolution.""" + +from __future__ import annotations + +import os + + +def base_url() -> str: + """Resolve the platform base URL (matches repo convention NMP_BASE_URL / NEMO_BASE_URL).""" + return (os.environ.get("NEMO_BASE_URL") or os.environ.get("NMP_BASE_URL") or "http://localhost:8080").rstrip("/") + + +def make_sdk(base: str): + """Construct a NeMoPlatform SDK client against *base*.""" + from nemo_platform import NeMoPlatform # lazy: keeps `doctor`/`setup` import light + + return NeMoPlatform(base_url=base) diff --git a/plugins/nemo-iron-swarm/src/nemo_iron_swarm_plugin/cli/credentials.py b/plugins/nemo-iron-swarm/src/nemo_iron_swarm_plugin/cli/credentials.py new file mode 100644 index 0000000000..db04eab4b5 --- /dev/null +++ b/plugins/nemo-iron-swarm/src/nemo_iron_swarm_plugin/cli/credentials.py @@ -0,0 +1,78 @@ +# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +"""Provision iron-swarm's own ``INFERENCE_API_KEY``. + +iron-swarm's orchestrator reads it straight from the process env (public NVIDIA endpoint, not the +platform gateway), so ``setup`` resolves it (Secrets → env → prompt) into the operator dotenv. +""" + +from __future__ import annotations + +import os +import sys + +import typer +from nemo_iron_swarm_plugin.cli.client import base_url, make_sdk +from nemo_iron_swarm_plugin.config import ( + INFERENCE_API_KEY_ENVVAR, + IronSwarmConfig, + read_env_file, + write_env_file, +) + + +def resolve_inference_key(config: IronSwarmConfig) -> tuple[str | None, str]: + """Resolve iron-swarm's own inference key: NeMo Secrets -> env -> interactive prompt. + + The platform Secrets store is authoritative (house standard); env is the offline fallback. An + explicit ``INFERENCE_API_KEY`` still wins at run time, where the job injects via ``setdefault``. + """ + try: + sdk = make_sdk(base_url()) + secret = sdk.secrets.access(config.inference_secret_name, workspace=config.default_workspace) + if secret and secret.value: + return secret.value, f"secret '{config.inference_secret_name}'" + except Exception: # Secrets store unreachable/absent → fall back to env + pass + + env_value = os.environ.get(INFERENCE_API_KEY_ENVVAR) + if env_value: + return env_value, "environment" + + if sys.stdin.isatty(): + value = typer.prompt(f"Enter {INFERENCE_API_KEY_ENVVAR}", hide_input=True, default="") + if value: + return value, "prompt" + + return None, "unresolved" + + +def write_operator_env(config: IronSwarmConfig, value: str) -> None: + """Persist INFERENCE_API_KEY into the operator dotenv, preserving other keys, mode 0600.""" + path = config.operator_env_file + path.parent.mkdir(parents=True, exist_ok=True, mode=0o700) + values = read_env_file(path) + values[INFERENCE_API_KEY_ENVVAR] = value + write_env_file(path, values) + + +def provision_operator_env(config: IronSwarmConfig, *, force: bool) -> None: + """Ensure iron-swarm's own inference credential is provisioned in the operator dotenv.""" + if not force and read_env_file(config.operator_env_file).get(INFERENCE_API_KEY_ENVVAR): + typer.echo(f"Inference credential already present in {config.operator_env_file}.") + return + + value, source = resolve_inference_key(config) + if value is None: + typer.secho( + f"Could not resolve {INFERENCE_API_KEY_ENVVAR} (no env var, no " + f"'{config.inference_secret_name}' secret, no tty to prompt). Create it with " + f"`nemo secrets create {config.inference_secret_name}` and re-run setup, or export " + f"{INFERENCE_API_KEY_ENVVAR} yourself.", + fg="red", + ) + raise typer.Exit(code=1) + + write_operator_env(config, value) + typer.secho(f"Inference credential provisioned from {source} -> {config.operator_env_file}.", fg="green") diff --git a/plugins/nemo-iron-swarm/src/nemo_iron_swarm_plugin/cli/main.py b/plugins/nemo-iron-swarm/src/nemo_iron_swarm_plugin/cli/main.py new file mode 100644 index 0000000000..643c199602 --- /dev/null +++ b/plugins/nemo-iron-swarm/src/nemo_iron_swarm_plugin/cli/main.py @@ -0,0 +1,348 @@ +# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +"""``nemo iron-swarm ...`` commands — registered under ``nemo.cli``. + +iron-swarm is never imported: it runs in its own venv, invoked by subprocess. Commands delegate +to the sibling modules (:mod:`checks`, :mod:`provisioning`, :mod:`credentials`, :mod:`client`); +each command's docstring is its ``--help`` text. +""" + +from __future__ import annotations + +import json +from dataclasses import dataclass +from pathlib import Path +from typing import Any + +import typer +import yaml +from nemo_iron_swarm_plugin.agent_resolver import AgentResolutionError, resolve_agent_to_manifest +from nemo_iron_swarm_plugin.cli import checks, credentials, provisioning +from nemo_iron_swarm_plugin.cli.client import base_url, make_sdk +from nemo_iron_swarm_plugin.config import IronSwarmConfig, missing_secrets +from nemo_iron_swarm_plugin.entities import IRON_SWARM_MANIFEST_TYPE, IronSwarmManifest +from nemo_iron_swarm_plugin.jobs.defenses import defense_ids, select_defense_ids +from nemo_platform_plugin.cli import NemoCLI + + +@dataclass(frozen=True) +class _CommandContext: + """Resolved preamble every SDK-backed command needs.""" + + config: IronSwarmConfig + sdk: Any + base_url: str + workspace: str + + +def _command_context(workspace: str | None, *, preflight: bool = True) -> _CommandContext: + """Shared command preamble: config, host preflight, SDK client, resolved workspace. + + Making *preflight* an explicit argument keeps the "which commands gate on host readiness" + policy one decision instead of one per command. + """ + config = IronSwarmConfig.get() + if preflight: + checks.require_preflight(config) + url = base_url() + return _CommandContext( + config=config, + sdk=make_sdk(url), + base_url=url, + workspace=workspace or config.default_workspace, + ) + + +class IronSwarmCLI(NemoCLI): + """Exposes plugin commands as ``nemo iron-swarm ...``.""" + + name = "iron-swarm" + description = "Red-team and harden deployed NAT agents with Iron Swarm." + + def get_cli(self) -> typer.Typer: + app = typer.Typer(help=self.description, no_args_is_help=True, add_completion=False) + + # ── doctor ──────────────────────────────────────────────────── + @app.command() + def doctor() -> None: + """Read-only preflight: iron-swarm venv, garak venv, Docker daemon, OpenShell gateway.""" + config = IronSwarmConfig.get() + typer.echo("Iron Swarm preflight:") + results = checks.run_checks(config) + checks.print_checks(results) + if all(check.ok for check in results): + typer.secho("\nAll checks passed.", fg="green") + return + typer.secho("\nSome checks failed — run `nemo iron-swarm setup`.", fg="yellow") + raise typer.Exit(code=1) + + # ── setup ───────────────────────────────────────────────────── + @app.command() + def setup( + force: bool = typer.Option(False, "--force", "-f", help="Recreate the venv even if it already exists."), + ) -> None: + """Provision iron-swarm's venv, the garak venv, and the inference credential, then check prereqs. + + iron-swarm's setup registers the OpenShell gateway (best-effort). Docker and the + OpenShell CLI/service install stay instructed — they need sudo/brew and are unreliable + under a sandbox. + """ + config = IronSwarmConfig.get() + provisioning.provision_venv(config, force=force) + provisioning.run_iron_swarm_setup(config, force=force) + credentials.provision_operator_env(config, force=force) + + typer.echo("\nChecking host prerequisites:") + results = checks.run_checks(config) + checks.print_checks(results) + failed = [check.label for check in results if not check.ok] + if failed: + typer.secho( + f"\nStill needed: {', '.join(failed)}. Follow the hints above, then re-run " + "`nemo iron-swarm doctor`.", + fg="yellow", + ) + raise typer.Exit(code=1) + typer.secho("\nSetup complete. Next: nemo iron-swarm init --agent ", fg="green") + + # ── init ────────────────────────────────────────────────────── + @app.command() + def init( + agent: str = typer.Option( + ..., "--agent", help="Deployed NeMo Platform agent to target (name or workspace/name)." + ), + name: str | None = typer.Option( + None, "--name", help="Saved-manifest name (the id later phases reference). Defaults to the agent name." + ), + workspace: str | None = typer.Option(None, "--workspace", help="Agent workspace."), + output: str = typer.Option("iron-swarm.yaml", "--output", "-o", help="Manifest path."), + project_dir: str | None = typer.Option( + None, "--project-dir", help="NAT project dir (required only for agents with custom components)." + ), + ) -> None: + """Scaffold an iron-swarm manifest from a deployed agent and save it as a reusable manifest.""" + ctx = _command_context(workspace) + sdk = ctx.sdk + ws = ctx.workspace + out_path = Path(output) + manifest_dir = out_path.parent + try: + resolved = resolve_agent_to_manifest( + agent, + sdk=sdk, + base_url=ctx.base_url, + default_workspace=ws, + manifest_dir=manifest_dir, + project_dir=project_dir, + ) + except AgentResolutionError as exc: + typer.secho(f"Error: {exc}", fg="red") + raise typer.Exit(code=1) from exc + + manifest_yaml = yaml.safe_dump(resolved.manifest, sort_keys=False) + out_path.write_text(manifest_yaml, encoding="utf-8") + for warning in resolved.warnings: + typer.secho(f" ! {warning}", fg="yellow") + typer.secho(f"Wrote {out_path}", fg="green") + typer.echo( + f" agent {resolved.workspace}/{resolved.agent_name}\n" + f" victim port {resolved.port}\n" + f" workflow {resolved.workflow_path}\n" + f" secrets {', '.join(resolved.secrets)}" + ) + + # Persist the manifest as a saved entity so `synth-benign` and `run` can reference it by name + # and share the cached benign suite (mirrors Studio's POST /manifests). + entity = IronSwarmManifest.from_agent_resolution( + name=name or resolved.agent_name, + workspace=ws, + agent_ref=f"{resolved.workspace}/{resolved.agent_name}", + manifest_yaml=manifest_yaml, + port=resolved.port, + secrets=resolved.secrets, + warnings=resolved.warnings, + ) + try: + sdk.entities.create( + IRON_SWARM_MANIFEST_TYPE, workspace=ws, data=entity._get_data_fields(), name=entity.name + ) + except Exception as exc: + typer.secho( + f" ! could not save manifest '{entity.name}' ({exc}); " + f"the local {out_path} still works with `run --config`.", + fg="yellow", + ) + raise typer.Exit(code=1) from exc + typer.secho(f"Saved manifest '{entity.name}'", fg="green") + typer.echo(f"\nNext: nemo iron-swarm synth-benign --manifest-id {entity.name}") + + # ── run ─────────────────────────────────────────────────────── + @app.command() + def run( + config_file: str | None = typer.Option( + None, "--config", "-c", help="Local manifest produced by `init` (default: iron-swarm.yaml)." + ), + manifest_id: str | None = typer.Option( + None, "--manifest-id", help="Saved manifest to run (reuses its cached benign suite)." + ), + env_file: str | None = typer.Option(None, "--env-file", help="Dotenv with the agent's secrets."), + workspace: str | None = typer.Option(None, "--workspace", help="Workspace for the run."), + benign_suite: str | None = typer.Option( + None, + "--benign-suite", + help="Benign-suite CSV (tool,payload,label,rationale,persona) to use as-is, overriding the cache.", + ), + ) -> None: + """Run the attack/defend/validate war-game against a local manifest or a saved manifest.""" + ctx = _command_context(workspace) + if config_file and manifest_id: + typer.secho("Pass either --config or --manifest-id, not both.", fg="red") + raise typer.Exit(code=1) + if benign_suite and not Path(benign_suite).is_file(): + typer.secho(f"Benign suite CSV {benign_suite} not found.", fg="red") + raise typer.Exit(code=1) + + # The saved-manifest path materializes server-side and checks victim secrets in the job; the + # local-file path validates the manifest exists and its secrets are satisfiable up front. + if not manifest_id: + config_file = config_file or "iron-swarm.yaml" + if not Path(config_file).exists(): + typer.secho( + f"Manifest {config_file} not found — run `nemo iron-swarm init --agent ` first.", + fg="red", + ) + raise typer.Exit(code=1) + env_files = [ctx.config.operator_env_file] + ([Path(env_file)] if env_file else []) + missing = missing_secrets(Path(config_file), env_files=env_files) + if missing: + typer.secho( + f"Missing required secrets: {', '.join(missing)}. Provide them via --env-file " + "or export them, then re-run.", + fg="red", + ) + raise typer.Exit(code=1) + + result = ctx.sdk.iron_swarm.run( + config=config_file, + manifest_id=manifest_id, + env_file=env_file, + workspace=ctx.workspace, + benign_suite=benign_suite, + ) + typer.echo(json.dumps(result, indent=2, default=str)) + raise typer.Exit(code=0 if result.get("status") == "completed" else 1) + + # ── synth-benign ────────────────────────────────────────────── + @app.command(name="synth-benign") + def synth_benign( + manifest_id: str = typer.Option(..., "--manifest-id", help="Saved manifest to synthesize a suite for."), + env_file: str | None = typer.Option(None, "--env-file", help="Dotenv with the agent's secrets."), + yes: bool = typer.Option( + False, "--yes", "-y", help="Run the interview but auto-accept each recommended default (no prompts)." + ), + no_interactive: bool = typer.Option( + False, "--no-interactive", help="Skip the interview entirely (leaner, rules-only suite; use in CI)." + ), + workspace: str | None = typer.Option(None, "--workspace", help="Workspace of the manifest."), + ) -> None: + """Synthesize the benign request suite for a saved manifest and cache it on the manifest. + + Brings the victim sandbox up, runs iron-swarm's interview/review (interactive by default), then + tears it down — the reviewed suite is stored on the manifest so a later `run --manifest-id` reuses it. + """ + ctx = _command_context(workspace) + if yes and no_interactive: + typer.secho("Pass either --yes or --no-interactive, not both.", fg="red") + raise typer.Exit(code=1) + interview = "skip" if no_interactive else "auto" if yes else "interactive" + + result = ctx.sdk.iron_swarm.synth_benign( + manifest_id=manifest_id, + env_file=env_file, + interview=interview, + workspace=ctx.workspace, + ) + if result.get("status") == "completed": + typer.secho( + f"Cached {result.get('suite_size', 0)} benign requests on manifest '{manifest_id}'.", fg="green" + ) + typer.echo(f"\nNext: nemo iron-swarm run --manifest-id {manifest_id}") + raise typer.Exit(code=0) + typer.echo(json.dumps(result, indent=2, default=str)) + raise typer.Exit(code=1) + + # ── sanity-check ────────────────────────────────────────────── + @app.command(name="sanity-check") + def sanity_check( + manifest_id: str = typer.Option(..., "--manifest-id", help="Saved manifest to validate against."), + mitigations_file: str = typer.Option( + ..., "--mitigations", help="Path to the run's mitigations.json (its 'defenses' list is selected from)." + ), + replay_hitlog: str = typer.Option( + ..., + "--replay-hitlog", + help="Fileset ref of the recorded garak hitlog to replay (run's hitlog_fileset).", + ), + keep: list[str] = typer.Option( + None, + "--keep", + help="Defense id to keep (repeatable). Default: keep all. Mutually exclusive with --exclude.", + ), + exclude: list[str] = typer.Option(None, "--exclude", help="Defense id to drop (repeatable)."), + env_file: str | None = typer.Option(None, "--env-file", help="Dotenv with the agent's secrets."), + workspace: str | None = typer.Option(None, "--workspace", help="Workspace for the run."), + ) -> None: + """Freeze a chosen subset of a run's recommended defenses and replay the recorded attacks + benign. + + Runs the war-game cycle one last time with the mitigation-generating defenders disabled and the + chosen defenses frozen as the victim baseline — a sanity check that reports which attacks the + selection blocks and which benign requests it wrongly blocks (false positives). Get the + ``mitigations.json`` and the hitlog fileset ref from a completed run (`nemo iron-swarm status`). + """ + ctx = _command_context(workspace) + if keep and exclude: + typer.secho("Pass either --keep or --exclude, not both.", fg="red") + raise typer.Exit(code=1) + path = Path(mitigations_file) + if not path.exists(): + typer.secho(f"Mitigations file {mitigations_file} not found.", fg="red") + raise typer.Exit(code=1) + mitigations = json.loads(path.read_text(encoding="utf-8")) + selected = select_defense_ids(defense_ids(mitigations), keep=keep or None, exclude=exclude or None) + typer.echo(f"Sanity-checking {len(selected)} defense(s): {', '.join(selected) or '(none)'}") + + result = ctx.sdk.iron_swarm.sanity_check( + manifest_id=manifest_id, + mitigations=mitigations, + selected_defense_ids=selected, + replay_hitlog_fileset=replay_hitlog, + env_file=env_file, + workspace=ctx.workspace, + ) + typer.echo(json.dumps(result, indent=2, default=str)) + + # ── status ──────────────────────────────────────────────────── + @app.command() + def status( + workspace: str | None = typer.Option(None, "--workspace", help="Workspace to read runs from."), + limit: int = typer.Option(5, "--limit", help="How many recent runs to show."), + ) -> None: + """Show recent Iron Swarm runs.""" + # No preflight: reading run records doesn't need Docker/OpenShell/the venvs. + ctx = _command_context(workspace, preflight=False) + ws = ctx.workspace + runs = ctx.sdk.iron_swarm.runs.list(workspace=ws, limit=limit) + if not runs: + typer.echo(f"No Iron Swarm runs in workspace '{ws}'.") + return + for record in runs: + mark = ( + typer.style("✓", fg="green") if record.get("status") == "completed" else typer.style("✗", fg="red") + ) + typer.echo( + f" {mark} {record.get('created_at', '?')} {record.get('agent', '?')} " + f"{record.get('status', '?')} (exit {record.get('returncode', '?')}) [{record.get('name', '')}]" + ) + + return app diff --git a/plugins/nemo-iron-swarm/src/nemo_iron_swarm_plugin/cli/provisioning.py b/plugins/nemo-iron-swarm/src/nemo_iron_swarm_plugin/cli/provisioning.py new file mode 100644 index 0000000000..b1787fb150 --- /dev/null +++ b/plugins/nemo-iron-swarm/src/nemo_iron_swarm_plugin/cli/provisioning.py @@ -0,0 +1,94 @@ +# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +"""Provision the two userspace venvs `nemo iron-swarm setup` needs. + +iron-swarm's own venv (installed via uv), and the separate garak venv its agent_breaker spawns +(delegated to iron-swarm's own ``setup``, which owns the garak version pin). +""" + +from __future__ import annotations + +import os +import shutil +import subprocess + +import typer +from nemo_iron_swarm_plugin.config import GARAK_PYTHON_ENVVAR, IronSwarmConfig + +# `uv pip install` pulls torch-sized wheels; generous enough for a cold cache on a slow link, but +# bounded so a hung download fails with a message instead of blocking setup forever. +SUBPROCESS_TIMEOUT_SECONDS = 900 + + +def run_subprocess( + cmd: list[str], + action: str, + env: dict[str, str] | None = None, + *, + timeout: int = SUBPROCESS_TIMEOUT_SECONDS, +) -> None: + """Run *cmd* with its output streamed to the terminal, exiting with *action* context on failure. + + Output is inherited rather than captured: these are multi-minute installs, and swallowing uv's + progress makes setup look hung. It also means both stdout and stderr reach the operator — uv + reports some failures on stdout. + """ + try: + proc = subprocess.run(cmd, check=False, env=env, timeout=timeout) + except subprocess.TimeoutExpired as exc: + typer.secho(f"Timed out after {timeout}s trying to {action} — the command made no progress.", fg="red") + raise typer.Exit(code=1) from exc + except (OSError, subprocess.SubprocessError) as exc: + typer.secho(f"Failed to {action}: {exc}", fg="red") + raise typer.Exit(code=1) from exc + if proc.returncode != 0: + typer.secho(f"Failed to {action} (exit {proc.returncode}) — see the output above.", fg="red") + raise typer.Exit(code=1) + + +def provision_venv(config: IronSwarmConfig, *, force: bool) -> None: + """Create iron-swarm's dedicated venv and install iron-swarm into it via uv.""" + if shutil.which("uv") is None: + typer.secho("uv not found — install it (https://docs.astral.sh/uv/) then re-run setup.", fg="red") + raise typer.Exit(code=1) + + if config.iron_swarm_bin.exists() and not force: + typer.echo(f"iron-swarm venv already present at {config.venv_path} (use --force to recreate).") + return + + config.venv_path.parent.mkdir(parents=True, exist_ok=True) + typer.echo(f"Creating iron-swarm venv at {config.venv_path} ...") + run_subprocess(["uv", "venv", "--python", "3.12", str(config.venv_path)], "create venv") + + typer.echo(f"Installing {config.iron_swarm_spec} into the venv ...") + run_subprocess( + ["uv", "pip", "install", "--python", str(config.venv_path / "bin" / "python"), config.iron_swarm_spec], + "install iron-swarm", + ) + if not config.iron_swarm_bin.exists(): + typer.secho( + f"Install finished but {config.iron_swarm_bin} is missing — check the package spec " + f"({config.iron_swarm_spec}).", + fg="red", + ) + raise typer.Exit(code=1) + typer.secho(f"iron-swarm installed: {config.iron_swarm_bin}", fg="green") + + +def run_iron_swarm_setup(config: IronSwarmConfig, *, force: bool) -> None: + """Run ``iron-swarm setup`` (idempotent): provisions the garak venv and registers the gateway. + + Not gated on the garak venv, so the OpenShell gateway is re-ensured on every setup (iron-swarm + fast-returns the existing venv). Needs iron-swarm installed first (``provision_venv``). The + plugin points garak provisioning at its managed location via ``IRON_SWARM_GARAK_PYTHON``. + """ + cmd = [str(config.iron_swarm_bin), "setup"] + if force: + cmd.append("--force") + typer.echo("Running `iron-swarm setup` (garak venv + OpenShell gateway) ...") + run_subprocess(cmd, "run iron-swarm setup", {**os.environ, GARAK_PYTHON_ENVVAR: str(config.garak_python)}) + if not config.garak_python.exists(): + typer.secho(f"iron-swarm setup finished but {config.garak_python} is missing.", fg="red") + raise typer.Exit(code=1) + typer.secho(f"garak venv ready: {config.garak_venv_path}", fg="green") diff --git a/plugins/nemo-iron-swarm/src/nemo_iron_swarm_plugin/config.py b/plugins/nemo-iron-swarm/src/nemo_iron_swarm_plugin/config.py new file mode 100644 index 0000000000..39e2bd0ba1 --- /dev/null +++ b/plugins/nemo-iron-swarm/src/nemo_iron_swarm_plugin/config.py @@ -0,0 +1,202 @@ +# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +"""Configuration for the Iron Swarm plugin. + +Declares :attr:`plugin_name` / :attr:`plugin_description` as ``ClassVar`` strings and +plugin-specific fields with defaults, following the +:class:`~nemo_platform_plugin.config.NemoConfig` pattern. + +Operators set values via environment variables (``NEMO_IRON_SWARM_*``) or the Helm +``platformConfig.iron_swarm`` key. iron-swarm runs in its own isolated venv (:attr:`venv_path`) +and the plugin invokes its CLI by subprocess rather than importing it. garak — which iron-swarm's +agent_breaker attacker spawns — lives in a *second* dedicated venv (:attr:`garak_venv_path`), kept +separate because garak pulls ``litellm`` (``httpx>=0.28``) and ``torch`` that would otherwise +conflict with iron-swarm's dependencies. The plugin points iron-swarm at it via the +``IRON_SWARM_GARAK_PYTHON`` environment variable. +""" + +from __future__ import annotations + +import os +from collections.abc import Iterable, Mapping +from pathlib import Path +from typing import ClassVar + +import yaml +from nemo_platform_plugin.config import NemoConfig +from pydantic import Field + +# Env var iron-swarm reads to locate the garak venv its agent_breaker attacker spawns. The plugin +# exports it (to ``garak_python``) for both ``iron-swarm setup`` (provision) and ``iron-swarm run``. +GARAK_PYTHON_ENVVAR = "IRON_SWARM_GARAK_PYTHON" + +# iron-swarm's orchestrator reads this directly from the process env (no IGW routing). +INFERENCE_API_KEY_ENVVAR = "INFERENCE_API_KEY" # pragma: allowlist secret + + +def _default_venv_path() -> Path: + """Default location for iron-swarm's dedicated venv (created by ``nemo iron-swarm setup``).""" + return Path.home() / ".iron-swarm" / "venv" + + +def _default_garak_venv_path() -> Path: + """Default location for the dedicated garak venv iron-swarm's agent_breaker spawns. + + Matches iron-swarm's own default (``~/.iron-swarm/garak-venv``) so the + ``IRON_SWARM_GARAK_PYTHON`` export and iron-swarm's fallback agree. + """ + return Path.home() / ".iron-swarm" / "garak-venv" + + +def _default_operator_env_file() -> Path: + """Default location for iron-swarm's own operator dotenv (provisioned by ``setup``).""" + return Path.home() / ".iron-swarm" / ".env" + + +def read_env_file(path: Path) -> dict[str, str]: + """Minimal dotenv reader: skips blank/`#` lines, strips `export `/quotes. `{}` if missing.""" + if not path.exists(): + return {} + values: dict[str, str] = {} + for line in path.read_text(encoding="utf-8").splitlines(): + stripped = line.strip() + if not stripped or stripped.startswith("#"): + continue + if stripped.startswith("export "): + stripped = stripped[len("export ") :] + key, sep, value = stripped.partition("=") + if not sep: + continue + key = key.strip() + value = value.strip() + if len(value) >= 2 and value[0] == value[-1] and value[0] in "\"'": + value = value[1:-1] + values[key] = value + return values + + +def write_env_file(path: Path, values: Mapping[str, str]) -> None: + """Write *values* as a dotenv at *path*, mode 0600 from creation. + + Opened with the mode applied up front rather than chmod'd afterwards: a plain write lands at the + default umask (typically 0644), leaving the credentials world-readable until the chmod lands. + Every dotenv this plugin writes holds provider keys, so both call sites go through here. + """ + body = "".join(f"{key}={value}\n" for key, value in values.items()) + fd = os.open(path, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600) + with os.fdopen(fd, "w", encoding="utf-8") as handle: + handle.write(body) + os.chmod(path, 0o600) # O_CREAT ignores the mode when the file already exists + + +def missing_secrets( + manifest_path: Path, + *, + env_files: Iterable[Path] = (), + environ: Mapping[str, str] | None = None, +) -> list[str]: + """Return the manifest's declared victim secrets that no available source provides. + + Sources: *environ* (defaults to ``os.environ``), the manifest's own ``secrets_file`` (resolved + next to the manifest), and each dotenv in *env_files* (e.g. the operator env + a ``--env-file``). + A name set to an empty value counts as missing — ``KEY=`` in a dotenv or ``export KEY=""`` would + otherwise pass this gate and resurface minutes later as a provider auth error. + Returns the missing names in declaration order; ``[]`` when none are declared or the manifest + can't be read (parsing is not this check's job). + """ + try: + data = yaml.safe_load(manifest_path.read_text(encoding="utf-8")) or {} + except (OSError, yaml.YAMLError): + return [] + agent = data.get("agent", {}) if isinstance(data, dict) else {} + declared = [name for name in (agent.get("secrets") or []) if isinstance(name, str)] + if not declared: + return [] + + available = _non_empty_keys(os.environ if environ is None else environ) + secrets_file = agent.get("secrets_file") + if isinstance(secrets_file, str) and secrets_file: + # `secrets_file` may be relative to the manifest or already absolute (see jobs/manifest.py); + # `/` handles both. + available |= _non_empty_keys(read_env_file(manifest_path.parent / secrets_file)) + for path in env_files: + available |= _non_empty_keys(read_env_file(Path(path))) + return [name for name in declared if name not in available] + + +def _non_empty_keys(values: Mapping[str, str]) -> set[str]: + """Names in *values* that carry an actual value; a blank one provides nothing.""" + return {name for name, value in values.items() if value and value.strip()} + + +class IronSwarmConfig(NemoConfig): + """Configuration for the NeMo Platform Iron Swarm plugin. + + All fields have defaults so the plugin loads without operator configuration; the + iron-swarm venv itself is provisioned on demand by ``nemo iron-swarm setup``. + """ + + plugin_name: ClassVar[str] = "iron_swarm" + plugin_description: ClassVar[str] = "Configuration for the NeMo Platform Iron Swarm plugin." + + default_workspace: str = Field( + default="default", + description="Workspace used to resolve agents and store run records when none is given.", + ) + venv_path: Path = Field( + default_factory=_default_venv_path, + description=( + "Directory holding iron-swarm's dedicated venv. The plugin invokes " + "{venv_path}/bin/iron-swarm by subprocess. Set NEMO_IRON_SWARM_VENV_PATH to override." + ), + ) + iron_swarm_spec: str = Field( + default="iron-swarm", + description=( + "Package spec `nemo iron-swarm setup` installs into the venv (e.g. 'iron-swarm', " + "'iron-swarm==0.0.1', or a local path/VCS URL for development)." + ), + ) + garak_venv_path: Path = Field( + default_factory=_default_garak_venv_path, + description=( + "Directory holding the dedicated garak venv. iron-swarm's agent_breaker spawns garak " + "from {garak_venv_path}/bin/python; the plugin exports IRON_SWARM_GARAK_PYTHON to it so " + "`iron-swarm setup` provisions there (the garak version pin lives in iron-swarm). " + "Set NEMO_IRON_SWARM_GARAK_VENV_PATH to override." + ), + ) + require_sandbox: bool = Field( + default=True, + description=( + "When True, init/run preflight (doctor) fails hard if Docker or the OpenShell " + "gateway is unavailable. Set False only for dry-run/manifest-only flows." + ), + ) + operator_env_file: Path = Field( + default_factory=_default_operator_env_file, + description=( + "Dotenv holding iron-swarm's own inference credential, provisioned by `setup` and " + "injected into every `run`. Set NEMO_IRON_SWARM_OPERATOR_ENV_FILE to override." + ), + ) + inference_secret_name: str = Field( + default="iron-swarm-inference-key", + description="NeMo Secret name `setup` reads iron-swarm's own inference key from, if present.", + ) + + @property + def state_dir(self) -> Path: + """Base dir for iron-swarm on-host state (the venvs live under it; also run-event logs).""" + return self.venv_path.parent + + @property + def iron_swarm_bin(self) -> Path: + """Path to the iron-swarm CLI inside the dedicated venv.""" + return self.venv_path / "bin" / "iron-swarm" + + @property + def garak_python(self) -> Path: + """Path to the Python interpreter inside the dedicated garak venv.""" + return self.garak_venv_path / "bin" / "python" diff --git a/plugins/nemo-iron-swarm/src/nemo_iron_swarm_plugin/entities.py b/plugins/nemo-iron-swarm/src/nemo_iron_swarm_plugin/entities.py index 2f583c99f1..f850b08b81 100644 --- a/plugins/nemo-iron-swarm/src/nemo_iron_swarm_plugin/entities.py +++ b/plugins/nemo-iron-swarm/src/nemo_iron_swarm_plugin/entities.py @@ -111,3 +111,33 @@ class IronSwarmManifest(NemoEntity, entity_type=IRON_SWARM_MANIFEST_TYPE): description="Stored default model selection (attack/analysis/agent groups); an unset group uses " "iron-swarm's built-in default. A run may override these per-launch.", ) + + @classmethod + def from_agent_resolution( + cls, + *, + name: str, + workspace: str, + agent_ref: str, + manifest_yaml: str, + port: int, + secrets: list[str], + warnings: list[str], + models: WarGameModels | None = None, + ) -> IronSwarmManifest: + """Build an ``agent``-source manifest entity from a resolved agent scaffold. + + Shared by the CLI ``init`` and the Studio ``POST /manifests`` handler so both persist the same + shape from :func:`resolve_agent_to_manifest`'s output (the run re-materializes from ``agent_ref``). + """ + return cls( + name=name, + workspace=workspace, + agent=agent_ref, + source_type="agent", + manifest_yaml=manifest_yaml, + port=port, + secrets=secrets, + warnings=warnings, + models=models or WarGameModels(), + ) diff --git a/plugins/nemo-iron-swarm/src/nemo_iron_swarm_plugin/filesets.py b/plugins/nemo-iron-swarm/src/nemo_iron_swarm_plugin/filesets.py new file mode 100644 index 0000000000..a1cad393cc --- /dev/null +++ b/plugins/nemo-iron-swarm/src/nemo_iron_swarm_plugin/filesets.py @@ -0,0 +1,106 @@ +# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +"""Download + safely expand an uploaded NAT project bundle stored as a platform fileset. + +An uploaded project is stored as a single-file fileset (one zip). The inspect endpoint, manifest +creation, and the war-game job all need the project on local disk, so this module downloads the whole +fileset and expands the zip with hardening (no absolute members, no symlinks, no traversal, bounded +size/entry count) — the archive is untrusted user input and is never executed here (only statically +scanned by ``iron-swarm inspect`` and later run inside the OpenShell sandbox). +""" + +from __future__ import annotations + +import stat +import zipfile +from pathlib import Path + +import fsspec.asyn +from nemo_platform import NeMoPlatform +from nemo_platform.filesets import FilesetFileSystem +from nemo_platform_plugin.client.adapter import client_from_platform +from nemo_platform_plugin.files.client import FilesClient + +_MAX_ENTRIES = 10_000 +_MAX_UNCOMPRESSED_BYTES = 500 * 1024 * 1024 # 500 MB expanded — a NAT project, not a dataset. +_IGNORED_TOP_LEVEL = frozenset({"__MACOSX"}) + + +def _is_absolute_member(name: str) -> bool: + """Return whether a zip member name is an absolute path (POSIX, Windows, or drive-letter).""" + return name.startswith(("/", "\\")) or (len(name) >= 2 and name[1] == ":") + + +def download_fileset(sdk: NeMoPlatform, ref: str, dest: Path) -> Path: + """Download an entire fileset (all files) into *dest* using the sync platform SDK. + + Whole-fileset download only — Iron Swarm stores the project as one zip, so there is no + fragment/glob handling (unlike the evaluator's dataset downloader). + """ + fs = FilesetFileSystem(client=client_from_platform(sdk, FilesClient)) + dest.mkdir(parents=True, exist_ok=True) + source = ref.rstrip("/") + "/" + fsspec.asyn.sync(fs.loop, fs._get, source, str(dest), True) + return dest + + +def upload_file_to_fileset(sdk: NeMoPlatform, local_path: Path, *, workspace: str) -> str: + """Upload a single file into a freshly-created fileset and return its ``workspace/name`` ref. + + Used to persist a war-game's produced garak hitlog so a later run can replay it: platform + persistent job storage is per-job, so the hitlog must live in a fileset to survive across runs. + """ + fileset = sdk.files.upload( + local_path=str(local_path), + workspace=workspace, + fileset_auto_create=True, # generates a unique fileset name + ) + return f"{workspace}/{fileset.name}" + + +def extract_zip_safely(zip_path: Path, dest: Path) -> Path: + """Expand *zip_path* into *dest*, rejecting absolute/symlink/traversing members and oversized archives. + + The size cap sums ``ZipInfo.file_size``, which is the archive's own declared figure. That is a sound + bound because ``zipfile`` also *reads* against it: a member claiming to be smaller than its data is + truncated at the declared length and fails its CRC, so an under-reporting bomb cannot expand past + the cap — it errors out instead. + """ + dest.mkdir(parents=True, exist_ok=True) + dest_resolved = dest.resolve() + with zipfile.ZipFile(zip_path) as archive: + infos = archive.infolist() + if len(infos) > _MAX_ENTRIES: + raise ValueError(f"Project archive has too many entries ({len(infos)} > {_MAX_ENTRIES}).") + total = 0 + for info in infos: + name = info.filename + if _is_absolute_member(name): + raise ValueError(f"Archive member has an absolute path: {name!r}") + if stat.S_ISLNK(info.external_attr >> 16): + raise ValueError(f"Archive contains a symlink ({name!r}); not allowed.") + if not (dest / name).resolve().is_relative_to(dest_resolved): + raise ValueError(f"Archive member escapes the destination: {name!r}") + total += info.file_size + if total > _MAX_UNCOMPRESSED_BYTES: + raise ValueError("Project archive is too large when uncompressed (max 500 MB).") + archive.extractall(dest) + return dest + + +def download_and_extract_project(sdk: NeMoPlatform, ref: str, workdir: Path) -> Path: + """Download the project fileset into *workdir*, expand its zip, and return the project root. + + Collapses a single wrapping top-level directory (the common ``repo-name/…`` zip layout) so the + returned path is the installable project itself. + """ + bundle_dir = download_fileset(sdk, ref, workdir / "bundle") + zips = sorted(bundle_dir.rglob("*.zip")) + if not zips: + raise ValueError(f"Fileset {ref!r} contains no .zip project bundle.") + extracted = extract_zip_safely(zips[0], workdir / "project") + entries = [p for p in extracted.iterdir() if p.name not in _IGNORED_TOP_LEVEL] + if len(entries) == 1 and entries[0].is_dir(): + return entries[0] + return extracted diff --git a/plugins/nemo-iron-swarm/src/nemo_iron_swarm_plugin/jobs/_common.py b/plugins/nemo-iron-swarm/src/nemo_iron_swarm_plugin/jobs/_common.py new file mode 100644 index 0000000000..ee70fb19fe --- /dev/null +++ b/plugins/nemo-iron-swarm/src/nemo_iron_swarm_plugin/jobs/_common.py @@ -0,0 +1,195 @@ +# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +"""Shared plumbing for the iron-swarm jobs. + +Both the war-game and (future) synth stages shell out to iron-swarm's CLI inside its dedicated venv and +need the same env wiring (garak venv + iron-swarm's own inference key + the victim's secrets) and the same +TTY-aware subprocess execution. This module holds that shared logic so the jobs don't duplicate it. +""" + +from __future__ import annotations + +import os +import subprocess +import sys +from pathlib import Path +from typing import Any + +import yaml +from nemo_iron_swarm_plugin.config import ( + GARAK_PYTHON_ENVVAR, + IronSwarmConfig, + missing_secrets, + read_env_file, + write_env_file, +) +from nemo_iron_swarm_plugin.jobs.errors import ( + CATEGORY_MISSING_CREDENTIAL, + CATEGORY_PROVISIONING, + IronSwarmRunError, +) +from nemo_iron_swarm_plugin.model_config import ModelChoice, WarGameModels +from nemo_platform_plugin.job_context import JobContext + + +def require_provisioned(plugin_config: IronSwarmConfig) -> None: + """Raise if iron-swarm or the garak venv isn't provisioned on this host.""" + if not plugin_config.iron_swarm_bin.exists(): + raise IronSwarmRunError( + CATEGORY_PROVISIONING, + f"iron-swarm is not provisioned at {plugin_config.iron_swarm_bin}. " + "Run `nemo iron-swarm setup` on the host that executes this job.", + ) + if not plugin_config.garak_python.exists(): + raise IronSwarmRunError( + CATEGORY_PROVISIONING, + f"garak venv is not provisioned at {plugin_config.garak_venv_path}. " + "Run `nemo iron-swarm setup` on the host that executes this job.", + ) + + +def build_subprocess_env(plugin_config: IronSwarmConfig, extra_env: dict[str, str] | None = None) -> dict[str, str]: + """Subprocess env: garak venv for the agent_breaker + iron-swarm's own key from the operator dotenv. + + Explicit shell env still wins over the operator dotenv (``setdefault``). ``extra_env`` (the user's + per-run model selection, see :func:`build_model_env`) is applied last so a chosen model / endpoint / + key overrides both the process env and the operator dotenv default. + """ + env = {**os.environ, GARAK_PYTHON_ENVVAR: str(plugin_config.garak_python)} + for key, value in read_env_file(plugin_config.operator_env_file).items(): + env.setdefault(key, value) + if extra_env: + env.update(extra_env) + return env + + +# Map each model group to iron-swarm's native env knobs. attack → garak's red-team + detector (name/uri +# + NIM_API_KEY); analysis → the shared llm factory default (IRON_SWARM_MODEL/BASE_URL + INFERENCE_API_KEY). +# The agent (victim) model is not an env knob — it rewrites the victim's manifest LLMs instead. +_ATTACK_MODEL_ENVVARS = ("GARAK_RED_TEAM_MODEL_NAME", "GARAK_DETECTOR_MODEL_NAME") +_ATTACK_BASE_URL_ENVVARS = ("GARAK_RED_TEAM_MODEL_URI", "GARAK_DETECTOR_MODEL_URI") +_ATTACK_KEY_ENVVAR = "NIM_API_KEY" # pragma: allowlist secret +_ANALYSIS_MODEL_ENVVAR = "IRON_SWARM_MODEL" +_ANALYSIS_BASE_URL_ENVVAR = "IRON_SWARM_BASE_URL" +_ANALYSIS_KEY_ENVVAR = "INFERENCE_API_KEY" # pragma: allowlist secret + + +def _resolve_secret(sdk: Any, name: str, workspace: str) -> str | None: + """Fetch a Secret's plaintext value via the platform SDK; None if unavailable (caller warns/fails).""" + if sdk is None: + return None + secret = sdk.secrets.access(name, workspace=workspace) + value = getattr(secret, "value", None) + return str(value) if value else None + + +def build_model_env(models: WarGameModels | None, *, sdk: Any, workspace: str) -> dict[str, str]: + """Translate the user's model selection into iron-swarm subprocess env vars. + + Only set knobs the user actually chose (``None`` leaves iron-swarm's built-in default in force). A + group's ``api_key_secret`` is resolved to its plaintext value and bound to that group's key env var, + so a custom provider's credential reaches garak (NIM_API_KEY) / the llm factory (INFERENCE_API_KEY). + """ + if models is None: + return {} + env: dict[str, str] = {} + _apply_group( + env, models.attack, _ATTACK_MODEL_ENVVARS, _ATTACK_BASE_URL_ENVVARS, _ATTACK_KEY_ENVVAR, sdk, workspace + ) + _apply_group( + env, + models.analysis, + (_ANALYSIS_MODEL_ENVVAR,), + (_ANALYSIS_BASE_URL_ENVVAR,), + _ANALYSIS_KEY_ENVVAR, + sdk, + workspace, + ) + return env + + +def _apply_group( + env: dict[str, str], + choice: ModelChoice | None, + model_envvars: tuple[str, ...], + base_url_envvars: tuple[str, ...], + key_envvar: str, + sdk: Any, + workspace: str, +) -> None: + """Set a group's model/base_url/key env vars from a :class:`ModelChoice` (skipping unset fields).""" + if choice is None: + return + if choice.model: + for name in model_envvars: + env[name] = choice.model + if choice.base_url: + for name in base_url_envvars: + env[name] = choice.base_url + if choice.api_key_secret: + value = _resolve_secret(sdk, choice.api_key_secret, workspace) + if value: + env[key_envvar] = value + + +def materialize_victim_env_file(manifest: str, env: dict[str, str], dest_dir: Path) -> str | None: + """Write the manifest's declared victim secrets (sourced from *env*) to a dotenv; return its path. + + Studio submits with no ``--env-file``, but iron-swarm reads the victim's provider credentials from a + project dotenv. We source the manifest's declared secrets from the subprocess env (which carries + iron-swarm's operator key, see :func:`build_subprocess_env`) and write them so the war-game has creds. + Returns ``None`` when the manifest declares no secrets or none are present in *env*. + """ + try: + data = yaml.safe_load(Path(manifest).read_text(encoding="utf-8")) or {} + except (OSError, yaml.YAMLError): + return None + agent = data.get("agent", {}) if isinstance(data, dict) else {} + declared = [name for name in (agent.get("secrets") or []) if isinstance(name, str)] + present = {name: env[name] for name in declared if name in env} + if not present: + return None + dest = dest_dir / ".env" + write_env_file(dest, present) # holds provider creds (INFERENCE_API_KEY et al.) — 0600 from creation + return str(dest) + + +def check_victim_secrets(manifest: str, env: dict[str, str], env_file: str | None) -> None: + """Fail fast if the manifest declares victim secrets no available source provides.""" + extra_env_files = [Path(env_file)] if env_file else [] + missing = missing_secrets(Path(manifest), env_files=extra_env_files, environ=env) + if missing: + raise IronSwarmRunError( + CATEGORY_MISSING_CREDENTIAL, + f"missing required secrets for the victim agent: {', '.join(missing)}. " + "Provide them via --env-file or the environment.", + ) + + +def execute( + cmd: list[str], env: dict[str, str], log_path: Path, ctx: JobContext, *, artifact_name: str +) -> tuple[subprocess.CompletedProcess, str, Any]: + """Run *cmd*, TTY-aware. + + With a terminal attached (a shell invocation) we inherit it so iron-swarm's interactive prompts + rich + UI work; iron-swarm writes its own logs, so we capture nothing. Headless (deployed job / no tty) we + capture stdout to *log_path* and save it as the *artifact_name* result. Returns + ``(completed, log_text, log_ref)``. + """ + if sys.stdin.isatty(): + completed = subprocess.run(cmd, text=True, check=False, env=env) # inherits the terminal + return completed, "", None + with log_path.open("w", encoding="utf-8") as log_file: + completed = subprocess.run( + cmd, + stdin=subprocess.DEVNULL, + stdout=log_file, + stderr=subprocess.STDOUT, + text=True, + check=False, + env=env, + ) + log_text = log_path.read_text(encoding="utf-8") if log_path.exists() else "" + log_ref = ctx.results.save(artifact_name, log_path) + return completed, log_text, log_ref diff --git a/plugins/nemo-iron-swarm/src/nemo_iron_swarm_plugin/jobs/artifacts.py b/plugins/nemo-iron-swarm/src/nemo_iron_swarm_plugin/jobs/artifacts.py index 0d16918985..c97003947f 100644 --- a/plugins/nemo-iron-swarm/src/nemo_iron_swarm_plugin/jobs/artifacts.py +++ b/plugins/nemo-iron-swarm/src/nemo_iron_swarm_plugin/jobs/artifacts.py @@ -120,7 +120,9 @@ def _save_events_fileset(sdk: Any, *, workspace: str, run_name: str) -> str: try: return upload_file_to_fileset(sdk, path, workspace=workspace) except Exception: - logger.warning("Failed to upload events.jsonl for run %r; history will not survive pod restart", run_name, exc_info=True) + logger.warning( + "Failed to upload events.jsonl for run %r; history will not survive pod restart", run_name, exc_info=True + ) return "" diff --git a/plugins/nemo-iron-swarm/src/nemo_iron_swarm_plugin/jobs/benign_suite.py b/plugins/nemo-iron-swarm/src/nemo_iron_swarm_plugin/jobs/benign_suite.py new file mode 100644 index 0000000000..ef9bd68efe --- /dev/null +++ b/plugins/nemo-iron-swarm/src/nemo_iron_swarm_plugin/jobs/benign_suite.py @@ -0,0 +1,53 @@ +# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +"""Read/write iron-swarm's benign suite (``requests.csv``). + +The plugin runs iron-swarm by subprocess (separate venv), so it can't import iron-swarm to parse the +suite. This module reproduces the CSV shape ``tool,payload,label,rationale,persona`` (iron-swarm +``profile_writer`` writer / ``smart_benign.validator._load_requests`` reader). The plugin hands the +written file to ``iron-swarm run --benign-suite ``, which seeds it into the target's own +``requests.csv`` — so the plugin no longer needs to mirror iron-swarm's internal on-disk layout. +""" + +from __future__ import annotations + +import csv +from pathlib import Path + +# Column order iron-swarm's profile_writer emits and _load_requests expects. +SUITE_FIELDS = ("tool", "payload", "label", "rationale", "persona") + + +def read_suite(csv_path: str | Path) -> list[dict[str, str]]: + """Parse a benign ``requests.csv`` into a list of row dicts. + + Skips rows missing ``tool``/``payload`` (mirrors iron-swarm's ``_load_requests``). Returns ``[]`` when + the file is absent so callers can detect an unsynthesized suite. + """ + csv_path = Path(csv_path) + if not csv_path.exists(): + return [] + with csv_path.open(newline="", encoding="utf-8") as handle: + rows = list(csv.DictReader(handle)) + suite: list[dict[str, str]] = [] + for row in rows: + if not (row.get("tool") and row.get("payload")): + continue + suite.append({field: (row.get(field) or "") for field in SUITE_FIELDS}) + return suite + + +def write_suite(csv_path: str | Path, suite: list[dict[str, str]]) -> None: + """Write *suite* back to ``requests.csv`` in iron-swarm's column order. + + Creates the parent dir if needed. Never touches ``input_hash.txt`` so ``--reuse-benign`` still treats + the suite as a valid cache hit. + """ + csv_path = Path(csv_path) + csv_path.parent.mkdir(parents=True, exist_ok=True) + with csv_path.open("w", newline="", encoding="utf-8") as handle: + writer = csv.writer(handle) + writer.writerow(SUITE_FIELDS) + for row in suite: + writer.writerow([row.get(field, "") for field in SUITE_FIELDS]) diff --git a/plugins/nemo-iron-swarm/src/nemo_iron_swarm_plugin/jobs/defenses.py b/plugins/nemo-iron-swarm/src/nemo_iron_swarm_plugin/jobs/defenses.py new file mode 100644 index 0000000000..98993b3cf3 --- /dev/null +++ b/plugins/nemo-iron-swarm/src/nemo_iron_swarm_plugin/jobs/defenses.py @@ -0,0 +1,115 @@ +# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +"""Compose a chosen subset of a run's recommended defenses into deployable workflow + policy YAML. + +A hardening run's ``mitigations`` artifact enumerates each individually selectable defense in +``defenses[]`` (one per ``custom_guardrail_N`` middleware plus, optionally, the hardened OpenShell +policy). The Studio "harden" flow lets the user pick a subset; this rebuilds the workflow with only the +selected guardrails and picks the hardened-vs-baseline policy, so the selection can be previewed, frozen +into a sanity-check run, and applied. Guardrails are structurally independent (a keyed global middleware +entry + a name in the attacked tool's ``middleware`` list), so dropping one is a clean delete. +""" + +from __future__ import annotations + +import re +from typing import Any + +import yaml + +_CUSTOM_GUARDRAIL_RE = re.compile(r"^custom_guardrail_\d+$") +_POLICY_DEFENSE_ID = "openshell_policy" + + +def defense_ids(mitigations: dict[str, Any]) -> list[str]: + """The ids of every selectable defense in the run's mitigations artifact (``defenses[].id``).""" + return [d["id"] for d in mitigations.get("defenses", []) if isinstance(d, dict) and d.get("id")] + + +def select_defense_ids( + all_ids: list[str], keep: list[str] | None = None, exclude: list[str] | None = None +) -> list[str]: + """Resolve a ``keep``/``exclude`` selection over *all_ids* (order preserved). + + ``keep`` wins when given (only those ids, if they exist); else ``exclude`` drops the named ids; else all. + """ + if keep: + keep_set = set(keep) + return [i for i in all_ids if i in keep_set] + if exclude: + exclude_set = set(exclude) + return [i for i in all_ids if i not in exclude_set] + return list(all_ids) + + +def compose_defense(mitigations: dict[str, Any], selected_ids: list[str]) -> tuple[str | None, str | None]: + """Build ``(workflow_yaml, policy_yaml)`` from the hardened mitigations keeping only *selected_ids*. + + - Workflow: the hardened workflow with every unselected ``custom_guardrail_N`` removed. ``None`` when the + run produced no workflow change. + - Policy: the hardened policy when ``"openshell_policy"`` is selected, else the baseline. ``None`` when the + run produced no policy change. + """ + selected = set(selected_ids) + workflow = mitigations.get("workflow") or {} + workflow_after = workflow.get("after") + workflow_yaml = _compose_workflow(workflow_after, selected) if isinstance(workflow_after, str) else None + + policy = mitigations.get("policy") or {} + policy_yaml: str | None = None + if policy: + policy_yaml = policy.get("after") if _POLICY_DEFENSE_ID in selected else policy.get("before") + + return workflow_yaml, policy_yaml + + +def _compose_workflow(after_text: str, selected: set[str]) -> str: + """Return the hardened workflow with unselected ``custom_guardrail_N`` middleware removed.""" + config = yaml.safe_load(after_text) or {} + middleware = config.get("middleware") + if not isinstance(middleware, dict): + return after_text # no guardrail middleware to prune + + removed = [ + name + for name in list(middleware) + if isinstance(name, str) and _CUSTOM_GUARDRAIL_RE.match(name) and name not in selected + ] + for name in removed: + middleware.pop(name, None) + _drop_middleware_refs(config, set(removed)) + + # The guardrails' shared safety_llm is only needed while some custom guardrail remains. + if not any(isinstance(k, str) and _CUSTOM_GUARDRAIL_RE.match(k) for k in middleware): + llms = config.get("llms") + if isinstance(llms, dict): + llms.pop("safety_llm", None) + + return yaml.safe_dump(config, sort_keys=False) + + +def _drop_middleware_refs(config: dict[str, Any], removed: set[str]) -> None: + """Remove references to *removed* guardrails from every middleware-bearing component. + + ``workflow`` is a single component dict alongside the ``functions``/``function_groups`` mappings and + can carry its own ``middleware`` list. Missing it leaves a name pointing at a middleware we just + deleted, and the victim then fails config validation ("middleware type not found") and never serves. + """ + components: list[Any] = [] + for block_key in ("functions", "function_groups"): + block = config.get(block_key) + if isinstance(block, dict): + components.extend(block.values()) + workflow = config.get("workflow") + if isinstance(workflow, dict): + components.append(workflow) + + for component in components: + if not isinstance(component, dict): + continue + refs = component.get("middleware") + if isinstance(refs, str): + refs = [refs] + if isinstance(refs, list): + component["middleware"] = [ref for ref in refs if ref not in removed] diff --git a/plugins/nemo-iron-swarm/src/nemo_iron_swarm_plugin/jobs/errors.py b/plugins/nemo-iron-swarm/src/nemo_iron_swarm_plugin/jobs/errors.py new file mode 100644 index 0000000000..eb21dcf0c9 --- /dev/null +++ b/plugins/nemo-iron-swarm/src/nemo_iron_swarm_plugin/jobs/errors.py @@ -0,0 +1,218 @@ +# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +"""Classified war-game failures + the classifiers the run boundary uses. + +Every failure that can affect a run's results is reduced to a :class:`RunFailure` — a stable +``category`` plus an operator-facing ``message`` and ``remediation`` — so :meth:`IronSwarmRunJob.run` +records the *cause* on every channel the user sees (the run record, the platform job's +``error_details``) instead of a bare "exited with code 1". Failures raise :class:`IronSwarmRunError` +at their source (subclass of ``RuntimeError`` so existing ``pytest.raises(RuntimeError)`` still hold); +anything else reaching the boundary is classified by :func:`classify_exception`. Subprocess failures +are classified from iron-swarm's own ``run-error.json`` (:func:`read_run_error`), falling back to the +exit code + log tail. +""" + +from __future__ import annotations + +import json +import logging +from dataclasses import dataclass +from pathlib import Path + +logger = logging.getLogger(__name__) + +# Env var pointing iron-swarm's CLI at the path where it should dump a structured failure (run-error.json). +# The plugin sets it for the primary up/run/serve subprocesses and reads the file back on a non-zero exit. +IRON_SWARM_ERROR_FILE_ENVVAR = "IRON_SWARM_ERROR_FILE" + +# Stable failure categories, shared in spirit with iron-swarm's own taxonomy (iron_swarm.errors). +CATEGORY_PROVISIONING = "provisioning" +CATEGORY_MISSING_CREDENTIAL = "missing_credential" +CATEGORY_MANIFEST = "manifest" +CATEGORY_FILESET = "fileset" +CATEGORY_SANDBOX = "sandbox" +CATEGORY_VICTIM_UNAVAILABLE = "victim_unavailable" +CATEGORY_SYNTH_SERVICE = "synth_service" +CATEGORY_HITL_TIMEOUT = "hitl_timeout" +CATEGORY_ATTACKER_FAILED = "attacker_failed" +CATEGORY_NETWORK = "network" +CATEGORY_MODEL_UNAVAILABLE = "model_unavailable" +# The war-game ran the full attack/defend/validate cycle but the round did not pass validation +# (some attacks were not blocked and/or some benign requests failed). iron-swarm exits non-zero and +# writes no structured error, so this is a *result*, not a crash — distinct from a victim/phase failure. +CATEGORY_VALIDATION_FAILED = "validation_failed" +CATEGORY_UNEXPECTED = "unexpected" + +# Default operator-facing next step per category; a call site may override with a more specific one. +CATEGORY_REMEDIATION: dict[str, str] = { + CATEGORY_PROVISIONING: "Run `nemo iron-swarm setup` on the host that executes this job, then retry.", + CATEGORY_MISSING_CREDENTIAL: "Provide the required secret (e.g. `nemo secrets create`) or set it in the environment.", + CATEGORY_MANIFEST: "Re-create the manifest or fix the target agent reference, then retry.", + CATEGORY_FILESET: "Re-upload the file and verify the Files service is reachable, then retry.", + CATEGORY_SANDBOX: "Check the Docker daemon and the OpenShell gateway on the host, then retry.", + CATEGORY_VICTIM_UNAVAILABLE: "Inspect the victim agent log; a malformed workflow or policy often stops it loading.", + CATEGORY_SYNTH_SERVICE: "Check the benign-suite service log (serve.log) on the host, then retry.", + CATEGORY_HITL_TIMEOUT: "Resubmit the run and respond to the interview/review prompt before it times out.", + CATEGORY_ATTACKER_FAILED: "The attacker did not finish (often a timeout on a heavy agent); the 0-hit result " + "is not valid. Re-run, raising the attacker timeout (garak.timeout_s) or lowering attack_intensity.", + CATEGORY_NETWORK: "Check connectivity to the NeMo Platform control plane, then retry.", + CATEGORY_MODEL_UNAVAILABLE: "Check the model name, endpoint URL, and API key for the flagged group; " + "the error lists the models those credentials can reach.", + CATEGORY_VALIDATION_FAILED: "The war-game completed but the round did not pass validation — some " + "attacks were not blocked and/or some benign requests failed. Review the scorecard; harden further " + "or adjust the benign suite.", + CATEGORY_UNEXPECTED: "See the run log for details; if it persists, file a bug.", +} + + +@dataclass(frozen=True) +class RunFailure: + """A classified, user-facing war-game failure. ``stack`` carries diagnostic context (log tail/traceback).""" + + category: str + message: str + remediation: str = "" + stack: str = "" + + def as_error_details(self) -> dict[str, str]: + """The platform job ``error_details`` payload (matches the automodel/unsloth convention).""" + return {"message": self.message, "type": self.category, "remediation": self.remediation} + + +class IronSwarmRunError(RuntimeError): + """A war-game failure raised at its source with a known :class:`RunFailure` category. + + Subclasses ``RuntimeError`` so call sites that previously raised ``RuntimeError`` (and the tests + asserting it) keep working while gaining a classified category the run boundary can surface. + """ + + def __init__(self, category: str, message: str, *, remediation: str | None = None) -> None: + super().__init__(message) + self.category = category + self.remediation = CATEGORY_REMEDIATION.get(category, "") if remediation is None else remediation + + def as_failure(self, *, stack: str = "") -> RunFailure: + return RunFailure(self.category, str(self), self.remediation, stack) + + +def classify_exception(exc: BaseException) -> RunFailure: + """Classify an arbitrary exception that reached the run boundary into a :class:`RunFailure`. + + Typed :class:`IronSwarmRunError`s carry their own category; an agent-resolution failure is a + manifest problem; an httpx/transport error is a network problem; everything else is ``unexpected`` + (its ``str`` is shown, its type recorded in ``stack``). + """ + if isinstance(exc, IronSwarmRunError): + return exc.as_failure(stack=_short_repr(exc)) + + # Imported lazily to avoid a hard dependency in a module the whole job graph imports. + from nemo_iron_swarm_plugin.agent_resolver import AgentResolutionError + + if isinstance(exc, AgentResolutionError): + return _failure(CATEGORY_MANIFEST, str(exc)) + if _is_network_error(exc): + return _failure(CATEGORY_NETWORK, str(exc) or exc.__class__.__name__) + return _failure(CATEGORY_UNEXPECTED, str(exc) or exc.__class__.__name__, stack=_short_repr(exc)) + + +def read_run_error(path: Path) -> RunFailure | None: + """Parse iron-swarm's ``run-error.json`` (written by its CLI boundary) into a :class:`RunFailure`. + + Returns ``None`` when the file is absent or unreadable — the caller then falls back to the exit + code + log tail. The file is trusted (iron-swarm wrote it), but parsing stays defensive. + """ + try: + raw = json.loads(path.read_text(encoding="utf-8")) + except (OSError, json.JSONDecodeError): + return None + if not isinstance(raw, dict): + return None + category = raw.get("category") + category = category if isinstance(category, str) and category else CATEGORY_UNEXPECTED + message = raw.get("message") + message = message if isinstance(message, str) and message else "iron-swarm reported a failure" + remediation = raw.get("remediation") + remediation = ( + remediation if isinstance(remediation, str) and remediation else CATEGORY_REMEDIATION.get(category, "") + ) + stack = raw.get("stack") if isinstance(raw.get("stack"), str) else "" + return RunFailure(category, message, remediation, stack or "") + + +def classify_subprocess(returncode: int, log_tail: str, run_error: RunFailure | None) -> IronSwarmRunError: + """Turn a non-zero ``iron-swarm`` subprocess exit into a classified :class:`IronSwarmRunError`. + + Prefers iron-swarm's structured ``run-error.json`` (precise category + remediation). Without it, + falls back to a light heuristic over the log tail, defaulting to ``unexpected`` with the exit code. + """ + if run_error is not None: + exc = IronSwarmRunError(run_error.category, run_error.message, remediation=run_error.remediation) + return exc + category = _heuristic_category(log_tail) + if category == CATEGORY_VALIDATION_FAILED: + message = "the war-game ran to completion but the round did not pass validation" + else: + message = f"iron-swarm exited with code {returncode}" + return IronSwarmRunError(category, message) + + +# --------------------------------------------------------------------------- # +# Helpers +# --------------------------------------------------------------------------- # +# Markers proving iron-swarm reached its final summary — i.e. the whole attack/defend/validate cycle +# ran. A non-zero exit *after* this is a round that didn't pass validation, not a crashed phase. +_RUN_COMPLETED_MARKERS: tuple[str, ...] = ("iron swarm final log", "validator results:") + +# Cue → category, scanned in order, ONLY for runs that did NOT reach the final summary (a genuine +# mid-run crash). Victim cues are specific failure phrases: bare "victim" appears in healthy logs +# ("victim health ready") and must not trigger a false victim_unavailable. +_HEURISTIC_CUES: tuple[tuple[str, str], ...] = ( + (CATEGORY_ATTACKER_FAILED, "attacker execution failed"), + (CATEGORY_ATTACKER_FAILED, "attacker agent status: failed"), + (CATEGORY_VICTIM_UNAVAILABLE, "server disconnected"), + (CATEGORY_VICTIM_UNAVAILABLE, "victim returned failure"), + (CATEGORY_VICTIM_UNAVAILABLE, "victim unavailable"), + (CATEGORY_VICTIM_UNAVAILABLE, "victim unreachable"), + (CATEGORY_VICTIM_UNAVAILABLE, "openshell victim returned http"), + (CATEGORY_SANDBOX, "sandbox"), + (CATEGORY_SANDBOX, "docker"), + (CATEGORY_SANDBOX, "openshell"), + (CATEGORY_MISSING_CREDENTIAL, "api key"), + (CATEGORY_MISSING_CREDENTIAL, "unauthorized"), + (CATEGORY_NETWORK, "connection refused"), + (CATEGORY_NETWORK, "timed out"), +) + + +def _heuristic_category(log_tail: str) -> str: + """Best-effort category from the log tail when iron-swarm wrote no structured error.""" + lowered = log_tail.lower() + # A completed run that exits non-zero failed *validation*, not a phase. Decide this first: the cue + # scan's infra terms ("openshell", "docker") appear in every normal log and would otherwise win. + if any(marker in lowered for marker in _RUN_COMPLETED_MARKERS): + return CATEGORY_VALIDATION_FAILED + for category, cue in _HEURISTIC_CUES: + if cue in lowered: + return category + return CATEGORY_UNEXPECTED + + +def _failure(category: str, message: str, *, stack: str = "") -> RunFailure: + return RunFailure(category, message, CATEGORY_REMEDIATION.get(category, ""), stack) + + +def _is_network_error(exc: BaseException) -> bool: + """True for transport-level failures (httpx errors, connection/OS socket errors).""" + try: + import httpx + + if isinstance(exc, httpx.HTTPError): + return True + except ImportError: # httpx is always present in practice; stay defensive + pass + return isinstance(exc, (ConnectionError, TimeoutError)) + + +def _short_repr(exc: BaseException) -> str: + return f"{exc.__class__.__module__}.{exc.__class__.__name__}: {exc}" diff --git a/plugins/nemo-iron-swarm/src/nemo_iron_swarm_plugin/jobs/execution.py b/plugins/nemo-iron-swarm/src/nemo_iron_swarm_plugin/jobs/execution.py new file mode 100644 index 0000000000..e179b74baa --- /dev/null +++ b/plugins/nemo-iron-swarm/src/nemo_iron_swarm_plugin/jobs/execution.py @@ -0,0 +1,393 @@ +# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +"""Run the iron-swarm war-game by subprocess. + +Two invocation paths against iron-swarm's CLI (own venv): the one-shot ``iron-swarm run`` and the +Studio service-driven flow (sandbox up -> benign-suite synth HITL over ``iron-swarm serve`` -> reuse +run). Both return a :class:`RunOutcome`; the job (:mod:`~nemo_iron_swarm_plugin.jobs.run`) orchestrates. + +Every primary subprocess runs through :func:`_run_iron_swarm`, which points iron-swarm at a structured +``run-error.json`` and classifies a non-zero exit into a :class:`RunFailure`. The service path guards all +work after the run record is created so a mid-run failure returns a *failed* ``RunOutcome`` carrying that +record's name — the job finalizes it rather than leaving it orphaned as ``running``. +""" + +from __future__ import annotations + +import logging +from dataclasses import dataclass +from pathlib import Path +from typing import Any + +import yaml +from nemo_iron_swarm_plugin.cli.client import base_url +from nemo_iron_swarm_plugin.config import IronSwarmConfig +from nemo_iron_swarm_plugin.jobs import _common, benign_suite +from nemo_iron_swarm_plugin.jobs.errors import ( + CATEGORY_SYNTH_SERVICE, + IRON_SWARM_ERROR_FILE_ENVVAR, + IronSwarmRunError, + RunFailure, + classify_exception, + classify_subprocess, + read_run_error, +) +from nemo_iron_swarm_plugin.jobs.hitl import StatusDetailsChannel, drive_synth_hitl +from nemo_iron_swarm_plugin.jobs.records import _create_run, _run_data, read_and_persist_suite +from nemo_iron_swarm_plugin.jobs.synth_client import launch_synth_service +from nemo_platform_plugin.job_context import JobContext + +logger = logging.getLogger(__name__) + +_LOG_TAIL = 4000 + + +@dataclass +class RunOutcome: + """Result of a war-game path. + + ``record_name`` is set only when the path created the run record up front (service-driven), so + ``run()`` finalizes rather than creates it. ``failure`` classifies a failed run so ``run()`` records + the cause on the run entity. + """ + + status: str + returncode: int + log_text: str = "" + log_ref: Any = None + record_name: str | None = None + failure: RunFailure | None = None + + +def _event_sink_url(workspace: str, run_name: str) -> str: + """Where iron-swarm's EventBus POSTs live events for this run (relayed to Studio over SSE).""" + return f"{base_url()}/apis/iron-swarm/v2/workspaces/{workspace}/runs/{run_name}/events" + + +def _run_command( + bin_path: Any, + manifest: str, + *, + benign_suite: str | None = None, + env_file: str | None = None, + rounds: int = 1, + replay_args: list[str] | None = None, + reuse: bool = False, +) -> list[str]: + """Build an ``iron-swarm run`` command line (the single source of truth for its flags). + + ``iron-swarm run`` has no ``--yes``; it auto-detects interactivity from stdin's tty. ``--rounds`` is + omitted for the default single round (iron-swarm's own default), so multi-round hardening only appears + when asked for. + """ + cmd = [str(bin_path), "run", "--config", manifest] + if reuse: + cmd.append("--reuse") + if benign_suite: + cmd += ["--benign-suite", benign_suite] + if env_file: + cmd += ["--env-file", env_file] + if rounds > 1: + cmd += ["--rounds", str(rounds)] + cmd += replay_args or [] + return cmd + + +def _run_iron_swarm( + cmd: list[str], env: dict[str, str], log_path: Path, ctx: JobContext, *, artifact_name: str +) -> tuple[Any, str, Any, RunFailure | None]: + """Run a primary ``iron-swarm`` subprocess, classifying a non-zero exit into a :class:`RunFailure`. + + Points iron-swarm at a fresh ``run-error.json`` (its CLI boundary writes a structured cause there) and, + on a non-zero exit, prefers that file over a log-tail heuristic. Returns ``(completed, log_text, log_ref, + failure)`` where ``failure`` is ``None`` on success. Teardown/best-effort commands use + :func:`~nemo_iron_swarm_plugin.jobs._common.execute` directly instead, so they never write the error file. + """ + err_path = ctx.storage.persistent / "run-error.json" + if err_path.exists(): + err_path.unlink() # a stale file from an earlier command in this run would misattribute the cause + cmd_env = {**env, IRON_SWARM_ERROR_FILE_ENVVAR: str(err_path)} + completed, log_text, log_ref = _common.execute(cmd, cmd_env, log_path, ctx, artifact_name=artifact_name) + failure: RunFailure | None = None + if completed.returncode != 0: + classified = classify_subprocess(completed.returncode, log_text[-_LOG_TAIL:], read_run_error(err_path)) + failure = classified.as_failure() + return completed, log_text, log_ref, failure + + +def _outcome( + completed: Any, log_text: str, log_ref: Any, record_name: str | None, failure: RunFailure | None +) -> RunOutcome: + """Map a finished primary subprocess to a :class:`RunOutcome` (status derived from the exit code).""" + status = "completed" if completed.returncode == 0 else "failed" + return RunOutcome(status, completed.returncode, log_text, log_ref, record_name, failure) + + +def _prepare_invocation( + manifest: str, + env_file: str | None, + plugin_config: IronSwarmConfig, + replay_args: list[str] | None = None, + benign_suite: str | None = None, + model_env: dict[str, str] | None = None, +) -> tuple[list[str], dict[str, str]]: + """Build the `iron-swarm run` command + subprocess env, failing fast on missing victim secrets.""" + cmd = _run_command( + plugin_config.iron_swarm_bin, manifest, benign_suite=benign_suite, env_file=env_file, replay_args=replay_args + ) + env = _common.build_subprocess_env(plugin_config, model_env) + _common.check_victim_secrets(manifest, env, env_file) + return cmd, env + + +def _run_one_shot( + manifest: str, + env_file: str | None, + plugin_config: IronSwarmConfig, + ctx: JobContext, + replay_args: list[str] | None = None, + benign_suite: str | None = None, + model_env: dict[str, str] | None = None, +) -> RunOutcome: + """The default path: one `iron-swarm run` (its own pre-flight synth, TTY interview if interactive). + + A supplied ``benign_suite`` CSV is passed as ``--benign-suite`` (skips synthesis); otherwise + iron-swarm runs its own pre-flight synth. + """ + cmd, env = _prepare_invocation(manifest, env_file, plugin_config, replay_args, benign_suite, model_env) + log_path = ctx.storage.persistent / "iron-swarm.log" + completed, log_text, log_ref, failure = _run_iron_swarm(cmd, env, log_path, ctx, artifact_name="iron-swarm-log") + return _outcome(completed, log_text, log_ref, None, failure) + + +def _run_service_driven( + manifest: str, + env_file: str | None, + plugin_config: IronSwarmConfig, + ctx: JobContext, + sdk: Any, + agent: str, + port: int, + *, + manifest_id: str | None = None, + cached_suite: list[dict[str, str]] | None = None, + stop_after_synth: bool = False, + prepared_run_name: str | None = None, + rounds: int = 1, + replay_args: list[str] | None = None, + benign_suite_override: str | None = None, + source_run: str = "", + model_env: dict[str, str] | None = None, +) -> RunOutcome: + """Studio-driven path: build sandbox -> (reuse or generate the benign suite) -> replay it in the attack. + + The run record is created up front (status ``running``) so its name can address the live event stream. + From that point on, any failure is classified and returned as a *failed* ``RunOutcome`` carrying the + record name, so ``run()`` finalizes the record instead of leaving it orphaned as ``running``. + """ + if not ctx.job_id: + raise RuntimeError( + "service-driven mode needs a submitted platform job (Studio drives the HITL via status_details)." + ) + env = _common.build_subprocess_env(plugin_config, model_env) + _common.check_victim_secrets(manifest, env, env_file) + + # Record the run up front so its name addresses the SSE event stream; point iron-swarm's sink at it. + # `compile` usually pre-creates it at submit (so Studio opens the live view instantly) — reuse that; + # otherwise create it now. + record_name = prepared_run_name or _create_run( + sdk, + workspace=ctx.workspace, + data=_run_data( + agent, + port, + manifest, + "running", + -1, + job_id=ctx.job_id, + manifest_id=manifest_id or "", + source_run=source_run, + ), + ) + if record_name: + env["IRON_SWARM_EVENT_SINK_URL"] = _event_sink_url(ctx.workspace, record_name) + + try: + return _drive_service_run( + manifest, + env_file, + env, + plugin_config.iron_swarm_bin, + ctx, + sdk, + manifest_id=manifest_id, + cached_suite=cached_suite, + stop_after_synth=stop_after_synth, + rounds=rounds, + replay_args=replay_args, + benign_suite_override=benign_suite_override, + record_name=record_name, + ) + except Exception as exc: # classify + finalize the record rather than orphaning it as `running` + failure = classify_exception(exc) + logger.exception("service-driven war-game failed [%s]: %s", failure.category, failure.message) + return RunOutcome("failed", 1, record_name=record_name, failure=failure) + + +def _drive_service_run( + manifest: str, + env_file: str | None, + env: dict[str, str], + bin_path: Any, + ctx: JobContext, + sdk: Any, + *, + manifest_id: str | None, + cached_suite: list[dict[str, str]] | None, + stop_after_synth: bool, + rounds: int, + replay_args: list[str] | None, + benign_suite_override: str | None, + record_name: str | None, +) -> RunOutcome: + """Execute the chosen service strategy against a warm/cold sandbox (assumes the record already exists).""" + # Explicit-suite path: use an uploaded suite override, else the manifest's cached suite. Hand the CSV to + # iron-swarm as a file (`--benign-suite`); it seeds the file into the target's own requests.csv, so the + # plugin doesn't mirror iron-swarm's storage layout and no synthesis/interview is needed. A single + # self-contained war-game (`run` builds its own sandbox + forward) — no separate `up`, whose forward + # would collide with the attack's. + suite_path = benign_suite_override + if suite_path is None and cached_suite: + suite_csv = ctx.storage.persistent / "benign-suite.csv" + benign_suite.write_suite(suite_csv, cached_suite) + suite_path = str(suite_csv) + if suite_path and not stop_after_synth: + cmd = _run_command( + bin_path, manifest, benign_suite=suite_path, env_file=env_file, rounds=rounds, replay_args=replay_args + ) + completed, log_text, log_ref, failure = _run_iron_swarm( + cmd, env, ctx.storage.persistent / "iron-swarm.log", ctx, artifact_name="iron-swarm-log" + ) + return _outcome(completed, log_text, log_ref, record_name, failure) + + # No cached suite (or regenerating): bring the sandbox up so synth can probe the live victim, run the + # interview/review HITL, and cache the reviewed suite back on the manifest. + up_cmd = [str(bin_path), "up", "--config", manifest, *(["--env-file", env_file] if env_file else [])] + up_done, _t, _r, up_failure = _run_iron_swarm( + up_cmd, env, ctx.storage.persistent / "up.log", ctx, artifact_name="up-log" + ) + if up_failure is not None: + return RunOutcome("failed", up_done.returncode, record_name=record_name, failure=up_failure) + + # The sandbox is up. Guarantee teardown on every exit path — normal return, exception, or a SIGTERM + # during the (minutes-long) HITL wait — so a cancelled or crashed run never orphans the victim + # container. `iron-swarm run` self-cleans via its own teardown, so the `down` below is a redundant + # no-op on the happy path but the safety net whenever `run` is never reached. + try: + # ctx.job_id is guaranteed set by _run_service_driven's guard before we get here. + channel = StatusDetailsChannel(sdk, name=ctx.job_id or "", workspace=ctx.workspace) + with launch_synth_service(bin_path, env, log_path=ctx.storage.persistent / "serve.log") as client: + csv_path = drive_synth_hitl(client, manifest, channel.publish, channel.await_response) + reviewed = ( + read_and_persist_suite(sdk, ctx, manifest_id, csv_path, interview=channel.interview) if csv_path else [] + ) + if stop_after_synth: + # Generate/refresh only. The reviewed suite is cached on the manifest; the later attack is a + # separate job that builds its own sandbox, so the finally below frees this one's port forward. + return RunOutcome("completed", 0, record_name=record_name) + + # War-game against the warm sandbox, validating the just-reviewed suite. `run` is a pure consumer + # now, so hand it the suite as a file — written to a distinct path so `run --benign-suite` doesn't + # copy the serve artifact onto itself. An empty suite (synth found nothing) is simply omitted. + suite_path = None + if reviewed: + suite_csv = ctx.storage.persistent / "benign-suite.csv" + benign_suite.write_suite(suite_csv, reviewed) + suite_path = str(suite_csv) + cmd = _run_command( + bin_path, + manifest, + benign_suite=suite_path, + env_file=env_file, + rounds=rounds, + replay_args=replay_args, + reuse=True, + ) + completed, log_text, log_ref, failure = _run_iron_swarm( + cmd, env, ctx.storage.persistent / "iron-swarm.log", ctx, artifact_name="iron-swarm-log" + ) + return _outcome(completed, log_text, log_ref, record_name, failure) + finally: + _teardown_sandbox(bin_path, manifest, env, ctx) + + +def run_synth_benign( + bin_path: Any, + manifest: str, + env_file: str | None, + env: dict[str, str], + ctx: JobContext, + *, + interview: str = "interactive", +) -> Path: + """Run native ``iron-swarm synth-benign`` against *manifest* and return the produced ``requests.csv``. + + ``synth-benign`` is self-contained (builds the victim sandbox, probes it, tears down). The interview + mode maps to iron-swarm's own flags: ``interactive`` (default TTY interview, inherited by + :func:`_common.execute`), ``auto`` (``--yes`` — accept recommended defaults), ``skip`` + (``--no-interactive`` — rules-only, no prompts). Storage is pinned so the output CSV is at a known path. + """ + root = _pin_synth_storage(manifest, ctx) + cmd = [str(bin_path), "synth-benign", "--config", manifest] + if env_file: + cmd += ["--env-file", env_file] + if interview == "auto": + cmd.append("--yes") + elif interview == "skip": + cmd.append("--no-interactive") + _completed, _log, _ref, failure = _run_iron_swarm( + cmd, env, ctx.storage.persistent / "synth-benign.log", ctx, artifact_name="synth-benign-log" + ) + if failure is not None: + raise IronSwarmRunError(failure.category, failure.message, remediation=failure.remediation) + return _benign_requests_csv(root) + + +def _pin_synth_storage(manifest: str, ctx: JobContext) -> Path: + """Point the manifest's iron-swarm ``storage.root_dir`` at a fresh dir so the output CSV is findable. + + ``synth-benign`` writes ``/benign_profiles//requests.csv``; pinning an + absolute, empty root lets us locate that one file without deriving iron-swarm's ```` slug. + """ + root = (ctx.storage.persistent / "synth-storage").resolve() + root.mkdir(parents=True, exist_ok=True) + path = Path(manifest) + data = yaml.safe_load(path.read_text(encoding="utf-8")) or {} + # iron-swarm's AgentManifest only permits agent|backends|garak|overrides; `storage` lives under + # `overrides` and is deep-merged into the expanded config (mirrors jobs/manifest.py's victim_policy_path). + data.setdefault("overrides", {}).setdefault("storage", {})["root_dir"] = str(root) + path.write_text(yaml.safe_dump(data, sort_keys=False), encoding="utf-8") + return root + + +def _benign_requests_csv(root: Path) -> Path: + """The ``requests.csv`` synth-benign wrote under the pinned storage root (newest if several targets).""" + matches = sorted(root.glob("benign_profiles/*/requests.csv"), key=lambda p: p.stat().st_mtime) + if not matches: + raise IronSwarmRunError( + CATEGORY_SYNTH_SERVICE, "synth-benign produced no requests.csv (see synth-benign.log on the host)." + ) + return matches[-1] + + +def _teardown_sandbox(bin_path: Any, manifest: str, env: dict[str, str], ctx: JobContext) -> None: + """Best-effort ``iron-swarm down`` — never masks the run outcome, and never writes the run-error file. + + (`down` takes only ``--config``; ``--env-file`` is an `up`/`run` option.) + """ + try: + down_cmd = [str(bin_path), "down", "--config", manifest] + _common.execute(down_cmd, env, ctx.storage.persistent / "down.log", ctx, artifact_name="down-log") + except Exception: # teardown is a best-effort safety net; never mask the original outcome + logger.warning("failed to tear down victim sandbox after service-driven run", exc_info=True) diff --git a/plugins/nemo-iron-swarm/src/nemo_iron_swarm_plugin/jobs/hitl.py b/plugins/nemo-iron-swarm/src/nemo_iron_swarm_plugin/jobs/hitl.py new file mode 100644 index 0000000000..5a0d8c76e5 --- /dev/null +++ b/plugins/nemo-iron-swarm/src/nemo_iron_swarm_plugin/jobs/hitl.py @@ -0,0 +1,119 @@ +# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +"""Bridge the ``iron-swarm serve`` synth HITL to the platform ``status_details`` channel. + +The war-game job drives the synth service (interview rounds, then benign-suite review) and relays each +checkpoint to the operator via the job's ``status_details`` — Studio renders it and PATCHes a response. +:func:`drive_synth_hitl` is the transport-agnostic loop (``publish``/``await_response`` injected so it is +unit-testable); :class:`StatusDetailsChannel` implements those over ``sdk.jobs`` for the real job. +""" + +from __future__ import annotations + +import logging +import time +from collections.abc import Callable +from typing import Any + +from nemo_iron_swarm_plugin.jobs.errors import ( + CATEGORY_HITL_TIMEOUT, + CATEGORY_NETWORK, + CATEGORY_SYNTH_SERVICE, + IronSwarmRunError, +) +from nemo_iron_swarm_plugin.jobs.synth_client import SynthClient + +logger = logging.getLogger(__name__) + +# Consecutive publish failures tolerated before the interview is abandoned as a control-plane outage. +_PUBLISH_MAX_ATTEMPTS = 3 + +# publish(kind, payload) -> None ; await_response(kind) -> list of answer/suite rows +Publish = Callable[[str, dict[str, Any]], None] +AwaitResponse = Callable[[str], list[dict[str, Any]]] + + +def drive_synth_hitl( + client: SynthClient, config: str, publish: Publish, await_response: AwaitResponse, *, validator: str | None = None +) -> str: + """Run the synth service to completion, relaying each interview round + the review via the channel. + + Returns the path to the written ``requests.csv``. Loops interview rounds (``publish`` questions → + ``await_response`` → ``POST /answers``) until the service reports ``review``, then relays the suite for + editing and writes it back. + """ + step = client.start(config, validator=validator) + while step.get("status") == "interview": + publish("interview", {"questions": step.get("questions", [])}) + answers = await_response("interview") + step = client.answers(step["thread_id"], answers) + if step.get("status") != "review": + raise IronSwarmRunError( + CATEGORY_SYNTH_SERVICE, f"synth service returned unexpected status {step.get('status')!r}" + ) + publish("review", {"suite": step.get("suite", [])}) + edited = await_response("review") + done = client.write_suite(step["thread_id"], edited) + return str(done.get("benign_csv", "")) + + +class StatusDetailsChannel: + """Implements ``publish``/``await_response`` over the job's ``status_details`` (Studio is the peer). + + Each ``publish`` stamps an incrementing ``round`` so a multi-round interview never reads a stale answer; + Studio echoes the round in its ``{kind}_response``. Polls (the job stays ``active`` — no platform pause). + """ + + def __init__( + self, sdk: Any, *, name: str, workspace: str, poll_interval: float = 2.0, timeout: float = 1800.0 + ) -> None: + self._sdk = sdk + self._name = name + self._workspace = workspace + self._poll_interval = poll_interval + self._timeout = timeout + self._round = 0 + # Interview answers accumulated across rounds, kept so the run can persist the Q&A for display. + self.interview: list[dict[str, Any]] = [] + + def publish(self, kind: str, payload: dict[str, Any]) -> None: + self._round += 1 + body = {kind: {**payload, "round": self._round}} + # Publishing the prompt is a write the operator's UI depends on; retry a transient control-plane + # blip, but a persistent failure must abort the run loudly (a silently-dropped prompt would hang + # the interview until the poll deadline with no explanation). + for attempt in range(1, _PUBLISH_MAX_ATTEMPTS + 1): + try: + self._sdk.jobs.update_status_details(self._name, workspace=self._workspace, body=body) + return + except Exception: + if attempt == _PUBLISH_MAX_ATTEMPTS: + raise IronSwarmRunError( + CATEGORY_NETWORK, + f"could not publish the {kind} prompt to the job after {_PUBLISH_MAX_ATTEMPTS} attempts", + ) + logger.warning("status_details publish failed for job %s; retrying", self._name, exc_info=True) + time.sleep(self._poll_interval) + + def await_response(self, kind: str) -> list[dict[str, Any]]: + key = f"{kind}_response" + deadline = time.monotonic() + self._timeout + while time.monotonic() < deadline: + try: + job = self._sdk.jobs.retrieve(self._name, workspace=self._workspace) + except Exception: # a transient poll failure must not abort a minutes-long human wait + logger.warning("status_details poll failed for job %s; retrying", self._name, exc_info=True) + time.sleep(self._poll_interval) + continue + resp = (getattr(job, "status_details", None) or {}).get(key) + if isinstance(resp, dict) and resp.get("round") == self._round: + rows = list(resp.get("answers") or resp.get("suite") or []) + if kind == "interview": + self.interview.extend(row for row in rows if isinstance(row, dict)) + return rows + time.sleep(self._poll_interval) + raise IronSwarmRunError( + CATEGORY_HITL_TIMEOUT, + f"no operator response to the {kind} prompt (round {self._round}) within {self._timeout:.0f}s", + ) diff --git a/plugins/nemo-iron-swarm/src/nemo_iron_swarm_plugin/jobs/manifest.py b/plugins/nemo-iron-swarm/src/nemo_iron_swarm_plugin/jobs/manifest.py new file mode 100644 index 0000000000..90551f6f76 --- /dev/null +++ b/plugins/nemo-iron-swarm/src/nemo_iron_swarm_plugin/jobs/manifest.py @@ -0,0 +1,213 @@ +# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +"""Build the on-host ``iron-swarm.yaml`` the war-game runs against. + +Materializes a saved manifest (agent- or project-sourced) onto disk, applies the run's overrides +(attacker intensity, defender selection, port), and seeds the frozen validate-only baseline. +""" + +from __future__ import annotations + +import logging +from pathlib import Path +from typing import Any + +import yaml +from nemo_iron_swarm_plugin.agent_resolver import resolve_agent_to_manifest +from nemo_iron_swarm_plugin.cli.client import base_url +from nemo_iron_swarm_plugin.entities import IRON_SWARM_MANIFEST_TYPE +from nemo_iron_swarm_plugin.filesets import download_and_extract_project +from nemo_iron_swarm_plugin.jobs.errors import CATEGORY_FILESET, CATEGORY_MANIFEST, IronSwarmRunError +from nemo_platform_plugin.job_context import JobContext + +logger = logging.getLogger(__name__) + + +# Attacker effort presets → garak knobs written into the manifest's top-level `garak:` block. +# "standard" is omitted so iron-swarm's own defaults apply. +INTENSITY_GARAK: dict[str, dict[str, int]] = { + "light": {"generations": 1, "max_attempts_per_tool": 1}, + "thorough": {"generations": 5, "max_attempts_per_tool": 10}, +} + +# Defender override entries mirroring iron_swarm.manifest._default_defenders (name + implementation + +# capabilities — iron-swarm's SessionConfig validator requires a non-empty `capabilities`). The entry's +# `config` is unused by the defense stage (the callable gets only its DefenderInput, and the victim policy +# comes from the sandbox), so it's omitted. Selecting a subset replaces the default defender list via the +# manifest's `overrides.defenders` (iron-swarm merges overrides with lists replacing). +DEFENDER_ENTRIES: dict[str, dict[str, Any]] = { + "openshell": { + "name": "openshell-policy-defender", + "implementation": "iron_swarm.agents.defenders.openshell_defender.openshell_defender_agent:run", + "timeout_seconds": 300, + "capabilities": ( + "Mitigates attacks that exploit Linux kernel security controls: network egress, filesystem " + "read/write access, process identity (UID/GID), seccomp syscall filtering, and Landlock path " + "restrictions. Generates and repairs OpenShell policy YAML patches." + ), + }, + "guardrails": { + "name": "defender-guardrails", + "implementation": "iron_swarm.agents.defenders.guardrails_defender_v2.guardrails_defender_agent:run", + "timeout_seconds": 300, + "capabilities": ( + "Mitigates prompt injection, unsafe tool invocations, sensitive content disclosure, " + "reconnaissance commands, and untrusted content handling through LLM-generated guardrail rules." + ), + }, +} + + +def _agent_model_override(data: dict[str, Any]) -> str | None: + """The user's chosen victim ("agent" group) model, if any — used to rewrite the victim's IGW LLMs.""" + agent = (data.get("models") or {}).get("agent") + model = agent.get("model") if isinstance(agent, dict) else None + return str(model) if model else None + + +def _apply_manifest_overrides(manifest: dict[str, Any], data: dict[str, Any]) -> None: + """Re-apply the manifest's persisted war-game overrides (attacker intensity + defender selection). + + The run rebuilds the thin manifest from the agent ref, so these choices — like the victim port — + must be re-injected here, as iron-swarm's native top-level ``garak:`` block and ``overrides.defenders`` + list. An empty defender selection leaves iron-swarm's defaults untouched. + """ + garak = INTENSITY_GARAK.get(str(data.get("attack_intensity") or "standard")) + if garak: + manifest["garak"] = garak + # Apply an explicit victim port only when set (stored on the manifest or a per-run override); otherwise + # leave the port the agent resolver derived from the running deployment. + if data.get("port"): + manifest.setdefault("agent", {})["port"] = int(data["port"]) + enabled = [key for key in (data.get("defenders") or []) if key in DEFENDER_ENTRIES] + if not enabled: + return + # Guardrails only applies when the agent has a workflow (iron-swarm gates it the same way). + has_workflow = bool(manifest.get("agent", {}).get("workflow")) + entries = [DEFENDER_ENTRIES[key] for key in enabled if key != "guardrails" or has_workflow] + if entries: + manifest.setdefault("overrides", {})["defenders"] = entries + + +def _materialize_manifest( + sdk: Any, manifest_id: str, ctx: JobContext, config_overrides: dict[str, Any] | None = None +) -> str: + """Materialize a saved manifest into an on-host ``iron-swarm.yaml``; return its path. + + Fetches the ``IronSwarmManifest`` record and dispatches on its source: ``agent`` re-resolves from + the stored agent ref via :func:`resolve_agent_to_manifest`; ``project`` re-downloads the uploaded + bundle and repoints the stored manifest at it. ``sdk`` is the platform SDK (submitted jobs only). + + ``config_overrides`` (per-run port/defenders/attack_intensity from the launch dialog) is overlaid + onto the stored config so the run can deviate from the manifest without persisting the change. + """ + if sdk is None: + raise IronSwarmRunError( + CATEGORY_MANIFEST, "running a saved manifest requires the platform SDK (submit the job, don't run locally)." + ) + record = sdk.entities.get_entity_by_name( + name=manifest_id, entity_type=IRON_SWARM_MANIFEST_TYPE, workspace=ctx.workspace + ) + data = {**(getattr(record, "data", {}) or {}), **(config_overrides or {})} + manifest_dir = ctx.storage.persistent + manifest_dir.mkdir(parents=True, exist_ok=True) + if (data.get("source_type") or "agent") == "project": + manifest = _materialize_project_manifest(sdk, manifest_id, data, manifest_dir) + else: + manifest = _materialize_agent_manifest(sdk, manifest_id, data, ctx, manifest_dir) + manifest_path = manifest_dir / "iron-swarm.yaml" + manifest_path.write_text(yaml.safe_dump(manifest, sort_keys=False), encoding="utf-8") + return str(manifest_path) + + +def _materialize_agent_manifest( + sdk: Any, manifest_id: str, data: dict[str, Any], ctx: JobContext, manifest_dir: Path +) -> dict[str, Any]: + """Re-resolve an agent-source manifest from its stored agent ref (regenerating the scaffold).""" + agent_ref = data.get("agent") + if not agent_ref: + raise IronSwarmRunError( + CATEGORY_MANIFEST, f"manifest {manifest_id!r} has no agent reference to materialize from." + ) + resolved = resolve_agent_to_manifest( + agent_ref, + sdk=sdk, + base_url=base_url(), + default_workspace=ctx.workspace, + manifest_dir=manifest_dir, + model_override=_agent_model_override(data), + ) + _apply_manifest_overrides(resolved.manifest, data) + for warning in resolved.warnings: + logger.warning("manifest %s: %s", manifest_id, warning) + return resolved.manifest + + +def _materialize_project_manifest( + sdk: Any, manifest_id: str, data: dict[str, Any], manifest_dir: Path +) -> dict[str, Any]: + """Re-download the uploaded project bundle and repoint the stored manifest's ``project_dir`` at it.""" + fileset = data.get("project_fileset") + manifest_yaml = data.get("manifest_yaml") + if not fileset or not manifest_yaml: + raise IronSwarmRunError( + CATEGORY_MANIFEST, f"project manifest {manifest_id!r} is missing its project_fileset or manifest_yaml." + ) + try: + project_dir = download_and_extract_project(sdk, fileset, manifest_dir) + except IronSwarmRunError: + raise + except Exception as exc: # a fileset download/extract failure is a distinct, actionable class + raise IronSwarmRunError( + CATEGORY_FILESET, f"could not download or unpack the project bundle for manifest {manifest_id!r}: {exc}" + ) from exc + manifest = yaml.safe_load(manifest_yaml) or {} + if not isinstance(manifest, dict) or not isinstance(manifest.get("agent"), dict): + raise IronSwarmRunError(CATEGORY_MANIFEST, f"project manifest {manifest_id!r} has malformed manifest_yaml.") + manifest["agent"]["project_dir"] = str(project_dir) + # The bundle's own .env only carries what the project shipped; the victim's secrets (incl. operator- + # provided ones like a host-backend URL) are materialized next to the manifest. Point secrets_file at + # that absolute path so iron-swarm's credential provider reads them (a relative ".env" would resolve + # against the task cwd, where no dotenv exists, and silently deliver nothing). + manifest["agent"]["secrets_file"] = str((manifest_dir / ".env").resolve()) + _apply_manifest_overrides(manifest, data) + return manifest + + +def _seed_validation_manifest( + manifest_path: str, defense_workflow: str | None, defense_policy: str | None, ctx: JobContext +) -> None: + """Rewrite a materialized manifest for a frozen validate-only run: zero defenders + composed baseline. + + Seeds the user-chosen composed workflow as the victim's baseline workflow (overwriting the materialized + scaffold) and, when a policy was chosen, points the victim at the composed OpenShell policy. Forces + ``overrides.defenders: []`` so iron-swarm runs no defender agents — it deploys this fixed baseline and only + replays + scores (see the frozen-validation design). Attacks/benign come from ``--replay`` + the suite. + """ + manifest_dir = ctx.storage.persistent + data = yaml.safe_load(Path(manifest_path).read_text(encoding="utf-8")) or {} + agent = data.get("agent", {}) + if defense_workflow and agent.get("project_dir") and agent.get("workflow"): + # project_dir may be relative (agent source) or absolute (project source); `/` handles both. + workflow_file = manifest_dir / agent["project_dir"] / agent["workflow"] + workflow_file.parent.mkdir(parents=True, exist_ok=True) + workflow_file.write_text(defense_workflow, encoding="utf-8") + overrides = data.setdefault("overrides", {}) + overrides["defenders"] = [] # zero defenders: deploy the frozen baseline, generate nothing + if defense_policy: + policy_file = manifest_dir / "composed-policy.yaml" + policy_file.write_text(defense_policy, encoding="utf-8") + overrides.setdefault("victim_control", {}).setdefault("config", {})["policy_path"] = str(policy_file) + overrides.setdefault("storage", {})["victim_policy_path"] = str(policy_file) + Path(manifest_path).write_text(yaml.safe_dump(data, sort_keys=False), encoding="utf-8") + + +def _manifest_facts(manifest_path: str) -> tuple[str, int]: + """Best-effort read of (agent_name, port) from the manifest for the run record.""" + try: + data = yaml.safe_load(Path(manifest_path).read_text(encoding="utf-8")) or {} + agent = data.get("agent", {}) if isinstance(data, dict) else {} + return str(agent.get("name", "")), int(agent.get("port", 0) or 0) + except (OSError, ValueError, yaml.YAMLError): + return "", 0 diff --git a/plugins/nemo-iron-swarm/src/nemo_iron_swarm_plugin/jobs/records.py b/plugins/nemo-iron-swarm/src/nemo_iron_swarm_plugin/jobs/records.py index ed45771579..13c1882ec6 100644 --- a/plugins/nemo-iron-swarm/src/nemo_iron_swarm_plugin/jobs/records.py +++ b/plugins/nemo-iron-swarm/src/nemo_iron_swarm_plugin/jobs/records.py @@ -19,6 +19,7 @@ IronSwarmManifest, IronSwarmRun, ) +from nemo_iron_swarm_plugin.jobs import benign_suite from nemo_iron_swarm_plugin.jobs.errors import RunFailure from nemo_platform_plugin.entity_client import NemoEntitiesClient from nemo_platform_plugin.job_context import JobContext @@ -110,6 +111,24 @@ async def _precreate_run( return None +def _run_facts(sdk: Any, *, workspace: str, name: str) -> tuple[str, int]: + """The ``(agent, port)`` already recorded on run *name*, or ``("", 0)`` (best-effort). + + Updates replace the whole record (see :func:`_run_data`), so a failure finalizing a pre-created + row must carry these forward or the run is left showing no agent. + """ + if sdk is None or not hasattr(sdk, "entities"): + return "", 0 + try: + record = sdk.entities.get_entity_by_name(name=name, entity_type=IRON_SWARM_RUN_TYPE, workspace=workspace) + data = getattr(record, "data", {}) or {} + port = data.get("port") + return str(data.get("agent") or ""), int(port) if isinstance(port, int) else 0 + except Exception: # reading back is best-effort; worst case the failure record loses the agent label + logger.warning("failed to read back IronSwarmRun %s", name, exc_info=True) + return "", 0 + + def _update_run(sdk: Any, *, workspace: str, name: str, data: dict[str, Any]) -> None: """Overwrite an existing IronSwarmRun record (e.g. running -> completed); best-effort.""" if sdk is None or not hasattr(sdk, "entities"): @@ -165,6 +184,26 @@ def _cached_benign_suite(sdk: Any, manifest_id: str, ctx: JobContext) -> list[di return [] +def read_and_persist_suite( + sdk: Any, + ctx: JobContext, + manifest_id: str | None, + csv_path: Any, + *, + interview: list[dict[str, Any]] | None = None, +) -> list[dict[str, str]]: + """Parse the synthesized ``requests.csv`` and cache it on the manifest; return the suite rows. + + The shared line both the CLI ``synth-benign`` and Studio's serve-driven HITL converge on: read the + suite iron-swarm wrote, then (when a ``manifest_id`` is known) persist it on the manifest entity. + Persistence is best-effort — an empty suite or missing manifest is simply not cached. + """ + suite = benign_suite.read_suite(csv_path) + if manifest_id and suite: + _persist_benign_suite(sdk, workspace=ctx.workspace, manifest_id=manifest_id, suite=suite, interview=interview) + return suite + + def _persist_benign_suite( sdk: Any, *, diff --git a/plugins/nemo-iron-swarm/src/nemo_iron_swarm_plugin/jobs/run.py b/plugins/nemo-iron-swarm/src/nemo_iron_swarm_plugin/jobs/run.py index 0e62df2123..f83e39ac72 100644 --- a/plugins/nemo-iron-swarm/src/nemo_iron_swarm_plugin/jobs/run.py +++ b/plugins/nemo-iron-swarm/src/nemo_iron_swarm_plugin/jobs/run.py @@ -21,7 +21,7 @@ from typing import Any, ClassVar, cast from nemo_iron_swarm_plugin.config import IronSwarmConfig -from nemo_iron_swarm_plugin.jobs import _common +from nemo_iron_swarm_plugin.jobs import _common, benign_suite from nemo_iron_swarm_plugin.jobs.artifacts import ( _replay_args, _save_composed_workflow, @@ -46,6 +46,7 @@ _manifest_rounds, _precreate_run, _run_data, + _run_facts, _update_run, ) from nemo_iron_swarm_plugin.jobs.spec import WarGameSpec @@ -137,6 +138,11 @@ def _preflight_message(label: str, choice: ModelChoice, verdict: Any) -> str: return f"The {label} model credentials were rejected by {endpoint} ({verdict.detail or 'unauthorized'})." if verdict.reason == "unreachable": return f"Could not reach the {label} model endpoint {endpoint} ({verdict.detail or 'no response'})." + if verdict.reason == "provider_error": + return ( + f"The {label} model endpoint {endpoint} returned an error ({verdict.detail or 'unknown'}); " + "the credentials were accepted, so this is the provider's side — retry shortly." + ) available = ", ".join(verdict.available[:20]) or "none" return ( f"The {label} model {choice.model!r} is not available at {endpoint}. " @@ -238,10 +244,15 @@ def _record_failure(self, ctx: JobContext, sdk: Any, config: dict, failure: RunF Reuses the pre-created record (``run_name``) when present so its live view resolves to the failure instead of a perpetual ``running``; otherwise creates a failed record now. Also reports terminal ``failed`` progress with the error details. Recording stays best-effort — it must not mask the cause. + + The update replaces the whole record, so the pre-created ``agent``/``port`` are read back and + carried forward — otherwise a failure would blank the agent the run was targeting. """ + prepared = config.get("run_name") + agent, port = _run_facts(sdk, workspace=ctx.workspace, name=str(prepared)) if prepared else ("", 0) data = _run_data( - "", - 0, + agent, + port or int(config.get("port") or 0), str(config.get("config") or ""), "failed", 1, @@ -250,7 +261,6 @@ def _record_failure(self, ctx: JobContext, sdk: Any, config: dict, failure: RunF source_run=str(config.get("source_run") or ""), failure=failure, ) - prepared = config.get("run_name") if prepared: _update_run(sdk, workspace=ctx.workspace, name=str(prepared), data=data) else: @@ -341,8 +351,15 @@ def _execute(self, config: dict, *, ctx: JobContext, sdk: Any = None) -> dict: model_env=model_env, ) else: + # One-shot `iron-swarm run` consumes a suite; it never synthesizes. Prefer an uploaded override, + # else fall back to the manifest's cached suite (from a prior `synth-benign`), written to a CSV. + suite_for_run = benign_override + if suite_for_run is None and cached_suite: + suite_csv = ctx.storage.persistent / "benign-suite.csv" + benign_suite.write_suite(suite_csv, cached_suite) + suite_for_run = str(suite_csv) outcome = _run_one_shot( - manifest, env_file, plugin_config, ctx, replay_args, benign_suite=benign_override, model_env=model_env + manifest, env_file, plugin_config, ctx, replay_args, benign_suite=suite_for_run, model_env=model_env ) # A validate-only run generates no mitigations (defenders: []); it produces the sanity-check diff --git a/plugins/nemo-iron-swarm/src/nemo_iron_swarm_plugin/jobs/spec.py b/plugins/nemo-iron-swarm/src/nemo_iron_swarm_plugin/jobs/spec.py new file mode 100644 index 0000000000..f9e74be02a --- /dev/null +++ b/plugins/nemo-iron-swarm/src/nemo_iron_swarm_plugin/jobs/spec.py @@ -0,0 +1,51 @@ +# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +"""The war-game job's input spec (the shape ``IronSwarmRunJob.run``/``compile`` see).""" + +from __future__ import annotations + +from nemo_iron_swarm_plugin.model_config import WarGameModels +from pydantic import BaseModel + + +class WarGameSpec(BaseModel): + """Canonical war-game inputs — the shape ``run()`` and ``compile()`` see. + + Supply either a saved ``manifest_id`` (the Studio path — materialized on the host from the stored + agent ref) or a ready ``config`` manifest path (the CLI path). + """ + + config: str | None = None + manifest_id: str | None = None + env_file: str | None = None + driver: str | None = None # "service" for the Studio-driven HITL path; else the one-shot run + stop_after_synth: bool = False # generate/refresh the benign suite (interview+review), then stop before the attack + # Replay recorded garak hits instead of a live attack: a fileset ref holding a garak hitlog (either + # uploaded by the user or a prior run's saved hitlog). When set, the job replays it via `--replay `. + replay_hitlog_fileset: str | None = None + # Override the benign suite for this run: a fileset ref holding an uploaded requests.csv. When set, it + # replaces the manifest's suite and is passed via `--benign-suite ` (skips synthesis). + benign_suite_fileset: str | None = None + # Per-run config overrides (None = use the manifest's stored default). The launch applies these over + # the materialized manifest without mutating it, so a run can deviate from the saved baseline. + port: int | None = None + defenders: list[str] | None = None + attack_intensity: str | None = None + rounds: int | None = None + # Sanity-check (validate-only) mode: freeze a user-chosen set of defenses as the victim's baseline and + # replay the recorded attacks + benign suite against it WITHOUT generating new mitigations, to measure + # which attacks are now blocked and which benign requests are wrongly blocked. `defense_workflow` / + # `defense_policy` are the composed YAMLs (see jobs.defenses.compose_defense); the run seeds them as the + # baseline and forces `overrides.defenders: []` (zero defenders → no new mitigations). + validate_only: bool = False + defense_workflow: str | None = None + defense_policy: str | None = None + # Per-run model override (None = use the manifest's stored default / iron-swarm built-ins). The launch + # merges these over the manifest's stored `models`; model names + base_urls become env vars for the + # iron-swarm subprocess (attack→GARAK_*, analysis→IRON_SWARM_*) and the agent model rewrites the victim + # LLMs. api_key_secret names are resolved to the corresponding env keys (NIM_API_KEY / INFERENCE_API_KEY). + models: WarGameModels | None = None + # The harden run a validate-only sanity check was launched from; recorded on the run so the Harden tab + # can re-attach its scorecard after a reload (see IronSwarmRun.source_run). + source_run: str | None = None diff --git a/plugins/nemo-iron-swarm/src/nemo_iron_swarm_plugin/jobs/synth_benign.py b/plugins/nemo-iron-swarm/src/nemo_iron_swarm_plugin/jobs/synth_benign.py new file mode 100644 index 0000000000..883d140a8c --- /dev/null +++ b/plugins/nemo-iron-swarm/src/nemo_iron_swarm_plugin/jobs/synth_benign.py @@ -0,0 +1,224 @@ +# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +"""``iron-swarm.synth`` job — synthesize a saved manifest's benign suite and cache it. + +The single entry point for benign-suite synthesis, selected by ``driver``: + +- ``native`` (CLI): shell out to native ``iron-swarm synth-benign`` (its own TTY interview), run locally. +- ``service`` (Studio): drive ``iron-swarm serve`` + the interview/review HITL over the platform job's + ``status_details`` — the exact serve path the war-game uses, via + :func:`~nemo_iron_swarm_plugin.jobs.execution._run_service_driven` with ``stop_after_synth=True``. + +Both converge on :func:`~nemo_iron_swarm_plugin.jobs.records.read_and_persist_suite`, caching the reviewed +suite on the manifest entity. +""" + +from __future__ import annotations + +import logging +import os +from pathlib import Path +from typing import Any, ClassVar, cast + +from nemo_iron_swarm_plugin.config import IronSwarmConfig +from nemo_iron_swarm_plugin.jobs import _common +from nemo_iron_swarm_plugin.jobs.artifacts import _save_events_fileset +from nemo_iron_swarm_plugin.jobs.errors import classify_exception +from nemo_iron_swarm_plugin.jobs.execution import RunOutcome, _run_service_driven, run_synth_benign +from nemo_iron_swarm_plugin.jobs.manifest import _manifest_facts, _materialize_manifest +from nemo_iron_swarm_plugin.jobs.records import _create_run, _run_data, _update_run, read_and_persist_suite +from nemo_iron_swarm_plugin.jobs.run import _effective_models +from nemo_platform_plugin.job import NemoJob +from nemo_platform_plugin.job_context import JobContext +from nemo_platform_plugin.jobs.api_factory import ( + EnvironmentVariable, + PlatformJobSpec, + PlatformJobStep, + SubprocessExecutionProviderSpec, +) +from nemo_platform_plugin.jobs.constants import DEFAULT_JOB_STORAGE_PATH, PERSISTENT_JOB_STORAGE_PATH_ENVVAR +from pydantic import BaseModel + +logger = logging.getLogger(__name__) + + +class SynthBenignSpec(BaseModel): + """Inputs for the benign-suite synthesis phase (the shape ``run()``/``compile()`` see).""" + + manifest_id: str + driver: str = "native" # "native" (CLI TTY) | "service" (Studio serve HITL over status_details) + env_file: str | None = None + # Interview mode for the native driver: "interactive" (TTY prompts), "auto" (--yes), "skip" (--no-interactive). + interview: str = "interactive" + # Reused pre-created run record name (service driver); unused for native. + run_name: str | None = None + source_run: str | None = None + + +class IronSwarmSynthBenignJob(NemoJob): + """Synthesize and cache the benign request suite for a saved manifest (native TTY or Studio serve HITL).""" + + name = "synth" # keeps the hand-written `nemo iron-swarm synth-benign` command unshadowed (cf. war-game/run) + description = "Synthesize a saved manifest's benign request suite and cache it on the manifest." + container = "cpu-tasks" + spec_schema: ClassVar[type[BaseModel] | None] = SynthBenignSpec + + @classmethod + async def compile( + cls, + *, + workspace: str, + spec: BaseModel, # SynthBenignSpec + entity_client: object, + job_name: str | None, + async_sdk: object, + profile: str | None = None, + options: dict | None = None, + ) -> PlatformJobSpec: + """A single subprocess step running the synth task on the provisioned host (mirrors the war-game). + + The ``service`` driver's HITL is relayed through the platform job's ``status_details``, so it must run + as a submitted job (``ctx.job_id`` set). The run record is created at runtime by ``_run_service_driven`` + (as the war-game ``stop_after_synth`` path does), so no pre-creation is needed here. + """ + del workspace, entity_client, job_name, async_sdk, profile, options + synth = cast(SynthBenignSpec, spec) + environment = [EnvironmentVariable(name=PERSISTENT_JOB_STORAGE_PATH_ENVVAR, value=DEFAULT_JOB_STORAGE_PATH)] + # The subprocess executor forwards only PATH/VIRTUAL_ENV; the sandbox reads its gateway registration + # from $HOME/.config/openshell and reaches Docker via $DOCKER_HOST (same as the war-game step). + for name in ("HOME", "DOCKER_HOST", "XDG_CONFIG_HOME"): + value = os.environ.get(name) + if value: + environment.append(EnvironmentVariable(name=name, value=value)) + return PlatformJobSpec( + steps=[ + PlatformJobStep( + name="synth", + executor=SubprocessExecutionProviderSpec( + provider="subprocess", + command=["python", "-m", "nemo_iron_swarm_plugin.tasks.synth_benign"], + ), + config=synth.model_dump(mode="json"), + environment=environment, + ), + ], + ) + + def run(self, config: dict, *, ctx: JobContext, sdk: Any = None, **_: Any) -> dict: + """Run synthesis, classifying any failure into an operator-facing error result.""" + try: + return self._execute(config, ctx=ctx, sdk=sdk) + except Exception as exc: + failure = classify_exception(exc) + logger.exception("iron-swarm synth-benign failed [%s]: %s", failure.category, failure.message) + return { + "status": "failed", + "returncode": 1, + "error": { + "category": failure.category, + "message": failure.message, + "remediation": failure.remediation, + }, + } + + def _execute(self, config: dict, *, ctx: JobContext, sdk: Any = None) -> dict: + plugin_config = IronSwarmConfig.get() + _common.require_provisioned(plugin_config) + + manifest_id = str(config["manifest_id"]) + manifest = _materialize_manifest(sdk, manifest_id, ctx) + env = _common.build_subprocess_env(plugin_config) + # Synthesis probes the live victim, so it needs the manifest's declared secrets. When no --env-file is + # supplied (Studio never sends one), synthesize one from the operator env (as the war-game does). + env_file = config.get("env_file") + if not env_file: + env_file = _common.materialize_victim_env_file(manifest, env, Path(manifest).parent) + _common.check_victim_secrets(manifest, env, env_file) + + if config.get("driver") == "service": + return self._run_service(config, ctx, sdk, plugin_config, manifest, manifest_id, env_file) + + csv_path = run_synth_benign( + plugin_config.iron_swarm_bin, + manifest, + env_file, + env, + ctx, + interview=str(config.get("interview") or "interactive"), + ) + suite = read_and_persist_suite(sdk, ctx, manifest_id, csv_path) + self.report_progress( + ctx, work_done=1, work_total=1, status="completed", details={"suite_size": str(len(suite))} + ) + return {"status": "completed", "returncode": 0, "manifest_id": manifest_id, "suite_size": len(suite)} + + def _run_service( + self, + config: dict, + ctx: JobContext, + sdk: Any, + plugin_config: IronSwarmConfig, + manifest: str, + manifest_id: str, + env_file: str | None, + ) -> dict: + """Studio path: drive the serve interview/review HITL, persist the suite, finalize the run record.""" + models = _effective_models(sdk, config, ctx) + model_env = _common.build_model_env(models, sdk=sdk, workspace=ctx.workspace) + agent, port = _manifest_facts(manifest) + outcome = _run_service_driven( + manifest, + env_file, + plugin_config, + ctx, + sdk, + agent, + port, + manifest_id=manifest_id, + stop_after_synth=True, + prepared_run_name=config.get("run_name") or None, + source_run=str(config.get("source_run") or ""), + model_env=model_env, + ) + self._finalize_run(sdk, ctx, config, manifest, agent, port, manifest_id, outcome) + return { + "status": outcome.status, + "returncode": outcome.returncode, + "manifest_id": manifest_id, + "run_record": outcome.record_name, + } + + def _finalize_run( + self, + sdk: Any, + ctx: JobContext, + config: dict, + manifest: str, + agent: str, + port: int, + manifest_id: str, + outcome: RunOutcome, + ) -> None: + """Finalize the run record ``_run_service_driven`` left as ``running`` (synth-only: no hitlog).""" + events_fileset = _save_events_fileset(sdk, workspace=ctx.workspace, run_name=outcome.record_name or "") + data = _run_data( + agent, + port, + manifest, + outcome.status, + outcome.returncode, + ctx.job_id or "", + manifest_id=manifest_id, + source_run=str(config.get("source_run") or ""), + failure=outcome.failure, + events_fileset=events_fileset, + ) + if outcome.record_name: + _update_run(sdk, workspace=ctx.workspace, name=outcome.record_name, data=data) + else: + _create_run(sdk, workspace=ctx.workspace, data=data) + details = {"returncode": str(outcome.returncode)} + if outcome.failure is not None: + details.update(outcome.failure.as_error_details()) + self.report_progress(ctx, work_done=1, work_total=1, status=outcome.status, details=details) diff --git a/plugins/nemo-iron-swarm/src/nemo_iron_swarm_plugin/jobs/synth_client.py b/plugins/nemo-iron-swarm/src/nemo_iron_swarm_plugin/jobs/synth_client.py new file mode 100644 index 0000000000..09a13a458c --- /dev/null +++ b/plugins/nemo-iron-swarm/src/nemo_iron_swarm_plugin/jobs/synth_client.py @@ -0,0 +1,117 @@ +# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +"""HTTP client for the iron-swarm ``serve`` synth service. + +The war-game job spawns ``iron-swarm serve`` (its own venv) and drives the interview + review over these +endpoints. Thin wrapper over httpx: each call returns the service's JSON dict +(``{thread_id, status, questions|suite, ...}``). +""" + +from __future__ import annotations + +import contextlib +import socket +import subprocess +import time +from collections.abc import Iterator +from pathlib import Path +from typing import Any + +import httpx +from nemo_iron_swarm_plugin.jobs.errors import CATEGORY_SYNTH_SERVICE, IronSwarmRunError + + +class SynthClient: + """Sync client for one synth run against a local ``iron-swarm serve`` instance.""" + + def __init__(self, base_url: str, *, timeout: float = 900.0, transport: httpx.BaseTransport | None = None) -> None: + self._client = httpx.Client(base_url=base_url.rstrip("/"), timeout=timeout, transport=transport) + + def __enter__(self) -> SynthClient: + return self + + def __exit__(self, *_exc: object) -> None: + self.close() + + def close(self) -> None: + self._client.close() + + def healthz(self) -> bool: + """True once the service is serving.""" + try: + resp = self._client.get("/healthz") + except httpx.HTTPError: + return False + return resp.status_code == 200 + + def start(self, config: str, *, validator: str | None = None) -> dict[str, Any]: + """Begin a synth run for the manifest at *config*; returns the first interview or review step.""" + return self._post("/synth", {"config": config, "validator": validator}) + + def answers(self, thread_id: str, answers: list[dict[str, Any]]) -> dict[str, Any]: + """Submit one interview round's answers; returns the next interview or review step.""" + return self._post(f"/synth/{thread_id}/answers", {"answers": answers}) + + def write_suite(self, thread_id: str, suite: list[dict[str, Any]]) -> dict[str, Any]: + """Persist the reviewed benign suite to ``requests.csv``; returns the final ``done`` step.""" + return self._post(f"/synth/{thread_id}/suite", {"suite": suite}) + + def _post(self, path: str, body: dict[str, Any]) -> dict[str, Any]: + # The synth service is a local iron-swarm subprocess; a transport error or non-2xx from it is a + # benign-suite generation failure, not a victim/network issue — classify it as such. + try: + resp = self._client.post(path, json=body) + resp.raise_for_status() + return resp.json() + except httpx.HTTPError as exc: + raise IronSwarmRunError( + CATEGORY_SYNTH_SERVICE, f"benign-suite service request to {path} failed: {exc}" + ) from exc + + +def _free_port() -> int: + """Pick a free localhost port (bind-and-release) to hand to ``iron-swarm serve``.""" + with socket.socket() as sock: + sock.bind(("127.0.0.1", 0)) + return int(sock.getsockname()[1]) + + +@contextlib.contextmanager +def launch_synth_service( + iron_swarm_bin: Path, env: dict[str, str], *, log_path: Path | None = None, ready_timeout: float = 90.0 +) -> Iterator[SynthClient]: + """Spawn ``iron-swarm serve`` on a free localhost port, yield a connected client, tear it down. + + Raises ``RuntimeError`` if the server exits early or isn't healthy within *ready_timeout*. + """ + port = _free_port() + cmd = [str(iron_swarm_bin), "serve", "--host", "127.0.0.1", "--port", str(port)] + with contextlib.ExitStack() as stack: + sink = stack.enter_context(log_path.open("w", encoding="utf-8")) if log_path else subprocess.DEVNULL + proc = subprocess.Popen(cmd, env=env, stdout=sink, stderr=subprocess.STDOUT) + stack.callback(_terminate, proc) + client = stack.enter_context(SynthClient(f"http://127.0.0.1:{port}")) + _await_ready(client, proc, ready_timeout) + yield client + + +def _await_ready(client: SynthClient, proc: subprocess.Popen, timeout: float) -> None: + deadline = time.monotonic() + timeout + while time.monotonic() < deadline: + if proc.poll() is not None: + raise IronSwarmRunError(CATEGORY_SYNTH_SERVICE, f"iron-swarm serve exited early (code {proc.returncode})") + if client.healthz(): + return + time.sleep(0.5) + raise IronSwarmRunError(CATEGORY_SYNTH_SERVICE, f"iron-swarm serve not healthy within {timeout:.0f}s") + + +def _terminate(proc: subprocess.Popen) -> None: + if proc.poll() is not None: + return + proc.terminate() + try: + proc.wait(timeout=10) + except subprocess.TimeoutExpired: + proc.kill() diff --git a/plugins/nemo-iron-swarm/src/nemo_iron_swarm_plugin/model_config.py b/plugins/nemo-iron-swarm/src/nemo_iron_swarm_plugin/model_config.py new file mode 100644 index 0000000000..f8db702b02 --- /dev/null +++ b/plugins/nemo-iron-swarm/src/nemo_iron_swarm_plugin/model_config.py @@ -0,0 +1,75 @@ +# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +"""User-selectable model configuration for a war-game. + +Iron Swarm's five model-driven roles collapse into three user-facing groups: + +- ``attack`` — garak's red-team + detector models (the adversary). +- ``analysis`` — the defenders + the benign validator (both its synth suite-generation and its judge) + — one shared "analysis" model. +- ``agent`` — the victim agent's own LLM (an optional override of what its workflow declares). + +Each group is a :class:`ModelChoice` (model name, optional custom ``base_url``, optional Secrets +name for a custom provider key). ``None`` anywhere means "use the built-in default", so an unset +config reproduces today's behavior exactly. + +This module is the single source of truth shared by the entity (stored default), the job spec +(per-run override), and the API (the defaults the UI pre-fills). It imports nothing plugin-internal +so it can be depended on from anywhere without cycles. +""" + +from __future__ import annotations + +from pydantic import BaseModel, Field + +# Built-in defaults, mirrored from iron-swarm's own literals so the UI can present them pre-filled. +# attack → iron_swarm.agents.attackers.agent_breaker.config; analysis → iron_swarm.llm. +ATTACK_DEFAULT_MODEL = "aws/anthropic/claude-opus-4-5" +ATTACK_DEFAULT_BASE_URL = "https://inference-api.nvidia.com/v1/" +ANALYSIS_DEFAULT_MODEL = "nvidia/nvidia/Nemotron-3-Nano-30B-A3B" +ANALYSIS_DEFAULT_BASE_URL = "https://inference-api.nvidia.com/v1" + + +class ModelChoice(BaseModel): + """One group's model selection. Every field is optional; ``None`` → the group's built-in default.""" + + model: str | None = Field(default=None, description="Model name/URN; null uses the group default.") + base_url: str | None = Field(default=None, description="Custom OpenAI-compatible endpoint; null uses the default.") + api_key_secret: str | None = Field( + default=None, + description="Name of a NeMo Secret holding the provider API key for a custom endpoint; null uses the " + "platform's provisioned iron-swarm inference key.", + ) + + +class WarGameModels(BaseModel): + """The three model groups for a war-game. An unset group uses iron-swarm's built-in default.""" + + attack: ModelChoice | None = Field(default=None, description="garak red-team + detector model.") + analysis: ModelChoice | None = Field( + default=None, description="Defenders + benign validator (synth suite-generation + judge) model." + ) + agent: ModelChoice | None = Field(default=None, description="Victim agent LLM override (model only).") + + +class ModelGroupDefault(BaseModel): + """The default model + endpoint the UI shows for one group.""" + + model: str + base_url: str + + +class ModelConfigDefaults(BaseModel): + """Defaults surfaced to the UI so pickers pre-fill without hardcoding iron-swarm's literals.""" + + attack: ModelGroupDefault + analysis: ModelGroupDefault + + +def model_config_defaults() -> ModelConfigDefaults: + """Return the built-in per-group model defaults (the values shown pre-filled in the UI).""" + return ModelConfigDefaults( + attack=ModelGroupDefault(model=ATTACK_DEFAULT_MODEL, base_url=ATTACK_DEFAULT_BASE_URL), + analysis=ModelGroupDefault(model=ANALYSIS_DEFAULT_MODEL, base_url=ANALYSIS_DEFAULT_BASE_URL), + ) diff --git a/plugins/nemo-iron-swarm/src/nemo_iron_swarm_plugin/model_preflight.py b/plugins/nemo-iron-swarm/src/nemo_iron_swarm_plugin/model_preflight.py new file mode 100644 index 0000000000..fc14002781 --- /dev/null +++ b/plugins/nemo-iron-swarm/src/nemo_iron_swarm_plugin/model_preflight.py @@ -0,0 +1,100 @@ +# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +"""Model connectivity preflight — probe an OpenAI-compatible endpoint before a costly war-game. + +A war-game spins up a Docker sandbox and runs for minutes; a mistyped model name or a wrong +``base_url``/key should fail in seconds, not after the sandbox is up. :func:`probe_models` lists the +models a credential can reach (``GET {base_url}/models``); :func:`validate_choice` turns that into a +verdict, and — crucially — hands back the *available* models so the caller can show the user what they +*can* use instead of what they typed. Providers that don't implement ``/models`` are a soft pass +(reachable, list unknown) rather than a hard failure. + +The same helper backs both the interactive Studio "Test connection" and the launch-time preflight. +""" + +from __future__ import annotations + +from dataclasses import dataclass, field + +import httpx + +_PROBE_TIMEOUT_S = 10.0 + + +@dataclass(frozen=True) +class ProbeResult: + """Outcome of listing an endpoint's models. ``list_supported`` is False when it has no ``/models``. + + ``auth_ok`` is strictly about 401/403. Any other error status leaves it True and clears + ``status_ok`` instead — a provider 500 or 429 is the provider's problem, not a bad credential. + """ + + reachable: bool + auth_ok: bool + available: list[str] = field(default_factory=list) + list_supported: bool = True + status_ok: bool = True + detail: str = "" + + +@dataclass(frozen=True) +class Validation: + """A model choice's verdict. ``available`` is populated so the UI/error can offer real options.""" + + ok: bool + reason: str = "" # "", "auth", "unreachable", "provider_error", "unknown_model" + available: list[str] = field(default_factory=list) + detail: str = "" + + +def probe_models(base_url: str, api_key: str | None, *, client: httpx.Client | None = None) -> ProbeResult: + """List the models reachable at ``{base_url}/models`` with *api_key* (best-effort, never raises).""" + url = base_url.rstrip("/") + "/models" + headers = {"Authorization": f"Bearer {api_key}"} if api_key else {} + owns = client is None + client = client or httpx.Client(timeout=_PROBE_TIMEOUT_S) + try: + resp = client.get(url, headers=headers) + except httpx.HTTPError as exc: + return ProbeResult(reachable=False, auth_ok=False, detail=str(exc) or exc.__class__.__name__) + finally: + if owns: + client.close() + if resp.status_code in (401, 403): + return ProbeResult(reachable=True, auth_ok=False, detail=f"HTTP {resp.status_code}") + if resp.status_code == 404: + # No OpenAI-compatible model list — reachable, but we can't enumerate. Soft pass. + return ProbeResult(reachable=True, auth_ok=True, list_supported=False, detail="endpoint has no /models") + if resp.status_code >= 400: + # Reachable and the credential wasn't rejected — the provider itself is erroring (5xx, 429, ...). + return ProbeResult(reachable=True, auth_ok=True, status_ok=False, detail=f"HTTP {resp.status_code}") + try: + data = resp.json().get("data", []) + ids = sorted(str(m["id"]) for m in data if isinstance(m, dict) and m.get("id")) + except (ValueError, KeyError, TypeError): + return ProbeResult(reachable=True, auth_ok=True, list_supported=False, detail="unparseable /models response") + return ProbeResult(reachable=True, auth_ok=True, available=ids) + + +def validate_choice( + model: str | None, base_url: str, api_key: str | None, *, client: httpx.Client | None = None +) -> Validation: + """Validate one model group's (model, base_url, key): reachable, authorized, and the model exists. + + A model that isn't in the reachable list fails with ``reason="unknown_model"`` and the available + list, so the caller can present real choices. If the endpoint has no ``/models`` we can't verify the + name — treat it as a pass (reachability + auth already confirmed). + """ + result = probe_models(base_url, api_key, client=client) + if not result.reachable: + return Validation(ok=False, reason="unreachable", detail=result.detail) + if not result.auth_ok: + return Validation(ok=False, reason="auth", detail=result.detail) + if not result.status_ok: + return Validation(ok=False, reason="provider_error", detail=result.detail) + if not result.list_supported: + return Validation(ok=True, detail=result.detail) + if model and model not in result.available: + return Validation(ok=False, reason="unknown_model", available=result.available) + return Validation(ok=True, available=result.available) diff --git a/plugins/nemo-iron-swarm/src/nemo_iron_swarm_plugin/sdk.py b/plugins/nemo-iron-swarm/src/nemo_iron_swarm_plugin/sdk.py new file mode 100644 index 0000000000..841f37f710 --- /dev/null +++ b/plugins/nemo-iron-swarm/src/nemo_iron_swarm_plugin/sdk.py @@ -0,0 +1,288 @@ +# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +"""SDK resources for the Iron Swarm plugin. + +Mounted on :class:`~nemo_platform.NeMoPlatform` as ``client.iron_swarm`` via the ``nemo.sdk`` +entry-point. Exposes ``run(config=..., env_file=..., workspace=...)`` which executes the +``iron-swarm.war-game`` job locally, in-process, via +:meth:`~nemo_platform_plugin.scheduler.NemoJobScheduler.run_local` — mirroring the auditor +plugin's ``client.auditor.run`` — plus ``client.iron_swarm.runs`` to read run records. +""" + +from __future__ import annotations + +import asyncio +import itertools +from collections.abc import Sequence +from pathlib import Path +from typing import Any + +from nemo_iron_swarm_plugin.cli.client import base_url, make_sdk +from nemo_iron_swarm_plugin.entities import IRON_SWARM_MANIFEST_TYPE, IRON_SWARM_RUN_TYPE +from nemo_iron_swarm_plugin.filesets import upload_file_to_fileset +from nemo_iron_swarm_plugin.jobs.defenses import compose_defense +from nemo_iron_swarm_plugin.jobs.run import IronSwarmRunJob +from nemo_iron_swarm_plugin.jobs.synth_benign import IronSwarmSynthBenignJob +from nemo_platform import AsyncNeMoPlatform, NeMoPlatform +from nemo_platform_plugin.scheduler import NemoJobScheduler +from nemo_platform_plugin.sdk import NemoPluginSDKResources + + +def _run_to_dict(entity: Any) -> dict[str, Any]: + """Flatten an entity-store record into a flat dict for display.""" + data = dict(getattr(entity, "data", {}) or {}) + data["name"] = getattr(entity, "name", "") + created = getattr(entity, "created_at", None) + if created is not None: + data["created_at"] = str(created) + return data + + +def _run_war_game( + sync_sdk: Any, + *, + config: str | None, + manifest_id: str | None, + env_file: str | None, + workspace: str, + benign_suite: str | None, +) -> dict: + """Blocking war-game launch shared by the sync and async resources. + + ``run_local`` runs the job synchronously and the job downloads its filesets (benign suite, replay + hitlog, materialized manifest) through the sync ``sdk``, so both entry points funnel through this + one sync body — the async twin just runs it on a worker thread with a sync client it builds. + + Pass a local ``config`` manifest path or a saved ``manifest_id`` (which materializes the manifest and + reuses its cached benign suite). Exactly one is required. + """ + if not (config or manifest_id): + raise ValueError("iron-swarm run requires a 'config' manifest path or a 'manifest_id'.") + spec: dict[str, Any] = {"config": config, "manifest_id": manifest_id, "env_file": env_file} + if benign_suite: + spec["benign_suite_fileset"] = upload_file_to_fileset(sync_sdk, Path(benign_suite), workspace=workspace) + return NemoJobScheduler().run_local(IronSwarmRunJob, spec, workspace=workspace, sdk=sync_sdk) + + +def _run_synth_benign(sync_sdk: Any, *, manifest_id: str, env_file: str | None, interview: str, workspace: str) -> dict: + """Blocking benign-suite synthesis for a saved manifest, shared by the sync and async resources. + + Materializes the manifest, runs native ``iron-swarm synth-benign`` (TTY interview), and caches the + reviewed suite on the manifest entity through the sync ``sdk``. + """ + spec: dict[str, Any] = {"manifest_id": manifest_id, "env_file": env_file, "interview": interview} + return NemoJobScheduler().run_local(IronSwarmSynthBenignJob, spec, workspace=workspace, sdk=sync_sdk) + + +def _list_newest(platform: NeMoPlatform, entity_type: str, *, workspace: str, limit: int) -> list[dict[str, Any]]: + """Return at most *limit* records of *entity_type*, newest first. + + ``entities.list`` returns a ``SyncDefaultPagination`` whose ``__iter__`` auto-paginates, so + ``page_size`` bounds the *page*, not the total — iterating it walks the entire history. We ask for + one page of *limit* and take only that page's items, which is a single request. + """ + page = platform.entities.list(entity_type, workspace=workspace, sort="-created_at", page_size=limit) + return [_run_to_dict(item) for item in itertools.islice(page, limit)] + + +class _RunsResource: + """``client.iron_swarm.runs`` — read IronSwarmRun records from the entity store.""" + + def __init__(self, platform: NeMoPlatform) -> None: + self._platform = platform + + def list(self, *, workspace: str = "default", limit: int = 20) -> Sequence[dict[str, Any]]: + return _list_newest(self._platform, IRON_SWARM_RUN_TYPE, workspace=workspace, limit=limit) + + def latest(self, *, workspace: str = "default") -> dict[str, Any] | None: + runs = self.list(workspace=workspace, limit=1) + return runs[0] if runs else None + + +class _ManifestsResource: + """``client.iron_swarm.manifests`` — read saved IronSwarmManifest records.""" + + def __init__(self, platform: NeMoPlatform) -> None: + self._platform = platform + + def list(self, *, workspace: str = "default", limit: int = 20) -> Sequence[dict[str, Any]]: + return _list_newest(self._platform, IRON_SWARM_MANIFEST_TYPE, workspace=workspace, limit=limit) + + +class IronSwarmPluginResource: + """Sync SDK namespace mounted as ``client.iron_swarm``.""" + + def __init__(self, platform: NeMoPlatform) -> None: + self._platform = platform + self._runs: _RunsResource | None = None + self._manifests: _ManifestsResource | None = None + + @property + def runs(self) -> _RunsResource: + if self._runs is None: + self._runs = _RunsResource(self._platform) + return self._runs + + @property + def manifests(self) -> _ManifestsResource: + if self._manifests is None: + self._manifests = _ManifestsResource(self._platform) + return self._manifests + + def run( + self, + *, + config: str | None = None, + manifest_id: str | None = None, + env_file: str | None = None, + workspace: str | None = None, + benign_suite: str | None = None, + ) -> dict: + """Run the war-game locally against a local ``config`` manifest or a saved ``manifest_id``. + + A saved ``manifest_id`` materializes the manifest and reuses its cached benign suite (from a prior + ``synth_benign``). ``benign_suite`` is a local CSV (tool,payload,label,rationale,persona) uploaded + as a fileset and passed to iron-swarm via ``--benign-suite``, overriding the cached suite. + """ + return _run_war_game( + self._platform, + config=config, + manifest_id=manifest_id, + env_file=env_file, + workspace=workspace or "default", + benign_suite=benign_suite, + ) + + def synth_benign( + self, + *, + manifest_id: str, + env_file: str | None = None, + interview: str = "interactive", + workspace: str | None = None, + ) -> dict: + """Synthesize a saved manifest's benign suite and cache it on the manifest. + + Shells out to native ``iron-swarm synth-benign`` (its own TTY interview). ``interview`` is + ``"interactive"`` (prompt), ``"auto"`` (accept recommended defaults), or ``"skip"`` (rules-only). + """ + return _run_synth_benign( + self._platform, + manifest_id=manifest_id, + env_file=env_file, + interview=interview, + workspace=workspace or "default", + ) + + def submit( + self, + *, + manifest_id: str | None = None, + config: str | None = None, + env_file: str | None = None, + driver: str | None = None, + workspace: str | None = None, + profile: str | None = None, + ) -> dict: + """Submit the war-game to the platform executor (remote-capable path Studio uses). + + Pass a saved ``manifest_id`` (Studio) or a ready ``config`` path. ``driver="service"`` selects + the Studio-driven interview/review HITL; omit it for the one-shot run. + """ + spec = {"manifest_id": manifest_id, "config": config, "env_file": env_file, "driver": driver} + return NemoJobScheduler().submit_remote( + IronSwarmRunJob, spec, base_url=base_url(), workspace=workspace or "default", profile=profile + ) + + def sanity_check( + self, + *, + manifest_id: str, + mitigations: dict[str, Any], + selected_defense_ids: list[str], + replay_hitlog_fileset: str, + env_file: str | None = None, + workspace: str | None = None, + profile: str | None = None, + ) -> dict: + """Submit a validate-only war-game: freeze the chosen defenses and replay the recorded attacks + benign. + + Composes the selected subset of the run's recommended defenses (guardrails + policy) into the victim's + frozen baseline, disables the mitigation-generating defenders, and replays ``replay_hitlog_fileset`` + against it — measuring which attacks are now blocked and which benign requests are wrongly blocked. The + produced ``validation`` job result holds the per-item verdicts. + """ + workflow_yaml, policy_yaml = compose_defense(mitigations, selected_defense_ids) + spec = { + "manifest_id": manifest_id, + "driver": "service", + "validate_only": True, + "replay_hitlog_fileset": replay_hitlog_fileset, + "env_file": env_file, + "defense_workflow": workflow_yaml, + "defense_policy": policy_yaml, + } + return NemoJobScheduler().submit_remote( + IronSwarmRunJob, spec, base_url=base_url(), workspace=workspace or "default", profile=profile + ) + + +class AsyncIronSwarmPluginResource: + """Async SDK namespace mounted as ``client.iron_swarm``.""" + + def __init__(self, platform: AsyncNeMoPlatform) -> None: + self._platform = platform + + async def run( + self, + *, + config: str | None = None, + manifest_id: str | None = None, + env_file: str | None = None, + workspace: str | None = None, + benign_suite: str | None = None, + ) -> dict: + """Async twin of :meth:`IronSwarmPluginResource.run`. + + ``run_local`` and the job it drives are synchronous and reach the platform through a *sync* + client (fileset uploads/downloads, manifest materialization). We build one targeting the same + base URL as the injected async client and run the whole blocking flow on a worker thread so the + caller's event loop stays free. Auth mirrors the CLI's direct-mode ``make_sdk`` (fine for the + local-platform path iron-swarm runs against). + """ + sync_sdk = make_sdk(str(self._platform.base_url)) + return await asyncio.to_thread( + _run_war_game, + sync_sdk, + config=config, + manifest_id=manifest_id, + env_file=env_file, + workspace=workspace or "default", + benign_suite=benign_suite, + ) + + async def synth_benign( + self, + *, + manifest_id: str, + env_file: str | None = None, + interview: str = "interactive", + workspace: str | None = None, + ) -> dict: + """Async twin of :meth:`IronSwarmPluginResource.synth_benign` (runs the blocking flow off-loop).""" + sync_sdk = make_sdk(str(self._platform.base_url)) + return await asyncio.to_thread( + _run_synth_benign, + sync_sdk, + manifest_id=manifest_id, + env_file=env_file, + interview=interview, + workspace=workspace or "default", + ) + + +iron_swarm_sdk_resources = NemoPluginSDKResources( + sync_resource=IronSwarmPluginResource, + async_resource=AsyncIronSwarmPluginResource, +) diff --git a/plugins/nemo-iron-swarm/src/nemo_iron_swarm_plugin/service.py b/plugins/nemo-iron-swarm/src/nemo_iron_swarm_plugin/service.py new file mode 100644 index 0000000000..be14437bb5 --- /dev/null +++ b/plugins/nemo-iron-swarm/src/nemo_iron_swarm_plugin/service.py @@ -0,0 +1,87 @@ +# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +"""Service surface for the Iron Swarm plugin. + +Mounts, under ``/apis/iron-swarm``: ``/v1/healthz``; read routes over ``IronSwarmRun`` plus +``apply-mitigation`` (write the hardened workflow onto the agent) and ``compose-defense`` +(preview a selected defense subset); ``IronSwarmManifest`` CRUD/init/inspect; the live event +relay (ingest from the run + SSE stream to Studio); and the war-game job collection. Runs and +manifests are created by the war-game job and the manifests API via the entities SDK. +""" + +from __future__ import annotations + +from typing import ClassVar + +from fastapi import APIRouter +from nemo_iron_swarm_plugin.authz import scope +from nemo_platform_plugin.authz import CallerKind, path_rule +from nemo_platform_plugin.service import NemoService, RouterSpec + + +class IronSwarmPluginService(NemoService): + """Iron Swarm plugin service. Exposes healthz and read-only access to war-game runs. + + Route authz lives on the handlers themselves (``@scope.read``/``@scope.write`` + ``@path_rule``); + permission ids come from :mod:`nemo_iron_swarm_plugin._perms`, and the war-game job collection is + ruled via ``add_job_routes``/``job_route_factory(authz=...)`` in :mod:`~.api.v2.jobs`. + """ + + name: ClassVar[str] = "iron-swarm" + dependencies: ClassVar[list[str]] = ["entities", "jobs"] + + def get_routers(self) -> list[RouterSpec]: + from nemo_iron_swarm_plugin.api.v2 import events, jobs, manifests, runs + + healthz_router = APIRouter() + + @healthz_router.get("/healthz") + @scope.read + @path_rule(callers=[CallerKind.PRINCIPAL], permissions=[]) # authenticated, no permission required + async def healthz() -> dict[str, object]: + return { + "plugin": self.name, + "status": "ok", + "jobs": ["iron-swarm.war-game", "iron-swarm.synth"], + "entities": ["iron_swarm_run", "iron_swarm_manifest"], + } + + return [ + RouterSpec( + router=healthz_router, + tag="Iron Swarm Plugin", + description="Iron Swarm plugin health.", + prefix="/v1", + ), + RouterSpec( + router=runs.router, + tag="Iron Swarm Runs", + description="Read-only access to war-game runs.", + prefix="/v2/workspaces/{workspace}", + ), + RouterSpec( + router=manifests.router, + tag="Iron Swarm Manifests", + description="Named war-game targets: init (create), list, get, delete.", + prefix="/v2/workspaces/{workspace}", + ), + RouterSpec( + router=events.router, + tag="Iron Swarm Events", + description="Live run-event ingest (from the run) + SSE stream (to Studio).", + prefix="/v2/workspaces/{workspace}", + ), + RouterSpec( + router=jobs.router, + tag="Iron Swarm Jobs", + description="Submit and manage the war-game job.", + prefix="/v2/workspaces/{workspace}", + ), + RouterSpec( + router=jobs.synth_router, + tag="Iron Swarm Synth Jobs", + description="Submit and manage the benign-suite synthesis job.", + prefix="/v2/workspaces/{workspace}/synth-benign", + ), + ] diff --git a/plugins/nemo-iron-swarm/src/nemo_iron_swarm_plugin/skills.py b/plugins/nemo-iron-swarm/src/nemo_iron_swarm_plugin/skills.py new file mode 100644 index 0000000000..c5d21962ad --- /dev/null +++ b/plugins/nemo-iron-swarm/src/nemo_iron_swarm_plugin/skills.py @@ -0,0 +1,9 @@ +# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +from pathlib import Path + + +def get_skills_path() -> Path: + """Return the directory containing Iron Swarm plugin skills.""" + return Path(__file__).parent / "skills" diff --git a/plugins/nemo-iron-swarm/src/nemo_iron_swarm_plugin/skills/iron-swarm/SKILL.md b/plugins/nemo-iron-swarm/src/nemo_iron_swarm_plugin/skills/iron-swarm/SKILL.md new file mode 100644 index 0000000000..73c68a1b00 --- /dev/null +++ b/plugins/nemo-iron-swarm/src/nemo_iron_swarm_plugin/skills/iron-swarm/SKILL.md @@ -0,0 +1,62 @@ +--- +name: iron-swarm +description: > + NeMo Iron Swarm CLI reference for red-teaming and hardening deployed NAT agents. + Use when the task involves attacking, red-teaming, hardening, or running a security + war-game against an agent, or any `nemo iron-swarm` CLI command. +--- + +# NeMo Iron Swarm CLI Reference + +Iron Swarm runs a security war-game against a NAT agent: garak attackers probe a sandboxed copy +of the agent, defenders harden it (OpenShell policy + workflow guardrails), and validators replay +the attacks plus benign traffic to confirm the fix. This plugin lets you point Iron Swarm at an +agent you already deployed in NeMo Platform — no project discovery, no manual manifest editing. + +## Prerequisites + +Iron Swarm needs Docker and an OpenShell gateway, and runs in its own isolated venv. Provision +once per host, then verify: + +```bash +# Provision iron-swarm's dedicated venv and check host prerequisites +nemo iron-swarm setup + +# Read-only preflight: venv present, Docker daemon up, OpenShell gateway connected +nemo iron-swarm doctor +``` + +`setup` provisions the Python venv for you. Host-level prerequisites (Docker, the OpenShell +gateway) are checked and the exact install command is printed if missing — they are never +silently installed (they need sudo/brew/systemd). + +## Typical flow + +```bash +# 1. Scaffold a manifest from an already-deployed agent (resolves name -> target automatically) +nemo iron-swarm init --agent +nemo iron-swarm init --agent / # qualified +nemo iron-swarm init --agent -o iron-swarm.yaml + +# 2. Run the war-game (preflights first, then runs the iron-swarm.run job) +nemo iron-swarm run --config iron-swarm.yaml + +# 3. Inspect the latest run +nemo iron-swarm status +``` + +The agent must already be deployed and running in NeMo Platform — confirm with +`nemo agents deployments list` before `init`. + +## Notes + +- Models default to iron-swarm's built-ins and the victim's own LLM resolves through the platform + Inference Gateway (no raw API keys needed). You can override the models per war-game — three groups: + **attack** (garak red-team + detector), **analysis** (defenders + the benign validator's synth + suite-generation and judge), and **agent** (the victim's own LLM). Set them as the manifest's stored + default or per-run; a custom endpoint's + key is supplied by name from the Secrets store. Before the sandbox spins up, a chosen model is + preflighted against its endpoint and the run fails fast (listing the reachable models) on a bad + name/key/URL. +- The war-game requires a host with Docker + OpenShell; the run job fails preflight with a clear + message otherwise. Do not report a run successful without verifying its status. diff --git a/plugins/nemo-iron-swarm/src/nemo_iron_swarm_plugin/tasks/synth_benign/__main__.py b/plugins/nemo-iron-swarm/src/nemo_iron_swarm_plugin/tasks/synth_benign/__main__.py new file mode 100644 index 0000000000..8732e4b9a1 --- /dev/null +++ b/plugins/nemo-iron-swarm/src/nemo_iron_swarm_plugin/tasks/synth_benign/__main__.py @@ -0,0 +1,43 @@ +# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +"""Task entrypoint for benign synthesis (``python -m nemo_iron_swarm_plugin.tasks.synth_benign``). + +The executor spawns this module with the ``NEMO_JOB_*`` env populated; it hands off to the framework's +``run_task`` dispatcher, which loads the step config, builds a ``JobContext``, and DI-injects ``ctx``/``sdk`` +into :meth:`IronSwarmSynthBenignJob.run`. Local responsibilities here are only SIGTERM handling and SDK +construction — mirrors :mod:`nemo_iron_swarm_plugin.tasks.war_game`. +""" + +from __future__ import annotations + +import logging +import signal +import sys +from types import FrameType + +from nemo_iron_swarm_plugin.jobs.synth_benign import IronSwarmSynthBenignJob +from nemo_platform_plugin.sdk_provider import get_task_sdk +from nemo_platform_plugin.tasks.dispatcher import run_task + +logger = logging.getLogger(__name__) + + +def _shutdown_handler(signum: int, _frame: FrameType | None) -> None: + logger.warning("Received shutdown signal (%d). Exiting.", signum) + raise SystemExit(128 + signum) + + +def main() -> int: + """Build the on-behalf-of SDK and dispatch to ``run_task``.""" + signal.signal(signal.SIGTERM, _shutdown_handler) + try: + sdk = get_task_sdk("iron-swarm") + except Exception: + logger.exception("Failed to build task SDK for iron-swarm") + return 2 + return run_task(IronSwarmSynthBenignJob, sdk=sdk) + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/plugins/nemo-iron-swarm/src/nemo_iron_swarm_plugin/tasks/war_game/__main__.py b/plugins/nemo-iron-swarm/src/nemo_iron_swarm_plugin/tasks/war_game/__main__.py new file mode 100644 index 0000000000..c3064045f6 --- /dev/null +++ b/plugins/nemo-iron-swarm/src/nemo_iron_swarm_plugin/tasks/war_game/__main__.py @@ -0,0 +1,42 @@ +# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +"""Task entrypoint for the war-game (``python -m nemo_iron_swarm_plugin.tasks.war_game``). + +The executor spawns this module with the ``NEMO_JOB_*`` env populated; it hands off to the framework's +``run_task`` dispatcher, which loads the step config, builds a ``JobContext``, and DI-injects ``ctx``/``sdk`` +into :meth:`IronSwarmRunJob.run`. Local responsibilities here are only SIGTERM handling and SDK construction. +""" + +from __future__ import annotations + +import logging +import signal +import sys +from types import FrameType + +from nemo_iron_swarm_plugin.jobs.run import IronSwarmRunJob +from nemo_platform_plugin.sdk_provider import get_task_sdk +from nemo_platform_plugin.tasks.dispatcher import run_task + +logger = logging.getLogger(__name__) + + +def _shutdown_handler(signum: int, _frame: FrameType | None) -> None: + logger.warning("Received shutdown signal (%d). Exiting.", signum) + raise SystemExit(128 + signum) + + +def main() -> int: + """Build the on-behalf-of SDK and dispatch to ``run_task``.""" + signal.signal(signal.SIGTERM, _shutdown_handler) + try: + sdk = get_task_sdk("iron-swarm") + except Exception: + logger.exception("Failed to build task SDK for iron-swarm") + return 2 + return run_task(IronSwarmRunJob, sdk=sdk) + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/plugins/nemo-iron-swarm/tests/unit/_doubles.py b/plugins/nemo-iron-swarm/tests/unit/_doubles.py new file mode 100644 index 0000000000..863321ece7 --- /dev/null +++ b/plugins/nemo-iron-swarm/tests/unit/_doubles.py @@ -0,0 +1,62 @@ +# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +"""Shared test doubles for the iron-swarm plugin. + +The job/SDK seams take concrete types (:class:`JobContext`, ``NeMoPlatform``) that are impractical to +build in a unit test, so these factories return duck-typed stand-ins narrowed with :func:`typing.cast`. +The cast is deliberate and lives here only: keeping the fakes in one place means a stub that drifts from +the real shape is fixed once, rather than per file — the drift that let a bad fixture hide a real bug +(see docs/iron-swarm-review/findings.md, #66). +""" + +from __future__ import annotations + +from pathlib import Path +from types import SimpleNamespace +from typing import Any, cast + +from nemo_platform import AsyncNeMoPlatform, NeMoPlatform +from nemo_platform_plugin.job_context import JobContext + + +def make_job_context( + tmp_path: Path, + *, + workspace: str = "default", + job_id: str = "job-123", + on_save: Any = None, +) -> JobContext: + """A :class:`JobContext` stand-in backed by *tmp_path* for persistent storage. + + Covers the surface the jobs actually touch: ``workspace``, ``job_id``, ``storage.persistent`` and + ``results.save``. Pass *on_save* to capture saved artifacts. + """ + return cast( + JobContext, + SimpleNamespace( + workspace=workspace, + job_id=job_id, + storage=SimpleNamespace(persistent=tmp_path), + results=SimpleNamespace(save=on_save or (lambda *_a, **_k: None)), + ), + ) + + +def make_sdk(entities: Any = None, **namespaces: Any) -> NeMoPlatform: + """A ``NeMoPlatform`` stand-in exposing only the namespaces a test needs (usually ``entities``).""" + return cast(NeMoPlatform, SimpleNamespace(entities=entities, **namespaces)) + + +def make_async_sdk(**namespaces: Any) -> AsyncNeMoPlatform: + """An ``AsyncNeMoPlatform`` stand-in (the async SDK resources only read ``base_url``).""" + return cast(AsyncNeMoPlatform, SimpleNamespace(**namespaces)) + + +def make_entity(**data: Any) -> Any: + """An entity-store record shaped like the real one: domain fields live under ``.data``. + + Deliberately *not* a bare ``Mock`` — ``Mock().some_field`` is truthy, which is exactly how the + events-fileset fallback bug reached production while its test passed. + """ + return SimpleNamespace(data=dict(data)) diff --git a/plugins/nemo-iron-swarm/tests/unit/test_agent_resolver.py b/plugins/nemo-iron-swarm/tests/unit/test_agent_resolver.py new file mode 100644 index 0000000000..4f4419a1cc --- /dev/null +++ b/plugins/nemo-iron-swarm/tests/unit/test_agent_resolver.py @@ -0,0 +1,228 @@ +# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +"""Unit tests for the agent resolver's pure logic and end-to-end manifest build. + +The orchestrator is exercised with a fake SDK (plain dicts, matching what +``client.agents.get`` / ``client.agents.deployments.list`` return), so no live platform or +iron-swarm install is needed. +""" + +from __future__ import annotations + +from typing import Any + +import pytest +import yaml +from nemo_iron_swarm_plugin.agent_resolver import ( + AgentResolutionError, + derive_secret_names, + detect_custom_components, + gateway_backend, + inject_gateway_url, + parse_agent_ref, + resolve_agent_to_manifest, +) + +# `list` is shadowed by the fake's own `list` method inside the class body, so the parameter type +# is aliased at module level. +DeploymentRows = list[dict[str, Any]] + + +class _FakeDeployments: + def __init__(self, deployments: DeploymentRows) -> None: + self._deployments = deployments + + def list(self, workspace: str = "default") -> dict: + # Mirror the real SDK shape: a paginated dict, not a bare list. + return {"data": self._deployments, "pagination": {"total_results": len(self._deployments)}} + + +class _FakeAgents: + def __init__(self, agent: dict | None, deployments: list[dict]) -> None: + self._agent = agent + self.deployments = _FakeDeployments(deployments) + + def get(self, name: str, workspace: str = "default") -> dict: + if self._agent is None: + raise KeyError(name) + return self._agent + + +class _FakeSDK: + def __init__(self, agent: dict | None, deployments: list[dict] | None = None) -> None: + self.agents = _FakeAgents(agent, deployments or []) + + +# --------------------------------------------------------------------------- parse_agent_ref +def test_parse_agent_ref_plain_name_uses_default_workspace(): + assert parse_agent_ref("calculator", "default") == ("default", "calculator") + + +def test_parse_agent_ref_qualified(): + assert parse_agent_ref("team-a/calculator", "default") == ("team-a", "calculator") + + +def test_parse_agent_ref_rejects_url(): + with pytest.raises(AgentResolutionError): + parse_agent_ref("http://localhost:9001", "default") + + +# --------------------------------------------------------------------------- inject_gateway_url +def test_inject_gateway_url_sets_base_url_for_openai_and_nim(): + config = {"llms": {"a": {"_type": "openai"}, "b": {"_type": "nim"}, "c": {"_type": "custom"}}} + out = inject_gateway_url(config, "ws1", "http://host:8080/") + expected = "http://host:8080/apis/inference-gateway/v2/workspaces/ws1/openai/-/v1" + assert out["llms"]["a"]["base_url"] == expected + assert out["llms"]["b"]["base_url"] == expected + assert "base_url" not in out["llms"]["c"] # non-IGW type untouched + assert config["llms"]["a"] == {"_type": "openai"} # original not mutated + + +def test_inject_gateway_url_preserves_explicit_base_url(): + config = {"llms": {"a": {"_type": "openai", "base_url": "http://explicit"}}} + out = inject_gateway_url(config, "ws1", "http://host:8080") + assert out["llms"]["a"]["base_url"] == "http://explicit" + + +# --------------------------------------------------------------------------- gateway_backend +def test_gateway_backend_declared_for_local_gateway(): + assert gateway_backend("http://localhost:8080") == {"name": "nemo-inference-gateway", "ports": [8080]} + assert gateway_backend("http://127.0.0.1:9000") == {"name": "nemo-inference-gateway", "ports": [9000]} + + +def test_gateway_backend_none_for_remote_gateway(): + assert gateway_backend("https://gateway.example.com") is None + + +# --------------------------------------------------------------------------- detect_custom_components +def test_detect_custom_components_flags_dotted_and_colon_types(): + config = { + "functions": {"f1": {"_type": "my_pkg.tools:search"}, "f2": {"_type": "current_datetime"}}, + "workflow": {"_type": "react_agent"}, + } + assert detect_custom_components(config) == ["my_pkg.tools:search"] + + +def test_detect_custom_components_empty_for_config_only_agent(): + config = {"functions": {"f": {"_type": "current_datetime"}}, "workflow": {"_type": "react_agent"}} + assert detect_custom_components(config) == [] + + +# --------------------------------------------------------------------------- derive_secret_names +def test_derive_secret_names_finds_env_refs_and_falls_back(): + config = {"functions": {"gh": {"_type": "github", "github_token": "${GITHUB_TOKEN}"}}} + assert "GITHUB_TOKEN" in derive_secret_names(config) + + +def test_derive_secret_names_default_when_none_found(): + assert derive_secret_names({"workflow": {"_type": "react_agent"}}) == ["INFERENCE_API_KEY"] + + +# --------------------------------------------------------------------------- resolve_agent_to_manifest +def test_resolve_config_only_agent_builds_manifest_and_scaffolds(tmp_path): + agent = {"config": {"llms": {"main": {"_type": "openai"}}, "workflow": {"_type": "react_agent"}}} + sdk = _FakeSDK( + agent, + deployments=[{"agent": "calc", "status": "running", "port": 9123}], + ) + resolved = resolve_agent_to_manifest( + "calc", + sdk=sdk, + base_url="http://host:8080", + default_workspace="default", + manifest_dir=tmp_path, + ) + # Port taken from the running deployment. + assert resolved.port == 9123 + # Manifest shape matches iron-swarm's AgentSpec keys. + agent_block = resolved.manifest["agent"] + assert agent_block["name"] == "calc" + assert agent_block["workflow"] == "workflow.yaml" + assert agent_block["port"] == 9123 + # Workflow materialized with IGW base_url injected. + written = yaml.safe_load(resolved.workflow_path.read_text()) + assert ( + written["llms"]["main"]["base_url"] + == "http://host:8080/apis/inference-gateway/v2/workspaces/default/openai/-/v1" + ) + # Scaffold project created (config-only path). + assert (resolved.project_dir / "pyproject.toml").exists() + + +def test_resolve_declares_gateway_backend_for_local_platform(tmp_path): + agent = {"config": {"llms": {"main": {"_type": "openai"}}, "workflow": {"_type": "react_agent"}}} + sdk = _FakeSDK(agent, deployments=[{"agent": "calc", "status": "running", "port": 9123}]) + resolved = resolve_agent_to_manifest( + "calc", sdk=sdk, base_url="http://localhost:8080", default_workspace="default", manifest_dir=tmp_path + ) + assert resolved.manifest["backends"] == [{"name": "nemo-inference-gateway", "ports": [8080]}] + + +def test_resolve_no_backend_for_remote_platform(tmp_path): + agent = {"config": {"llms": {"main": {"_type": "openai"}}, "workflow": {"_type": "react_agent"}}} + sdk = _FakeSDK(agent, deployments=[{"agent": "calc", "status": "running", "port": 9123}]) + resolved = resolve_agent_to_manifest( + "calc", sdk=sdk, base_url="https://gw.example.com", default_workspace="default", manifest_dir=tmp_path + ) + assert resolved.manifest["backends"] == [] + + +def test_resolve_defaults_port_when_no_running_deployment(tmp_path): + agent = {"config": {"workflow": {"_type": "react_agent"}}} + sdk = _FakeSDK(agent, deployments=[]) + resolved = resolve_agent_to_manifest( + "calc", sdk=sdk, base_url="http://h:8080", default_workspace="default", manifest_dir=tmp_path + ) + assert resolved.port == 8000 + assert any("no running deployment" in w for w in resolved.warnings) + + +def test_resolve_forwards_egress_and_overrides(tmp_path): + agent = {"config": {"llms": {"main": {"_type": "openai"}}, "workflow": {"_type": "react_agent"}}} + sdk = _FakeSDK(agent, deployments=[{"agent": "calc", "status": "running", "port": 9123}]) + resolved = resolve_agent_to_manifest( + "calc", + sdk=sdk, + base_url="http://host:8080", + default_workspace="default", + manifest_dir=tmp_path, + egress=["en.wikipedia.org", "raw.githubusercontent.com"], + port=7000, + secrets=["MY_KEY"], + ) + agent_block = resolved.manifest["agent"] + # Egress is allow-listed on the manifest; port/secrets overrides win over derivation. + assert agent_block["egress"] == ["en.wikipedia.org", "raw.githubusercontent.com"] + assert agent_block["port"] == 7000 + assert agent_block["secrets"] == ["MY_KEY"] + assert resolved.port == 7000 + assert resolved.secrets == ["MY_KEY"] + + +def test_resolve_omits_egress_key_when_none(tmp_path): + agent = {"config": {"workflow": {"_type": "react_agent"}}} + sdk = _FakeSDK(agent, deployments=[{"agent": "calc", "status": "running", "port": 9123}]) + resolved = resolve_agent_to_manifest( + "calc", sdk=sdk, base_url="http://h:8080", default_workspace="default", manifest_dir=tmp_path + ) + # No egress supplied → no egress key, and port/secrets fall back to derivation. + assert "egress" not in resolved.manifest["agent"] + assert resolved.port == 9123 + + +def test_resolve_custom_code_requires_project_dir(tmp_path): + agent = {"config": {"functions": {"f": {"_type": "my_pkg:tool"}}, "workflow": {"_type": "react_agent"}}} + sdk = _FakeSDK(agent, deployments=[]) + with pytest.raises(AgentResolutionError, match="custom components"): + resolve_agent_to_manifest( + "calc", sdk=sdk, base_url="http://h:8080", default_workspace="default", manifest_dir=tmp_path + ) + + +def test_resolve_missing_agent_raises(tmp_path): + sdk = _FakeSDK(agent=None) + with pytest.raises(AgentResolutionError, match="not found"): + resolve_agent_to_manifest( + "ghost", sdk=sdk, base_url="http://h:8080", default_workspace="default", manifest_dir=tmp_path + ) diff --git a/plugins/nemo-iron-swarm/tests/unit/test_api_manifests.py b/plugins/nemo-iron-swarm/tests/unit/test_api_manifests.py new file mode 100644 index 0000000000..a2d0c66cd4 --- /dev/null +++ b/plugins/nemo-iron-swarm/tests/unit/test_api_manifests.py @@ -0,0 +1,365 @@ +# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +"""Tests for the IronSwarmManifest routes (list/get/delete + the `init` create path). + +TestClient + dependency_overrides mock the entity client; the platform SDK and the (network-bound) +agent resolver are monkeypatched so `init` exercises the request/response flow without a live agent. +""" + +from __future__ import annotations + +import subprocess +from datetime import datetime, timezone +from pathlib import Path +from unittest.mock import AsyncMock, MagicMock + +import pytest +from fastapi import FastAPI +from fastapi.testclient import TestClient +from nemo_iron_swarm_plugin.agent_resolver import ResolvedManifest +from nemo_iron_swarm_plugin.api.v2 import manifests as manifests_module +from nemo_iron_swarm_plugin.entities import IronSwarmManifest +from nemo_platform_plugin.entity_client import NemoEntityNotFoundError, NemoPaginationInfo, get_entity_client + +NOW = datetime.now(timezone.utc) +PREFIX = "/apis/iron-swarm/v2/workspaces/{workspace}" + + +def _resolved() -> ResolvedManifest: + return ResolvedManifest( + manifest={"agent": {"name": "clockbot", "port": 8000}, "backends": []}, + workflow_path=Path("/tmp/workflow.yaml"), + project_dir=Path("/tmp/proj"), + workspace="default", + agent_name="clockbot", + port=8000, + secrets=["INFERENCE_API_KEY"], + warnings=["no running deployment; defaulting port to 8000."], + ) + + +@pytest.fixture +def mock_entity_client() -> AsyncMock: + return AsyncMock() + + +@pytest.fixture +def client(mock_entity_client: AsyncMock, monkeypatch: pytest.MonkeyPatch) -> TestClient: + monkeypatch.setattr(manifests_module, "get_platform_sdk", lambda **_: MagicMock()) + monkeypatch.setattr(manifests_module, "resolve_agent_to_manifest", lambda *_a, **_k: _resolved()) + app = FastAPI() + app.include_router(manifests_module.router, prefix=PREFIX) + app.dependency_overrides[get_entity_client] = lambda: mock_entity_client + return TestClient(app, raise_server_exceptions=False) + + +def test_init_from_agent_creates_manifest(client, mock_entity_client) -> None: + mock_entity_client.create = AsyncMock(side_effect=lambda entity: entity) + + resp = client.post( + "/apis/iron-swarm/v2/workspaces/default/manifests", + json={"name": "clockbot-hardening", "source_type": "agent", "agent": "clockbot"}, + ) + + assert resp.status_code == 201, resp.text + body = resp.json() + assert body["name"] == "clockbot-hardening" + assert body["agent"] == "default/clockbot" + assert body["port"] == 8000 + assert "clockbot" in body["manifest_yaml"] + call = mock_entity_client.create.await_args + assert call is not None + created = call.args[0] + assert isinstance(created, IronSwarmManifest) + + +def test_inspect_agent_returns_derived_defaults(client, monkeypatch) -> None: + monkeypatch.setattr( + manifests_module, + "inspect_agent", + lambda *_a, **_k: ("default/clockbot", 9123, ["INFERENCE_API_KEY"], ["heads up"]), + ) + resp = client.post( + "/apis/iron-swarm/v2/workspaces/default/manifests/inspect-agent", + json={"agent": "clockbot"}, + ) + assert resp.status_code == 200, resp.text + body = resp.json() + assert body == { + "agent": "default/clockbot", + "port": 9123, + "secrets": ["INFERENCE_API_KEY"], + "warnings": ["heads up"], + } + + +def test_inspect_agent_reports_resolution_error(client, monkeypatch) -> None: + def _boom(*_a, **_k): + raise manifests_module.AgentResolutionError("agent 'ghost' not found") + + monkeypatch.setattr(manifests_module, "inspect_agent", _boom) + resp = client.post( + "/apis/iron-swarm/v2/workspaces/default/manifests/inspect-agent", + json={"agent": "ghost"}, + ) + assert resp.status_code == 400 + assert "not found" in resp.json()["detail"] + + +def test_init_agent_source_requires_agent(client) -> None: + resp = client.post( + "/apis/iron-swarm/v2/workspaces/default/manifests", + json={"name": "no-agent", "source_type": "agent"}, + ) + assert resp.status_code == 422 + + +def test_init_project_source_requires_fileset(client) -> None: + resp = client.post( + "/apis/iron-swarm/v2/workspaces/default/manifests", + json={"name": "from-project", "source_type": "project"}, + ) + assert resp.status_code == 422 + assert "project_fileset" in resp.json()["detail"] + + +_INSPECT_JSON = ( + '{"project_dir": "myproject", "workflows": ["agents/research/workflow.yaml"], "dockerfiles": [], ' + '"suggested_launch_mode": "workflow", "default_agent_name": "research", "default_port": 8000, ' + '"secrets_file": ".env", "secret_names": ["INFERENCE_API_KEY"], "egress": ["inference-api.nvidia.com"]}' +) + + +def _stub_project_subprocess( + monkeypatch: pytest.MonkeyPatch, *, returncode: int, stdout: str = "", stderr: str = "" +) -> None: + """Stub the fileset download + iron-swarm subprocess for the project manifest paths.""" + monkeypatch.setattr(manifests_module, "download_and_extract_project", lambda *_a, **_k: Path("/tmp/proj")) + monkeypatch.setattr( + manifests_module.IronSwarmConfig, + "get", + classmethod(lambda cls: MagicMock(iron_swarm_bin=Path("/bin/iron-swarm"))), + ) + + def fake_run(cmd, **_kwargs): + # `init` writes to the -o path; emulate it so _init can read the manifest back. + if returncode == 0 and "-o" in cmd: + out = Path(cmd[cmd.index("-o") + 1]) + out.write_text("agent:\n name: research\n project_dir: /tmp/proj\n port: 8000\n", encoding="utf-8") + return MagicMock(returncode=returncode, stdout=stdout, stderr=stderr) + + monkeypatch.setattr(manifests_module.subprocess, "run", fake_run) + + +def test_inspect_project_returns_detection(client, monkeypatch) -> None: + _stub_project_subprocess(monkeypatch, returncode=0, stdout=_INSPECT_JSON) + + resp = client.post( + "/apis/iron-swarm/v2/workspaces/default/manifests/inspect", + json={"project_fileset": "default/proj-bundle"}, + ) + + assert resp.status_code == 200, resp.text + body = resp.json() + assert body["workflows"] == ["agents/research/workflow.yaml"] + assert body["default_agent_name"] == "research" + assert body["secret_names"] == ["INFERENCE_API_KEY"] + + +def test_inspect_project_reports_subprocess_failure(client, monkeypatch) -> None: + _stub_project_subprocess(monkeypatch, returncode=1, stderr="no workflow found") + + resp = client.post( + "/apis/iron-swarm/v2/workspaces/default/manifests/inspect", + json={"project_fileset": "default/proj-bundle"}, + ) + + assert resp.status_code == 400 + assert "no workflow found" in resp.json()["detail"] + + +def _stub_hanging_subprocess(monkeypatch: pytest.MonkeyPatch) -> dict[str, object]: + """Make the iron-swarm subprocess time out, recording the timeout it was given.""" + monkeypatch.setattr(manifests_module, "download_and_extract_project", lambda *_a, **_k: Path("/tmp/proj")) + monkeypatch.setattr( + manifests_module.IronSwarmConfig, + "get", + classmethod(lambda cls: MagicMock(iron_swarm_bin=Path("/bin/iron-swarm"))), + ) + seen: dict[str, object] = {} + + def fake_run(cmd, **kwargs): + seen["timeout"] = kwargs.get("timeout") + raise subprocess.TimeoutExpired(cmd=cmd, timeout=kwargs.get("timeout") or 0) + + monkeypatch.setattr(manifests_module.subprocess, "run", fake_run) + return seen + + +def test_inspect_project_bounds_a_hanging_subprocess(client, monkeypatch) -> None: + """Unbounded, a wedged `iron-swarm inspect` pins its threadpool worker for the process's life.""" + seen = _stub_hanging_subprocess(monkeypatch) + + resp = client.post( + "/apis/iron-swarm/v2/workspaces/default/manifests/inspect", + json={"project_fileset": "default/proj-bundle"}, + ) + + assert resp.status_code == 504 + assert "timed out" in resp.json()["detail"] + assert seen["timeout"] == manifests_module._SUBPROCESS_TIMEOUT_SECONDS + + +def test_create_project_manifest_bounds_a_hanging_subprocess(client, monkeypatch) -> None: + seen = _stub_hanging_subprocess(monkeypatch) + + resp = client.post( + "/apis/iron-swarm/v2/workspaces/default/manifests", + json={"name": "m1", "source_type": "project", "project_fileset": "default/proj-bundle"}, + ) + + assert resp.status_code == 504 + assert seen["timeout"] == manifests_module._SUBPROCESS_TIMEOUT_SECONDS + + +def test_create_project_manifest(client, mock_entity_client, monkeypatch) -> None: + _stub_project_subprocess(monkeypatch, returncode=0) + mock_entity_client.create = AsyncMock(side_effect=lambda entity: entity) + + resp = client.post( + "/apis/iron-swarm/v2/workspaces/default/manifests", + json={ + "name": "research-hardening", + "source_type": "project", + "project_fileset": "default/proj-bundle", + "workflow": "agents/research/workflow.yaml", + "secrets": ["INFERENCE_API_KEY"], + }, + ) + + assert resp.status_code == 201, resp.text + body = resp.json() + assert body["source_type"] == "project" + assert body["project_fileset"] == "default/proj-bundle" + assert body["workflow"] == "agents/research/workflow.yaml" + # The persisted manifest can't hold the temp path; project_dir is normalized to '.'. + assert "project_dir: ." in body["manifest_yaml"] + + +def test_create_project_manifest_forwards_egress(client, mock_entity_client, monkeypatch) -> None: + monkeypatch.setattr(manifests_module, "download_and_extract_project", lambda *_a, **_k: Path("/tmp/proj")) + monkeypatch.setattr( + manifests_module.IronSwarmConfig, + "get", + classmethod(lambda cls: MagicMock(iron_swarm_bin=Path("/bin/iron-swarm"))), + ) + captured: list[list[str]] = [] + + def fake_run(cmd, **_kwargs): + captured.append(cmd) + Path(cmd[cmd.index("-o") + 1]).write_text("agent:\n name: research\n project_dir: .\n", encoding="utf-8") + return MagicMock(returncode=0, stdout="", stderr="") + + monkeypatch.setattr(manifests_module.subprocess, "run", fake_run) + mock_entity_client.create = AsyncMock(side_effect=lambda entity: entity) + + resp = client.post( + "/apis/iron-swarm/v2/workspaces/default/manifests", + json={ + "name": "research-hardening", + "source_type": "project", + "project_fileset": "default/proj-bundle", + "workflow": "agents/research/workflow.yaml", + "egress": ["host.docker.internal:8086", "inference-api.nvidia.com"], + }, + ) + + assert resp.status_code == 201, resp.text + argv = captured[0] + egress_flags = [argv[i + 1] for i, tok in enumerate(argv) if tok == "--egress"] + assert egress_flags == ["host.docker.internal:8086", "inference-api.nvidia.com"] + + +def test_create_project_manifest_forwards_backends(client, mock_entity_client, monkeypatch) -> None: + monkeypatch.setattr(manifests_module, "download_and_extract_project", lambda *_a, **_k: Path("/tmp/proj")) + monkeypatch.setattr( + manifests_module.IronSwarmConfig, + "get", + classmethod(lambda cls: MagicMock(iron_swarm_bin=Path("/bin/iron-swarm"))), + ) + captured: list[list[str]] = [] + + def fake_run(cmd, **_kwargs): + captured.append(cmd) + Path(cmd[cmd.index("-o") + 1]).write_text("agent:\n name: research\n project_dir: .\n", encoding="utf-8") + return MagicMock(returncode=0, stdout="", stderr="") + + monkeypatch.setattr(manifests_module.subprocess, "run", fake_run) + mock_entity_client.create = AsyncMock(side_effect=lambda entity: entity) + + resp = client.post( + "/apis/iron-swarm/v2/workspaces/default/manifests", + json={ + "name": "finance", + "source_type": "project", + "project_fileset": "default/proj-bundle", + "workflow": "agents_lab/agents/finance/workflow.yaml", + "backends": ["finance:8086", "cache:6379,6380"], + }, + ) + + assert resp.status_code == 201, resp.text + argv = captured[0] + backend_flags = [argv[i + 1] for i, tok in enumerate(argv) if tok == "--backend"] + assert backend_flags == ["finance:8086", "cache:6379,6380"] + + +def test_delete_missing_manifest_returns_404(client, mock_entity_client) -> None: + mock_entity_client.delete = AsyncMock(side_effect=NemoEntityNotFoundError("nope")) + + resp = client.delete("/apis/iron-swarm/v2/workspaces/default/manifests/ghost") + + assert resp.status_code == 404 + + +def test_patch_updates_benign_suite_and_port(client, mock_entity_client) -> None: + existing = IronSwarmManifest(name="m1", workspace="default", agent="default/clockbot", port=8000) + mock_entity_client.get = AsyncMock(return_value=existing) + mock_entity_client.update = AsyncMock(side_effect=lambda entity: entity) + + resp = client.patch( + "/apis/iron-swarm/v2/workspaces/default/manifests/m1", + json={"benign_suite": [{"tool": "clock", "payload": "what time", "label": "benign"}], "port": 9000}, + ) + + assert resp.status_code == 200, resp.text + body = resp.json() + assert body["port"] == 9000 + assert body["benign_suite"][0]["tool"] == "clock" + call = mock_entity_client.update.await_args + assert call is not None + updated = call.args[0] + assert updated.port == 9000 + assert updated.benign_suite[0]["payload"] == "what time" + + +def test_patch_missing_manifest_returns_404(client, mock_entity_client) -> None: + mock_entity_client.get = AsyncMock(side_effect=NemoEntityNotFoundError("nope")) + + resp = client.patch("/apis/iron-swarm/v2/workspaces/default/manifests/ghost", json={"port": 9000}) + + assert resp.status_code == 404 + + +def test_list_returns_envelope(client, mock_entity_client) -> None: + manifest = IronSwarmManifest(name="m1", workspace="default", agent="default/clockbot") + page = MagicMock() + page.data = [manifest] + page.pagination = NemoPaginationInfo(page=1, page_size=20, current_page_size=1, total_pages=1, total_results=1) + mock_entity_client.list = AsyncMock(return_value=page) + + resp = client.get("/apis/iron-swarm/v2/workspaces/default/manifests") + + assert resp.status_code == 200, resp.text + assert [m["name"] for m in resp.json()["data"]] == ["m1"] diff --git a/plugins/nemo-iron-swarm/tests/unit/test_api_runs.py b/plugins/nemo-iron-swarm/tests/unit/test_api_runs.py new file mode 100644 index 0000000000..c58b214a64 --- /dev/null +++ b/plugins/nemo-iron-swarm/tests/unit/test_api_runs.py @@ -0,0 +1,135 @@ +# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +"""Tests for the read-only IronSwarmRun route handlers. + +Uses FastAPI's TestClient with dependency_overrides to mock the entity client. The router is +mounted at the same prefix the platform mounts in production, so the URLs match what Studio hits. +""" + +from __future__ import annotations + +from datetime import datetime, timezone +from unittest.mock import AsyncMock, MagicMock + +import pytest +from fastapi import FastAPI +from fastapi.testclient import TestClient +from nemo_iron_swarm_plugin.api.v2 import runs as runs_router_module +from nemo_iron_swarm_plugin.entities import IronSwarmRun +from nemo_platform_plugin.entity_client import ( + NemoEntityNotFoundError, + NemoPaginationInfo, + get_entity_client, +) + +NOW = datetime.now(timezone.utc) +PREFIX = "/apis/iron-swarm/v2/workspaces/{workspace}" + + +def _make_run(name: str = "run-1", workspace: str = "default", **fields) -> IronSwarmRun: + fields.setdefault("agent", "clockbot") + fields.setdefault("status", "completed") + run = IronSwarmRun(name=name, workspace=workspace, **fields) + run._id = f"iron-swarm-run-{name}-id" + run._created_at = NOW + run._updated_at = NOW + return run + + +def _list_response(items): + resp = MagicMock() + resp.data = items + resp.pagination = NemoPaginationInfo( + page=1, page_size=20, current_page_size=len(items), total_pages=1, total_results=len(items) + ) + return resp + + +@pytest.fixture +def mock_entity_client() -> AsyncMock: + return AsyncMock() + + +@pytest.fixture +def client(mock_entity_client: AsyncMock) -> TestClient: + app = FastAPI() + app.include_router(runs_router_module.router, prefix=PREFIX) + app.dependency_overrides[get_entity_client] = lambda: mock_entity_client + return TestClient(app, raise_server_exceptions=False) + + +class TestListRuns: + def test_returns_envelope(self, client, mock_entity_client) -> None: + mock_entity_client.list = AsyncMock(return_value=_list_response([_make_run("run-1")])) + + resp = client.get("/apis/iron-swarm/v2/workspaces/default/runs") + + assert resp.status_code == 200, resp.text + body = resp.json() + assert [r["name"] for r in body["data"]] == ["run-1"] + assert body["pagination"]["total_results"] == 1 + assert body["sort"] == "-created_at" + + def test_agent_filter_is_passed_through(self, client, mock_entity_client) -> None: + mock_entity_client.list = AsyncMock(return_value=_list_response([])) + + resp = client.get("/apis/iron-swarm/v2/workspaces/default/runs?filter[agent]=clockbot") + + assert resp.status_code == 200, resp.text + call = mock_entity_client.list.await_args + assert call is not None + assert call.kwargs["filter_obj"] == {"agent": "clockbot"} + + def test_unknown_filter_key_returns_422(self, client, mock_entity_client) -> None: + mock_entity_client.list = AsyncMock(return_value=_list_response([])) + + resp = client.get("/apis/iron-swarm/v2/workspaces/default/runs?filter[bogus]=x") + + assert resp.status_code == 422, resp.text + + def test_store_error_returns_500(self, client, mock_entity_client) -> None: + mock_entity_client.list = AsyncMock(side_effect=RuntimeError("boom")) + + resp = client.get("/apis/iron-swarm/v2/workspaces/default/runs") + + assert resp.status_code == 500 + + +class TestGetRun: + def test_returns_run(self, client, mock_entity_client) -> None: + mock_entity_client.get = AsyncMock(return_value=_make_run("run-1", returncode=0)) + + resp = client.get("/apis/iron-swarm/v2/workspaces/default/runs/run-1") + + assert resp.status_code == 200, resp.text + body = resp.json() + assert body["name"] == "run-1" + assert body["agent"] == "clockbot" + assert body["id"] == "iron-swarm-run-run-1-id" + + def test_missing_run_returns_404(self, client, mock_entity_client) -> None: + mock_entity_client.get = AsyncMock(side_effect=NemoEntityNotFoundError("nope")) + + resp = client.get("/apis/iron-swarm/v2/workspaces/default/runs/ghost") + + assert resp.status_code == 404 + + +class TestDeleteRun: + def test_deletes_run(self, client, mock_entity_client) -> None: + mock_entity_client.delete = AsyncMock(return_value=None) + + resp = client.delete("/apis/iron-swarm/v2/workspaces/default/runs/run-1") + + assert resp.status_code == 204, resp.text + call = mock_entity_client.delete.await_args + assert call is not None + assert call.kwargs["name"] == "run-1" + + def test_missing_run_returns_404(self, client, mock_entity_client) -> None: + mock_entity_client.delete = AsyncMock(side_effect=NemoEntityNotFoundError("nope")) + + resp = client.delete("/apis/iron-swarm/v2/workspaces/default/runs/ghost") + + assert resp.status_code == 404 diff --git a/plugins/nemo-iron-swarm/tests/unit/test_apply_mitigation.py b/plugins/nemo-iron-swarm/tests/unit/test_apply_mitigation.py new file mode 100644 index 0000000000..575f753cce --- /dev/null +++ b/plugins/nemo-iron-swarm/tests/unit/test_apply_mitigation.py @@ -0,0 +1,110 @@ +# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +"""Tests for POST /runs/{name}/apply-mitigation and the strip_gateway_url helper it relies on.""" + +from __future__ import annotations + +from unittest.mock import AsyncMock + +import pytest +import yaml +from fastapi import FastAPI +from fastapi.testclient import TestClient +from nemo_agents_plugin.entities import Agent +from nemo_iron_swarm_plugin.agent_resolver import strip_gateway_url +from nemo_iron_swarm_plugin.api.v2 import runs as runs_module +from nemo_iron_swarm_plugin.entities import IronSwarmRun +from nemo_platform_plugin.entity_client import NemoEntityNotFoundError, get_entity_client + +PREFIX = "/apis/iron-swarm/v2/workspaces/{workspace}" +GATEWAY = "http://localhost:8080/apis/inference-gateway/v2/workspaces/default/openai/-/v1" + +HARDENED_WORKFLOW = yaml.safe_dump( + { + "llms": {"llm": {"_type": "openai", "model_name": "m", "base_url": GATEWAY, "api_key": "not-used"}}, + "middleware": {"custom_guardrail_1": {"_type": "pre_tool_verifier", "target_function_or_group": "Clock"}}, + "workflow": {"_type": "react_agent"}, + }, + sort_keys=False, +) + + +def test_strip_gateway_url_removes_only_injected_values() -> None: + config = yaml.safe_load(HARDENED_WORKFLOW) + config["llms"]["author"] = {"_type": "openai", "base_url": "https://api.example.com/v1", "api_key": "sk-real"} + + stripped = strip_gateway_url(config) + + # The injected gateway base_url + placeholder key are gone... + assert "base_url" not in stripped["llms"]["llm"] + assert "api_key" not in stripped["llms"]["llm"] + # ...but author-set values and the hardening (middleware) are preserved. + assert stripped["llms"]["author"]["base_url"] == "https://api.example.com/v1" + assert stripped["llms"]["author"]["api_key"] == "sk-real" + assert "custom_guardrail_1" in stripped["middleware"] + # Input is not mutated. + assert config["llms"]["llm"]["base_url"] == GATEWAY + + +@pytest.fixture +def mock_entity_client() -> AsyncMock: + return AsyncMock() + + +@pytest.fixture +def client(mock_entity_client: AsyncMock) -> TestClient: + app = FastAPI() + app.include_router(runs_module.router, prefix=PREFIX) + app.dependency_overrides[get_entity_client] = lambda: mock_entity_client + return TestClient(app, raise_server_exceptions=False) + + +def _run(agent: str = "clockbot") -> IronSwarmRun: + return IronSwarmRun(name="run-1", workspace="default", agent=agent) + + +def test_apply_mitigation_updates_agent_config(client: TestClient, mock_entity_client: AsyncMock) -> None: + agent = Agent(name="clockbot", workspace="default", config={"llms": {}}) + saved: list[Agent] = [] + mock_entity_client.get = AsyncMock(side_effect=[_run(), agent]) + mock_entity_client.update = AsyncMock(side_effect=lambda entity: saved.append(entity) or entity) + + resp = client.post( + "/apis/iron-swarm/v2/workspaces/default/runs/run-1/apply-mitigation", + json={"workflow_yaml": HARDENED_WORKFLOW}, + ) + + assert resp.status_code == 200, resp.text + assert resp.json()["applied"] is True + assert resp.json()["agent"] == "clockbot" + # The stored config is the hardened workflow with the gateway binding stripped. + assert "base_url" not in saved[0].config["llms"]["llm"] + assert "custom_guardrail_1" in saved[0].config["middleware"] + + +def test_apply_mitigation_rejects_non_yaml(client: TestClient, mock_entity_client: AsyncMock) -> None: + resp = client.post( + "/apis/iron-swarm/v2/workspaces/default/runs/run-1/apply-mitigation", + json={"workflow_yaml": "not: valid: yaml: ::"}, + ) + assert resp.status_code == 422, resp.text + mock_entity_client.update.assert_not_called() + + +def test_apply_mitigation_missing_run_is_404(client: TestClient, mock_entity_client: AsyncMock) -> None: + mock_entity_client.get = AsyncMock(side_effect=NemoEntityNotFoundError("nope")) + resp = client.post( + "/apis/iron-swarm/v2/workspaces/default/runs/ghost/apply-mitigation", + json={"workflow_yaml": HARDENED_WORKFLOW}, + ) + assert resp.status_code == 404, resp.text + + +def test_apply_mitigation_run_without_agent_is_409(client: TestClient, mock_entity_client: AsyncMock) -> None: + mock_entity_client.get = AsyncMock(return_value=_run(agent="")) + resp = client.post( + "/apis/iron-swarm/v2/workspaces/default/runs/run-1/apply-mitigation", + json={"workflow_yaml": HARDENED_WORKFLOW}, + ) + assert resp.status_code == 409, resp.text diff --git a/plugins/nemo-iron-swarm/tests/unit/test_benign_suite.py b/plugins/nemo-iron-swarm/tests/unit/test_benign_suite.py new file mode 100644 index 0000000000..5015258529 --- /dev/null +++ b/plugins/nemo-iron-swarm/tests/unit/test_benign_suite.py @@ -0,0 +1,36 @@ +# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +"""Unit tests for the benign-suite read/write helpers.""" + +from __future__ import annotations + +from pathlib import Path + +from nemo_iron_swarm_plugin.jobs import benign_suite + + +def test_read_suite_missing_file_returns_empty(tmp_path: Path) -> None: + assert benign_suite.read_suite(tmp_path / "nope.csv") == [] + + +def test_write_then_read_round_trips_in_column_order(tmp_path: Path) -> None: + csv_path = tmp_path / "requests.csv" + suite = [ + {"tool": "clock", "payload": "what time is it?", "label": "benign", "rationale": "basic", "persona": "user"}, + {"tool": "clock", "payload": "date please", "label": "benign", "rationale": "basic", "persona": ""}, + ] + benign_suite.write_suite(csv_path, suite) + + assert csv_path.read_text(encoding="utf-8").splitlines()[0] == "tool,payload,label,rationale,persona" + assert benign_suite.read_suite(csv_path) == suite + + +def test_read_suite_skips_rows_missing_tool_or_payload(tmp_path: Path) -> None: + csv_path = tmp_path / "requests.csv" + csv_path.write_text( + "tool,payload,label,rationale,persona\nclock,valid,benign,r,p\n,no-tool,benign,r,p\nclock,,benign,r,p\n", + encoding="utf-8", + ) + suite = benign_suite.read_suite(csv_path) + assert [row["payload"] for row in suite] == ["valid"] diff --git a/plugins/nemo-iron-swarm/tests/unit/test_compose_defense.py b/plugins/nemo-iron-swarm/tests/unit/test_compose_defense.py new file mode 100644 index 0000000000..c1b6cd3c32 --- /dev/null +++ b/plugins/nemo-iron-swarm/tests/unit/test_compose_defense.py @@ -0,0 +1,106 @@ +# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +"""Tests for compose_defense (build a chosen defense subset) + the compose-defense endpoint.""" + +from __future__ import annotations + +import yaml +from fastapi import FastAPI +from fastapi.testclient import TestClient +from nemo_iron_swarm_plugin.api.v2 import runs as runs_module +from nemo_iron_swarm_plugin.jobs.defenses import compose_defense + +PREFIX = "/apis/iron-swarm/v2/workspaces/{workspace}" + + +def _hardened_workflow() -> str: + return yaml.safe_dump( + { + "llms": {"llm": {"_type": "openai"}, "safety_llm": {"_type": "openai"}}, + "functions": { + "send_email": {"_type": "email", "middleware": ["custom_guardrail_1"]}, + "read_file": {"_type": "fs", "middleware": ["custom_guardrail_2"]}, + }, + "middleware": { + "custom_guardrail_1": {"_type": "pre_tool_verifier", "target_function_or_group": "send_email"}, + "custom_guardrail_2": {"_type": "pre_tool_verifier", "target_function_or_group": "read_file"}, + }, + # The workflow entry is a middleware-bearing component too, not just a marker. + "workflow": {"_type": "react_agent", "middleware": ["custom_guardrail_1", "custom_guardrail_2"]}, + }, + sort_keys=False, + ) + + +def _mitigations() -> dict: + return { + "workflow": {"before": "workflow: {}\n", "after": _hardened_workflow()}, + "policy": {"before": "version: 1\n", "after": "version: 1\nhardened: true\n"}, + } + + +def test_compose_keeps_only_selected_guardrail() -> None: + workflow_yaml, policy_yaml = compose_defense(_mitigations(), ["custom_guardrail_1", "openshell_policy"]) + assert workflow_yaml is not None and policy_yaml is not None + config = yaml.safe_load(workflow_yaml) + + # Only the selected guardrail survives, in the global middleware and on its tool. + assert set(config["middleware"]) == {"custom_guardrail_1"} + assert config["functions"]["send_email"]["middleware"] == ["custom_guardrail_1"] + assert config["functions"]["read_file"]["middleware"] == [] # custom_guardrail_2 reference dropped + # The workflow component's refs are pruned too — a name left pointing at a deleted middleware makes + # the victim fail config validation ("middleware type not found") and never serve. + assert config["workflow"]["middleware"] == ["custom_guardrail_1"] + # safety_llm kept while a guardrail remains; hardened policy selected. + assert "safety_llm" in config["llms"] + assert "hardened: true" in policy_yaml + + +def test_compose_leaves_no_dangling_middleware_reference() -> None: + """Every surviving reference must name a middleware that still exists.""" + for selection in ([], ["custom_guardrail_1"], ["custom_guardrail_2"], ["custom_guardrail_1", "custom_guardrail_2"]): + workflow_yaml, _ = compose_defense(_mitigations(), selection) + assert workflow_yaml is not None + config = yaml.safe_load(workflow_yaml) + defined = set(config.get("middleware") or {}) + referenced = set(config["workflow"].get("middleware") or []) + for tool in config.get("functions", {}).values(): + referenced |= set(tool.get("middleware") or []) + assert referenced <= defined, f"dangling refs {referenced - defined} for selection {selection}" + + +def test_compose_drops_all_guardrails_and_safety_llm() -> None: + workflow_yaml, policy_yaml = compose_defense(_mitigations(), []) + assert workflow_yaml is not None + config = yaml.safe_load(workflow_yaml) + + assert config["middleware"] == {} + assert "safety_llm" not in config["llms"] # no guardrails left → safety_llm removed + assert config["functions"]["send_email"]["middleware"] == [] + # openshell_policy not selected → baseline policy. + assert policy_yaml == "version: 1\n" + + +def test_compose_handles_missing_sections() -> None: + workflow_yaml, policy_yaml = compose_defense({}, ["custom_guardrail_1"]) + assert workflow_yaml is None + assert policy_yaml is None + + +def _client() -> TestClient: + app = FastAPI() + app.include_router(runs_module.router, prefix=PREFIX) + return TestClient(app, raise_server_exceptions=False) + + +def test_compose_defense_endpoint_composes_selection() -> None: + resp = _client().post( + "/apis/iron-swarm/v2/workspaces/default/runs/run-1/compose-defense", + json={"mitigations": _mitigations(), "selected_defense_ids": ["custom_guardrail_2"]}, + ) + assert resp.status_code == 200, resp.text + body = resp.json() + config = yaml.safe_load(body["workflow_yaml"]) + assert set(config["middleware"]) == {"custom_guardrail_2"} + assert body["policy_yaml"] == "version: 1\n" # policy not selected → baseline diff --git a/plugins/nemo-iron-swarm/tests/unit/test_errors.py b/plugins/nemo-iron-swarm/tests/unit/test_errors.py new file mode 100644 index 0000000000..fa3605e72f --- /dev/null +++ b/plugins/nemo-iron-swarm/tests/unit/test_errors.py @@ -0,0 +1,168 @@ +# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +"""Tests for the war-game failure taxonomy + classifiers (jobs.errors).""" + +from __future__ import annotations + +import json +from pathlib import Path + +import httpx +import pytest +from nemo_iron_swarm_plugin.agent_resolver import AgentResolutionError +from nemo_iron_swarm_plugin.jobs.errors import ( + CATEGORY_FILESET, + CATEGORY_MANIFEST, + CATEGORY_NETWORK, + CATEGORY_SANDBOX, + CATEGORY_UNEXPECTED, + CATEGORY_VALIDATION_FAILED, + CATEGORY_VICTIM_UNAVAILABLE, + IronSwarmRunError, + classify_exception, + classify_subprocess, + read_run_error, +) + +# A log from a run that ran the whole cycle but didn't pass validation (reaches the final summary). +# It also contains "openshell"/"victim" as normal content, which previously mislabeled it. +_COMPLETED_BUT_FAILED = ( + "sandbox ready\nopenshell-victim health ready\nIron Swarm final log\n" + " round-0001: success=no\nValidator results:\n" + " - smart-benign-validator (benign): FAILED — 16/20 benign requests complied (0 refused, 4 errors)\n" +) + + +def test_iron_swarm_run_error_is_runtime_error_with_default_remediation() -> None: + exc = IronSwarmRunError(CATEGORY_FILESET, "boom") + assert isinstance(exc, RuntimeError) # keeps `pytest.raises(RuntimeError)` call sites working + assert exc.category == CATEGORY_FILESET + assert exc.remediation # a default remediation is filled in from the category + + +def test_iron_swarm_run_error_explicit_remediation_wins() -> None: + exc = IronSwarmRunError(CATEGORY_FILESET, "boom", remediation="do X") + assert exc.remediation == "do X" + + +def test_classify_exception_passes_through_typed_error() -> None: + failure = classify_exception(IronSwarmRunError(CATEGORY_SANDBOX, "sandbox down")) + assert failure.category == CATEGORY_SANDBOX + assert failure.message == "sandbox down" + + +def test_classify_exception_maps_agent_resolution_to_manifest() -> None: + assert classify_exception(AgentResolutionError("no agent")).category == CATEGORY_MANIFEST + + +def test_classify_exception_maps_transport_error_to_network() -> None: + assert classify_exception(httpx.ConnectError("refused")).category == CATEGORY_NETWORK + assert classify_exception(ConnectionError("reset")).category == CATEGORY_NETWORK + + +def test_classify_exception_defaults_to_unexpected() -> None: + failure = classify_exception(ValueError("weird")) + assert failure.category == CATEGORY_UNEXPECTED + assert "weird" in failure.message + + +def test_read_run_error_parses_structured_dump(tmp_path: Path) -> None: + path = tmp_path / "run-error.json" + path.write_text( + json.dumps({"category": "sandbox", "message": "docker down", "remediation": "start docker", "stack": "..."}), + encoding="utf-8", + ) + failure = read_run_error(path) + assert failure is not None + assert (failure.category, failure.message, failure.remediation) == ("sandbox", "docker down", "start docker") + + +def test_read_run_error_missing_or_malformed_returns_none(tmp_path: Path) -> None: + assert read_run_error(tmp_path / "absent.json") is None + bad = tmp_path / "bad.json" + bad.write_text("not json", encoding="utf-8") + assert read_run_error(bad) is None + + +def test_read_run_error_defaults_unknown_category(tmp_path: Path) -> None: + path = tmp_path / "run-error.json" + path.write_text(json.dumps({"message": "something"}), encoding="utf-8") + failure = read_run_error(path) + assert failure is not None + assert failure.category == CATEGORY_UNEXPECTED + assert failure.message == "something" + + +def test_classify_subprocess_prefers_structured_error(tmp_path: Path) -> None: + path = tmp_path / "run-error.json" + path.write_text(json.dumps({"category": "victim_unavailable", "message": "victim died"}), encoding="utf-8") + exc = classify_subprocess(1, "irrelevant log", read_run_error(path)) + assert exc.category == "victim_unavailable" + assert str(exc) == "victim died" + + +def test_classify_subprocess_falls_back_to_log_heuristic() -> None: + exc = classify_subprocess(1, "... could not reach the OpenShell gateway ...", None) + assert exc.category == CATEGORY_SANDBOX + + +def test_classify_subprocess_unknown_log_is_unexpected() -> None: + exc = classify_subprocess(3, "totally opaque output", None) + assert exc.category == CATEGORY_UNEXPECTED + assert "code 3" in str(exc) + + +@pytest.mark.parametrize( + ("log", "expected"), + [ + ("connection refused by host", CATEGORY_NETWORK), + ("the victim returned server disconnected", "victim_unavailable"), + ("docker daemon not running", CATEGORY_SANDBOX), + ("... attacker execution failed ...", "attacker_failed"), + ("Attacker results:\n attacker agent status: failed=1", "attacker_failed"), + ], +) +def test_classify_subprocess_heuristic_cues(log: str, expected: str) -> None: + assert classify_subprocess(1, log, None).category == expected + + +def test_completed_run_classifies_as_validation_failed_not_victim() -> None: + # The reported bug: a run that finished the full cycle but failed validation was mislabeled + # victim_unavailable (its log contains "openshell"/"victim") with a misleading "inspect the victim + # log" message. It must now be validation_failed with a clear message. + exc = classify_subprocess(1, _COMPLETED_BUT_FAILED, None) + assert exc.category == CATEGORY_VALIDATION_FAILED + assert "did not pass validation" in str(exc) + assert "exited with code" not in str(exc) + + +def test_healthy_victim_phrase_alone_is_not_victim_unavailable() -> None: + # "victim health ready" is a healthy line; with no failure cue and no completion marker it must + # not be mistaken for a victim failure (the old broad "victim" cue did exactly that). + assert classify_subprocess(1, "victim health ready\n", None).category == CATEGORY_UNEXPECTED + + +def test_genuine_victim_http_failure_still_victim_unavailable() -> None: + # A real mid-run victim failure (never reaches the final summary) stays victim_unavailable. + log = "sandbox ready\nOpenShell victim returned HTTP 422 - invalid model\n" + assert classify_subprocess(1, log, None).category == CATEGORY_VICTIM_UNAVAILABLE + + +def test_read_run_error_surfaces_attacker_failed_from_iron_swarm(tmp_path: Path) -> None: + # iron-swarm's AttackerError serializes category "attacker_failed" with its own remediation. + path = tmp_path / "run-error.json" + path.write_text( + json.dumps( + { + "category": "attacker_failed", + "message": "attacker(s) garak-agent-breaker did not complete: TimeoutError", + "remediation": "raise garak.timeout_s or lower attack_intensity", + } + ), + encoding="utf-8", + ) + failure = read_run_error(path) + assert failure is not None + assert failure.category == "attacker_failed" + assert "TimeoutError" in failure.message diff --git a/plugins/nemo-iron-swarm/tests/unit/test_events.py b/plugins/nemo-iron-swarm/tests/unit/test_events.py index ae6153f218..363036a73d 100644 --- a/plugins/nemo-iron-swarm/tests/unit/test_events.py +++ b/plugins/nemo-iron-swarm/tests/unit/test_events.py @@ -7,6 +7,7 @@ import json from pathlib import Path +from types import SimpleNamespace from unittest.mock import MagicMock, patch import pytest @@ -92,8 +93,9 @@ def test_get_events_falls_back_to_fileset_when_local_missing(tmp_path: Path) -> missing_path = tmp_path / "missing" / "events.jsonl" mock_sdk = MagicMock() - mock_run = MagicMock() - mock_run.events_fileset = "default/my-events-fs" + # Shaped like the real entity-store record: get_entity_by_name returns a generic Entity whose + # domain fields live under `.data` (a bare MagicMock would falsely expose `.events_fileset`). + mock_run = SimpleNamespace(name="my-run", data={"events_fileset": "default/my-events-fs"}) mock_sdk.entities.get_entity_by_name.return_value = mock_run def fake_download(sdk, ref, dest): @@ -123,8 +125,7 @@ def test_get_events_returns_empty_when_no_local_and_no_fileset(tmp_path: Path) - missing_path = tmp_path / "missing" / "events.jsonl" mock_sdk = MagicMock() - mock_run = MagicMock() - mock_run.events_fileset = "" + mock_run = SimpleNamespace(name="my-run", data={"events_fileset": ""}) mock_sdk.entities.get_entity_by_name.return_value = mock_run with ( diff --git a/plugins/nemo-iron-swarm/tests/unit/test_filesets.py b/plugins/nemo-iron-swarm/tests/unit/test_filesets.py new file mode 100644 index 0000000000..5262237c01 --- /dev/null +++ b/plugins/nemo-iron-swarm/tests/unit/test_filesets.py @@ -0,0 +1,90 @@ +# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +"""Tests for the uploaded-project zip-expansion guards (traversal/symlink/absolute/size).""" + +from __future__ import annotations + +import zipfile +from pathlib import Path + +import pytest +from nemo_iron_swarm_plugin.filesets import extract_zip_safely + + +def _write_zip(path: Path, members: dict[str, str]) -> Path: + with zipfile.ZipFile(path, "w") as archive: + for name, content in members.items(): + archive.writestr(name, content) + return path + + +def test_extract_zip_safely_extracts_normal_project(tmp_path: Path) -> None: + zip_path = _write_zip(tmp_path / "p.zip", {"pyproject.toml": "[project]\n", "pkg/workflow.yaml": "_type: x\n"}) + dest = extract_zip_safely(zip_path, tmp_path / "out") + assert (dest / "pyproject.toml").exists() + assert (dest / "pkg" / "workflow.yaml").read_text() == "_type: x\n" + + +def test_extract_zip_safely_rejects_traversal(tmp_path: Path) -> None: + zip_path = _write_zip(tmp_path / "p.zip", {"../escape.txt": "x"}) + with pytest.raises(ValueError, match="escapes the destination"): + extract_zip_safely(zip_path, tmp_path / "out") + + +def test_extract_zip_safely_rejects_absolute_member(tmp_path: Path) -> None: + zip_path = _write_zip(tmp_path / "p.zip", {"/etc/passwd": "x"}) + with pytest.raises(ValueError, match="absolute path"): + extract_zip_safely(zip_path, tmp_path / "out") + + +def test_extract_zip_safely_rejects_symlink(tmp_path: Path) -> None: + zip_path = tmp_path / "p.zip" + with zipfile.ZipFile(zip_path, "w") as archive: + info = zipfile.ZipInfo("link") + info.external_attr = (0o120777) << 16 # S_IFLNK — a symlink entry + archive.writestr(info, "/etc/passwd") + with pytest.raises(ValueError, match="symlink"): + extract_zip_safely(zip_path, tmp_path / "out") + + +def test_extract_zip_safely_rejects_too_many_entries(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setattr("nemo_iron_swarm_plugin.filesets._MAX_ENTRIES", 2) + zip_path = _write_zip(tmp_path / "p.zip", {"a": "1", "b": "2", "c": "3"}) + with pytest.raises(ValueError, match="too many entries"): + extract_zip_safely(zip_path, tmp_path / "out") + + +def test_extract_zip_safely_rejects_oversized_by_declared_size(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setattr("nemo_iron_swarm_plugin.filesets._MAX_UNCOMPRESSED_BYTES", 8) + zip_path = _write_zip(tmp_path / "p.zip", {"big.txt": "x" * 64}) + with pytest.raises(ValueError, match="too large when uncompressed"): + extract_zip_safely(zip_path, tmp_path / "out") + + +def test_a_zip_under_reporting_its_size_cannot_beat_the_cap(tmp_path: Path) -> None: + """Why summing the declared `file_size` is sound: zipfile reads against that same figure. + + A member claiming to be smaller than its data is truncated at the declared length and fails its + CRC, so lying to slip past the cap yields an error, not an oversized extraction. + """ + zip_path = tmp_path / "bomb.zip" + with zipfile.ZipFile(zip_path, "w", zipfile.ZIP_DEFLATED) as archive: + archive.writestr("bomb.txt", "x" * 8192) + _under_report(zip_path) + with zipfile.ZipFile(zip_path) as archive: + assert archive.infolist()[0].file_size == 1 # the archive now claims 1 byte + + with pytest.raises(zipfile.BadZipFile): + extract_zip_safely(zip_path, tmp_path / "out") + + +def _under_report(zip_path: Path) -> None: + """Patch every recorded uncompressed size in *zip_path* to 1 byte, leaving the data intact.""" + raw = bytearray(zip_path.read_bytes()) + for signature, offset in ((b"PK\x03\x04", 22), (b"PK\x01\x02", 24)): + start = 0 + while (found := raw.find(signature, start)) != -1: + raw[found + offset : found + offset + 4] = (1).to_bytes(4, "little") + start = found + 4 + zip_path.write_bytes(bytes(raw)) diff --git a/plugins/nemo-iron-swarm/tests/unit/test_garak_provision.py b/plugins/nemo-iron-swarm/tests/unit/test_garak_provision.py new file mode 100644 index 0000000000..71e2b78001 --- /dev/null +++ b/plugins/nemo-iron-swarm/tests/unit/test_garak_provision.py @@ -0,0 +1,118 @@ +# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +"""Unit tests for the plugin's garak-venv preflight check and provisioning delegation.""" + +import subprocess +from pathlib import Path + +import pytest +import typer +from nemo_iron_swarm_plugin.cli import checks, provisioning +from nemo_iron_swarm_plugin.config import GARAK_PYTHON_ENVVAR, IronSwarmConfig + + +def _config(tmp_path: Path) -> IronSwarmConfig: + return IronSwarmConfig(venv_path=tmp_path / "venv", garak_venv_path=tmp_path / "garak-venv") + + +def test_run_checks_includes_garak_venv(tmp_path: Path) -> None: + labels = [c.label for c in checks.run_checks(_config(tmp_path))] + assert "iron-swarm venv" in labels + assert "garak venv" in labels + + +def test_garak_venv_ok_reports_missing(tmp_path: Path) -> None: + ok, detail = checks.garak_venv_ok(_config(tmp_path)) + assert ok is False + assert "garak venv missing" in detail + + +def test_garak_venv_ok_reports_present(tmp_path: Path) -> None: + cfg = _config(tmp_path) + cfg.garak_python.parent.mkdir(parents=True) + cfg.garak_python.touch() + ok, _ = checks.garak_venv_ok(cfg) + assert ok is True + + +def test_run_iron_swarm_setup_delegates(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + cfg = _config(tmp_path) + recorded_cmd: list[str] = [] + recorded_env: dict[str, str] = {} + + def fake_run(cmd: list[str], action: str, env: dict[str, str] | None = None) -> None: + recorded_cmd[:] = cmd + recorded_env.update(env or {}) + cfg.garak_python.parent.mkdir(parents=True, exist_ok=True) + cfg.garak_python.touch() # simulate iron-swarm setup creating the venv + + monkeypatch.setattr(provisioning, "run_subprocess", fake_run) + provisioning.run_iron_swarm_setup(cfg, force=False) + + assert recorded_cmd == [str(cfg.iron_swarm_bin), "setup"] + assert recorded_env[GARAK_PYTHON_ENVVAR] == str(cfg.garak_python) + + +def test_run_iron_swarm_setup_force_passes_flag(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + cfg = _config(tmp_path) + cfg.garak_python.parent.mkdir(parents=True) + cfg.garak_python.touch() + recorded: dict[str, object] = {} + + def fake_run(cmd: list[str], action: str, env: dict[str, str] | None = None) -> None: + recorded["cmd"] = cmd + + monkeypatch.setattr(provisioning, "run_subprocess", fake_run) + provisioning.run_iron_swarm_setup(cfg, force=True) + assert recorded["cmd"] == [str(cfg.iron_swarm_bin), "setup", "--force"] + + +def test_run_iron_swarm_setup_runs_even_when_garak_present(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + cfg = _config(tmp_path) + cfg.garak_python.parent.mkdir(parents=True) + cfg.garak_python.touch() # garak already present, yet setup still runs to re-ensure the gateway + called = False + + def fake_run(cmd: list[str], action: str, env: dict[str, str] | None = None) -> None: + nonlocal called + called = True + + monkeypatch.setattr(provisioning, "run_subprocess", fake_run) + provisioning.run_iron_swarm_setup(cfg, force=False) + assert called is True # not gated on the garak venv — gateway is re-ensured every setup + + +# ── run_subprocess: streaming + timeout ────────────────────────────────── + + +def test_run_subprocess_streams_instead_of_capturing() -> None: + """A captured multi-minute `uv pip install` shows no progress and reads as hung.""" + recorded: dict[str, object] = {} + + def fake_run(cmd: list[str], **kwargs: object) -> subprocess.CompletedProcess: + recorded.update(kwargs) + return subprocess.CompletedProcess(cmd, returncode=0) + + with pytest.MonkeyPatch.context() as patch: + patch.setattr(provisioning.subprocess, "run", fake_run) + provisioning.run_subprocess(["uv", "pip", "install", "iron-swarm"], "install iron-swarm") + + assert "capture_output" not in recorded and "stdout" not in recorded # inherits the terminal + assert recorded["timeout"] == provisioning.SUBPROCESS_TIMEOUT_SECONDS + + +def test_run_subprocess_exits_on_timeout(monkeypatch: pytest.MonkeyPatch) -> None: + def fake_run(cmd: list[str], **kwargs: object) -> subprocess.CompletedProcess: + raise subprocess.TimeoutExpired(cmd=cmd, timeout=provisioning.SUBPROCESS_TIMEOUT_SECONDS) + + monkeypatch.setattr(provisioning.subprocess, "run", fake_run) + with pytest.raises(typer.Exit) as excinfo: + provisioning.run_subprocess(["uv", "pip", "install", "iron-swarm"], "install iron-swarm", timeout=1) + assert excinfo.value.exit_code == 1 + + +def test_run_subprocess_exits_on_nonzero_returncode(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setattr(provisioning.subprocess, "run", lambda cmd, **_: subprocess.CompletedProcess(cmd, returncode=2)) + with pytest.raises(typer.Exit): + provisioning.run_subprocess(["uv", "venv"], "create venv") diff --git a/plugins/nemo-iron-swarm/tests/unit/test_model_config.py b/plugins/nemo-iron-swarm/tests/unit/test_model_config.py new file mode 100644 index 0000000000..e1daced882 --- /dev/null +++ b/plugins/nemo-iron-swarm/tests/unit/test_model_config.py @@ -0,0 +1,89 @@ +# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +"""Unit tests for user-selectable model configuration: env mapping, merge, and victim-model rewrite.""" + +from __future__ import annotations + +from pathlib import Path +from types import SimpleNamespace +from typing import Any + +from _doubles import make_entity, make_job_context, make_sdk +from nemo_iron_swarm_plugin.agent_resolver import inject_gateway_url +from nemo_iron_swarm_plugin.jobs import _common +from nemo_iron_swarm_plugin.jobs import run as run_module +from nemo_iron_swarm_plugin.model_config import ModelChoice, WarGameModels + + +class _FakeSecrets: + def __init__(self, values: dict[str, str]) -> None: + self._values = values + + def access(self, name: str, *, workspace: str) -> Any: + return SimpleNamespace(value=self._values.get(name)) + + +def _sdk(secrets: dict[str, str]) -> Any: + return SimpleNamespace(secrets=_FakeSecrets(secrets)) + + +def test_build_model_env_maps_attack_and_analysis_groups() -> None: + models = WarGameModels( + attack=ModelChoice(model="atk/model", base_url="https://atk/v1", api_key_secret="atk-key"), + analysis=ModelChoice(model="ana/model", base_url="https://ana/v1", api_key_secret="ana-key"), + ) + env = _common.build_model_env(models, sdk=_sdk({"atk-key": "AK", "ana-key": "NK"}), workspace="default") + assert env["GARAK_RED_TEAM_MODEL_NAME"] == "atk/model" + assert env["GARAK_DETECTOR_MODEL_NAME"] == "atk/model" + assert env["GARAK_RED_TEAM_MODEL_URI"] == "https://atk/v1" + assert env["GARAK_DETECTOR_MODEL_URI"] == "https://atk/v1" + assert env["NIM_API_KEY"] == "AK" + assert env["IRON_SWARM_MODEL"] == "ana/model" + assert env["IRON_SWARM_BASE_URL"] == "https://ana/v1" + assert env["INFERENCE_API_KEY"] == "NK" + + +def test_build_model_env_skips_unset_fields_and_agent_group() -> None: + # Only a model name for analysis; no base_url/secret, and the agent group is never an env knob. + models = WarGameModels(analysis=ModelChoice(model="ana/model"), agent=ModelChoice(model="victim/model")) + env = _common.build_model_env(models, sdk=_sdk({}), workspace="default") + assert env == {"IRON_SWARM_MODEL": "ana/model"} + + +def test_build_model_env_none_is_empty() -> None: + assert _common.build_model_env(None, sdk=_sdk({}), workspace="default") == {} + + +def test_effective_models_merges_override_over_stored_default(tmp_path: Path) -> None: + stored = {"attack": {"model": "stored/atk", "base_url": "https://stored/v1"}} + sdk = make_sdk(SimpleNamespace(get_entity_by_name=lambda **_k: make_entity(models=stored))) + config = {"manifest_id": "m1", "models": {"attack": {"model": "override/atk"}, "analysis": {"model": "ana"}}} + merged = run_module._effective_models(sdk, config, ctx=make_job_context(tmp_path)) + assert merged is not None + # override wins per field; the stored base_url is preserved where the override left it unset. + assert merged.attack is not None and merged.attack.model == "override/atk" + assert merged.attack.base_url == "https://stored/v1" + assert merged.analysis is not None and merged.analysis.model == "ana" + + +def test_effective_models_none_when_nothing_selected(tmp_path: Path) -> None: + sdk = make_sdk(SimpleNamespace(get_entity_by_name=lambda **_k: make_entity())) + config = {"manifest_id": "m1"} + assert run_module._effective_models(sdk, config, ctx=make_job_context(tmp_path)) is None + + +def test_inject_gateway_url_overrides_victim_model_when_set() -> None: + config = {"llms": {"main": {"_type": "openai", "model": "orig"}, "other": {"_type": "nim", "model": "orig2"}}} + injected = inject_gateway_url(config, "default", "https://gw", model_override="chosen/model") + assert injected["llms"]["main"]["model"] == "chosen/model" + assert injected["llms"]["other"]["model"] == "chosen/model" + # base_url/api_key are still gateway-bound. + assert "/apis/inference-gateway/" in injected["llms"]["main"]["base_url"] + assert injected["llms"]["main"]["api_key"] == "not-used" + + +def test_inject_gateway_url_keeps_model_when_no_override() -> None: + config = {"llms": {"main": {"_type": "openai", "model": "orig"}}} + injected = inject_gateway_url(config, "default", "https://gw") + assert injected["llms"]["main"]["model"] == "orig" diff --git a/plugins/nemo-iron-swarm/tests/unit/test_model_preflight.py b/plugins/nemo-iron-swarm/tests/unit/test_model_preflight.py new file mode 100644 index 0000000000..fb1c696c77 --- /dev/null +++ b/plugins/nemo-iron-swarm/tests/unit/test_model_preflight.py @@ -0,0 +1,100 @@ +# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +"""Unit tests for the model connectivity preflight (probe + validate + launch-time guard).""" + +from __future__ import annotations + +from types import SimpleNamespace +from typing import Any + +import httpx +import pytest +from nemo_iron_swarm_plugin.jobs import run as run_module +from nemo_iron_swarm_plugin.jobs.errors import CATEGORY_MODEL_UNAVAILABLE, IronSwarmRunError +from nemo_iron_swarm_plugin.model_config import ModelChoice, WarGameModels +from nemo_iron_swarm_plugin.model_preflight import probe_models, validate_choice + + +def _client(handler: Any) -> httpx.Client: + return httpx.Client(transport=httpx.MockTransport(handler)) + + +def test_probe_lists_available_models() -> None: + def handler(_req: httpx.Request) -> httpx.Response: + return httpx.Response(200, json={"data": [{"id": "b/model"}, {"id": "a/model"}]}) + + result = probe_models("https://x/v1", "key", client=_client(handler)) + assert result.reachable and result.auth_ok + assert result.available == ["a/model", "b/model"] # sorted + + +def test_probe_auth_failure() -> None: + result = probe_models("https://x/v1", "bad", client=_client(lambda _r: httpx.Response(401))) + assert result.reachable and not result.auth_ok + + +def test_probe_no_model_list_is_soft_pass() -> None: + result = probe_models("https://x/v1", "key", client=_client(lambda _r: httpx.Response(404))) + assert result.reachable and result.auth_ok and not result.list_supported + + +@pytest.mark.parametrize("status", [429, 500, 502, 503]) +def test_a_provider_side_error_is_not_reported_as_bad_credentials(status: int) -> None: + """A 5xx/429 used to set auth_ok=False, telling the user to go rotate a perfectly good key.""" + result = probe_models("https://x/v1", "key", client=_client(lambda _r: httpx.Response(status))) + assert result.reachable and result.auth_ok and not result.status_ok + + verdict = validate_choice("a/model", "https://x/v1", "key", client=_client(lambda _r: httpx.Response(status))) + assert not verdict.ok and verdict.reason == "provider_error" + assert f"HTTP {status}" in verdict.detail + + +@pytest.mark.parametrize("status", [401, 403]) +def test_only_401_403_are_credential_failures(status: int) -> None: + verdict = validate_choice("a/model", "https://x/v1", "bad", client=_client(lambda _r: httpx.Response(status))) + assert not verdict.ok and verdict.reason == "auth" + + +def test_validate_unknown_model_returns_available() -> None: + def handler(_req: httpx.Request) -> httpx.Response: + return httpx.Response(200, json={"data": [{"id": "real/model"}]}) + + v = validate_choice("typo/model", "https://x/v1", "key", client=_client(handler)) + assert not v.ok and v.reason == "unknown_model" and v.available == ["real/model"] + + +def test_validate_known_model_ok() -> None: + def handler(_req: httpx.Request) -> httpx.Response: + return httpx.Response(200, json={"data": [{"id": "real/model"}]}) + + v = validate_choice("real/model", "https://x/v1", "key", client=_client(handler)) + assert v.ok + + +def test_preflight_raises_model_unavailable_with_list(monkeypatch: pytest.MonkeyPatch) -> None: + # A configured analysis model that the endpoint doesn't serve → classified failure listing real models. + monkeypatch.setattr( + run_module, + "validate_choice", + lambda model, base_url, key: SimpleNamespace( + ok=False, reason="unknown_model", available=["real/a", "real/b"], detail="" + ), + ) + models = WarGameModels(analysis=ModelChoice(model="typo", base_url="https://x/v1")) + with pytest.raises(IronSwarmRunError) as exc: + run_module._preflight_models(models, sdk=None, workspace="default", default_key="k") + assert exc.value.category == CATEGORY_MODEL_UNAVAILABLE + assert "real/a" in str(exc.value) + + +def test_preflight_skips_default_only_groups(monkeypatch: pytest.MonkeyPatch) -> None: + # No model/base_url set → nothing probed (defaults are known-good), so validate is never called. + called = {"n": 0} + monkeypatch.setattr( + run_module, + "validate_choice", + lambda *a, **k: called.__setitem__("n", called["n"] + 1) or SimpleNamespace(ok=True), + ) + run_module._preflight_models(WarGameModels(), sdk=None, workspace="default", default_key="k") + assert called["n"] == 0 diff --git a/plugins/nemo-iron-swarm/tests/unit/test_operator_env.py b/plugins/nemo-iron-swarm/tests/unit/test_operator_env.py new file mode 100644 index 0000000000..6e1c0bcd0b --- /dev/null +++ b/plugins/nemo-iron-swarm/tests/unit/test_operator_env.py @@ -0,0 +1,272 @@ +# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +"""Unit tests for iron-swarm's own operator inference credential (dotenv read/write/resolve).""" + +from __future__ import annotations + +import os +import types +from pathlib import Path + +import pytest +import yaml +from _doubles import make_job_context +from nemo_iron_swarm_plugin.cli import credentials +from nemo_iron_swarm_plugin.config import ( + INFERENCE_API_KEY_ENVVAR, + IronSwarmConfig, + missing_secrets, + read_env_file, +) +from nemo_iron_swarm_plugin.jobs import _common +from nemo_iron_swarm_plugin.jobs.run import IronSwarmRunJob + + +def _config(tmp_path: Path) -> IronSwarmConfig: + return IronSwarmConfig( + venv_path=tmp_path / "venv", + garak_venv_path=tmp_path / "garak-venv", + operator_env_file=tmp_path / "operator.env", + ) + + +# ── read_env_file ──────────────────────────────────────────────────────── + + +def test_read_env_file_missing_returns_empty(tmp_path: Path) -> None: + assert read_env_file(tmp_path / "nope.env") == {} + + +def test_read_env_file_parses_comments_blank_export_quotes(tmp_path: Path) -> None: + path = tmp_path / ".env" + path.write_text( + "\n".join( + [ + "# a comment", + "", + "export FOO=bar", + 'QUOTED="hello world"', + "SINGLE='it works'", + "PLAIN=value", + ] + ), + encoding="utf-8", + ) + assert read_env_file(path) == { + "FOO": "bar", + "QUOTED": "hello world", + "SINGLE": "it works", + "PLAIN": "value", + } + + +# ── _resolve_inference_key ─────────────────────────────────────────────── + + +def test_resolve_inference_key_prefers_secrets_over_env(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setenv(INFERENCE_API_KEY_ENVVAR, "env-value") # store must still win + secret = types.SimpleNamespace(value="secret-value") + fake_sdk = types.SimpleNamespace(secrets=types.SimpleNamespace(access=lambda name, workspace: secret)) + monkeypatch.setattr(credentials, "make_sdk", lambda base: fake_sdk) + + value, source = credentials.resolve_inference_key(_config(tmp_path)) + assert value == "secret-value" + assert "iron-swarm-inference-key" in source + + +def test_resolve_inference_key_falls_back_to_env_when_store_unavailable( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + monkeypatch.setenv(INFERENCE_API_KEY_ENVVAR, "env-value") + + def _raise(base: str) -> None: + raise RuntimeError("platform unreachable") + + monkeypatch.setattr(credentials, "make_sdk", _raise) + monkeypatch.setattr(credentials.sys.stdin, "isatty", lambda: False) + + value, source = credentials.resolve_inference_key(_config(tmp_path)) + assert (value, source) == ("env-value", "environment") + + +def test_resolve_inference_key_returns_none_when_platform_down_and_non_interactive( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + monkeypatch.delenv(INFERENCE_API_KEY_ENVVAR, raising=False) + + def _raise(base: str) -> None: + raise RuntimeError("platform unreachable") + + monkeypatch.setattr(credentials, "make_sdk", _raise) + monkeypatch.setattr(credentials.sys.stdin, "isatty", lambda: False) + + value, source = credentials.resolve_inference_key(_config(tmp_path)) + assert (value, source) == (None, "unresolved") + + +# ── _write_operator_env ────────────────────────────────────────────────── + + +def test_write_operator_env_sets_mode_and_preserves_existing_keys(tmp_path: Path) -> None: + cfg = _config(tmp_path) + cfg.operator_env_file.parent.mkdir(parents=True, exist_ok=True) + cfg.operator_env_file.write_text("OTHER=keep-me\n", encoding="utf-8") + + credentials.write_operator_env(cfg, "new-key") + + values = read_env_file(cfg.operator_env_file) + assert values == {"OTHER": "keep-me", INFERENCE_API_KEY_ENVVAR: "new-key"} + assert (cfg.operator_env_file.stat().st_mode & 0o777) == 0o600 + + +def test_materialized_victim_env_is_never_world_readable(tmp_path: Path) -> None: + """The victim dotenv holds provider creds too — same 0600-at-creation rule as the operator one.""" + manifest = tmp_path / "iron-swarm.yaml" + manifest.write_text( + yaml.safe_dump({"agent": {"name": "v", "secrets": ["GITHUB_TOKEN"]}}), + encoding="utf-8", + ) + dest_dir = tmp_path / "dest" + dest_dir.mkdir() + previous = os.umask(0o000) + try: + written = _common.materialize_victim_env_file(str(manifest), {"GITHUB_TOKEN": "ghp_x"}, dest_dir) + finally: + os.umask(previous) + + assert written is not None + path = Path(written) + assert (path.stat().st_mode & 0o777) == 0o600 + assert read_env_file(path) == {"GITHUB_TOKEN": "ghp_x"} + + +def test_write_operator_env_never_creates_a_world_readable_file(tmp_path: Path) -> None: + """A fresh dotenv must be 0600 at creation — not chmod'd after a default-umask write.""" + cfg = _config(tmp_path) + previous = os.umask(0o000) # the permissive umask that made the old chmod-after-write racy + try: + credentials.write_operator_env(cfg, "brand-new-key") + finally: + os.umask(previous) + + assert (cfg.operator_env_file.stat().st_mode & 0o777) == 0o600 + assert (cfg.operator_env_file.parent.stat().st_mode & 0o077) == 0 + assert read_env_file(cfg.operator_env_file)[INFERENCE_API_KEY_ENVVAR] == "brand-new-key" + + +# ── run job env injection ──────────────────────────────────────────────── + + +def test_run_job_injects_operator_env_without_overriding_shell(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + plugin_config = _config(tmp_path) + plugin_config.venv_path.mkdir(parents=True) + plugin_config.iron_swarm_bin.parent.mkdir(parents=True, exist_ok=True) + plugin_config.iron_swarm_bin.touch() + plugin_config.garak_python.parent.mkdir(parents=True, exist_ok=True) + plugin_config.garak_python.touch() + plugin_config.operator_env_file.write_text(f"{INFERENCE_API_KEY_ENVVAR}=from-dotenv\n", encoding="utf-8") + + monkeypatch.setattr("nemo_iron_swarm_plugin.jobs.run.IronSwarmConfig.get", lambda: plugin_config) + monkeypatch.setattr("nemo_iron_swarm_plugin.jobs._common.sys.stdin.isatty", lambda: False) + + captured: dict[str, dict[str, str]] = {} + + def fake_run(cmd, **kwargs): + captured["env"] = kwargs["env"] + return types.SimpleNamespace(returncode=0) + + monkeypatch.setattr("nemo_iron_swarm_plugin.jobs._common.subprocess.run", fake_run) + + manifest = tmp_path / "iron-swarm.yaml" + manifest.write_text("agent:\n name: calc\n port: 1\n", encoding="utf-8") + ctx = make_job_context(tmp_path, job_id="", on_save=lambda *_a, **_k: types.SimpleNamespace(model_dump=lambda: {})) + + job = IronSwarmRunJob() + monkeypatch.setattr(job, "report_progress", lambda *a, **k: None) + job.run({"config": str(manifest)}, ctx=ctx, sdk=None) + + assert captured["env"][INFERENCE_API_KEY_ENVVAR] == "from-dotenv" + + +def test_run_job_does_not_override_explicit_shell_env(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + plugin_config = _config(tmp_path) + plugin_config.venv_path.mkdir(parents=True) + plugin_config.iron_swarm_bin.parent.mkdir(parents=True, exist_ok=True) + plugin_config.iron_swarm_bin.touch() + plugin_config.garak_python.parent.mkdir(parents=True, exist_ok=True) + plugin_config.garak_python.touch() + plugin_config.operator_env_file.write_text(f"{INFERENCE_API_KEY_ENVVAR}=from-dotenv\n", encoding="utf-8") + + monkeypatch.setenv(INFERENCE_API_KEY_ENVVAR, "from-shell") + monkeypatch.setattr("nemo_iron_swarm_plugin.jobs.run.IronSwarmConfig.get", lambda: plugin_config) + monkeypatch.setattr("nemo_iron_swarm_plugin.jobs._common.sys.stdin.isatty", lambda: False) + + captured: dict[str, dict[str, str]] = {} + + def fake_run(cmd, **kwargs): + captured["env"] = kwargs["env"] + return types.SimpleNamespace(returncode=0) + + monkeypatch.setattr("nemo_iron_swarm_plugin.jobs._common.subprocess.run", fake_run) + + manifest = tmp_path / "iron-swarm.yaml" + manifest.write_text("agent:\n name: calc\n port: 1\n", encoding="utf-8") + ctx = make_job_context(tmp_path, job_id="", on_save=lambda *_a, **_k: types.SimpleNamespace(model_dump=lambda: {})) + + job = IronSwarmRunJob() + monkeypatch.setattr(job, "report_progress", lambda *a, **k: None) + job.run({"config": str(manifest)}, ctx=ctx, sdk=None) + + assert captured["env"][INFERENCE_API_KEY_ENVVAR] == "from-shell" + + +# ── missing_secrets ────────────────────────────────────────────────────── + + +def _manifest(tmp_path: Path, secrets: list[str], secrets_file: str = ".env") -> Path: + path = tmp_path / "iron-swarm.yaml" + path.write_text( + yaml.safe_dump({"agent": {"name": "victim", "secrets": secrets, "secrets_file": secrets_file}}), + encoding="utf-8", + ) + return path + + +def test_missing_secrets_empty_when_none_declared(tmp_path: Path) -> None: + assert missing_secrets(_manifest(tmp_path, []), environ={}) == [] + + +def test_missing_secrets_reports_unresolvable(tmp_path: Path) -> None: + manifest = _manifest(tmp_path, ["GITHUB_TOKEN", "OTHER_KEY"]) + assert missing_secrets(manifest, environ={}) == ["GITHUB_TOKEN", "OTHER_KEY"] + + +def test_missing_secrets_resolved_from_environ_env_file_and_secrets_file(tmp_path: Path) -> None: + manifest = _manifest(tmp_path, ["FROM_ENV", "FROM_FILE", "FROM_SECRETS_FILE"]) + (tmp_path / ".env").write_text("FROM_SECRETS_FILE=x\n", encoding="utf-8") + extra = tmp_path / "creds.env" + extra.write_text("FROM_FILE=y\n", encoding="utf-8") + missing = missing_secrets(manifest, env_files=[extra], environ={"FROM_ENV": "z"}) + assert missing == [] + + +def test_missing_secrets_returns_empty_on_unreadable_manifest(tmp_path: Path) -> None: + assert missing_secrets(tmp_path / "nope.yaml", environ={}) == [] + + +def test_missing_secrets_treats_a_blank_value_as_missing(tmp_path: Path) -> None: + """`export KEY=""` used to satisfy the gate, then fail deep in the run as a provider auth error.""" + manifest = _manifest(tmp_path, ["FROM_ENV", "FROM_FILE", "FROM_SECRETS_FILE"]) + (tmp_path / ".env").write_text("FROM_SECRETS_FILE=\n", encoding="utf-8") + extra = tmp_path / "creds.env" + extra.write_text('FROM_FILE=""\n', encoding="utf-8") + + missing = missing_secrets(manifest, env_files=[extra], environ={"FROM_ENV": " "}) + + assert missing == ["FROM_ENV", "FROM_FILE", "FROM_SECRETS_FILE"] + + +def test_missing_secrets_still_accepts_real_values(tmp_path: Path) -> None: + manifest = _manifest(tmp_path, ["REAL"]) + assert missing_secrets(manifest, environ={"REAL": "sk-abc"}) == [] diff --git a/plugins/nemo-iron-swarm/tests/unit/test_preflight.py b/plugins/nemo-iron-swarm/tests/unit/test_preflight.py new file mode 100644 index 0000000000..b818a8156f --- /dev/null +++ b/plugins/nemo-iron-swarm/tests/unit/test_preflight.py @@ -0,0 +1,85 @@ +# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +"""Unit tests for gateway-status parsing and the preflight result cache.""" + +import subprocess +from collections.abc import Callable +from pathlib import Path + +import pytest +import typer +from nemo_iron_swarm_plugin.cli import checks +from nemo_iron_swarm_plugin.config import IronSwarmConfig + +# Real `openshell status --gateway auto-defender` output, ANSI codes included. +CONNECTED = ( + "\x1b[1m\x1b[36mServer Status\x1b[39m\x1b[0m\n\n" + " \x1b[2mGateway:\x1b[0m auto-defender\n" + " \x1b[2mServer:\x1b[0m https://127.0.0.1:17670\n" + " \x1b[2mStatus:\x1b[0m \x1b[32mConnected\x1b[39m\n" + " \x1b[2mVersion:\x1b[0m 0.0.44\n" +) + + +def _config(tmp_path: Path, *, require_sandbox: bool = True) -> IronSwarmConfig: + return IronSwarmConfig( + venv_path=tmp_path / "venv", + garak_venv_path=tmp_path / "garak-venv", + require_sandbox=require_sandbox, + ) + + +@pytest.mark.parametrize( + ("stdout", "expected"), + [ + (CONNECTED, "Connected"), + (CONNECTED.replace("Connected", "Not Connected"), "Not Connected"), + (" Status: Disconnected\n Last Connected: 2026-01-01\n", "Disconnected"), + ("no status row here", ""), + ], +) +def test_gateway_status_parses_the_status_field(stdout: str, expected: str) -> None: + assert checks.gateway_status(stdout) == expected + + +def test_gateway_status_does_not_match_substrings_of_other_rows() -> None: + """The bug this replaced: `"Connected" in stdout` read these as healthy.""" + for stdout in (CONNECTED.replace("Connected", "Not Connected"), " Status: Down\n Last Connected: never\n"): + assert checks.gateway_status(stdout).casefold() != "connected" + + +def test_probes_bound_a_wedged_daemon_to_a_few_seconds() -> None: + """A hung `docker info`/`openshell status` used to stall every command for 20s/30s.""" + assert checks.PROBE_TIMEOUT_SECONDS <= 5 + + +@pytest.mark.parametrize( + ("probe", "binary", "expected"), + [(checks.docker_ok, "docker", "docker info"), (checks.openshell_gateway_ok, "openshell", "openshell status")], +) +def test_a_timed_out_probe_fails_with_a_wedged_message( + probe: Callable[[], tuple[bool, str]], binary: str, expected: str, monkeypatch: pytest.MonkeyPatch +) -> None: + monkeypatch.setattr(checks.shutil, "which", lambda _name: f"/usr/bin/{binary}") + + def _timeout(*_args: object, **kwargs: object) -> object: + assert kwargs["timeout"] == checks.PROBE_TIMEOUT_SECONDS + raise subprocess.TimeoutExpired(cmd=binary, timeout=checks.PROBE_TIMEOUT_SECONDS) + + monkeypatch.setattr(checks.subprocess, "run", _timeout) + ok, detail = probe() + assert ok is False + assert expected in detail and "timed out" in detail + + +def test_require_preflight_is_a_noop_without_sandbox(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + config = _config(tmp_path, require_sandbox=False) + monkeypatch.setattr(checks, "run_checks", lambda _c: pytest.fail("checks must not run")) + checks.require_preflight(config) + + +def test_require_preflight_exits_when_a_check_fails(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setattr(checks, "run_checks", lambda _c: [checks.CheckResult("docker", False, "daemon down")]) + with pytest.raises(typer.Exit): + checks.require_preflight(_config(tmp_path)) diff --git a/plugins/nemo-iron-swarm/tests/unit/test_run_cli.py b/plugins/nemo-iron-swarm/tests/unit/test_run_cli.py new file mode 100644 index 0000000000..06412e4fb8 --- /dev/null +++ b/plugins/nemo-iron-swarm/tests/unit/test_run_cli.py @@ -0,0 +1,143 @@ +# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +"""Tests for the `--benign-suite` wiring on the SDK `run` method and the CLI `run` command.""" + +from __future__ import annotations + +import asyncio +from pathlib import Path +from types import SimpleNamespace +from typing import Any + +import pytest +from _doubles import make_async_sdk, make_sdk +from typer.testing import CliRunner + + +def _write_suite(path: Path) -> Path: + path.write_text("tool,payload,label,rationale,persona\nt,p,benign,r,pe\n", encoding="utf-8") + return path + + +def test_sdk_run_uploads_benign_suite_into_spec(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + from nemo_iron_swarm_plugin import sdk as sdk_module + + captured: dict[str, Any] = {} + + class _Scheduler: + def run_local(self, _job: Any, spec: dict, **kwargs: Any) -> dict: + captured["spec"] = spec + captured["kwargs"] = kwargs + return {"status": "completed"} + + monkeypatch.setattr(sdk_module, "NemoJobScheduler", _Scheduler) + monkeypatch.setattr( + sdk_module, "upload_file_to_fileset", lambda _sdk, path, *, workspace: f"{workspace}/uploaded-{path.name}" + ) + + suite = _write_suite(tmp_path / "suite.csv") + resource = sdk_module.IronSwarmPluginResource(make_sdk()) + resource.run(config="iron-swarm.yaml", benign_suite=str(suite), workspace="ws1") + + assert captured["spec"]["benign_suite_fileset"] == "ws1/uploaded-suite.csv" + assert captured["kwargs"]["workspace"] == "ws1" + + +def test_sdk_run_without_benign_suite_omits_fileset(monkeypatch: pytest.MonkeyPatch) -> None: + from nemo_iron_swarm_plugin import sdk as sdk_module + + captured: dict[str, Any] = {} + + class _Scheduler: + def run_local(self, _job: Any, spec: dict, **kwargs: Any) -> dict: + captured["spec"] = spec + return {"status": "completed"} + + monkeypatch.setattr(sdk_module, "NemoJobScheduler", _Scheduler) + + resource = sdk_module.IronSwarmPluginResource(make_sdk()) + resource.run(config="iron-swarm.yaml") + + assert "benign_suite_fileset" not in captured["spec"] + + +def test_async_sdk_run_builds_sync_client_and_uploads_benign_suite( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + from nemo_iron_swarm_plugin import sdk as sdk_module + + captured: dict[str, Any] = {} + sync_client = SimpleNamespace(name="sync-sdk") + + class _Scheduler: + def run_local(self, _job: Any, spec: dict, **kwargs: Any) -> dict: + captured["spec"] = spec + captured["kwargs"] = kwargs + return {"status": "completed"} + + def _fake_make_sdk(base: str) -> Any: + captured["base"] = base + return sync_client + + monkeypatch.setattr(sdk_module, "NemoJobScheduler", _Scheduler) + monkeypatch.setattr(sdk_module, "make_sdk", _fake_make_sdk) + monkeypatch.setattr( + sdk_module, "upload_file_to_fileset", lambda sdk, path, *, workspace: f"{workspace}/uploaded-{path.name}" + ) + + suite = _write_suite(tmp_path / "suite.csv") + async_platform = make_async_sdk(base_url="http://localhost:8080/") + resource = sdk_module.AsyncIronSwarmPluginResource(async_platform) + asyncio.run(resource.run(config="iron-swarm.yaml", benign_suite=str(suite), workspace="ws1")) + + assert captured["base"] == "http://localhost:8080/" # sync client targets the async client's base URL + assert captured["spec"]["benign_suite_fileset"] == "ws1/uploaded-suite.csv" + assert captured["kwargs"]["workspace"] == "ws1" + assert captured["kwargs"]["sdk"] is sync_client # job runs against the sync client, not async_sdk + + +def _patch_cli(cli_main: Any, monkeypatch: pytest.MonkeyPatch, captured: dict[str, Any]) -> Any: + fake_sdk = SimpleNamespace( + iron_swarm=SimpleNamespace(run=lambda **kwargs: captured.update(kwargs) or {"status": "completed"}) + ) + monkeypatch.setattr(cli_main.checks, "require_preflight", lambda _c: None) + monkeypatch.setattr(cli_main, "make_sdk", lambda _u: fake_sdk) + monkeypatch.setattr(cli_main, "base_url", lambda: "http://localhost:8080") + monkeypatch.setattr(cli_main, "missing_secrets", lambda _p, env_files: []) + monkeypatch.setattr( + cli_main.IronSwarmConfig, + "get", + classmethod(lambda _cls: SimpleNamespace(default_workspace="default", operator_env_file=Path(".env"))), + ) + return cli_main.IronSwarmCLI().get_cli() + + +def test_cli_run_forwards_benign_suite(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + from nemo_iron_swarm_plugin.cli import main as cli_main + + config = tmp_path / "iron-swarm.yaml" + config.write_text("agent: {}\n", encoding="utf-8") + suite = _write_suite(tmp_path / "suite.csv") + + captured: dict[str, Any] = {} + app = _patch_cli(cli_main, monkeypatch, captured) + result = CliRunner().invoke(app, ["run", "--config", str(config), "--benign-suite", str(suite)]) + + assert result.exit_code == 0, result.output + assert captured["benign_suite"] == str(suite) + + +def test_cli_run_rejects_missing_benign_suite(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + from nemo_iron_swarm_plugin.cli import main as cli_main + + config = tmp_path / "iron-swarm.yaml" + config.write_text("agent: {}\n", encoding="utf-8") + + captured: dict[str, Any] = {} + app = _patch_cli(cli_main, monkeypatch, captured) + result = CliRunner().invoke(app, ["run", "--config", str(config), "--benign-suite", str(tmp_path / "nope.csv")]) + + assert result.exit_code == 1 + assert "not found" in result.output + assert captured == {} # bailed before invoking the SDK diff --git a/plugins/nemo-iron-swarm/tests/unit/test_run_service.py b/plugins/nemo-iron-swarm/tests/unit/test_run_service.py new file mode 100644 index 0000000000..40711fac60 --- /dev/null +++ b/plugins/nemo-iron-swarm/tests/unit/test_run_service.py @@ -0,0 +1,736 @@ +# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +"""Test the service-driven war-game orchestration: sandbox up -> synth HITL -> reuse-benign run.""" + +from __future__ import annotations + +import contextlib +import json +from pathlib import Path +from types import SimpleNamespace +from typing import Any + +import pytest +import yaml +from _doubles import make_job_context, make_sdk +from nemo_iron_swarm_plugin.config import IronSwarmConfig +from nemo_iron_swarm_plugin.jobs import artifacts, benign_suite, execution +from nemo_iron_swarm_plugin.jobs import manifest as manifest_mod +from nemo_iron_swarm_plugin.jobs import run as run_module +from nemo_iron_swarm_plugin.jobs.errors import IronSwarmRunError +from nemo_platform import NeMoPlatform +from nemo_platform_plugin.job_context import JobContext + + +def _provisioned_config(tmp_path: Path) -> IronSwarmConfig: + cfg = IronSwarmConfig( + venv_path=tmp_path / "venv", + garak_venv_path=tmp_path / "garak-venv", + operator_env_file=tmp_path / "operator.env", + ) + cfg.iron_swarm_bin.parent.mkdir(parents=True, exist_ok=True) + cfg.iron_swarm_bin.touch() + cfg.garak_python.parent.mkdir(parents=True, exist_ok=True) + cfg.garak_python.touch() + return cfg + + +def _ctx(tmp_path: Path) -> JobContext: + return make_job_context(tmp_path) + + +def test_service_driven_flow_sequences_up_hitl_then_reuse_run(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + manifest = tmp_path / "iron-swarm.yaml" + manifest.write_text("agent:\n name: clockbot\n port: 1\n", encoding="utf-8") + + monkeypatch.setattr(run_module.IronSwarmConfig, "get", lambda: _provisioned_config(tmp_path)) + + commands: list[list[str]] = [] + + def fake_execute(cmd, _env, _log_path, _ctx, *, artifact_name): # noqa: ANN001, ANN202 - test stub + commands.append(cmd) + return SimpleNamespace(returncode=0), "log-tail", None + + monkeypatch.setattr(run_module._common, "execute", fake_execute) + + @contextlib.contextmanager + def fake_launch(*_a: Any, **_k: Any): + yield object() # client is unused because drive_synth_hitl is stubbed + + monkeypatch.setattr(execution, "launch_synth_service", fake_launch) + + hitl_calls: list[str] = [] + monkeypatch.setattr( + execution, "drive_synth_hitl", lambda _c, cfg, *_a, **_k: hitl_calls.append(cfg) or "/x/requests.csv" + ) + # The reviewed suite from the serve step is read, written to a distinct CSV, and handed to `run`. + monkeypatch.setattr( + benign_suite, + "read_suite", + lambda _p: [{"tool": "clock", "payload": "t", "label": "benign", "rationale": "", "persona": ""}], + ) + monkeypatch.setattr(benign_suite, "write_suite", lambda path, suite: None) + + job = run_module.IronSwarmRunJob() + monkeypatch.setattr(job, "report_progress", lambda *a, **k: None) + result = job.run({"config": str(manifest), "driver": "service"}, ctx=_ctx(tmp_path), sdk=None) + + assert result["status"] == "completed" + assert hitl_calls == [str(manifest)] # HITL ran once, for this manifest + # up first, then the war-game reusing the warm sandbox with the reviewed suite handed in as a file. + assert commands[0][1] == "up" + assert commands[1][1] == "run" + assert "--reuse" in commands[1] and "--reuse-benign" not in commands[1] + assert commands[1][commands[1].index("--benign-suite") + 1].endswith("benign-suite.csv") + + +def test_service_driven_reuses_cached_suite_and_skips_interview( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + manifest = tmp_path / "iron-swarm.yaml" + manifest.write_text("agent:\n name: clockbot\n port: 1\n", encoding="utf-8") + cfg = _provisioned_config(tmp_path) + + commands: list[list[str]] = [] + monkeypatch.setattr( + run_module._common, + "execute", + lambda cmd, *a, **k: (commands.append(cmd), (SimpleNamespace(returncode=0), "", None))[1], + ) + monkeypatch.setattr( + execution, "drive_synth_hitl", lambda *a, **k: pytest.fail("interview must be skipped when cached") + ) + written: dict[str, Any] = {} + monkeypatch.setattr(benign_suite, "write_suite", lambda path, suite: written.update(path=str(path), suite=suite)) + sdk = SimpleNamespace(entities=SimpleNamespace(create=lambda *a, **k: SimpleNamespace(name="run-x"))) + + outcome = execution._run_service_driven( + str(manifest), + None, + cfg, + _ctx(tmp_path), + sdk, + "clockbot", + 1, + manifest_id="m1", + cached_suite=[{"tool": "clock", "payload": "time?", "label": "benign", "rationale": "", "persona": ""}], + stop_after_synth=False, + ) + + assert outcome.status == "completed" + assert written["suite"][0]["tool"] == "clock" # cached suite written to the temp CSV handed to iron-swarm + # A single self-contained war-game (no separate `up` whose forward would collide with the attack's). + assert [c[1] for c in commands] == ["run"] + # The cached suite is passed explicitly (iron-swarm seeds it), not via the on-disk `--reuse-benign` cache. + assert commands[0][commands[0].index("--benign-suite") + 1].endswith("benign-suite.csv") + assert "--reuse" not in commands[0] + assert "--rounds" not in commands[0] # default (1 round) omits the flag + + +def test_service_driven_passes_rounds_flag_when_multi_round(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + manifest = tmp_path / "iron-swarm.yaml" + manifest.write_text("agent:\n name: clockbot\n port: 1\n", encoding="utf-8") + cfg = _provisioned_config(tmp_path) + + commands: list[list[str]] = [] + monkeypatch.setattr( + run_module._common, + "execute", + lambda cmd, *a, **k: (commands.append(cmd), (SimpleNamespace(returncode=0), "", None))[1], + ) + monkeypatch.setattr(benign_suite, "write_suite", lambda path, suite: None) + sdk = SimpleNamespace(entities=SimpleNamespace(create=lambda *a, **k: SimpleNamespace(name="run-x"))) + + execution._run_service_driven( + str(manifest), + None, + cfg, + _ctx(tmp_path), + sdk, + "clockbot", + 1, + manifest_id="m1", + cached_suite=[{"tool": "clock", "payload": "t", "label": "b", "rationale": "", "persona": ""}], + stop_after_synth=False, + rounds=3, + ) + + assert commands[0][commands[0].index("--rounds") + 1] == "3" # manifest's rounds passed to `run` + + +def test_service_driven_generate_persists_suite_and_stops_before_attack( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + manifest = tmp_path / "iron-swarm.yaml" + manifest.write_text("agent:\n name: clockbot\n port: 1\n", encoding="utf-8") + cfg = _provisioned_config(tmp_path) + + commands: list[list[str]] = [] + monkeypatch.setattr( + run_module._common, + "execute", + lambda cmd, *a, **k: (commands.append(cmd), (SimpleNamespace(returncode=0), "", None))[1], + ) + + @contextlib.contextmanager + def fake_launch(*_a: Any, **_k: Any): + yield object() + + monkeypatch.setattr(execution, "launch_synth_service", fake_launch) + monkeypatch.setattr(execution, "drive_synth_hitl", lambda *a, **k: "/x/requests.csv") + rows = [{"tool": "clock", "payload": "time?", "label": "benign", "rationale": "", "persona": ""}] + monkeypatch.setattr(benign_suite, "read_suite", lambda _path: rows) + + persisted: dict[str, Any] = {} + sdk = SimpleNamespace( + entities=SimpleNamespace( + create=lambda *a, **k: SimpleNamespace(name="run-x"), + get_entity_by_name=lambda **k: SimpleNamespace(data={}), + update_entity_by_name=lambda **k: persisted.update(k["data"]), + ) + ) + + outcome = execution._run_service_driven( + str(manifest), + None, + cfg, + _ctx(tmp_path), + sdk, + "clockbot", + 1, + manifest_id="m1", + cached_suite=[], + stop_after_synth=True, + ) + + assert outcome.status == "completed" + assert persisted["benign_suite"] == rows # reviewed suite cached on the manifest + # Generation stops before the attack "run" and tears its sandbox down (frees the victim-port forward). + assert [c[1] for c in commands] == ["up", "down"] + + +async def test_compile_builds_subprocess_step_carrying_the_spec() -> None: + spec = run_module.WarGameSpec(config="iron-swarm.yaml", driver="service") + job_spec = await run_module.IronSwarmRunJob.compile( + workspace="default", spec=spec, entity_client=None, job_name=None, async_sdk=None + ) + + steps = list(job_spec["steps"]) + assert len(steps) == 1 + step = steps[0] + assert step["name"] == "war-game" + assert step["executor"]["provider"] == "subprocess" + assert step["executor"]["command"] == ["python", "-m", "nemo_iron_swarm_plugin.tasks.war_game"] + assert step["config"] == { + "config": "iron-swarm.yaml", + "manifest_id": None, + "env_file": None, + "driver": "service", + "stop_after_synth": False, + "replay_hitlog_fileset": None, + "benign_suite_fileset": None, + "port": None, + "defenders": None, + "attack_intensity": None, + "rounds": None, + "validate_only": False, + "defense_workflow": None, + "defense_policy": None, + "source_run": None, + "models": None, + } + + +async def test_compile_precreates_run_record_for_service_manifest() -> None: + created: dict[str, Any] = {} + + class FakeEntityClient: + async def get(self, _entity_type: Any, *, name: str, workspace: str) -> Any: + assert name == "m1" + return SimpleNamespace(agent="default/clockbot", port=8000) + + async def create(self, entity: Any) -> Any: + created["entity"] = entity + return SimpleNamespace(name="iron-swarm-run-abc") + + spec = run_module.WarGameSpec(manifest_id="m1", driver="service") + job_spec = await run_module.IronSwarmRunJob.compile( + workspace="default", spec=spec, entity_client=FakeEntityClient(), job_name="job-1", async_sdk=None + ) + + # The pre-created run's name rides in the step config so the worker reuses it (no second record). + assert list(job_spec["steps"])[0]["config"]["run_name"] == "iron-swarm-run-abc" + assert created["entity"].job_id == "job-1" + assert created["entity"].agent == "default/clockbot" + assert created["entity"].status == "running" + + +async def test_compile_skips_precreation_when_generating_suite() -> None: + async def _fail(*_a: Any, **_k: Any) -> Any: + pytest.fail("generate-only (stop_after_synth) runs must not pre-create a run record") + + spec = run_module.WarGameSpec(manifest_id="m1", driver="service", stop_after_synth=True) + job_spec = await run_module.IronSwarmRunJob.compile( + workspace="default", + spec=spec, + entity_client=SimpleNamespace(get=_fail, create=_fail), + job_name="job-1", + async_sdk=None, + ) + assert "run_name" not in list(job_spec["steps"])[0]["config"] + + +def test_materialize_manifest_writes_yaml_from_agent_ref(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + record = SimpleNamespace(data={"agent": "default/clockbot"}) + sdk = SimpleNamespace(entities=SimpleNamespace(get_entity_by_name=lambda **_k: record)) + monkeypatch.setattr( + manifest_mod, + "resolve_agent_to_manifest", + lambda *_a, **_k: SimpleNamespace(manifest={"agent": {"name": "clockbot", "port": 8000}}, warnings=[]), + ) + ctx = make_job_context(tmp_path) + + path = manifest_mod._materialize_manifest(sdk, "clockbot-hardening", ctx) + + assert path.endswith("iron-swarm.yaml") + assert "clockbot" in (tmp_path / "iron-swarm.yaml").read_text(encoding="utf-8") + + +def test_materialize_manifest_needs_sdk(tmp_path: Path) -> None: + ctx = make_job_context(tmp_path) + with pytest.raises(RuntimeError, match="platform SDK"): + manifest_mod._materialize_manifest(None, "m", ctx) + + +def test_materialize_project_manifest_repoints_project_dir(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + stored_yaml = "agent:\n name: research\n project_dir: .\n workflow: pkg/workflow.yaml\n port: 8000\n" + record = SimpleNamespace( + data={"source_type": "project", "project_fileset": "default/bundle", "manifest_yaml": stored_yaml} + ) + sdk = SimpleNamespace(entities=SimpleNamespace(get_entity_by_name=lambda **_k: record)) + restored = tmp_path / "restored-project" + monkeypatch.setattr(manifest_mod, "download_and_extract_project", lambda *_a, **_k: restored) + ctx = make_job_context(tmp_path) + + path = manifest_mod._materialize_manifest(sdk, "research-hardening", ctx) + + written = (tmp_path / "iron-swarm.yaml").read_text(encoding="utf-8") + assert path.endswith("iron-swarm.yaml") + # project_dir is repointed at the freshly restored bundle; the workflow stays project-relative. + assert f"project_dir: {restored}" in written + assert "workflow: pkg/workflow.yaml" in written + + +def test_materialize_project_manifest_requires_fileset_and_yaml(tmp_path: Path) -> None: + record = SimpleNamespace(data={"source_type": "project", "manifest_yaml": "agent: {}"}) + sdk = SimpleNamespace(entities=SimpleNamespace(get_entity_by_name=lambda **_k: record)) + ctx = make_job_context(tmp_path) + with pytest.raises(RuntimeError, match="project_fileset"): + manifest_mod._materialize_manifest(sdk, "research-hardening", ctx) + + +def _capturing_sdk() -> tuple[NeMoPlatform, list[dict[str, Any]]]: + """A fake SDK whose entity create/update calls capture the recorded run data.""" + captured: list[dict[str, Any]] = [] + entities = SimpleNamespace( + create=lambda _t, *, workspace, data: (captured.append(data), SimpleNamespace(name="run-1"))[1], + update_entity_by_name=lambda *, name, entity_type, workspace, data: captured.append(data), + ) + return make_sdk(entities), captured + + +def test_run_boundary_records_classified_subprocess_failure(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + manifest = tmp_path / "iron-swarm.yaml" + manifest.write_text("agent:\n name: clockbot\n port: 1\n", encoding="utf-8") + monkeypatch.setattr(run_module.IronSwarmConfig, "get", lambda: _provisioned_config(tmp_path)) + + def failing_execute(_cmd, env, _log_path, _ctx, *, artifact_name): # noqa: ANN001, ANN202 - test stub + # iron-swarm writes a structured cause to the error file, then exits non-zero. + Path(env["IRON_SWARM_ERROR_FILE"]).write_text( + json.dumps({"category": "sandbox", "message": "docker down", "remediation": "start docker"}), + encoding="utf-8", + ) + return SimpleNamespace(returncode=1), "log", None + + monkeypatch.setattr(run_module._common, "execute", failing_execute) + sdk, captured = _capturing_sdk() + job = run_module.IronSwarmRunJob() + monkeypatch.setattr(job, "report_progress", lambda *a, **k: None) + + result = job.run({"config": str(manifest)}, ctx=_ctx(tmp_path), sdk=sdk) + + assert result["status"] == "failed" + assert result["error"]["category"] == "sandbox" + assert "docker down" in result["error"]["message"] + assert captured[-1]["status"] == "failed" + assert captured[-1]["error_category"] == "sandbox" + assert captured[-1]["error_remediation"] == "start docker" + + +def test_failure_preserves_the_precreated_agent_and_port(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + """An update replaces the whole record, so a failure must carry the pre-created facts forward. + + Otherwise the Hardening list shows the failed run targeting no agent at all. + """ + cfg = IronSwarmConfig( + venv_path=tmp_path / "venv", garak_venv_path=tmp_path / "garak", operator_env_file=tmp_path / "op.env" + ) + monkeypatch.setattr(run_module.IronSwarmConfig, "get", lambda: cfg) # unprovisioned → fails early + + captured: list[dict[str, Any]] = [] + precreated = SimpleNamespace(data={"agent": "default/clockbot", "port": 9000, "status": "running"}) + sdk = SimpleNamespace( + entities=SimpleNamespace( + get_entity_by_name=lambda **_k: precreated, + create=lambda _t, *, workspace, data: (captured.append(data), SimpleNamespace(name="run-1"))[1], + update_entity_by_name=lambda *, name, entity_type, workspace, data: captured.append(data), + ) + ) + job = run_module.IronSwarmRunJob() + monkeypatch.setattr(job, "report_progress", lambda *a, **k: None) + + result = job.run({"manifest_id": "m1", "run_name": "run-1"}, ctx=_ctx(tmp_path), sdk=sdk) + + assert result["status"] == "failed" + assert captured[-1]["status"] == "failed" + assert captured[-1]["agent"] == "default/clockbot" # not blanked + assert captured[-1]["port"] == 9000 + assert "clockbot" in captured[-1]["summary"] + + +def test_failure_without_a_precreated_record_has_no_agent_to_preserve( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + cfg = IronSwarmConfig( + venv_path=tmp_path / "venv", garak_venv_path=tmp_path / "garak", operator_env_file=tmp_path / "op.env" + ) + monkeypatch.setattr(run_module.IronSwarmConfig, "get", lambda: cfg) + sdk, captured = _capturing_sdk() + job = run_module.IronSwarmRunJob() + monkeypatch.setattr(job, "report_progress", lambda *a, **k: None) + + job.run({"config": str(tmp_path / "nope.yaml")}, ctx=_ctx(tmp_path), sdk=sdk) + + assert captured[-1]["agent"] == "" # nothing was ever recorded to carry forward + + +def test_run_boundary_classifies_unprovisioned_host(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + manifest = tmp_path / "iron-swarm.yaml" + manifest.write_text("agent:\n name: clockbot\n port: 1\n", encoding="utf-8") + # A config whose venvs were never provisioned (bins absent) trips require_provisioned before any subprocess. + cfg = IronSwarmConfig( + venv_path=tmp_path / "venv", garak_venv_path=tmp_path / "garak", operator_env_file=tmp_path / "op.env" + ) + monkeypatch.setattr(run_module.IronSwarmConfig, "get", lambda: cfg) + sdk, captured = _capturing_sdk() + job = run_module.IronSwarmRunJob() + monkeypatch.setattr(job, "report_progress", lambda *a, **k: None) + + result = job.run({"config": str(manifest)}, ctx=_ctx(tmp_path), sdk=sdk) + + assert result["status"] == "failed" + assert result["error"]["category"] == "provisioning" + assert captured[-1]["error_category"] == "provisioning" + assert captured[-1]["status"] == "failed" + + +def test_service_driven_requires_a_platform_job(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + manifest = tmp_path / "iron-swarm.yaml" + manifest.write_text("agent:\n name: clockbot\n port: 1\n", encoding="utf-8") + monkeypatch.setattr(run_module.IronSwarmConfig, "get", lambda: _provisioned_config(tmp_path)) + monkeypatch.setattr(run_module._common, "execute", lambda *a, **k: (SimpleNamespace(returncode=0), "", None)) + + ctx = _ctx(tmp_path) + ctx.job_id = None # local run_local: no submitted job to drive status_details HITL + job = run_module.IronSwarmRunJob() + monkeypatch.setattr(job, "report_progress", lambda *a, **k: None) + # run() no longer raises: the boundary classifies the failure and surfaces it as a failed result. + result = job.run({"config": str(manifest), "driver": "service"}, ctx=ctx, sdk=None) + assert result["status"] == "failed" + assert "submitted platform job" in result["error"]["message"] + + +def test_replay_args_empty_without_fileset(tmp_path: Path) -> None: + assert artifacts._replay_args(None, sdk=object(), ctx=_ctx(tmp_path)) == [] + + +def test_replay_args_downloads_fileset_and_points_replay_at_the_file( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + def fake_download(_sdk: Any, ref: str, dest: Path) -> Path: + assert ref == "default/hits" + dest.mkdir(parents=True, exist_ok=True) + (dest / "attack.hitlog.jsonl").write_text("{}\n", encoding="utf-8") + return dest + + monkeypatch.setattr(artifacts, "download_fileset", fake_download) + + args = artifacts._replay_args("default/hits", sdk=object(), ctx=_ctx(tmp_path)) + + assert args[0] == "--replay" + assert args[1].endswith("attack.hitlog.jsonl") + + +def test_replay_args_raises_when_fileset_has_no_file(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setattr(artifacts, "download_fileset", lambda _s, _r, dest: (dest.mkdir(parents=True), dest)[1]) + with pytest.raises(IronSwarmRunError, match="contained no file"): + artifacts._replay_args("default/hits", sdk=object(), ctx=_ctx(tmp_path)) + + +def test_service_driven_replay_appends_replay_to_run_command(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + manifest = tmp_path / "iron-swarm.yaml" + manifest.write_text("agent:\n name: clockbot\n port: 1\n", encoding="utf-8") + cfg = _provisioned_config(tmp_path) + + commands: list[list[str]] = [] + monkeypatch.setattr( + run_module._common, + "execute", + lambda cmd, *a, **k: (commands.append(cmd), (SimpleNamespace(returncode=0), "", None))[1], + ) + monkeypatch.setattr(benign_suite, "write_suite", lambda path, suite: None) + sdk = SimpleNamespace(entities=SimpleNamespace(create=lambda *a, **k: SimpleNamespace(name="run-x"))) + + execution._run_service_driven( + str(manifest), + None, + cfg, + _ctx(tmp_path), + sdk, + "clockbot", + 1, + manifest_id="m1", + cached_suite=[{"tool": "clock", "payload": "t", "label": "b", "rationale": "", "persona": ""}], + stop_after_synth=False, + replay_args=["--replay", "/tmp/hits.hitlog.jsonl"], + ) + + # Replay composes with the cached-suite fast path: still passes the benign suite, plus --replay . + assert "--benign-suite" in commands[0] + assert commands[0][commands[0].index("--replay") + 1] == "/tmp/hits.hitlog.jsonl" + + +def test_save_hitlog_fileset_uploads_newest_hitlog(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + hitlog = tmp_path / ".iron-swarm" / "run-logs" / "run-1" / "round_1" / "garak" / "agent-breaker.uuid.hitlog.jsonl" + hitlog.parent.mkdir(parents=True) + hitlog.write_text("{}\n", encoding="utf-8") + + uploaded: dict[str, Any] = {} + + def fake_upload(_sdk: Any, path: Path, *, workspace: str) -> str: + uploaded.update(path=path, workspace=workspace) + return f"{workspace}/hitlog-abc" + + monkeypatch.setattr(artifacts, "upload_file_to_fileset", fake_upload) + + ref = artifacts._save_hitlog_fileset(object(), _ctx(tmp_path), "default") + + assert ref == "default/hitlog-abc" + assert uploaded["path"] == hitlog + + +def test_save_hitlog_fileset_empty_when_no_hitlog(tmp_path: Path) -> None: + assert artifacts._save_hitlog_fileset(object(), _ctx(tmp_path), "default") == "" + + +def test_uploaded_benign_suite_downloads_fileset(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + def fake_download(_sdk: Any, ref: str, dest: Path) -> Path: + assert ref == "default/suite" + dest.mkdir(parents=True, exist_ok=True) + (dest / "requests.csv").write_text("tool,payload\nclock,now\n", encoding="utf-8") + return dest + + monkeypatch.setattr(artifacts, "download_fileset", fake_download) + path = artifacts._uploaded_benign_suite("default/suite", sdk=object(), ctx=_ctx(tmp_path)) + assert path is not None and path.endswith("requests.csv") + + +def test_uploaded_benign_suite_none_without_fileset(tmp_path: Path) -> None: + assert artifacts._uploaded_benign_suite(None, sdk=object(), ctx=_ctx(tmp_path)) is None + + +def test_prepare_invocation_appends_benign_suite(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + manifest = tmp_path / "iron-swarm.yaml" + manifest.write_text("agent:\n name: clockbot\n", encoding="utf-8") + cfg = _provisioned_config(tmp_path) + monkeypatch.setattr(run_module._common, "build_subprocess_env", lambda _c, _e=None: {}) + monkeypatch.setattr(run_module._common, "check_victim_secrets", lambda *a, **k: None) + + with_suite, _ = execution._prepare_invocation(str(manifest), None, cfg, None, "/tmp/suite.csv") + assert with_suite[with_suite.index("--benign-suite") + 1] == "/tmp/suite.csv" + + without_suite, _ = execution._prepare_invocation(str(manifest), None, cfg, None, None) + assert "--benign-suite" not in without_suite # unchanged one-shot behavior when none supplied + + +def test_one_shot_forwards_benign_suite_to_run(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + manifest = tmp_path / "iron-swarm.yaml" + manifest.write_text("agent:\n name: clockbot\n", encoding="utf-8") + cfg = _provisioned_config(tmp_path) + monkeypatch.setattr(run_module._common, "build_subprocess_env", lambda _c, _e=None: {}) + monkeypatch.setattr(run_module._common, "check_victim_secrets", lambda *a, **k: None) + + commands: list[list[str]] = [] + monkeypatch.setattr( + run_module._common, + "execute", + lambda cmd, *a, **k: (commands.append(cmd), (SimpleNamespace(returncode=0), "", None))[1], + ) + + execution._run_one_shot(str(manifest), None, cfg, _ctx(tmp_path), None, benign_suite="/tmp/suite.csv") + assert commands[0][commands[0].index("--benign-suite") + 1] == "/tmp/suite.csv" + + +def test_service_driven_uploaded_suite_overrides_and_skips_synth( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + manifest = tmp_path / "iron-swarm.yaml" + manifest.write_text("agent:\n name: clockbot\n port: 1\n", encoding="utf-8") + cfg = _provisioned_config(tmp_path) + + commands: list[list[str]] = [] + monkeypatch.setattr( + run_module._common, + "execute", + lambda cmd, *a, **k: (commands.append(cmd), (SimpleNamespace(returncode=0), "", None))[1], + ) + monkeypatch.setattr( + execution, "drive_synth_hitl", lambda *a, **k: pytest.fail("uploaded suite must skip synthesis") + ) + sdk = SimpleNamespace(entities=SimpleNamespace(create=lambda *a, **k: SimpleNamespace(name="run-x"))) + + # No cached suite, but an uploaded suite override is supplied → still the explicit-suite path (no `up`/synth). + execution._run_service_driven( + str(manifest), + None, + cfg, + _ctx(tmp_path), + sdk, + "clockbot", + 1, + manifest_id="m1", + cached_suite=[], + benign_suite_override="/tmp/uploaded-suite.csv", + ) + + assert [c[1] for c in commands] == ["run"] + assert commands[0][commands[0].index("--benign-suite") + 1] == "/tmp/uploaded-suite.csv" + + +def test_apply_manifest_overrides_maps_intensity_and_selects_defenders() -> None: + manifest: dict[str, Any] = {"agent": {"name": "clockbot", "workflow": "workflow.yaml"}, "backends": []} + manifest_mod._apply_manifest_overrides(manifest, {"attack_intensity": "thorough", "defenders": ["openshell"]}) + assert manifest["garak"] == {"generations": 5, "max_attempts_per_tool": 10} + # Subset selection replaces the defender list; entries carry capabilities (validator requires it) but + # omit the unused config block. + entry = manifest["overrides"]["defenders"][0] + assert entry["name"] == "openshell-policy-defender" + assert entry["capabilities"] # non-empty — iron-swarm's SessionConfig validator requires it + assert "config" not in entry + + +def test_apply_manifest_overrides_standard_and_empty_are_noops() -> None: + manifest = {"agent": {"name": "x", "workflow": "w"}, "backends": []} + manifest_mod._apply_manifest_overrides(manifest, {"attack_intensity": "standard", "defenders": []}) + assert "garak" not in manifest # standard = engine defaults + assert "overrides" not in manifest # empty selection = iron-swarm defaults + + +def test_apply_manifest_overrides_drops_guardrails_without_workflow() -> None: + manifest = {"agent": {"name": "x"}, "backends": []} # no workflow → guardrails unavailable + manifest_mod._apply_manifest_overrides(manifest, {"defenders": ["guardrails"]}) + assert "overrides" not in manifest + + +def test_apply_manifest_overrides_applies_explicit_port_only() -> None: + manifest = {"agent": {"name": "x", "port": 8000, "workflow": "w"}, "backends": []} + manifest_mod._apply_manifest_overrides(manifest, {"port": 9001}) + assert manifest["agent"]["port"] == 9001 + # No port in data → leave the resolver-derived port untouched. + manifest2 = {"agent": {"name": "x", "port": 8000, "workflow": "w"}, "backends": []} + manifest_mod._apply_manifest_overrides(manifest2, {"defenders": []}) + assert manifest2["agent"]["port"] == 8000 + + +def test_materialize_manifest_overlays_per_run_overrides(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + # Stored manifest config: standard intensity, port 8000. Override to thorough + port 9100 for this run only. + record = SimpleNamespace(data={"agent": "default/clockbot", "attack_intensity": "standard", "port": 8000}) + sdk = SimpleNamespace(entities=SimpleNamespace(get_entity_by_name=lambda **_k: record)) + monkeypatch.setattr( + manifest_mod, + "resolve_agent_to_manifest", + lambda *_a, **_k: SimpleNamespace(manifest={"agent": {"name": "clockbot", "port": 8000}}, warnings=[]), + ) + ctx = make_job_context(tmp_path) + + manifest_mod._materialize_manifest(sdk, "m1", ctx, {"attack_intensity": "thorough", "port": 9100}) + + written = yaml.safe_load((tmp_path / "iron-swarm.yaml").read_text(encoding="utf-8")) + assert written["garak"] == {"generations": 5, "max_attempts_per_tool": 10} # thorough override applied + assert written["agent"]["port"] == 9100 # per-run port override applied; manifest entity untouched + + +def test_service_driven_records_manifest_id_on_run(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + manifest = tmp_path / "iron-swarm.yaml" + manifest.write_text("agent:\n name: clockbot\n port: 1\n", encoding="utf-8") + cfg = _provisioned_config(tmp_path) + monkeypatch.setattr(run_module._common, "execute", lambda *a, **k: (SimpleNamespace(returncode=0), "", None)) + monkeypatch.setattr(benign_suite, "write_suite", lambda path, suite: None) + + created: dict[str, Any] = {} + sdk = SimpleNamespace( + entities=SimpleNamespace( + create=lambda _t, *, workspace, data: created.update(data) or SimpleNamespace(name="run-x") + ) + ) + + execution._run_service_driven( + str(manifest), + None, + cfg, + _ctx(tmp_path), + sdk, + "clockbot", + 1, + manifest_id="clockbot-hardening", + cached_suite=[{"tool": "c", "payload": "t", "label": "b", "rationale": "", "persona": ""}], + ) + + assert created["manifest_id"] == "clockbot-hardening" + + +def test_seed_validation_manifest_zeros_defenders_and_seeds_baseline(tmp_path: Path) -> None: + # A materialized manifest points agent.workflow at a scaffold file under project_dir (relative here). + (tmp_path / "scaffold").mkdir() + (tmp_path / "scaffold" / "workflow.yaml").write_text("workflow: original\n", encoding="utf-8") + manifest = {"agent": {"name": "clockbot", "project_dir": "scaffold", "workflow": "workflow.yaml", "port": 1}} + manifest_path = tmp_path / "iron-swarm.yaml" + manifest_path.write_text(yaml.safe_dump(manifest), encoding="utf-8") + + manifest_mod._seed_validation_manifest(str(manifest_path), "workflow: hardened\n", "version: 2\n", _ctx(tmp_path)) + + seeded = yaml.safe_load(manifest_path.read_text(encoding="utf-8")) + # Zero defenders → iron-swarm generates no new mitigations (frozen validation). + assert seeded["overrides"]["defenders"] == [] + # Composed policy seeded as the victim's baseline policy. + assert seeded["overrides"]["victim_control"]["config"]["policy_path"].endswith("composed-policy.yaml") + assert seeded["overrides"]["storage"]["victim_policy_path"].endswith("composed-policy.yaml") + assert (tmp_path / "composed-policy.yaml").read_text(encoding="utf-8") == "version: 2\n" + # Composed workflow overwrites the materialized scaffold workflow (victim boots already-hardened). + assert (tmp_path / "scaffold" / "workflow.yaml").read_text(encoding="utf-8") == "workflow: hardened\n" + + +def test_seed_validation_manifest_without_policy_only_zeros_defenders(tmp_path: Path) -> None: + (tmp_path / "scaffold").mkdir() + (tmp_path / "scaffold" / "workflow.yaml").write_text("workflow: original\n", encoding="utf-8") + manifest = {"agent": {"name": "x", "project_dir": "scaffold", "workflow": "workflow.yaml"}} + manifest_path = tmp_path / "iron-swarm.yaml" + manifest_path.write_text(yaml.safe_dump(manifest), encoding="utf-8") + + manifest_mod._seed_validation_manifest(str(manifest_path), "workflow: hardened\n", None, _ctx(tmp_path)) + + seeded = yaml.safe_load(manifest_path.read_text(encoding="utf-8")) + assert seeded["overrides"]["defenders"] == [] + assert "victim_control" not in seeded["overrides"] # no policy chosen → no policy override + assert not (tmp_path / "composed-policy.yaml").exists() diff --git a/plugins/nemo-iron-swarm/tests/unit/test_sanity_check_cli.py b/plugins/nemo-iron-swarm/tests/unit/test_sanity_check_cli.py new file mode 100644 index 0000000000..fdce138110 --- /dev/null +++ b/plugins/nemo-iron-swarm/tests/unit/test_sanity_check_cli.py @@ -0,0 +1,155 @@ +# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +"""Tests for the sanity-check selection helpers, the SDK sanity_check method, and the CLI command.""" + +from __future__ import annotations + +import json +from pathlib import Path +from types import SimpleNamespace +from typing import Any + +import pytest +import yaml +from _doubles import make_sdk +from nemo_iron_swarm_plugin.jobs.defenses import defense_ids, select_defense_ids +from typer.testing import CliRunner + + +def _mitigations() -> dict: + workflow = yaml.safe_dump( + { + "middleware": { + "custom_guardrail_1": {"_type": "pre_tool_verifier"}, + "custom_guardrail_2": {"_type": "pre_tool_verifier"}, + } + } + ) + return { + "workflow": {"before": "{}\n", "after": workflow}, + "policy": {"before": "v: 1\n", "after": "v: 1\nhardened: true\n"}, + "defenses": [ + {"id": "custom_guardrail_1", "kind": "guardrail"}, + {"id": "custom_guardrail_2", "kind": "guardrail"}, + {"id": "openshell_policy", "kind": "policy"}, + ], + } + + +def test_defense_ids_reads_ids() -> None: + assert defense_ids(_mitigations()) == ["custom_guardrail_1", "custom_guardrail_2", "openshell_policy"] + + +def test_select_defense_ids_keep_exclude_default() -> None: + all_ids = ["custom_guardrail_1", "custom_guardrail_2", "openshell_policy"] + assert select_defense_ids(all_ids) == all_ids + assert select_defense_ids(all_ids, keep=["custom_guardrail_1", "openshell_policy"]) == [ + "custom_guardrail_1", + "openshell_policy", + ] + assert select_defense_ids(all_ids, exclude=["custom_guardrail_2"]) == ["custom_guardrail_1", "openshell_policy"] + + +def test_sdk_sanity_check_composes_and_builds_validate_only_spec(monkeypatch: pytest.MonkeyPatch) -> None: + from nemo_iron_swarm_plugin import sdk as sdk_module + + captured: dict[str, Any] = {} + + class _Scheduler: + def submit_remote(self, _job: Any, spec: dict, **kwargs: Any) -> dict: + captured["spec"] = spec + captured["kwargs"] = kwargs + return {"name": "job-x"} + + monkeypatch.setattr(sdk_module, "NemoJobScheduler", _Scheduler) + monkeypatch.setattr(sdk_module, "base_url", lambda: "http://localhost:8080") + + resource = sdk_module.IronSwarmPluginResource(make_sdk()) + resource.sanity_check( + manifest_id="clockbot-hardening", + mitigations=_mitigations(), + selected_defense_ids=["custom_guardrail_1"], # drop guardrail_2 and the policy + replay_hitlog_fileset="default/hits", + ) + + spec = captured["spec"] + assert spec["validate_only"] is True + assert spec["driver"] == "service" + assert spec["replay_hitlog_fileset"] == "default/hits" + # Composed workflow keeps only guardrail_1; policy not selected → baseline policy. + workflow = yaml.safe_load(spec["defense_workflow"]) + assert set(workflow["middleware"]) == {"custom_guardrail_1"} + assert spec["defense_policy"] == "v: 1\n" + + +def test_cli_sanity_check_selects_and_submits(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + from nemo_iron_swarm_plugin.cli import main as cli_main + + mitigations_file = tmp_path / "mitigations.json" + mitigations_file.write_text(json.dumps(_mitigations()), encoding="utf-8") + + captured: dict[str, Any] = {} + fake_sdk = SimpleNamespace( + iron_swarm=SimpleNamespace(sanity_check=lambda **kwargs: captured.update(kwargs) or {"name": "job-x"}) + ) + monkeypatch.setattr(cli_main.checks, "require_preflight", lambda _c: None) + monkeypatch.setattr(cli_main, "make_sdk", lambda _u: fake_sdk) + monkeypatch.setattr(cli_main, "base_url", lambda: "http://localhost:8080") + monkeypatch.setattr( + cli_main.IronSwarmConfig, "get", classmethod(lambda _cls: SimpleNamespace(default_workspace="default")) + ) + + app = cli_main.IronSwarmCLI().get_cli() + result = CliRunner().invoke( + app, + [ + "sanity-check", + "--manifest-id", + "clockbot-hardening", + "--mitigations", + str(mitigations_file), + "--replay-hitlog", + "default/hits", + "--exclude", + "custom_guardrail_2", + ], + ) + + assert result.exit_code == 0, result.output + assert captured["manifest_id"] == "clockbot-hardening" + assert captured["replay_hitlog_fileset"] == "default/hits" + # --exclude drops guardrail_2; the rest are kept. + assert captured["selected_defense_ids"] == ["custom_guardrail_1", "openshell_policy"] + + +def test_cli_sanity_check_rejects_keep_and_exclude_together(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + from nemo_iron_swarm_plugin.cli import main as cli_main + + mitigations_file = tmp_path / "mitigations.json" + mitigations_file.write_text(json.dumps(_mitigations()), encoding="utf-8") + monkeypatch.setattr(cli_main.checks, "require_preflight", lambda _c: None) + monkeypatch.setattr( + cli_main.IronSwarmConfig, "get", classmethod(lambda _cls: SimpleNamespace(default_workspace="default")) + ) + + app = cli_main.IronSwarmCLI().get_cli() + result = CliRunner().invoke( + app, + [ + "sanity-check", + "--manifest-id", + "m1", + "--mitigations", + str(mitigations_file), + "--replay-hitlog", + "default/hits", + "--keep", + "custom_guardrail_1", + "--exclude", + "custom_guardrail_2", + ], + ) + + assert result.exit_code == 1 + assert "either --keep or --exclude" in result.output diff --git a/plugins/nemo-iron-swarm/tests/unit/test_sdk_resources.py b/plugins/nemo-iron-swarm/tests/unit/test_sdk_resources.py new file mode 100644 index 0000000000..92f02998c4 --- /dev/null +++ b/plugins/nemo-iron-swarm/tests/unit/test_sdk_resources.py @@ -0,0 +1,91 @@ +# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +"""Tests for `client.iron_swarm.runs` / `.manifests` — the auto-pagination bound on `limit`.""" + +from __future__ import annotations + +import types +from typing import Any + +from nemo_iron_swarm_plugin.sdk import IronSwarmPluginResource + + +class _AutoPaginatingPage: + """Stand-in for `SyncDefaultPagination`: iterating it walks every page, like the real client. + + `_base_client.BaseSyncPage.__iter__` loops `while True: ... page.get_next_page()`, so `page_size` + bounds a page, never the total. That is what made `status --limit` return the whole history. + """ + + def __init__(self, records: list[Any], page_size: int, requests: list[int]) -> None: + self._records = records + self._page_size = page_size + self._requests = requests + + def __iter__(self) -> Any: + for start in range(0, len(self._records), self._page_size): + self._requests.append(start) # one HTTP round-trip per page + yield from self._records[start : start + self._page_size] + + +class _FakeEntities: + def __init__(self, total: int) -> None: + self.records = [ + types.SimpleNamespace(data={"agent": f"a{i}", "status": "completed"}, name=f"run-{i}", created_at=i) + for i in range(total) + ] + self.requests: list[int] = [] + self.calls: list[dict[str, Any]] = [] + + def list(self, entity_type: str, **kwargs: Any) -> _AutoPaginatingPage: + self.calls.append({"entity_type": entity_type, **kwargs}) + return _AutoPaginatingPage(self.records, kwargs["page_size"], self.requests) + + +def _resource(total: int) -> tuple[IronSwarmPluginResource, _FakeEntities]: + entities = _FakeEntities(total) + platform: Any = types.SimpleNamespace(entities=entities) + return IronSwarmPluginResource(platform), entities + + +def test_runs_list_returns_at_most_limit() -> None: + resource, entities = _resource(total=200) + + runs = resource.runs.list(workspace="default", limit=5) + + assert len(runs) == 5 + assert [r["name"] for r in runs] == [f"run-{i}" for i in range(5)] + assert len(entities.requests) == 1 # one page fetched, not 40 + + +def test_runs_list_sorts_newest_first_in_the_query() -> None: + resource, entities = _resource(total=3) + resource.runs.list(workspace="ws", limit=2) + assert entities.calls[0]["sort"] == "-created_at" + assert entities.calls[0]["workspace"] == "ws" + + +def test_runs_list_handles_fewer_records_than_limit() -> None: + resource, _ = _resource(total=2) + assert len(resource.runs.list(limit=20)) == 2 + + +def test_latest_fetches_one_record_not_the_whole_history() -> None: + resource, entities = _resource(total=200) + + latest = resource.runs.latest(workspace="default") + + assert latest is not None and latest["name"] == "run-0" + assert len(entities.requests) == 1 + + +def test_latest_is_none_when_no_runs_exist() -> None: + resource, _ = _resource(total=0) + assert resource.runs.latest() is None + + +def test_manifests_list_is_bounded_too() -> None: + resource, entities = _resource(total=50) + assert len(resource.manifests.list(limit=3)) == 3 + assert len(entities.requests) == 1 diff --git a/plugins/nemo-iron-swarm/tests/unit/test_service.py b/plugins/nemo-iron-swarm/tests/unit/test_service.py new file mode 100644 index 0000000000..81b6efc169 --- /dev/null +++ b/plugins/nemo-iron-swarm/tests/unit/test_service.py @@ -0,0 +1,64 @@ +# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +"""Service-surface tests: the war-game job route is mounted and its authz derives cleanly. + +These guard the regression where the service exposed runs/manifests but never mounted the job +collection, so ``POST /jobs`` (the Studio "Run war-game" path) 404'd, and the derivation gate that +every route carries a ``@path_rule`` (the OPA bundle fails closed otherwise). +""" + +from __future__ import annotations + +from fastapi import FastAPI +from fastapi.testclient import TestClient +from nemo_iron_swarm_plugin.service import IronSwarmPluginService + + +def test_service_declares_jobs_dependency() -> None: + assert "jobs" in IronSwarmPluginService().dependencies + + +def test_service_routes_include_war_game_jobs_path() -> None: + service = IronSwarmPluginService() + app = FastAPI() + for spec in service.get_routers(): + app.include_router(spec.router, prefix=spec.prefix) + + spec = TestClient(app).get("/openapi.json").json() + + assert "/v2/workspaces/{workspace}/jobs" in spec["paths"] + assert "post" in spec["paths"]["/v2/workspaces/{workspace}/jobs"] + assert "WarGameJobRequest" in spec["components"]["schemas"] + + +def test_service_authz_derives_from_routes() -> None: + """Authz is derived from the ``@path_rule``/``@scope`` stamps on every route (there is no + ``get_authz_contribution``). Doubles as the derivation gate: the service must derive with no + problems (every route ruled) and no fail-closed DENY bindings. + """ + from nemo_platform_plugin.authz_discovery import _derive_service_contribution + + contribution, problems, _warnings = _derive_service_contribution(IronSwarmPluginService()) + + assert problems == [] + assert not any(spec.deny for methods in contribution.endpoints.values() for spec in methods.values()) + + # Job collection (scope.child("jobs") → iron-swarm.jobs.*) plus route permissions. + for perm_id in ( + "iron-swarm.jobs.create", + "iron-swarm.jobs.list", + "iron-swarm.runs.list", + "iron-swarm.runs.events.write", + "iron-swarm.manifests.write", + "iron-swarm.manifests.inspect", + ): + assert perm_id in contribution.permissions + + base = "/apis/iron-swarm/v2/workspaces/{workspace}" + assert contribution.endpoints[f"{base}/jobs"]["post"].permissions == ["iron-swarm.jobs.create"] + assert contribution.endpoints[f"{base}/runs"]["get"].permissions == ["iron-swarm.runs.list"] + assert contribution.endpoints[f"{base}/runs"]["get"].scopes == ["iron-swarm:read", "platform:read"] + assert contribution.endpoints[f"{base}/runs/{{name}}/events"]["post"].permissions == [ + "iron-swarm.runs.events.write" + ] diff --git a/plugins/nemo-iron-swarm/tests/unit/test_synth_benign.py b/plugins/nemo-iron-swarm/tests/unit/test_synth_benign.py new file mode 100644 index 0000000000..8c7004e1ce --- /dev/null +++ b/plugins/nemo-iron-swarm/tests/unit/test_synth_benign.py @@ -0,0 +1,483 @@ +# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +"""Tests for the three-phase CLI: manifest persistence, native synth-benign wiring, run --manifest-id.""" + +from __future__ import annotations + +from pathlib import Path +from types import SimpleNamespace +from typing import Any + +import pytest +import yaml +from _doubles import make_job_context, make_sdk +from nemo_platform_plugin.job_context import JobContext +from typer.testing import CliRunner + +_HEADER = "tool,payload,label,rationale,persona\n" + + +def _ctx(tmp_path: Path, workspace: str = "ws1") -> JobContext: + return make_job_context(tmp_path, workspace=workspace, job_id="job-1") + + +# ── IronSwarmManifest.from_agent_resolution ────────────────────────────────── + + +def test_from_agent_resolution_builds_agent_source_entity() -> None: + from nemo_iron_swarm_plugin.entities import IronSwarmManifest + + manifest = IronSwarmManifest.from_agent_resolution( + name="my-target", + workspace="ws1", + agent_ref="ws1/chatbot", + manifest_yaml="agent: {}\n", + port=8000, + secrets=["OPENAI_API_KEY"], + warnings=["heads up"], + ) + + assert manifest.name == "my-target" + assert manifest.source_type == "agent" + assert manifest.agent == "ws1/chatbot" + assert manifest.port == 8000 + assert manifest.secrets == ["OPENAI_API_KEY"] + # _get_data_fields carries only domain fields (what the CLI persists via sdk.entities.create). + assert "benign_suite" in manifest._get_data_fields() + assert "name" not in manifest._get_data_fields() + + +# ── records.read_and_persist_suite ─────────────────────────────────────────── + + +def test_read_and_persist_suite_persists_when_manifest_and_rows( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + from nemo_iron_swarm_plugin.jobs import records + + csv = tmp_path / "requests.csv" + csv.write_text(_HEADER + "search,hello,benign,r,pe\n", encoding="utf-8") + calls: dict[str, Any] = {} + monkeypatch.setattr( + records, + "_persist_benign_suite", + lambda sdk, *, workspace, manifest_id, suite, interview=None: calls.update( + ws=workspace, mid=manifest_id, suite=suite, interview=interview + ), + ) + + out = records.read_and_persist_suite(object(), _ctx(tmp_path), "m1", csv, interview=[{"q": "a"}]) + + assert len(out) == 1 + assert calls == {"ws": "ws1", "mid": "m1", "suite": out, "interview": [{"q": "a"}]} + + +def test_read_and_persist_suite_skips_persist_without_manifest(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + from nemo_iron_swarm_plugin.jobs import records + + csv = tmp_path / "requests.csv" + csv.write_text(_HEADER + "search,hello,benign,r,pe\n", encoding="utf-8") + calls: list[Any] = [] + monkeypatch.setattr(records, "_persist_benign_suite", lambda *a, **k: calls.append(1)) + + out = records.read_and_persist_suite(object(), _ctx(tmp_path), None, csv) + + assert len(out) == 1 + assert calls == [] # no manifest_id → nothing cached + + +def test_read_and_persist_suite_skips_persist_for_empty_suite(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + from nemo_iron_swarm_plugin.jobs import records + + csv = tmp_path / "requests.csv" + csv.write_text(_HEADER, encoding="utf-8") # header only → no rows + calls: list[Any] = [] + monkeypatch.setattr(records, "_persist_benign_suite", lambda *a, **k: calls.append(1)) + + out = records.read_and_persist_suite(object(), _ctx(tmp_path), "m1", csv) + + assert out == [] + assert calls == [] + + +# ── execution.run_synth_benign ─────────────────────────────────────────────── + + +def _write_manifest(tmp_path: Path) -> Path: + manifest = tmp_path / "iron-swarm.yaml" + manifest.write_text("agent:\n name: a\n", encoding="utf-8") + return manifest + + +def _fake_run_iron_swarm_success(tmp_path: Path): + """A _run_iron_swarm stand-in that simulates synth-benign writing requests.csv under the pinned root.""" + + def _fake(cmd: list[str], env: dict, log_path: Path, ctx: Any, *, artifact_name: str): + target = tmp_path / "synth-storage" / "benign_profiles" / "target-x" + target.mkdir(parents=True, exist_ok=True) + (target / "requests.csv").write_text(_HEADER + "search,hi,benign,r,pe\n", encoding="utf-8") + return SimpleNamespace(returncode=0), "", None, None + + return _fake + + +@pytest.mark.parametrize( + ("interview", "expected_flag", "unexpected_flag"), + [("interactive", None, "--yes"), ("auto", "--yes", "--no-interactive"), ("skip", "--no-interactive", "--yes")], +) +def test_run_synth_benign_maps_interview_to_flags( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch, interview: str, expected_flag: str | None, unexpected_flag: str +) -> None: + from nemo_iron_swarm_plugin.jobs import execution + + manifest = _write_manifest(tmp_path) + captured: dict[str, Any] = {} + + def _capture(cmd: list[str], env: dict, log_path: Path, ctx: Any, *, artifact_name: str): + captured["cmd"] = cmd + return _fake_run_iron_swarm_success(tmp_path)(cmd, env, log_path, ctx, artifact_name=artifact_name) + + monkeypatch.setattr(execution, "_run_iron_swarm", _capture) + + csv_path = execution.run_synth_benign( + "iron-swarm-bin", str(manifest), None, {}, _ctx(tmp_path), interview=interview + ) + + assert csv_path.name == "requests.csv" + assert csv_path.exists() + assert captured["cmd"][:4] == ["iron-swarm-bin", "synth-benign", "--config", str(manifest)] + if expected_flag: + assert expected_flag in captured["cmd"] + assert unexpected_flag not in captured["cmd"] + # storage root is pinned into the manifest (under overrides, the only place iron-swarm's + # AgentManifest accepts it) so the output CSV is at a known path. + data = yaml.safe_load(manifest.read_text(encoding="utf-8")) + assert data["overrides"]["storage"]["root_dir"].endswith("synth-storage") + + +def test_run_synth_benign_passes_env_file(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + from nemo_iron_swarm_plugin.jobs import execution + + manifest = _write_manifest(tmp_path) + captured: dict[str, Any] = {} + + def _capture(cmd: list[str], *a: Any, **k: Any): + captured["cmd"] = cmd + return _fake_run_iron_swarm_success(tmp_path)(cmd, *a, **k) + + monkeypatch.setattr(execution, "_run_iron_swarm", _capture) + execution.run_synth_benign("bin", str(manifest), "/tmp/.env", {}, _ctx(tmp_path)) + + assert "--env-file" in captured["cmd"] + assert captured["cmd"][captured["cmd"].index("--env-file") + 1] == "/tmp/.env" + + +def test_run_synth_benign_raises_on_subprocess_failure(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + from nemo_iron_swarm_plugin.jobs import execution + from nemo_iron_swarm_plugin.jobs.errors import IronSwarmRunError + + manifest = _write_manifest(tmp_path) + failure = SimpleNamespace(category="sandbox", message="boom", remediation="retry") + monkeypatch.setattr( + execution, "_run_iron_swarm", lambda *a, **k: (SimpleNamespace(returncode=1), "log", None, failure) + ) + + with pytest.raises(IronSwarmRunError) as exc: + execution.run_synth_benign("bin", str(manifest), None, {}, _ctx(tmp_path)) + assert exc.value.category == "sandbox" + + +def test_run_synth_benign_raises_when_no_csv_written(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + from nemo_iron_swarm_plugin.jobs import execution + from nemo_iron_swarm_plugin.jobs.errors import IronSwarmRunError + + manifest = _write_manifest(tmp_path) + # Success exit but the run produced no requests.csv under the pinned root. + monkeypatch.setattr(execution, "_run_iron_swarm", lambda *a, **k: (SimpleNamespace(returncode=0), "", None, None)) + + with pytest.raises(IronSwarmRunError): + execution.run_synth_benign("bin", str(manifest), None, {}, _ctx(tmp_path)) + + +# ── IronSwarmSynthBenignJob._execute ───────────────────────────────────────── + + +def test_synth_benign_job_execute_happy_path(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + from nemo_iron_swarm_plugin.jobs import synth_benign as job_mod + + manifest = _write_manifest(tmp_path) + monkeypatch.setattr(job_mod._common, "require_provisioned", lambda _c: None) + monkeypatch.setattr(job_mod._common, "build_subprocess_env", lambda _c: {}) + monkeypatch.setattr(job_mod._common, "materialize_victim_env_file", lambda *a, **k: None) + monkeypatch.setattr(job_mod._common, "check_victim_secrets", lambda *a, **k: None) + monkeypatch.setattr(job_mod, "_materialize_manifest", lambda sdk, mid, ctx: str(manifest)) + monkeypatch.setattr(job_mod.IronSwarmConfig, "get", classmethod(lambda _cls: SimpleNamespace(iron_swarm_bin="bin"))) + seen: dict[str, Any] = {} + + def _fake_run_synth(bin_path, mani, env_file, env, ctx, *, interview): + seen["interview"] = interview + return tmp_path / "requests.csv" + + monkeypatch.setattr(job_mod, "run_synth_benign", _fake_run_synth) + monkeypatch.setattr(job_mod, "read_and_persist_suite", lambda sdk, ctx, mid, csv: [{"tool": "t"}, {"tool": "u"}]) + + job = job_mod.IronSwarmSynthBenignJob() + monkeypatch.setattr(job, "report_progress", lambda *a, **k: None) + out = job.run({"manifest_id": "m1", "interview": "auto"}, ctx=_ctx(tmp_path), sdk=object()) + + assert out == {"status": "completed", "returncode": 0, "manifest_id": "m1", "suite_size": 2} + assert seen["interview"] == "auto" + + +def test_synth_benign_job_classifies_failure(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + from nemo_iron_swarm_plugin.jobs import synth_benign as job_mod + from nemo_iron_swarm_plugin.jobs.errors import IronSwarmRunError + + monkeypatch.setattr(job_mod._common, "require_provisioned", lambda _c: None) + monkeypatch.setattr(job_mod.IronSwarmConfig, "get", classmethod(lambda _cls: SimpleNamespace(iron_swarm_bin="bin"))) + + def _boom(sdk, mid, ctx): + raise IronSwarmRunError("manifest", "no such manifest") + + monkeypatch.setattr(job_mod, "_materialize_manifest", _boom) + + job = job_mod.IronSwarmSynthBenignJob() + out = job.run({"manifest_id": "missing"}, ctx=_ctx(tmp_path), sdk=object()) + + assert out["status"] == "failed" + assert out["error"]["category"] == "manifest" + + +def test_synth_benign_job_service_driver_runs_serve_and_finalizes( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + """driver=service drives _run_service_driven(stop_after_synth=True) and finalizes the run record.""" + from nemo_iron_swarm_plugin.jobs import synth_benign as job_mod + from nemo_iron_swarm_plugin.jobs.execution import RunOutcome + + manifest = _write_manifest(tmp_path) + monkeypatch.setattr(job_mod._common, "require_provisioned", lambda _c: None) + monkeypatch.setattr(job_mod._common, "build_subprocess_env", lambda _c: {}) + monkeypatch.setattr(job_mod._common, "materialize_victim_env_file", lambda *a, **k: None) + monkeypatch.setattr(job_mod._common, "check_victim_secrets", lambda *a, **k: None) + monkeypatch.setattr(job_mod._common, "build_model_env", lambda *a, **k: {}) + monkeypatch.setattr(job_mod, "_materialize_manifest", lambda sdk, mid, ctx: str(manifest)) + monkeypatch.setattr(job_mod, "_effective_models", lambda sdk, config, ctx: None) + monkeypatch.setattr(job_mod, "_manifest_facts", lambda mani: ("agent-x", 8000)) + monkeypatch.setattr(job_mod, "_save_events_fileset", lambda *a, **k: "") + monkeypatch.setattr(job_mod.IronSwarmConfig, "get", classmethod(lambda _cls: SimpleNamespace(iron_swarm_bin="bin"))) + + seen: dict[str, Any] = {} + + def _fake_service(mani, env_file, plugin_config, ctx, sdk, agent, port, **kw): + seen.update( + agent=agent, port=port, stop_after_synth=kw.get("stop_after_synth"), manifest_id=kw.get("manifest_id") + ) + return RunOutcome("completed", 0, record_name="run-42") + + updated: dict[str, Any] = {} + monkeypatch.setattr(job_mod, "_run_service_driven", _fake_service) + monkeypatch.setattr( + job_mod, "_update_run", lambda sdk, *, workspace, name, data: updated.update(name=name, data=data) + ) + monkeypatch.setattr(job_mod, "_create_run", lambda *a, **k: pytest.fail("service path should update, not create")) + + job = job_mod.IronSwarmSynthBenignJob() + monkeypatch.setattr(job, "report_progress", lambda *a, **k: None) + out = job.run({"manifest_id": "m1", "driver": "service"}, ctx=_ctx(tmp_path), sdk=object()) + + assert out == {"status": "completed", "returncode": 0, "manifest_id": "m1", "run_record": "run-42"} + assert seen == {"agent": "agent-x", "port": 8000, "stop_after_synth": True, "manifest_id": "m1"} + assert updated["name"] == "run-42" # the running record is finalized to completed + + +def test_synth_benign_job_compile_builds_synth_task_step(monkeypatch: pytest.MonkeyPatch) -> None: + import asyncio + + from nemo_iron_swarm_plugin.jobs import synth_benign as job_mod + + spec = job_mod.SynthBenignSpec(manifest_id="m1", driver="service") + platform_spec = asyncio.run( + job_mod.IronSwarmSynthBenignJob.compile( + workspace="ws1", spec=spec, entity_client=object(), job_name="job-1", async_sdk=object() + ) + ) + + step = list(platform_spec["steps"])[0] # PlatformJobSpec is a TypedDict; steps is an Iterable + # Assert the provider first: it narrows the executor union to the subprocess variant. + assert step["executor"]["provider"] == "subprocess" + assert step["executor"]["command"] == ["python", "-m", "nemo_iron_swarm_plugin.tasks.synth_benign"] + assert step["config"]["manifest_id"] == "m1" + assert step["config"]["driver"] == "service" + + +# ── SDK routing ────────────────────────────────────────────────────────────── + + +class _CaptureScheduler: + captured: dict[str, Any] = {} + + def run_local(self, job: Any, spec: dict, **kwargs: Any) -> dict: + _CaptureScheduler.captured = {"job": job, "spec": spec, "kwargs": kwargs} + return {"status": "completed", "suite_size": 3} + + +def test_sdk_synth_benign_routes_to_job(monkeypatch: pytest.MonkeyPatch) -> None: + from nemo_iron_swarm_plugin import sdk as sdk_module + + monkeypatch.setattr(sdk_module, "NemoJobScheduler", _CaptureScheduler) + platform = make_sdk() + resource = sdk_module.IronSwarmPluginResource(platform) + + resource.synth_benign(manifest_id="m1", interview="auto", workspace="ws1") + + cap = _CaptureScheduler.captured + assert cap["job"] is sdk_module.IronSwarmSynthBenignJob + assert cap["spec"] == {"manifest_id": "m1", "env_file": None, "interview": "auto"} + assert cap["kwargs"]["workspace"] == "ws1" + assert cap["kwargs"]["sdk"] is platform + + +def test_sdk_run_puts_manifest_id_in_spec(monkeypatch: pytest.MonkeyPatch) -> None: + from nemo_iron_swarm_plugin import sdk as sdk_module + + monkeypatch.setattr(sdk_module, "NemoJobScheduler", _CaptureScheduler) + resource = sdk_module.IronSwarmPluginResource(make_sdk()) + + resource.run(manifest_id="m1", workspace="ws1") + + cap = _CaptureScheduler.captured + assert cap["spec"]["manifest_id"] == "m1" + assert cap["spec"]["config"] is None + + +def test_run_war_game_requires_config_or_manifest_id() -> None: + from nemo_iron_swarm_plugin import sdk as sdk_module + + with pytest.raises(ValueError, match="config.*manifest_id"): + sdk_module._run_war_game( + SimpleNamespace(), config=None, manifest_id=None, env_file=None, workspace="d", benign_suite=None + ) + + +# ── CLI wiring ─────────────────────────────────────────────────────────────── + + +def _patch_cli(monkeypatch: pytest.MonkeyPatch, captured: dict[str, Any]) -> Any: + from nemo_iron_swarm_plugin.cli import main as cli_main + + fake_iron = SimpleNamespace( + run=lambda **kw: captured.update(run=kw) or {"status": "completed"}, + synth_benign=lambda **kw: captured.update(synth=kw) or {"status": "completed", "suite_size": 4}, + ) + monkeypatch.setattr(cli_main.checks, "require_preflight", lambda _c: None) + monkeypatch.setattr(cli_main, "make_sdk", lambda _u: SimpleNamespace(iron_swarm=fake_iron)) + monkeypatch.setattr(cli_main, "base_url", lambda: "http://localhost:8080") + monkeypatch.setattr(cli_main, "missing_secrets", lambda _p, env_files: []) + monkeypatch.setattr( + cli_main.IronSwarmConfig, + "get", + classmethod(lambda _cls: SimpleNamespace(default_workspace="default", operator_env_file=Path(".env"))), + ) + return cli_main.IronSwarmCLI().get_cli() + + +def test_cli_synth_benign_defaults_to_interactive(monkeypatch: pytest.MonkeyPatch) -> None: + captured: dict[str, Any] = {} + app = _patch_cli(monkeypatch, captured) + + result = CliRunner().invoke(app, ["synth-benign", "--manifest-id", "m1"]) + + assert result.exit_code == 0, result.output + assert captured["synth"] == { + "manifest_id": "m1", + "env_file": None, + "interview": "interactive", + "workspace": "default", + } + + +@pytest.mark.parametrize(("flag", "mode"), [("--yes", "auto"), ("--no-interactive", "skip")]) +def test_cli_synth_benign_maps_flags(monkeypatch: pytest.MonkeyPatch, flag: str, mode: str) -> None: + captured: dict[str, Any] = {} + app = _patch_cli(monkeypatch, captured) + + result = CliRunner().invoke(app, ["synth-benign", "--manifest-id", "m1", flag]) + + assert result.exit_code == 0, result.output + assert captured["synth"]["interview"] == mode + + +def test_cli_synth_benign_rejects_conflicting_flags(monkeypatch: pytest.MonkeyPatch) -> None: + captured: dict[str, Any] = {} + app = _patch_cli(monkeypatch, captured) + + result = CliRunner().invoke(app, ["synth-benign", "--manifest-id", "m1", "--yes", "--no-interactive"]) + + assert result.exit_code == 1 + assert "synth" not in captured + + +def test_cli_run_forwards_manifest_id(monkeypatch: pytest.MonkeyPatch) -> None: + captured: dict[str, Any] = {} + app = _patch_cli(monkeypatch, captured) + + result = CliRunner().invoke(app, ["run", "--manifest-id", "m1"]) + + assert result.exit_code == 0, result.output + assert captured["run"]["manifest_id"] == "m1" + assert captured["run"]["config"] is None + + +def test_cli_run_rejects_config_and_manifest_id(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + captured: dict[str, Any] = {} + app = _patch_cli(monkeypatch, captured) + config = tmp_path / "iron-swarm.yaml" + config.write_text("agent: {}\n", encoding="utf-8") + + result = CliRunner().invoke(app, ["run", "--config", str(config), "--manifest-id", "m1"]) + + assert result.exit_code == 1 + assert "run" not in captured + + +def test_cli_init_persists_manifest_entity(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + from nemo_iron_swarm_plugin.cli import main as cli_main + + resolved = SimpleNamespace( + manifest={"agent": {"name": "chatbot"}}, + workflow_path="workflow.yaml", + project_dir=str(tmp_path), + workspace="default", + agent_name="chatbot", + port=8000, + secrets=["OPENAI_API_KEY"], + warnings=[], + ) + created: dict[str, Any] = {} + fake_entities = SimpleNamespace( + create=lambda entity_type, *, workspace, data, name: created.update( + entity_type=entity_type, workspace=workspace, data=data, name=name + ) + ) + monkeypatch.setattr(cli_main.checks, "require_preflight", lambda _c: None) + monkeypatch.setattr(cli_main, "make_sdk", lambda _u: SimpleNamespace(entities=fake_entities)) + monkeypatch.setattr(cli_main, "base_url", lambda: "http://localhost:8080") + monkeypatch.setattr(cli_main, "resolve_agent_to_manifest", lambda *a, **k: resolved) + monkeypatch.setattr( + cli_main.IronSwarmConfig, + "get", + classmethod(lambda _cls: SimpleNamespace(default_workspace="default", operator_env_file=Path(".env"))), + ) + + app = cli_main.IronSwarmCLI().get_cli() + result = CliRunner().invoke( + app, ["init", "--agent", "chatbot", "--name", "my-target", "-o", str(tmp_path / "iron-swarm.yaml")] + ) + + assert result.exit_code == 0, result.output + assert created["name"] == "my-target" + assert created["entity_type"] == "iron_swarm_manifest" + assert created["data"]["agent"] == "default/chatbot" + assert (tmp_path / "iron-swarm.yaml").exists() # local yaml still written diff --git a/plugins/nemo-iron-swarm/tests/unit/test_synth_hitl.py b/plugins/nemo-iron-swarm/tests/unit/test_synth_hitl.py new file mode 100644 index 0000000000..fe4acedab2 --- /dev/null +++ b/plugins/nemo-iron-swarm/tests/unit/test_synth_hitl.py @@ -0,0 +1,90 @@ +# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +"""Tests for the synth HTTP client and the status_details HITL bridge.""" + +from __future__ import annotations + +from types import SimpleNamespace +from typing import Any, cast + +import httpx +from nemo_iron_swarm_plugin.jobs import hitl +from nemo_iron_swarm_plugin.jobs.synth_client import SynthClient + + +def test_synth_client_maps_endpoints() -> None: + def handler(request: httpx.Request) -> httpx.Response: + path = request.url.path + bodies = { + "/healthz": {"status": "ok"}, + "/synth": {"thread_id": "t1", "status": "interview", "questions": [{"gap": "g"}]}, + "/synth/t1/answers": {"thread_id": "t1", "status": "review", "suite": [{"tool": "clock"}]}, + "/synth/t1/suite": {"thread_id": "t1", "status": "done", "benign_csv": "/x/requests.csv"}, + } + return httpx.Response(200, json=bodies[path]) if path in bodies else httpx.Response(404) + + with SynthClient("http://svc", transport=httpx.MockTransport(handler)) as client: + assert client.healthz() is True + assert client.start("m.yaml")["status"] == "interview" + assert client.answers("t1", [{"gap": "g", "answer": "a"}])["status"] == "review" + assert client.write_suite("t1", [{"tool": "clock", "payload": "p"}])["benign_csv"].endswith("requests.csv") + + +class _FakeSynthService: + """Duck-typed SynthClient: one interview round, then review, then done.""" + + def __init__(self) -> None: + self.answered: list[list[dict[str, Any]]] = [] + self.written: list[dict[str, Any]] = [] + + def start(self, _config: str, *, validator: str | None = None) -> dict[str, Any]: + return {"thread_id": "t", "status": "interview", "questions": [{"gap": "g1"}]} + + def answers(self, _thread_id: str, answers: list[dict[str, Any]]) -> dict[str, Any]: + self.answered.append(answers) + return {"thread_id": "t", "status": "review", "suite": [{"tool": "clock", "payload": "time?"}]} + + def write_suite(self, _thread_id: str, suite: list[dict[str, Any]]) -> dict[str, Any]: + self.written = suite + return {"thread_id": "t", "status": "done", "benign_csv": "/x/requests.csv"} + + +def test_drive_synth_hitl_relays_interview_then_review() -> None: + published: list[tuple[str, dict[str, Any]]] = [] + responses = {"interview": [{"gap": "g1", "answer": "a"}], "review": [{"tool": "clock", "payload": "edited"}]} + service = _FakeSynthService() + + path = hitl.drive_synth_hitl( + cast(SynthClient, service), + "m.yaml", + lambda kind, payload: published.append((kind, payload)), + lambda kind: responses[kind], + ) + + assert [kind for kind, _ in published] == ["interview", "review"] + assert service.answered == [[{"gap": "g1", "answer": "a"}]] + assert service.written == [{"tool": "clock", "payload": "edited"}] + assert path.endswith("requests.csv") + + +def test_status_details_channel_publishes_and_matches_round() -> None: + published: dict[str, Any] = {} + + class _Jobs: + def update_status_details(self, _name: str, *, workspace: str, body: dict[str, Any]) -> None: + published.update(body) + + def retrieve(self, _name: str, *, workspace: str) -> Any: + return SimpleNamespace( + status_details={"interview_response": {"round": 1, "answers": [{"gap": "g", "answer": "a"}]}} + ) + + channel = hitl.StatusDetailsChannel( + SimpleNamespace(jobs=_Jobs()), name="job1", workspace="default", poll_interval=0.0 + ) + channel.publish("interview", {"questions": [{"gap": "g"}]}) + assert published["interview"]["round"] == 1 + assert channel.await_response("interview") == [{"gap": "g", "answer": "a"}] + # Interview answers are accumulated so the run can persist the Q&A on the manifest for display. + assert channel.interview == [{"gap": "g", "answer": "a"}] From 1f65219058bfd463c71333d0ee96ba589cff2305 Mon Sep 17 00:00:00 2001 From: Koral Chapnik Verbun Date: Mon, 27 Jul 2026 21:17:05 +0300 Subject: [PATCH 13/55] wire iron-swarm into the platform and Studio Registers the plugin as a workspace member, adds the generated SDK client, and adds the Studio Iron Swarm surface (run list/detail with the swarm graph, manifest CRUD, harden flow) behind VITE_FF_IRON_SWARM_ENABLED. Signed-off-by: Koral Chapnik Verbun --- pyproject.toml | 4 + .../studio/src/nmp/studio/env_mappings.py | 5 + uv.lock | 40 + web/packages/sdk/generated/iron-swarm/api.ts | 2313 ++++++++++++++++- web/packages/sdk/orval/constants.ts | 7 + web/packages/sdk/package.json | 2 + web/packages/studio/package.json | 1 + web/packages/studio/src/api/ironSwarm.ts | 77 + .../IronSwarmManifestsDataView/index.tsx | 170 ++ .../dataViews/IronSwarmRunsDataView/index.tsx | 213 ++ .../ironSwarm/BenignInterviewCard.tsx | 31 + .../ironSwarm/BenignSuiteEditor.tsx | 101 + .../components/ironSwarm/BenignSuiteTable.tsx | 174 ++ .../src/components/ironSwarm/HardenPanel.tsx | 434 ++++ .../components/ironSwarm/InterviewPanel.tsx | 124 + .../components/ironSwarm/ModelGroupFields.tsx | 289 ++ .../ironSwarm/ProjectManifestWizard.tsx | 262 ++ .../components/ironSwarm/ReconChecklist.tsx | 40 + .../src/components/ironSwarm/ReviewPanel.tsx | 41 + .../ironSwarm/SanityCheckReport.tsx | 130 + .../src/components/ironSwarm/YamlDiff.tsx | 60 + .../src/components/ironSwarm/eventTypes.ts | 98 + .../src/components/ironSwarm/hitlTypes.ts | 64 + .../ironSwarm/swarm/MessageFeed.tsx | 205 ++ .../components/ironSwarm/swarm/NodeDetail.tsx | 215 ++ .../components/ironSwarm/swarm/SwarmGraph.tsx | 283 ++ .../ironSwarm/swarm/swarmModel.test.ts | 135 + .../components/ironSwarm/swarm/swarmModel.ts | 376 +++ .../ironSwarm/swarm/useSwarmEvents.ts | 28 +- .../ironSwarm/useGenerateBenignSuite.ts | 131 + .../ironSwarm/useMitigations.test.ts | 63 + .../components/ironSwarm/useMitigations.ts | 230 ++ .../src/components/ironSwarm/useRunWarGame.ts | 42 + .../components/ironSwarm/useSanityCheck.ts | 176 ++ .../studio/src/constants/environment.ts | 1 + .../constants/featureFlags/featureFlags.ts | 1 + web/packages/studio/src/constants/routes.ts | 7 + .../IronSwarmManifestDetailRoute/index.tsx | 711 +++++ .../IronSwarmManifestListRoute/index.tsx | 40 + .../routes/IronSwarmRunDetailsRoute/index.tsx | 269 ++ .../routes/IronSwarmRunListRoute/index.tsx | 35 + .../NewIronSwarmManifestRoute/index.tsx | 266 ++ .../WorkspaceLayout/WorkspaceSideNav.tsx | 35 +- .../studio/src/routes/groups/index.ts | 1 + .../src/routes/groups/ironSwarmRoutes.tsx | 73 + web/packages/studio/src/routes/index.tsx | 2 + web/packages/studio/src/routes/utils.ts | 30 + .../studio/src/tests/title-change.test.tsx | 2 + web/pnpm-lock.yaml | 105 +- 49 files changed, 8022 insertions(+), 120 deletions(-) create mode 100644 web/packages/studio/src/api/ironSwarm.ts create mode 100644 web/packages/studio/src/components/dataViews/IronSwarmManifestsDataView/index.tsx create mode 100644 web/packages/studio/src/components/dataViews/IronSwarmRunsDataView/index.tsx create mode 100644 web/packages/studio/src/components/ironSwarm/BenignInterviewCard.tsx create mode 100644 web/packages/studio/src/components/ironSwarm/BenignSuiteEditor.tsx create mode 100644 web/packages/studio/src/components/ironSwarm/BenignSuiteTable.tsx create mode 100644 web/packages/studio/src/components/ironSwarm/HardenPanel.tsx create mode 100644 web/packages/studio/src/components/ironSwarm/InterviewPanel.tsx create mode 100644 web/packages/studio/src/components/ironSwarm/ModelGroupFields.tsx create mode 100644 web/packages/studio/src/components/ironSwarm/ProjectManifestWizard.tsx create mode 100644 web/packages/studio/src/components/ironSwarm/ReconChecklist.tsx create mode 100644 web/packages/studio/src/components/ironSwarm/ReviewPanel.tsx create mode 100644 web/packages/studio/src/components/ironSwarm/SanityCheckReport.tsx create mode 100644 web/packages/studio/src/components/ironSwarm/YamlDiff.tsx create mode 100644 web/packages/studio/src/components/ironSwarm/eventTypes.ts create mode 100644 web/packages/studio/src/components/ironSwarm/hitlTypes.ts create mode 100644 web/packages/studio/src/components/ironSwarm/swarm/MessageFeed.tsx create mode 100644 web/packages/studio/src/components/ironSwarm/swarm/NodeDetail.tsx create mode 100644 web/packages/studio/src/components/ironSwarm/swarm/SwarmGraph.tsx create mode 100644 web/packages/studio/src/components/ironSwarm/swarm/swarmModel.test.ts create mode 100644 web/packages/studio/src/components/ironSwarm/swarm/swarmModel.ts create mode 100644 web/packages/studio/src/components/ironSwarm/useGenerateBenignSuite.ts create mode 100644 web/packages/studio/src/components/ironSwarm/useMitigations.test.ts create mode 100644 web/packages/studio/src/components/ironSwarm/useMitigations.ts create mode 100644 web/packages/studio/src/components/ironSwarm/useRunWarGame.ts create mode 100644 web/packages/studio/src/components/ironSwarm/useSanityCheck.ts create mode 100644 web/packages/studio/src/routes/IronSwarmManifestDetailRoute/index.tsx create mode 100644 web/packages/studio/src/routes/IronSwarmManifestListRoute/index.tsx create mode 100644 web/packages/studio/src/routes/IronSwarmRunDetailsRoute/index.tsx create mode 100644 web/packages/studio/src/routes/IronSwarmRunListRoute/index.tsx create mode 100644 web/packages/studio/src/routes/NewIronSwarmManifestRoute/index.tsx create mode 100644 web/packages/studio/src/routes/groups/ironSwarmRoutes.tsx diff --git a/pyproject.toml b/pyproject.toml index 84452d8779..5c3221c614 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -352,6 +352,7 @@ override-dependencies = [ [tool.uv.sources] nmp-auth = { workspace = true } nemo-anonymizer-plugin = { workspace = true } +nemo-iron-swarm-plugin = { workspace = true } nemo-data-designer-plugin = { workspace = true } data-designer-nemo = { workspace = true } nemo-evaluator-sdk = { workspace = true } @@ -444,6 +445,7 @@ members = [ "services/guardrails", "services/intake", "plugins/nemo-anonymizer", + "plugins/nemo-iron-swarm", "plugins/nemo-data-designer", "services/core/auth", "services/platform-seed", @@ -598,6 +600,8 @@ extra-paths = [ "plugins/nemo-guardrails/src", # So ``integration.*`` and ``from .utils import`` resolve in plugin tests (PEP 420). "plugins/nemo-guardrails/tests", + # So ``_doubles`` (the iron-swarm plugin's shared test stand-ins) resolves in its tests. + "plugins/nemo-iron-swarm/tests/unit", # nemo-evaluator plugin is not a workspace member; add its src so ty can # resolve nemo_evaluator imports when checking plugin test files. "plugins/nemo-evaluator/src", diff --git a/services/studio/src/nmp/studio/env_mappings.py b/services/studio/src/nmp/studio/env_mappings.py index f450152fd4..d4ea551a32 100644 --- a/services/studio/src/nmp/studio/env_mappings.py +++ b/services/studio/src/nmp/studio/env_mappings.py @@ -94,6 +94,11 @@ class EnvMapping: config_path="studio.feature_flags.datasets_enabled", default="true", ), + EnvMapping( + marker="STUDIO_UI_VITE_FF_IRON_SWARM_ENABLED", + config_path="studio.feature_flags.iron_swarm_enabled", + default="false", + ), EnvMapping( marker="STUDIO_UI_VITE_FF_DEPLOYMENTS_ENABLED", config_path="studio.feature_flags.deployments_enabled", diff --git a/uv.lock b/uv.lock index 69fd80b99c..580042cc6c 100644 --- a/uv.lock +++ b/uv.lock @@ -39,6 +39,7 @@ members = [ "nemo-experimentalist-plugin", "nemo-guardrails-plugin", "nemo-insights-plugin", + "nemo-iron-swarm-plugin", "nemo-nb", "nemo-optimization-plugin", "nemo-platform", @@ -4633,6 +4634,45 @@ requires-dist = [ { name = "tzdata", specifier = "==2026.2" }, ] +[[package]] +name = "nemo-iron-swarm-plugin" +version = "0.1.0" +source = { editable = "plugins/nemo-iron-swarm" } +dependencies = [ + { name = "httpx", marker = "(platform_machine == 'arm64' and sys_platform == 'darwin') or (platform_machine == 'aarch64' and sys_platform == 'linux') or (platform_machine == 'x86_64' and sys_platform == 'linux')" }, + { name = "nemo-agents-plugin", marker = "(platform_machine == 'arm64' and sys_platform == 'darwin') or (platform_machine == 'aarch64' and sys_platform == 'linux') or (platform_machine == 'x86_64' and sys_platform == 'linux')" }, + { name = "nemo-platform", marker = "(platform_machine == 'arm64' and sys_platform == 'darwin') or (platform_machine == 'aarch64' and sys_platform == 'linux') or (platform_machine == 'x86_64' and sys_platform == 'linux')" }, + { name = "nemo-platform-plugin", marker = "(platform_machine == 'arm64' and sys_platform == 'darwin') or (platform_machine == 'aarch64' and sys_platform == 'linux') or (platform_machine == 'x86_64' and sys_platform == 'linux')" }, + { name = "pydantic", extra = ["email"], marker = "(platform_machine == 'arm64' and sys_platform == 'darwin') or (platform_machine == 'aarch64' and sys_platform == 'linux') or (platform_machine == 'x86_64' and sys_platform == 'linux')" }, + { name = "pyyaml", marker = "(platform_machine == 'arm64' and sys_platform == 'darwin') or (platform_machine == 'aarch64' and sys_platform == 'linux') or (platform_machine == 'x86_64' and sys_platform == 'linux')" }, + { name = "typer", marker = "(platform_machine == 'arm64' and sys_platform == 'darwin') or (platform_machine == 'aarch64' and sys_platform == 'linux') or (platform_machine == 'x86_64' and sys_platform == 'linux')" }, +] + +[package.dev-dependencies] +dev = [ + { name = "pytest", marker = "(platform_machine == 'arm64' and sys_platform == 'darwin') or (platform_machine == 'aarch64' and sys_platform == 'linux') or (platform_machine == 'x86_64' and sys_platform == 'linux')" }, + { name = "pytest-asyncio", marker = "(platform_machine == 'arm64' and sys_platform == 'darwin') or (platform_machine == 'aarch64' and sys_platform == 'linux') or (platform_machine == 'x86_64' and sys_platform == 'linux')" }, + { name = "ruff", marker = "(platform_machine == 'arm64' and sys_platform == 'darwin') or (platform_machine == 'aarch64' and sys_platform == 'linux') or (platform_machine == 'x86_64' and sys_platform == 'linux')" }, +] + +[package.metadata] +requires-dist = [ + { name = "httpx", specifier = ">=0.27" }, + { name = "nemo-agents-plugin", editable = "plugins/nemo-agents" }, + { name = "nemo-platform", editable = "packages/nemo_platform" }, + { name = "nemo-platform-plugin", editable = "packages/nemo_platform_plugin" }, + { name = "pydantic", specifier = ">=2.10.6" }, + { name = "pyyaml", specifier = ">=6.0.2" }, + { name = "typer", specifier = ">=0.20.0" }, +] + +[package.metadata.requires-dev] +dev = [ + { name = "pytest", specifier = ">=8.3.4" }, + { name = "pytest-asyncio", specifier = ">=0.25.3" }, + { name = "ruff", specifier = ">=0.11.8" }, +] + [[package]] name = "nemo-nb" version = "0.0.0" diff --git a/web/packages/sdk/generated/iron-swarm/api.ts b/web/packages/sdk/generated/iron-swarm/api.ts index 3613aaf688..c2c8b6babe 100644 --- a/web/packages/sdk/generated/iron-swarm/api.ts +++ b/web/packages/sdk/generated/iron-swarm/api.ts @@ -30,18 +30,22 @@ import type { ComposeDefenseRequest, ComposeDefenseResponse, EventIn, + EventsResponse, HTTPValidationError, HealthzApisIronSwarmV1HealthzGet200, InspectAgentRequest, InspectAgentResponse, InspectProjectRequest, InspectProjectResponse, + IronSwarmGetEventsParams, IronSwarmGetJobLogsParams, + IronSwarmGetSynthBenignJobLogsParams, IronSwarmListJobsParams, IronSwarmListManifests200, IronSwarmListManifestsParams, IronSwarmListRuns200, IronSwarmListRunsParams, + IronSwarmListSynthBenignJobsParams, IronSwarmManifest, IronSwarmRun, ManifestInit, @@ -51,6 +55,9 @@ import type { PlatformJobLogPage, PlatformJobResultResponse, PlatformJobStatusResponse, + SynthBenignJob, + SynthBenignJobRequest, + SynthBenignJobsPage, ValidateModelRequest, ValidateModelResponse, WarGameJob, @@ -60,16 +67,6 @@ import type { import { customFetch } from '../fetchers/iron-swarm.ts'; import type { ErrorType } from '../fetchers/iron-swarm.ts'; - -export interface IronSwarmGetRunEventsParams { - after?: number; - [key: string]: unknown; -} - -export interface EventsResponse { - events: Record[]; -} - const withQueryKey = (query: T, queryKey: K): T & { queryKey: K } => { const result = { queryKey } as T & { queryKey: K }; for (const key of Object.keys(query)) { @@ -4215,12 +4212,15 @@ export const useIronSwarmIngestEvent = < /** * Return all persisted run events with sequence id greater than *after*. + * + * Falls back to downloading from the run's ``events_fileset`` when the local + * file is absent (e.g. after a pod restart). * @summary Get Events */ -export const ironSwarmGetRunEvents = ( +export const ironSwarmGetEvents = ( workspace: string, name: string, - params?: IronSwarmGetRunEventsParams, + params?: IronSwarmGetEventsParams, signal?: AbortSignal ) => { return customFetch({ @@ -4231,10 +4231,10 @@ export const ironSwarmGetRunEvents = ( }); }; -export const getIronSwarmGetRunEventsQueryKey = ( +export const getIronSwarmGetEventsQueryKey = ( workspace: string, name: string, - params?: IronSwarmGetRunEventsParams + params?: IronSwarmGetEventsParams ) => { return [ `/apis/iron-swarm/v2/workspaces/${workspace}/runs/${name}/events`, @@ -4242,97 +4242,90 @@ export const getIronSwarmGetRunEventsQueryKey = ( ] as const; }; -export const getIronSwarmGetRunEventsQueryOptions = < - TData = Awaited>, +export const getIronSwarmGetEventsQueryOptions = < + TData = Awaited>, TError = ErrorType, >( workspace: string, name: string, - params?: IronSwarmGetRunEventsParams, + params?: IronSwarmGetEventsParams, options?: { - query?: Partial< - UseQueryOptions>, TError, TData> - >; + query?: Partial>, TError, TData>>; } ) => { const { query: queryOptions } = options ?? {}; - const queryKey = - queryOptions?.queryKey ?? getIronSwarmGetRunEventsQueryKey(workspace, name, params); + const queryKey = queryOptions?.queryKey ?? getIronSwarmGetEventsQueryKey(workspace, name, params); - const queryFn: QueryFunction>> = ({ signal }) => - ironSwarmGetRunEvents(workspace, name, params, signal); + const queryFn: QueryFunction>> = ({ signal }) => + ironSwarmGetEvents(workspace, name, params, signal); return { queryKey, queryFn, enabled: workspace !== null && workspace !== undefined && name !== null && name !== undefined, ...queryOptions, - } as UseQueryOptions>, TError, TData> & { + } as UseQueryOptions>, TError, TData> & { queryKey: DataTag; }; }; -export type IronSwarmGetRunEventsQueryResult = NonNullable< - Awaited> +export type IronSwarmGetEventsQueryResult = NonNullable< + Awaited> >; -export type IronSwarmGetRunEventsQueryError = ErrorType; +export type IronSwarmGetEventsQueryError = ErrorType; -export function useIronSwarmGetRunEvents< - TData = Awaited>, +export function useIronSwarmGetEvents< + TData = Awaited>, TError = ErrorType, >( workspace: string, name: string, - params: undefined | IronSwarmGetRunEventsParams, + params: undefined | IronSwarmGetEventsParams, options: { - query: Partial< - UseQueryOptions>, TError, TData> - > & + query: Partial>, TError, TData>> & Pick< DefinedInitialDataOptions< - Awaited>, + Awaited>, TError, - Awaited> + Awaited> >, 'initialData' >; }, queryClient?: QueryClient ): DefinedUseQueryResult & { queryKey: DataTag }; -export function useIronSwarmGetRunEvents< - TData = Awaited>, +export function useIronSwarmGetEvents< + TData = Awaited>, TError = ErrorType, >( workspace: string, name: string, - params?: IronSwarmGetRunEventsParams, + params?: IronSwarmGetEventsParams, options?: { query?: Partial< - UseQueryOptions>, TError, TData> + UseQueryOptions>, TError, TData> > & Pick< UndefinedInitialDataOptions< - Awaited>, + Awaited>, TError, - Awaited> + Awaited> >, 'initialData' >; }, queryClient?: QueryClient ): UseQueryResult & { queryKey: DataTag }; -export function useIronSwarmGetRunEvents< - TData = Awaited>, +export function useIronSwarmGetEvents< + TData = Awaited>, TError = ErrorType, >( workspace: string, name: string, - params?: IronSwarmGetRunEventsParams, + params?: IronSwarmGetEventsParams, options?: { - query?: Partial< - UseQueryOptions>, TError, TData> - >; + query?: Partial>, TError, TData>>; }, queryClient?: QueryClient ): UseQueryResult & { queryKey: DataTag }; @@ -4340,21 +4333,19 @@ export function useIronSwarmGetRunEvents< * @summary Get Events */ -export function useIronSwarmGetRunEvents< - TData = Awaited>, +export function useIronSwarmGetEvents< + TData = Awaited>, TError = ErrorType, >( workspace: string, name: string, - params?: IronSwarmGetRunEventsParams, + params?: IronSwarmGetEventsParams, options?: { - query?: Partial< - UseQueryOptions>, TError, TData> - >; + query?: Partial>, TError, TData>>; }, queryClient?: QueryClient ): UseQueryResult & { queryKey: DataTag } { - const queryOptions = getIronSwarmGetRunEventsQueryOptions(workspace, name, params, options); + const queryOptions = getIronSwarmGetEventsQueryOptions(workspace, name, params, options); const query = useQuery(queryOptions, queryClient) as UseQueryResult & { queryKey: DataTag; @@ -4363,77 +4354,76 @@ export function useIronSwarmGetRunEvents< return withQueryKey(query, queryOptions.queryKey); } -export const getIronSwarmGetRunEventsSuspenseQueryOptions = < - TData = Awaited>, +export const getIronSwarmGetEventsSuspenseQueryOptions = < + TData = Awaited>, TError = ErrorType, >( workspace: string, name: string, - params?: IronSwarmGetRunEventsParams, + params?: IronSwarmGetEventsParams, options?: { query?: Partial< - UseSuspenseQueryOptions>, TError, TData> + UseSuspenseQueryOptions>, TError, TData> >; } ) => { const { query: queryOptions } = options ?? {}; - const queryKey = - queryOptions?.queryKey ?? getIronSwarmGetRunEventsQueryKey(workspace, name, params); + const queryKey = queryOptions?.queryKey ?? getIronSwarmGetEventsQueryKey(workspace, name, params); - const queryFn: QueryFunction>> = ({ signal }) => - ironSwarmGetRunEvents(workspace, name, params, signal); + const queryFn: QueryFunction>> = ({ signal }) => + ironSwarmGetEvents(workspace, name, params, signal); return { queryKey, queryFn, ...queryOptions } as UseSuspenseQueryOptions< - Awaited>, + Awaited>, TError, TData > & { queryKey: DataTag }; }; -export type IronSwarmGetRunEventsSuspenseQueryResult = NonNullable< - Awaited> +export type IronSwarmGetEventsSuspenseQueryResult = NonNullable< + Awaited> >; -export type IronSwarmGetRunEventsSuspenseQueryError = ErrorType; +export type IronSwarmGetEventsSuspenseQueryError = ErrorType; -export function useIronSwarmGetRunEventsSuspense< - TData = Awaited>, +export function useIronSwarmGetEventsSuspense< + TData = Awaited>, TError = ErrorType, >( workspace: string, name: string, - params: undefined | IronSwarmGetRunEventsParams, + params: undefined | IronSwarmGetEventsParams, options: { query: Partial< - UseSuspenseQueryOptions>, TError, TData> + UseSuspenseQueryOptions>, TError, TData> >; }, queryClient?: QueryClient ): UseSuspenseQueryResult & { queryKey: DataTag }; -export function useIronSwarmGetRunEventsSuspense< - TData = Awaited>, +export function useIronSwarmGetEventsSuspense< + TData = Awaited>, TError = ErrorType, >( workspace: string, name: string, - params?: IronSwarmGetRunEventsParams, + params?: IronSwarmGetEventsParams, options?: { query?: Partial< - UseSuspenseQueryOptions>, TError, TData> + UseSuspenseQueryOptions>, TError, TData> >; }, queryClient?: QueryClient ): UseSuspenseQueryResult & { queryKey: DataTag }; -export function useIronSwarmGetRunEventsSuspense< - TData = Awaited>, +export function useIronSwarmGetEventsSuspense< + TData = Awaited>, TError = ErrorType, >( workspace: string, name: string, - params?: IronSwarmGetRunEventsParams, + params?: IronSwarmGetEventsParams, options?: { query?: Partial< - UseSuspenseQueryOptions>, TError, TData> + UseSuspenseQueryOptions>, TError, TData> >; }, queryClient?: QueryClient @@ -4442,24 +4432,2175 @@ export function useIronSwarmGetRunEventsSuspense< * @summary Get Events */ -export function useIronSwarmGetRunEventsSuspense< - TData = Awaited>, +export function useIronSwarmGetEventsSuspense< + TData = Awaited>, TError = ErrorType, >( workspace: string, name: string, - params?: IronSwarmGetRunEventsParams, + params?: IronSwarmGetEventsParams, options?: { query?: Partial< - UseSuspenseQueryOptions>, TError, TData> + UseSuspenseQueryOptions>, TError, TData> >; }, queryClient?: QueryClient ): UseSuspenseQueryResult & { queryKey: DataTag } { - const queryOptions = getIronSwarmGetRunEventsSuspenseQueryOptions( - workspace, - name, + const queryOptions = getIronSwarmGetEventsSuspenseQueryOptions(workspace, name, params, options); + + const query = useSuspenseQuery(queryOptions, queryClient) as UseSuspenseQueryResult< + TData, + TError + > & { queryKey: DataTag }; + + return withQueryKey(query, queryOptions.queryKey); +} + +/** + * @summary Create Job + */ +export const ironSwarmCreateSynthBenignJob = ( + workspace: string, + synthBenignJobRequest: SynthBenignJobRequest, + signal?: AbortSignal +) => { + return customFetch({ + url: `/apis/iron-swarm/v2/workspaces/${encodeURIComponent(String(workspace))}/synth-benign/jobs`, + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + data: synthBenignJobRequest, + signal, + }); +}; + +export const getIronSwarmCreateSynthBenignJobMutationOptions = < + TError = ErrorType, + TContext = unknown, +>(options?: { + mutation?: UseMutationOptions< + Awaited>, + TError, + { workspace: string; data: SynthBenignJobRequest }, + TContext + >; +}): UseMutationOptions< + Awaited>, + TError, + { workspace: string; data: SynthBenignJobRequest }, + TContext +> => { + const mutationKey = ['ironSwarmCreateSynthBenignJob']; + const { mutation: mutationOptions } = options + ? options.mutation && 'mutationKey' in options.mutation && options.mutation.mutationKey + ? options + : { ...options, mutation: { ...options.mutation, mutationKey } } + : { mutation: { mutationKey } }; + + const mutationFn: MutationFunction< + Awaited>, + { workspace: string; data: SynthBenignJobRequest } + > = (props) => { + const { workspace, data } = props ?? {}; + + return ironSwarmCreateSynthBenignJob(workspace, data); + }; + + return { mutationFn, ...mutationOptions }; +}; + +export type IronSwarmCreateSynthBenignJobMutationResult = NonNullable< + Awaited> +>; +export type IronSwarmCreateSynthBenignJobMutationBody = SynthBenignJobRequest; +export type IronSwarmCreateSynthBenignJobMutationError = ErrorType; + +/** + * @summary Create Job + */ +export const useIronSwarmCreateSynthBenignJob = < + TError = ErrorType, + TContext = unknown, +>( + options?: { + mutation?: UseMutationOptions< + Awaited>, + TError, + { workspace: string; data: SynthBenignJobRequest }, + TContext + >; + }, + queryClient?: QueryClient +): UseMutationResult< + Awaited>, + TError, + { workspace: string; data: SynthBenignJobRequest }, + TContext +> => { + return useMutation(getIronSwarmCreateSynthBenignJobMutationOptions(options), queryClient); +}; + +/** + * @summary List Jobs + */ +export const ironSwarmListSynthBenignJobs = ( + workspace: string, + params?: IronSwarmListSynthBenignJobsParams, + signal?: AbortSignal +) => { + return customFetch({ + url: `/apis/iron-swarm/v2/workspaces/${encodeURIComponent(String(workspace))}/synth-benign/jobs`, + method: 'GET', params, + signal, + }); +}; + +export const getIronSwarmListSynthBenignJobsQueryKey = ( + workspace: string, + params?: IronSwarmListSynthBenignJobsParams +) => { + return [ + `/apis/iron-swarm/v2/workspaces/${workspace}/synth-benign/jobs`, + ...(params ? [params] : []), + ] as const; +}; + +export const getIronSwarmListSynthBenignJobsQueryOptions = < + TData = Awaited>, + TError = ErrorType, +>( + workspace: string, + params?: IronSwarmListSynthBenignJobsParams, + options?: { + query?: Partial< + UseQueryOptions>, TError, TData> + >; + } +) => { + const { query: queryOptions } = options ?? {}; + + const queryKey = + queryOptions?.queryKey ?? getIronSwarmListSynthBenignJobsQueryKey(workspace, params); + + const queryFn: QueryFunction>> = ({ + signal, + }) => ironSwarmListSynthBenignJobs(workspace, params, signal); + + return { + queryKey, + queryFn, + enabled: workspace !== null && workspace !== undefined, + ...queryOptions, + } as UseQueryOptions>, TError, TData> & { + queryKey: DataTag; + }; +}; + +export type IronSwarmListSynthBenignJobsQueryResult = NonNullable< + Awaited> +>; +export type IronSwarmListSynthBenignJobsQueryError = ErrorType; + +export function useIronSwarmListSynthBenignJobs< + TData = Awaited>, + TError = ErrorType, +>( + workspace: string, + params: undefined | IronSwarmListSynthBenignJobsParams, + options: { + query: Partial< + UseQueryOptions>, TError, TData> + > & + Pick< + DefinedInitialDataOptions< + Awaited>, + TError, + Awaited> + >, + 'initialData' + >; + }, + queryClient?: QueryClient +): DefinedUseQueryResult & { queryKey: DataTag }; +export function useIronSwarmListSynthBenignJobs< + TData = Awaited>, + TError = ErrorType, +>( + workspace: string, + params?: IronSwarmListSynthBenignJobsParams, + options?: { + query?: Partial< + UseQueryOptions>, TError, TData> + > & + Pick< + UndefinedInitialDataOptions< + Awaited>, + TError, + Awaited> + >, + 'initialData' + >; + }, + queryClient?: QueryClient +): UseQueryResult & { queryKey: DataTag }; +export function useIronSwarmListSynthBenignJobs< + TData = Awaited>, + TError = ErrorType, +>( + workspace: string, + params?: IronSwarmListSynthBenignJobsParams, + options?: { + query?: Partial< + UseQueryOptions>, TError, TData> + >; + }, + queryClient?: QueryClient +): UseQueryResult & { queryKey: DataTag }; +/** + * @summary List Jobs + */ + +export function useIronSwarmListSynthBenignJobs< + TData = Awaited>, + TError = ErrorType, +>( + workspace: string, + params?: IronSwarmListSynthBenignJobsParams, + options?: { + query?: Partial< + UseQueryOptions>, TError, TData> + >; + }, + queryClient?: QueryClient +): UseQueryResult & { queryKey: DataTag } { + const queryOptions = getIronSwarmListSynthBenignJobsQueryOptions(workspace, params, options); + + const query = useQuery(queryOptions, queryClient) as UseQueryResult & { + queryKey: DataTag; + }; + + return withQueryKey(query, queryOptions.queryKey); +} + +export const getIronSwarmListSynthBenignJobsSuspenseQueryOptions = < + TData = Awaited>, + TError = ErrorType, +>( + workspace: string, + params?: IronSwarmListSynthBenignJobsParams, + options?: { + query?: Partial< + UseSuspenseQueryOptions< + Awaited>, + TError, + TData + > + >; + } +) => { + const { query: queryOptions } = options ?? {}; + + const queryKey = + queryOptions?.queryKey ?? getIronSwarmListSynthBenignJobsQueryKey(workspace, params); + + const queryFn: QueryFunction>> = ({ + signal, + }) => ironSwarmListSynthBenignJobs(workspace, params, signal); + + return { queryKey, queryFn, ...queryOptions } as UseSuspenseQueryOptions< + Awaited>, + TError, + TData + > & { queryKey: DataTag }; +}; + +export type IronSwarmListSynthBenignJobsSuspenseQueryResult = NonNullable< + Awaited> +>; +export type IronSwarmListSynthBenignJobsSuspenseQueryError = ErrorType; + +export function useIronSwarmListSynthBenignJobsSuspense< + TData = Awaited>, + TError = ErrorType, +>( + workspace: string, + params: undefined | IronSwarmListSynthBenignJobsParams, + options: { + query: Partial< + UseSuspenseQueryOptions< + Awaited>, + TError, + TData + > + >; + }, + queryClient?: QueryClient +): UseSuspenseQueryResult & { queryKey: DataTag }; +export function useIronSwarmListSynthBenignJobsSuspense< + TData = Awaited>, + TError = ErrorType, +>( + workspace: string, + params?: IronSwarmListSynthBenignJobsParams, + options?: { + query?: Partial< + UseSuspenseQueryOptions< + Awaited>, + TError, + TData + > + >; + }, + queryClient?: QueryClient +): UseSuspenseQueryResult & { queryKey: DataTag }; +export function useIronSwarmListSynthBenignJobsSuspense< + TData = Awaited>, + TError = ErrorType, +>( + workspace: string, + params?: IronSwarmListSynthBenignJobsParams, + options?: { + query?: Partial< + UseSuspenseQueryOptions< + Awaited>, + TError, + TData + > + >; + }, + queryClient?: QueryClient +): UseSuspenseQueryResult & { queryKey: DataTag }; +/** + * @summary List Jobs + */ + +export function useIronSwarmListSynthBenignJobsSuspense< + TData = Awaited>, + TError = ErrorType, +>( + workspace: string, + params?: IronSwarmListSynthBenignJobsParams, + options?: { + query?: Partial< + UseSuspenseQueryOptions< + Awaited>, + TError, + TData + > + >; + }, + queryClient?: QueryClient +): UseSuspenseQueryResult & { queryKey: DataTag } { + const queryOptions = getIronSwarmListSynthBenignJobsSuspenseQueryOptions( + workspace, + params, + options + ); + + const query = useSuspenseQuery(queryOptions, queryClient) as UseSuspenseQueryResult< + TData, + TError + > & { queryKey: DataTag }; + + return withQueryKey(query, queryOptions.queryKey); +} + +/** + * @summary Get Job Result + */ +export const ironSwarmGetSynthBenignJobResult = ( + workspace: string, + job: string, + name: string, + signal?: AbortSignal +) => { + return customFetch({ + url: `/apis/iron-swarm/v2/workspaces/${encodeURIComponent(String(workspace))}/synth-benign/jobs/${encodeURIComponent(String(job))}/results/${encodeURIComponent(String(name))}`, + method: 'GET', + signal, + }); +}; + +export const getIronSwarmGetSynthBenignJobResultQueryKey = ( + workspace: string, + job: string, + name: string +) => { + return [ + `/apis/iron-swarm/v2/workspaces/${workspace}/synth-benign/jobs/${job}/results/${name}`, + ] as const; +}; + +export const getIronSwarmGetSynthBenignJobResultQueryOptions = < + TData = Awaited>, + TError = ErrorType, +>( + workspace: string, + job: string, + name: string, + options?: { + query?: Partial< + UseQueryOptions>, TError, TData> + >; + } +) => { + const { query: queryOptions } = options ?? {}; + + const queryKey = + queryOptions?.queryKey ?? getIronSwarmGetSynthBenignJobResultQueryKey(workspace, job, name); + + const queryFn: QueryFunction>> = ({ + signal, + }) => ironSwarmGetSynthBenignJobResult(workspace, job, name, signal); + + return { + queryKey, + queryFn, + enabled: + workspace !== null && + workspace !== undefined && + job !== null && + job !== undefined && + name !== null && + name !== undefined, + ...queryOptions, + } as UseQueryOptions< + Awaited>, + TError, + TData + > & { queryKey: DataTag }; +}; + +export type IronSwarmGetSynthBenignJobResultQueryResult = NonNullable< + Awaited> +>; +export type IronSwarmGetSynthBenignJobResultQueryError = ErrorType; + +export function useIronSwarmGetSynthBenignJobResult< + TData = Awaited>, + TError = ErrorType, +>( + workspace: string, + job: string, + name: string, + options: { + query: Partial< + UseQueryOptions>, TError, TData> + > & + Pick< + DefinedInitialDataOptions< + Awaited>, + TError, + Awaited> + >, + 'initialData' + >; + }, + queryClient?: QueryClient +): DefinedUseQueryResult & { queryKey: DataTag }; +export function useIronSwarmGetSynthBenignJobResult< + TData = Awaited>, + TError = ErrorType, +>( + workspace: string, + job: string, + name: string, + options?: { + query?: Partial< + UseQueryOptions>, TError, TData> + > & + Pick< + UndefinedInitialDataOptions< + Awaited>, + TError, + Awaited> + >, + 'initialData' + >; + }, + queryClient?: QueryClient +): UseQueryResult & { queryKey: DataTag }; +export function useIronSwarmGetSynthBenignJobResult< + TData = Awaited>, + TError = ErrorType, +>( + workspace: string, + job: string, + name: string, + options?: { + query?: Partial< + UseQueryOptions>, TError, TData> + >; + }, + queryClient?: QueryClient +): UseQueryResult & { queryKey: DataTag }; +/** + * @summary Get Job Result + */ + +export function useIronSwarmGetSynthBenignJobResult< + TData = Awaited>, + TError = ErrorType, +>( + workspace: string, + job: string, + name: string, + options?: { + query?: Partial< + UseQueryOptions>, TError, TData> + >; + }, + queryClient?: QueryClient +): UseQueryResult & { queryKey: DataTag } { + const queryOptions = getIronSwarmGetSynthBenignJobResultQueryOptions( + workspace, + job, + name, + options + ); + + const query = useQuery(queryOptions, queryClient) as UseQueryResult & { + queryKey: DataTag; + }; + + return withQueryKey(query, queryOptions.queryKey); +} + +export const getIronSwarmGetSynthBenignJobResultSuspenseQueryOptions = < + TData = Awaited>, + TError = ErrorType, +>( + workspace: string, + job: string, + name: string, + options?: { + query?: Partial< + UseSuspenseQueryOptions< + Awaited>, + TError, + TData + > + >; + } +) => { + const { query: queryOptions } = options ?? {}; + + const queryKey = + queryOptions?.queryKey ?? getIronSwarmGetSynthBenignJobResultQueryKey(workspace, job, name); + + const queryFn: QueryFunction>> = ({ + signal, + }) => ironSwarmGetSynthBenignJobResult(workspace, job, name, signal); + + return { queryKey, queryFn, ...queryOptions } as UseSuspenseQueryOptions< + Awaited>, + TError, + TData + > & { queryKey: DataTag }; +}; + +export type IronSwarmGetSynthBenignJobResultSuspenseQueryResult = NonNullable< + Awaited> +>; +export type IronSwarmGetSynthBenignJobResultSuspenseQueryError = ErrorType; + +export function useIronSwarmGetSynthBenignJobResultSuspense< + TData = Awaited>, + TError = ErrorType, +>( + workspace: string, + job: string, + name: string, + options: { + query: Partial< + UseSuspenseQueryOptions< + Awaited>, + TError, + TData + > + >; + }, + queryClient?: QueryClient +): UseSuspenseQueryResult & { queryKey: DataTag }; +export function useIronSwarmGetSynthBenignJobResultSuspense< + TData = Awaited>, + TError = ErrorType, +>( + workspace: string, + job: string, + name: string, + options?: { + query?: Partial< + UseSuspenseQueryOptions< + Awaited>, + TError, + TData + > + >; + }, + queryClient?: QueryClient +): UseSuspenseQueryResult & { queryKey: DataTag }; +export function useIronSwarmGetSynthBenignJobResultSuspense< + TData = Awaited>, + TError = ErrorType, +>( + workspace: string, + job: string, + name: string, + options?: { + query?: Partial< + UseSuspenseQueryOptions< + Awaited>, + TError, + TData + > + >; + }, + queryClient?: QueryClient +): UseSuspenseQueryResult & { queryKey: DataTag }; +/** + * @summary Get Job Result + */ + +export function useIronSwarmGetSynthBenignJobResultSuspense< + TData = Awaited>, + TError = ErrorType, +>( + workspace: string, + job: string, + name: string, + options?: { + query?: Partial< + UseSuspenseQueryOptions< + Awaited>, + TError, + TData + > + >; + }, + queryClient?: QueryClient +): UseSuspenseQueryResult & { queryKey: DataTag } { + const queryOptions = getIronSwarmGetSynthBenignJobResultSuspenseQueryOptions( + workspace, + job, + name, + options + ); + + const query = useSuspenseQuery(queryOptions, queryClient) as UseSuspenseQueryResult< + TData, + TError + > & { queryKey: DataTag }; + + return withQueryKey(query, queryOptions.queryKey); +} + +/** + * @summary Download Job Result + */ +export const ironSwarmDownloadSynthBenignJobResult = ( + workspace: string, + job: string, + name: string, + signal?: AbortSignal +) => { + return customFetch({ + url: `/apis/iron-swarm/v2/workspaces/${encodeURIComponent(String(workspace))}/synth-benign/jobs/${encodeURIComponent(String(job))}/results/${encodeURIComponent(String(name))}/download`, + method: 'GET', + responseType: 'blob', + signal, + }); +}; + +export const getIronSwarmDownloadSynthBenignJobResultQueryKey = ( + workspace: string, + job: string, + name: string +) => { + return [ + `/apis/iron-swarm/v2/workspaces/${workspace}/synth-benign/jobs/${job}/results/${name}/download`, + ] as const; +}; + +export const getIronSwarmDownloadSynthBenignJobResultQueryOptions = < + TData = Awaited>, + TError = ErrorType, +>( + workspace: string, + job: string, + name: string, + options?: { + query?: Partial< + UseQueryOptions< + Awaited>, + TError, + TData + > + >; + } +) => { + const { query: queryOptions } = options ?? {}; + + const queryKey = + queryOptions?.queryKey ?? + getIronSwarmDownloadSynthBenignJobResultQueryKey(workspace, job, name); + + const queryFn: QueryFunction< + Awaited> + > = ({ signal }) => ironSwarmDownloadSynthBenignJobResult(workspace, job, name, signal); + + return { + queryKey, + queryFn, + enabled: + workspace !== null && + workspace !== undefined && + job !== null && + job !== undefined && + name !== null && + name !== undefined, + ...queryOptions, + } as UseQueryOptions< + Awaited>, + TError, + TData + > & { queryKey: DataTag }; +}; + +export type IronSwarmDownloadSynthBenignJobResultQueryResult = NonNullable< + Awaited> +>; +export type IronSwarmDownloadSynthBenignJobResultQueryError = ErrorType; + +export function useIronSwarmDownloadSynthBenignJobResult< + TData = Awaited>, + TError = ErrorType, +>( + workspace: string, + job: string, + name: string, + options: { + query: Partial< + UseQueryOptions< + Awaited>, + TError, + TData + > + > & + Pick< + DefinedInitialDataOptions< + Awaited>, + TError, + Awaited> + >, + 'initialData' + >; + }, + queryClient?: QueryClient +): DefinedUseQueryResult & { queryKey: DataTag }; +export function useIronSwarmDownloadSynthBenignJobResult< + TData = Awaited>, + TError = ErrorType, +>( + workspace: string, + job: string, + name: string, + options?: { + query?: Partial< + UseQueryOptions< + Awaited>, + TError, + TData + > + > & + Pick< + UndefinedInitialDataOptions< + Awaited>, + TError, + Awaited> + >, + 'initialData' + >; + }, + queryClient?: QueryClient +): UseQueryResult & { queryKey: DataTag }; +export function useIronSwarmDownloadSynthBenignJobResult< + TData = Awaited>, + TError = ErrorType, +>( + workspace: string, + job: string, + name: string, + options?: { + query?: Partial< + UseQueryOptions< + Awaited>, + TError, + TData + > + >; + }, + queryClient?: QueryClient +): UseQueryResult & { queryKey: DataTag }; +/** + * @summary Download Job Result + */ + +export function useIronSwarmDownloadSynthBenignJobResult< + TData = Awaited>, + TError = ErrorType, +>( + workspace: string, + job: string, + name: string, + options?: { + query?: Partial< + UseQueryOptions< + Awaited>, + TError, + TData + > + >; + }, + queryClient?: QueryClient +): UseQueryResult & { queryKey: DataTag } { + const queryOptions = getIronSwarmDownloadSynthBenignJobResultQueryOptions( + workspace, + job, + name, + options + ); + + const query = useQuery(queryOptions, queryClient) as UseQueryResult & { + queryKey: DataTag; + }; + + return withQueryKey(query, queryOptions.queryKey); +} + +export const getIronSwarmDownloadSynthBenignJobResultSuspenseQueryOptions = < + TData = Awaited>, + TError = ErrorType, +>( + workspace: string, + job: string, + name: string, + options?: { + query?: Partial< + UseSuspenseQueryOptions< + Awaited>, + TError, + TData + > + >; + } +) => { + const { query: queryOptions } = options ?? {}; + + const queryKey = + queryOptions?.queryKey ?? + getIronSwarmDownloadSynthBenignJobResultQueryKey(workspace, job, name); + + const queryFn: QueryFunction< + Awaited> + > = ({ signal }) => ironSwarmDownloadSynthBenignJobResult(workspace, job, name, signal); + + return { queryKey, queryFn, ...queryOptions } as UseSuspenseQueryOptions< + Awaited>, + TError, + TData + > & { queryKey: DataTag }; +}; + +export type IronSwarmDownloadSynthBenignJobResultSuspenseQueryResult = NonNullable< + Awaited> +>; +export type IronSwarmDownloadSynthBenignJobResultSuspenseQueryError = + ErrorType; + +export function useIronSwarmDownloadSynthBenignJobResultSuspense< + TData = Awaited>, + TError = ErrorType, +>( + workspace: string, + job: string, + name: string, + options: { + query: Partial< + UseSuspenseQueryOptions< + Awaited>, + TError, + TData + > + >; + }, + queryClient?: QueryClient +): UseSuspenseQueryResult & { queryKey: DataTag }; +export function useIronSwarmDownloadSynthBenignJobResultSuspense< + TData = Awaited>, + TError = ErrorType, +>( + workspace: string, + job: string, + name: string, + options?: { + query?: Partial< + UseSuspenseQueryOptions< + Awaited>, + TError, + TData + > + >; + }, + queryClient?: QueryClient +): UseSuspenseQueryResult & { queryKey: DataTag }; +export function useIronSwarmDownloadSynthBenignJobResultSuspense< + TData = Awaited>, + TError = ErrorType, +>( + workspace: string, + job: string, + name: string, + options?: { + query?: Partial< + UseSuspenseQueryOptions< + Awaited>, + TError, + TData + > + >; + }, + queryClient?: QueryClient +): UseSuspenseQueryResult & { queryKey: DataTag }; +/** + * @summary Download Job Result + */ + +export function useIronSwarmDownloadSynthBenignJobResultSuspense< + TData = Awaited>, + TError = ErrorType, +>( + workspace: string, + job: string, + name: string, + options?: { + query?: Partial< + UseSuspenseQueryOptions< + Awaited>, + TError, + TData + > + >; + }, + queryClient?: QueryClient +): UseSuspenseQueryResult & { queryKey: DataTag } { + const queryOptions = getIronSwarmDownloadSynthBenignJobResultSuspenseQueryOptions( + workspace, + job, + name, + options + ); + + const query = useSuspenseQuery(queryOptions, queryClient) as UseSuspenseQueryResult< + TData, + TError + > & { queryKey: DataTag }; + + return withQueryKey(query, queryOptions.queryKey); +} + +/** + * @summary Get Job + */ +export const ironSwarmGetSynthBenignJob = ( + workspace: string, + name: string, + signal?: AbortSignal +) => { + return customFetch({ + url: `/apis/iron-swarm/v2/workspaces/${encodeURIComponent(String(workspace))}/synth-benign/jobs/${encodeURIComponent(String(name))}`, + method: 'GET', + signal, + }); +}; + +export const getIronSwarmGetSynthBenignJobQueryKey = (workspace: string, name: string) => { + return [`/apis/iron-swarm/v2/workspaces/${workspace}/synth-benign/jobs/${name}`] as const; +}; + +export const getIronSwarmGetSynthBenignJobQueryOptions = < + TData = Awaited>, + TError = ErrorType, +>( + workspace: string, + name: string, + options?: { + query?: Partial< + UseQueryOptions>, TError, TData> + >; + } +) => { + const { query: queryOptions } = options ?? {}; + + const queryKey = queryOptions?.queryKey ?? getIronSwarmGetSynthBenignJobQueryKey(workspace, name); + + const queryFn: QueryFunction>> = ({ + signal, + }) => ironSwarmGetSynthBenignJob(workspace, name, signal); + + return { + queryKey, + queryFn, + enabled: workspace !== null && workspace !== undefined && name !== null && name !== undefined, + ...queryOptions, + } as UseQueryOptions>, TError, TData> & { + queryKey: DataTag; + }; +}; + +export type IronSwarmGetSynthBenignJobQueryResult = NonNullable< + Awaited> +>; +export type IronSwarmGetSynthBenignJobQueryError = ErrorType; + +export function useIronSwarmGetSynthBenignJob< + TData = Awaited>, + TError = ErrorType, +>( + workspace: string, + name: string, + options: { + query: Partial< + UseQueryOptions>, TError, TData> + > & + Pick< + DefinedInitialDataOptions< + Awaited>, + TError, + Awaited> + >, + 'initialData' + >; + }, + queryClient?: QueryClient +): DefinedUseQueryResult & { queryKey: DataTag }; +export function useIronSwarmGetSynthBenignJob< + TData = Awaited>, + TError = ErrorType, +>( + workspace: string, + name: string, + options?: { + query?: Partial< + UseQueryOptions>, TError, TData> + > & + Pick< + UndefinedInitialDataOptions< + Awaited>, + TError, + Awaited> + >, + 'initialData' + >; + }, + queryClient?: QueryClient +): UseQueryResult & { queryKey: DataTag }; +export function useIronSwarmGetSynthBenignJob< + TData = Awaited>, + TError = ErrorType, +>( + workspace: string, + name: string, + options?: { + query?: Partial< + UseQueryOptions>, TError, TData> + >; + }, + queryClient?: QueryClient +): UseQueryResult & { queryKey: DataTag }; +/** + * @summary Get Job + */ + +export function useIronSwarmGetSynthBenignJob< + TData = Awaited>, + TError = ErrorType, +>( + workspace: string, + name: string, + options?: { + query?: Partial< + UseQueryOptions>, TError, TData> + >; + }, + queryClient?: QueryClient +): UseQueryResult & { queryKey: DataTag } { + const queryOptions = getIronSwarmGetSynthBenignJobQueryOptions(workspace, name, options); + + const query = useQuery(queryOptions, queryClient) as UseQueryResult & { + queryKey: DataTag; + }; + + return withQueryKey(query, queryOptions.queryKey); +} + +export const getIronSwarmGetSynthBenignJobSuspenseQueryOptions = < + TData = Awaited>, + TError = ErrorType, +>( + workspace: string, + name: string, + options?: { + query?: Partial< + UseSuspenseQueryOptions>, TError, TData> + >; + } +) => { + const { query: queryOptions } = options ?? {}; + + const queryKey = queryOptions?.queryKey ?? getIronSwarmGetSynthBenignJobQueryKey(workspace, name); + + const queryFn: QueryFunction>> = ({ + signal, + }) => ironSwarmGetSynthBenignJob(workspace, name, signal); + + return { queryKey, queryFn, ...queryOptions } as UseSuspenseQueryOptions< + Awaited>, + TError, + TData + > & { queryKey: DataTag }; +}; + +export type IronSwarmGetSynthBenignJobSuspenseQueryResult = NonNullable< + Awaited> +>; +export type IronSwarmGetSynthBenignJobSuspenseQueryError = ErrorType; + +export function useIronSwarmGetSynthBenignJobSuspense< + TData = Awaited>, + TError = ErrorType, +>( + workspace: string, + name: string, + options: { + query: Partial< + UseSuspenseQueryOptions>, TError, TData> + >; + }, + queryClient?: QueryClient +): UseSuspenseQueryResult & { queryKey: DataTag }; +export function useIronSwarmGetSynthBenignJobSuspense< + TData = Awaited>, + TError = ErrorType, +>( + workspace: string, + name: string, + options?: { + query?: Partial< + UseSuspenseQueryOptions>, TError, TData> + >; + }, + queryClient?: QueryClient +): UseSuspenseQueryResult & { queryKey: DataTag }; +export function useIronSwarmGetSynthBenignJobSuspense< + TData = Awaited>, + TError = ErrorType, +>( + workspace: string, + name: string, + options?: { + query?: Partial< + UseSuspenseQueryOptions>, TError, TData> + >; + }, + queryClient?: QueryClient +): UseSuspenseQueryResult & { queryKey: DataTag }; +/** + * @summary Get Job + */ + +export function useIronSwarmGetSynthBenignJobSuspense< + TData = Awaited>, + TError = ErrorType, +>( + workspace: string, + name: string, + options?: { + query?: Partial< + UseSuspenseQueryOptions>, TError, TData> + >; + }, + queryClient?: QueryClient +): UseSuspenseQueryResult & { queryKey: DataTag } { + const queryOptions = getIronSwarmGetSynthBenignJobSuspenseQueryOptions(workspace, name, options); + + const query = useSuspenseQuery(queryOptions, queryClient) as UseSuspenseQueryResult< + TData, + TError + > & { queryKey: DataTag }; + + return withQueryKey(query, queryOptions.queryKey); +} + +/** + * @summary Delete Job + */ +export const ironSwarmDeleteSynthBenignJob = ( + workspace: string, + name: string, + signal?: AbortSignal +) => { + return customFetch({ + url: `/apis/iron-swarm/v2/workspaces/${encodeURIComponent(String(workspace))}/synth-benign/jobs/${encodeURIComponent(String(name))}`, + method: 'DELETE', + signal, + }); +}; + +export const getIronSwarmDeleteSynthBenignJobMutationOptions = < + TError = ErrorType, + TContext = unknown, +>(options?: { + mutation?: UseMutationOptions< + Awaited>, + TError, + { workspace: string; name: string }, + TContext + >; +}): UseMutationOptions< + Awaited>, + TError, + { workspace: string; name: string }, + TContext +> => { + const mutationKey = ['ironSwarmDeleteSynthBenignJob']; + const { mutation: mutationOptions } = options + ? options.mutation && 'mutationKey' in options.mutation && options.mutation.mutationKey + ? options + : { ...options, mutation: { ...options.mutation, mutationKey } } + : { mutation: { mutationKey } }; + + const mutationFn: MutationFunction< + Awaited>, + { workspace: string; name: string } + > = (props) => { + const { workspace, name } = props ?? {}; + + return ironSwarmDeleteSynthBenignJob(workspace, name); + }; + + return { mutationFn, ...mutationOptions }; +}; + +export type IronSwarmDeleteSynthBenignJobMutationResult = NonNullable< + Awaited> +>; + +export type IronSwarmDeleteSynthBenignJobMutationError = ErrorType; + +/** + * @summary Delete Job + */ +export const useIronSwarmDeleteSynthBenignJob = < + TError = ErrorType, + TContext = unknown, +>( + options?: { + mutation?: UseMutationOptions< + Awaited>, + TError, + { workspace: string; name: string }, + TContext + >; + }, + queryClient?: QueryClient +): UseMutationResult< + Awaited>, + TError, + { workspace: string; name: string }, + TContext +> => { + return useMutation(getIronSwarmDeleteSynthBenignJobMutationOptions(options), queryClient); +}; + +/** + * @summary Cancel Job + */ +export const ironSwarmCancelSynthBenignJob = ( + workspace: string, + name: string, + signal?: AbortSignal +) => { + return customFetch({ + url: `/apis/iron-swarm/v2/workspaces/${encodeURIComponent(String(workspace))}/synth-benign/jobs/${encodeURIComponent(String(name))}/cancel`, + method: 'POST', + signal, + }); +}; + +export const getIronSwarmCancelSynthBenignJobMutationOptions = < + TError = ErrorType, + TContext = unknown, +>(options?: { + mutation?: UseMutationOptions< + Awaited>, + TError, + { workspace: string; name: string }, + TContext + >; +}): UseMutationOptions< + Awaited>, + TError, + { workspace: string; name: string }, + TContext +> => { + const mutationKey = ['ironSwarmCancelSynthBenignJob']; + const { mutation: mutationOptions } = options + ? options.mutation && 'mutationKey' in options.mutation && options.mutation.mutationKey + ? options + : { ...options, mutation: { ...options.mutation, mutationKey } } + : { mutation: { mutationKey } }; + + const mutationFn: MutationFunction< + Awaited>, + { workspace: string; name: string } + > = (props) => { + const { workspace, name } = props ?? {}; + + return ironSwarmCancelSynthBenignJob(workspace, name); + }; + + return { mutationFn, ...mutationOptions }; +}; + +export type IronSwarmCancelSynthBenignJobMutationResult = NonNullable< + Awaited> +>; + +export type IronSwarmCancelSynthBenignJobMutationError = ErrorType; + +/** + * @summary Cancel Job + */ +export const useIronSwarmCancelSynthBenignJob = < + TError = ErrorType, + TContext = unknown, +>( + options?: { + mutation?: UseMutationOptions< + Awaited>, + TError, + { workspace: string; name: string }, + TContext + >; + }, + queryClient?: QueryClient +): UseMutationResult< + Awaited>, + TError, + { workspace: string; name: string }, + TContext +> => { + return useMutation(getIronSwarmCancelSynthBenignJobMutationOptions(options), queryClient); +}; + +/** + * @summary Get Job Logs + */ +export const ironSwarmGetSynthBenignJobLogs = ( + workspace: string, + name: string, + params?: IronSwarmGetSynthBenignJobLogsParams, + signal?: AbortSignal +) => { + return customFetch({ + url: `/apis/iron-swarm/v2/workspaces/${encodeURIComponent(String(workspace))}/synth-benign/jobs/${encodeURIComponent(String(name))}/logs`, + method: 'GET', + params, + signal, + }); +}; + +export const getIronSwarmGetSynthBenignJobLogsQueryKey = ( + workspace: string, + name: string, + params?: IronSwarmGetSynthBenignJobLogsParams +) => { + return [ + `/apis/iron-swarm/v2/workspaces/${workspace}/synth-benign/jobs/${name}/logs`, + ...(params ? [params] : []), + ] as const; +}; + +export const getIronSwarmGetSynthBenignJobLogsQueryOptions = < + TData = Awaited>, + TError = ErrorType, +>( + workspace: string, + name: string, + params?: IronSwarmGetSynthBenignJobLogsParams, + options?: { + query?: Partial< + UseQueryOptions>, TError, TData> + >; + } +) => { + const { query: queryOptions } = options ?? {}; + + const queryKey = + queryOptions?.queryKey ?? getIronSwarmGetSynthBenignJobLogsQueryKey(workspace, name, params); + + const queryFn: QueryFunction>> = ({ + signal, + }) => ironSwarmGetSynthBenignJobLogs(workspace, name, params, signal); + + return { + queryKey, + queryFn, + enabled: workspace !== null && workspace !== undefined && name !== null && name !== undefined, + ...queryOptions, + } as UseQueryOptions< + Awaited>, + TError, + TData + > & { queryKey: DataTag }; +}; + +export type IronSwarmGetSynthBenignJobLogsQueryResult = NonNullable< + Awaited> +>; +export type IronSwarmGetSynthBenignJobLogsQueryError = ErrorType; + +export function useIronSwarmGetSynthBenignJobLogs< + TData = Awaited>, + TError = ErrorType, +>( + workspace: string, + name: string, + params: undefined | IronSwarmGetSynthBenignJobLogsParams, + options: { + query: Partial< + UseQueryOptions>, TError, TData> + > & + Pick< + DefinedInitialDataOptions< + Awaited>, + TError, + Awaited> + >, + 'initialData' + >; + }, + queryClient?: QueryClient +): DefinedUseQueryResult & { queryKey: DataTag }; +export function useIronSwarmGetSynthBenignJobLogs< + TData = Awaited>, + TError = ErrorType, +>( + workspace: string, + name: string, + params?: IronSwarmGetSynthBenignJobLogsParams, + options?: { + query?: Partial< + UseQueryOptions>, TError, TData> + > & + Pick< + UndefinedInitialDataOptions< + Awaited>, + TError, + Awaited> + >, + 'initialData' + >; + }, + queryClient?: QueryClient +): UseQueryResult & { queryKey: DataTag }; +export function useIronSwarmGetSynthBenignJobLogs< + TData = Awaited>, + TError = ErrorType, +>( + workspace: string, + name: string, + params?: IronSwarmGetSynthBenignJobLogsParams, + options?: { + query?: Partial< + UseQueryOptions>, TError, TData> + >; + }, + queryClient?: QueryClient +): UseQueryResult & { queryKey: DataTag }; +/** + * @summary Get Job Logs + */ + +export function useIronSwarmGetSynthBenignJobLogs< + TData = Awaited>, + TError = ErrorType, +>( + workspace: string, + name: string, + params?: IronSwarmGetSynthBenignJobLogsParams, + options?: { + query?: Partial< + UseQueryOptions>, TError, TData> + >; + }, + queryClient?: QueryClient +): UseQueryResult & { queryKey: DataTag } { + const queryOptions = getIronSwarmGetSynthBenignJobLogsQueryOptions( + workspace, + name, + params, + options + ); + + const query = useQuery(queryOptions, queryClient) as UseQueryResult & { + queryKey: DataTag; + }; + + return withQueryKey(query, queryOptions.queryKey); +} + +export const getIronSwarmGetSynthBenignJobLogsSuspenseQueryOptions = < + TData = Awaited>, + TError = ErrorType, +>( + workspace: string, + name: string, + params?: IronSwarmGetSynthBenignJobLogsParams, + options?: { + query?: Partial< + UseSuspenseQueryOptions< + Awaited>, + TError, + TData + > + >; + } +) => { + const { query: queryOptions } = options ?? {}; + + const queryKey = + queryOptions?.queryKey ?? getIronSwarmGetSynthBenignJobLogsQueryKey(workspace, name, params); + + const queryFn: QueryFunction>> = ({ + signal, + }) => ironSwarmGetSynthBenignJobLogs(workspace, name, params, signal); + + return { queryKey, queryFn, ...queryOptions } as UseSuspenseQueryOptions< + Awaited>, + TError, + TData + > & { queryKey: DataTag }; +}; + +export type IronSwarmGetSynthBenignJobLogsSuspenseQueryResult = NonNullable< + Awaited> +>; +export type IronSwarmGetSynthBenignJobLogsSuspenseQueryError = ErrorType; + +export function useIronSwarmGetSynthBenignJobLogsSuspense< + TData = Awaited>, + TError = ErrorType, +>( + workspace: string, + name: string, + params: undefined | IronSwarmGetSynthBenignJobLogsParams, + options: { + query: Partial< + UseSuspenseQueryOptions< + Awaited>, + TError, + TData + > + >; + }, + queryClient?: QueryClient +): UseSuspenseQueryResult & { queryKey: DataTag }; +export function useIronSwarmGetSynthBenignJobLogsSuspense< + TData = Awaited>, + TError = ErrorType, +>( + workspace: string, + name: string, + params?: IronSwarmGetSynthBenignJobLogsParams, + options?: { + query?: Partial< + UseSuspenseQueryOptions< + Awaited>, + TError, + TData + > + >; + }, + queryClient?: QueryClient +): UseSuspenseQueryResult & { queryKey: DataTag }; +export function useIronSwarmGetSynthBenignJobLogsSuspense< + TData = Awaited>, + TError = ErrorType, +>( + workspace: string, + name: string, + params?: IronSwarmGetSynthBenignJobLogsParams, + options?: { + query?: Partial< + UseSuspenseQueryOptions< + Awaited>, + TError, + TData + > + >; + }, + queryClient?: QueryClient +): UseSuspenseQueryResult & { queryKey: DataTag }; +/** + * @summary Get Job Logs + */ + +export function useIronSwarmGetSynthBenignJobLogsSuspense< + TData = Awaited>, + TError = ErrorType, +>( + workspace: string, + name: string, + params?: IronSwarmGetSynthBenignJobLogsParams, + options?: { + query?: Partial< + UseSuspenseQueryOptions< + Awaited>, + TError, + TData + > + >; + }, + queryClient?: QueryClient +): UseSuspenseQueryResult & { queryKey: DataTag } { + const queryOptions = getIronSwarmGetSynthBenignJobLogsSuspenseQueryOptions( + workspace, + name, + params, + options + ); + + const query = useSuspenseQuery(queryOptions, queryClient) as UseSuspenseQueryResult< + TData, + TError + > & { queryKey: DataTag }; + + return withQueryKey(query, queryOptions.queryKey); +} + +/** + * @summary List Job Results + */ +export const ironSwarmListSynthBenignJobResults = ( + workspace: string, + name: string, + signal?: AbortSignal +) => { + return customFetch({ + url: `/apis/iron-swarm/v2/workspaces/${encodeURIComponent(String(workspace))}/synth-benign/jobs/${encodeURIComponent(String(name))}/results`, + method: 'GET', + signal, + }); +}; + +export const getIronSwarmListSynthBenignJobResultsQueryKey = (workspace: string, name: string) => { + return [`/apis/iron-swarm/v2/workspaces/${workspace}/synth-benign/jobs/${name}/results`] as const; +}; + +export const getIronSwarmListSynthBenignJobResultsQueryOptions = < + TData = Awaited>, + TError = ErrorType, +>( + workspace: string, + name: string, + options?: { + query?: Partial< + UseQueryOptions>, TError, TData> + >; + } +) => { + const { query: queryOptions } = options ?? {}; + + const queryKey = + queryOptions?.queryKey ?? getIronSwarmListSynthBenignJobResultsQueryKey(workspace, name); + + const queryFn: QueryFunction>> = ({ + signal, + }) => ironSwarmListSynthBenignJobResults(workspace, name, signal); + + return { + queryKey, + queryFn, + enabled: workspace !== null && workspace !== undefined && name !== null && name !== undefined, + ...queryOptions, + } as UseQueryOptions< + Awaited>, + TError, + TData + > & { queryKey: DataTag }; +}; + +export type IronSwarmListSynthBenignJobResultsQueryResult = NonNullable< + Awaited> +>; +export type IronSwarmListSynthBenignJobResultsQueryError = ErrorType; + +export function useIronSwarmListSynthBenignJobResults< + TData = Awaited>, + TError = ErrorType, +>( + workspace: string, + name: string, + options: { + query: Partial< + UseQueryOptions>, TError, TData> + > & + Pick< + DefinedInitialDataOptions< + Awaited>, + TError, + Awaited> + >, + 'initialData' + >; + }, + queryClient?: QueryClient +): DefinedUseQueryResult & { queryKey: DataTag }; +export function useIronSwarmListSynthBenignJobResults< + TData = Awaited>, + TError = ErrorType, +>( + workspace: string, + name: string, + options?: { + query?: Partial< + UseQueryOptions>, TError, TData> + > & + Pick< + UndefinedInitialDataOptions< + Awaited>, + TError, + Awaited> + >, + 'initialData' + >; + }, + queryClient?: QueryClient +): UseQueryResult & { queryKey: DataTag }; +export function useIronSwarmListSynthBenignJobResults< + TData = Awaited>, + TError = ErrorType, +>( + workspace: string, + name: string, + options?: { + query?: Partial< + UseQueryOptions>, TError, TData> + >; + }, + queryClient?: QueryClient +): UseQueryResult & { queryKey: DataTag }; +/** + * @summary List Job Results + */ + +export function useIronSwarmListSynthBenignJobResults< + TData = Awaited>, + TError = ErrorType, +>( + workspace: string, + name: string, + options?: { + query?: Partial< + UseQueryOptions>, TError, TData> + >; + }, + queryClient?: QueryClient +): UseQueryResult & { queryKey: DataTag } { + const queryOptions = getIronSwarmListSynthBenignJobResultsQueryOptions(workspace, name, options); + + const query = useQuery(queryOptions, queryClient) as UseQueryResult & { + queryKey: DataTag; + }; + + return withQueryKey(query, queryOptions.queryKey); +} + +export const getIronSwarmListSynthBenignJobResultsSuspenseQueryOptions = < + TData = Awaited>, + TError = ErrorType, +>( + workspace: string, + name: string, + options?: { + query?: Partial< + UseSuspenseQueryOptions< + Awaited>, + TError, + TData + > + >; + } +) => { + const { query: queryOptions } = options ?? {}; + + const queryKey = + queryOptions?.queryKey ?? getIronSwarmListSynthBenignJobResultsQueryKey(workspace, name); + + const queryFn: QueryFunction>> = ({ + signal, + }) => ironSwarmListSynthBenignJobResults(workspace, name, signal); + + return { queryKey, queryFn, ...queryOptions } as UseSuspenseQueryOptions< + Awaited>, + TError, + TData + > & { queryKey: DataTag }; +}; + +export type IronSwarmListSynthBenignJobResultsSuspenseQueryResult = NonNullable< + Awaited> +>; +export type IronSwarmListSynthBenignJobResultsSuspenseQueryError = ErrorType; + +export function useIronSwarmListSynthBenignJobResultsSuspense< + TData = Awaited>, + TError = ErrorType, +>( + workspace: string, + name: string, + options: { + query: Partial< + UseSuspenseQueryOptions< + Awaited>, + TError, + TData + > + >; + }, + queryClient?: QueryClient +): UseSuspenseQueryResult & { queryKey: DataTag }; +export function useIronSwarmListSynthBenignJobResultsSuspense< + TData = Awaited>, + TError = ErrorType, +>( + workspace: string, + name: string, + options?: { + query?: Partial< + UseSuspenseQueryOptions< + Awaited>, + TError, + TData + > + >; + }, + queryClient?: QueryClient +): UseSuspenseQueryResult & { queryKey: DataTag }; +export function useIronSwarmListSynthBenignJobResultsSuspense< + TData = Awaited>, + TError = ErrorType, +>( + workspace: string, + name: string, + options?: { + query?: Partial< + UseSuspenseQueryOptions< + Awaited>, + TError, + TData + > + >; + }, + queryClient?: QueryClient +): UseSuspenseQueryResult & { queryKey: DataTag }; +/** + * @summary List Job Results + */ + +export function useIronSwarmListSynthBenignJobResultsSuspense< + TData = Awaited>, + TError = ErrorType, +>( + workspace: string, + name: string, + options?: { + query?: Partial< + UseSuspenseQueryOptions< + Awaited>, + TError, + TData + > + >; + }, + queryClient?: QueryClient +): UseSuspenseQueryResult & { queryKey: DataTag } { + const queryOptions = getIronSwarmListSynthBenignJobResultsSuspenseQueryOptions( + workspace, + name, + options + ); + + const query = useSuspenseQuery(queryOptions, queryClient) as UseSuspenseQueryResult< + TData, + TError + > & { queryKey: DataTag }; + + return withQueryKey(query, queryOptions.queryKey); +} + +/** + * @summary Get Job Status + */ +export const ironSwarmGetSynthBenignJobStatus = ( + workspace: string, + name: string, + signal?: AbortSignal +) => { + return customFetch({ + url: `/apis/iron-swarm/v2/workspaces/${encodeURIComponent(String(workspace))}/synth-benign/jobs/${encodeURIComponent(String(name))}/status`, + method: 'GET', + signal, + }); +}; + +export const getIronSwarmGetSynthBenignJobStatusQueryKey = (workspace: string, name: string) => { + return [`/apis/iron-swarm/v2/workspaces/${workspace}/synth-benign/jobs/${name}/status`] as const; +}; + +export const getIronSwarmGetSynthBenignJobStatusQueryOptions = < + TData = Awaited>, + TError = ErrorType, +>( + workspace: string, + name: string, + options?: { + query?: Partial< + UseQueryOptions>, TError, TData> + >; + } +) => { + const { query: queryOptions } = options ?? {}; + + const queryKey = + queryOptions?.queryKey ?? getIronSwarmGetSynthBenignJobStatusQueryKey(workspace, name); + + const queryFn: QueryFunction>> = ({ + signal, + }) => ironSwarmGetSynthBenignJobStatus(workspace, name, signal); + + return { + queryKey, + queryFn, + enabled: workspace !== null && workspace !== undefined && name !== null && name !== undefined, + ...queryOptions, + } as UseQueryOptions< + Awaited>, + TError, + TData + > & { queryKey: DataTag }; +}; + +export type IronSwarmGetSynthBenignJobStatusQueryResult = NonNullable< + Awaited> +>; +export type IronSwarmGetSynthBenignJobStatusQueryError = ErrorType; + +export function useIronSwarmGetSynthBenignJobStatus< + TData = Awaited>, + TError = ErrorType, +>( + workspace: string, + name: string, + options: { + query: Partial< + UseQueryOptions>, TError, TData> + > & + Pick< + DefinedInitialDataOptions< + Awaited>, + TError, + Awaited> + >, + 'initialData' + >; + }, + queryClient?: QueryClient +): DefinedUseQueryResult & { queryKey: DataTag }; +export function useIronSwarmGetSynthBenignJobStatus< + TData = Awaited>, + TError = ErrorType, +>( + workspace: string, + name: string, + options?: { + query?: Partial< + UseQueryOptions>, TError, TData> + > & + Pick< + UndefinedInitialDataOptions< + Awaited>, + TError, + Awaited> + >, + 'initialData' + >; + }, + queryClient?: QueryClient +): UseQueryResult & { queryKey: DataTag }; +export function useIronSwarmGetSynthBenignJobStatus< + TData = Awaited>, + TError = ErrorType, +>( + workspace: string, + name: string, + options?: { + query?: Partial< + UseQueryOptions>, TError, TData> + >; + }, + queryClient?: QueryClient +): UseQueryResult & { queryKey: DataTag }; +/** + * @summary Get Job Status + */ + +export function useIronSwarmGetSynthBenignJobStatus< + TData = Awaited>, + TError = ErrorType, +>( + workspace: string, + name: string, + options?: { + query?: Partial< + UseQueryOptions>, TError, TData> + >; + }, + queryClient?: QueryClient +): UseQueryResult & { queryKey: DataTag } { + const queryOptions = getIronSwarmGetSynthBenignJobStatusQueryOptions(workspace, name, options); + + const query = useQuery(queryOptions, queryClient) as UseQueryResult & { + queryKey: DataTag; + }; + + return withQueryKey(query, queryOptions.queryKey); +} + +export const getIronSwarmGetSynthBenignJobStatusSuspenseQueryOptions = < + TData = Awaited>, + TError = ErrorType, +>( + workspace: string, + name: string, + options?: { + query?: Partial< + UseSuspenseQueryOptions< + Awaited>, + TError, + TData + > + >; + } +) => { + const { query: queryOptions } = options ?? {}; + + const queryKey = + queryOptions?.queryKey ?? getIronSwarmGetSynthBenignJobStatusQueryKey(workspace, name); + + const queryFn: QueryFunction>> = ({ + signal, + }) => ironSwarmGetSynthBenignJobStatus(workspace, name, signal); + + return { queryKey, queryFn, ...queryOptions } as UseSuspenseQueryOptions< + Awaited>, + TError, + TData + > & { queryKey: DataTag }; +}; + +export type IronSwarmGetSynthBenignJobStatusSuspenseQueryResult = NonNullable< + Awaited> +>; +export type IronSwarmGetSynthBenignJobStatusSuspenseQueryError = ErrorType; + +export function useIronSwarmGetSynthBenignJobStatusSuspense< + TData = Awaited>, + TError = ErrorType, +>( + workspace: string, + name: string, + options: { + query: Partial< + UseSuspenseQueryOptions< + Awaited>, + TError, + TData + > + >; + }, + queryClient?: QueryClient +): UseSuspenseQueryResult & { queryKey: DataTag }; +export function useIronSwarmGetSynthBenignJobStatusSuspense< + TData = Awaited>, + TError = ErrorType, +>( + workspace: string, + name: string, + options?: { + query?: Partial< + UseSuspenseQueryOptions< + Awaited>, + TError, + TData + > + >; + }, + queryClient?: QueryClient +): UseSuspenseQueryResult & { queryKey: DataTag }; +export function useIronSwarmGetSynthBenignJobStatusSuspense< + TData = Awaited>, + TError = ErrorType, +>( + workspace: string, + name: string, + options?: { + query?: Partial< + UseSuspenseQueryOptions< + Awaited>, + TError, + TData + > + >; + }, + queryClient?: QueryClient +): UseSuspenseQueryResult & { queryKey: DataTag }; +/** + * @summary Get Job Status + */ + +export function useIronSwarmGetSynthBenignJobStatusSuspense< + TData = Awaited>, + TError = ErrorType, +>( + workspace: string, + name: string, + options?: { + query?: Partial< + UseSuspenseQueryOptions< + Awaited>, + TError, + TData + > + >; + }, + queryClient?: QueryClient +): UseSuspenseQueryResult & { queryKey: DataTag } { + const queryOptions = getIronSwarmGetSynthBenignJobStatusSuspenseQueryOptions( + workspace, + name, options ); diff --git a/web/packages/sdk/orval/constants.ts b/web/packages/sdk/orval/constants.ts index 98f4fc2531..055fcf60b1 100644 --- a/web/packages/sdk/orval/constants.ts +++ b/web/packages/sdk/orval/constants.ts @@ -61,6 +61,12 @@ export const serviceConfigs: Record = { apiEnvKeys: ['VITE_PLATFORM_BASE_URL'], zod: true, }, + 'iron-swarm': { + path: 'iron-swarm', + url: `../../../../plugins/nemo-iron-swarm/openapi/openapi.yaml`, + apiEnvKeys: ['VITE_PLATFORM_BASE_URL'], + zod: true, + }, }; export const serviceToConfig = { @@ -72,6 +78,7 @@ export const serviceToConfig = { 'entity-store': 'nemoMicroservices', evaluation: 'nemoMicroservices', guardrails: 'nemoMicroservices', + 'iron-swarm': 'nemoMicroservices', intake: 'nemoMicroservices', jobs: 'nemoMicroservices', 'safe-synthesizer': 'nemoMicroservices', diff --git a/web/packages/sdk/package.json b/web/packages/sdk/package.json index b0cc9c1118..aa3d198588 100644 --- a/web/packages/sdk/package.json +++ b/web/packages/sdk/package.json @@ -19,6 +19,8 @@ "gen:data-designer-zod": "ORVAL_CLIENT=zod tsx ./orval/generate.ts data-designer", "gen:evaluator": "tsx ./orval/generate.ts evaluator", "gen:evaluator-zod": "ORVAL_CLIENT=zod tsx ./orval/generate.ts evaluator", + "gen:iron-swarm": "tsx ./orval/generate.ts iron-swarm", + "gen:iron-swarm-zod": "ORVAL_CLIENT=zod tsx ./orval/generate.ts iron-swarm", "gen:deployment-management": "tsx ./orval/generate.ts deployment-management", "gen:entity-store": "tsx ./orval/generate.ts entity-store", "gen:guardrails": "tsx ./orval/generate.ts guardrails", diff --git a/web/packages/studio/package.json b/web/packages/studio/package.json index aba8d58f11..90ba6a0e87 100644 --- a/web/packages/studio/package.json +++ b/web/packages/studio/package.json @@ -75,6 +75,7 @@ "p-limit": "catalog:", "papaparse": "^5.5.3", "react": "catalog:", + "react-diff-viewer-continued": "^4.2.2", "react-dom": "catalog:", "react-dropzone": "catalog:", "react-hook-form": "catalog:", diff --git a/web/packages/studio/src/api/ironSwarm.ts b/web/packages/studio/src/api/ironSwarm.ts new file mode 100644 index 0000000000..979224baed --- /dev/null +++ b/web/packages/studio/src/api/ironSwarm.ts @@ -0,0 +1,77 @@ +// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { customFetch } from '@nemo/sdk/generated/fetchers/iron-swarm'; +import { filesCreateFileset, filesUploadFile } from '@nemo/sdk/generated/platform/api'; +import { useMutation } from '@tanstack/react-query'; + +export interface UploadFilesetParams { + workspace: string; + /** Manifest id, used to derive a recognizable fileset name. */ + manifestName: string; + /** The single file to store. */ + file: File; +} + +/** + * Create a generic fileset and upload a single file into it; return its `workspace/name` ref. + * + * The Iron Swarm plugin re-downloads the fileset on the job host (project bundles when it inspects and + * materializes the victim; hitlogs when it replays recorded attacks). + */ +async function uploadToFileset( + { workspace, manifestName, file }: UploadFilesetParams, + kind: string, + fallbackType: string +): Promise { + const name = `${manifestName}-${kind}-${Date.now().toString(36)}`; + const fileset = await filesCreateFileset(workspace, { name, purpose: 'generic' }); + const blob = new Blob([await file.arrayBuffer()], { type: file.type || fallbackType }); + await filesUploadFile(fileset.workspace, fileset.name, file.name, blob); + return `${fileset.workspace}/${fileset.name}`; +} + +/** Store an uploaded NAT project zip; the ref feeds inspect + war-game materialization. */ +export const useUploadProjectFileset = () => + useMutation({ + mutationFn: (params: UploadFilesetParams) => + uploadToFileset(params, 'project', 'application/zip'), + }); + +/** Store an uploaded garak hitlog (.jsonl); the ref feeds a replay-mode war-game via `--replay`. */ +export const useUploadHitlogFileset = () => + useMutation({ + mutationFn: (params: UploadFilesetParams) => + uploadToFileset(params, 'hitlog', 'application/jsonl'), + }); + +/** Store an uploaded benign suite (requests.csv); the ref overrides the manifest suite via `--benign-suite`. */ +export const useUploadBenignSuiteFileset = () => + useMutation({ + mutationFn: (params: UploadFilesetParams) => + uploadToFileset(params, 'benign-suite', 'text/csv'), + }); + +/** Auto-derived defaults for the deployed-agent create form (victim port + secret names). */ +export interface InspectAgentResult { + agent: string; + port: number; + secrets: string[]; + warnings: string[]; +} + +/** + * Derive a deployed agent's victim port + secret names (read-only) to pre-fill the create form. + * + * Not in the generated SDK; calls the plugin endpoint via the SDK's fetcher so auth/base-url match. + */ +export const useInspectAgent = () => + useMutation({ + mutationFn: ({ workspace, agent }: { workspace: string; agent: string }) => + customFetch({ + url: `/apis/iron-swarm/v2/workspaces/${encodeURIComponent(workspace)}/manifests/inspect-agent`, + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + data: { agent }, + }), + }); diff --git a/web/packages/studio/src/components/dataViews/IronSwarmManifestsDataView/index.tsx b/web/packages/studio/src/components/dataViews/IronSwarmManifestsDataView/index.tsx new file mode 100644 index 0000000000..1b5e6f7c2b --- /dev/null +++ b/web/packages/studio/src/components/dataViews/IronSwarmManifestsDataView/index.tsx @@ -0,0 +1,170 @@ +// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { StudioDataView } from '@nemo/common/src/components/DataView/StudioDataView'; +import { RelativeTime } from '@nemo/common/src/components/RelativeTime'; +import { TableEmptyState } from '@nemo/common/src/components/TableEmptyState'; +import { useStudioDataViewState } from '@nemo/common/src/hooks/useStudioDataViewState'; +import { useToast } from '@nemo/common/src/providers/toast/useToast'; +import { getSortParam } from '@nemo/common/src/utils/query'; +import { + getIronSwarmListManifestsQueryKey, + useIronSwarmDeleteManifest, + useIronSwarmListManifests, +} from '@nemo/sdk/generated/iron-swarm/api'; +import type { IronSwarmManifest } from '@nemo/sdk/generated/iron-swarm/schema'; +import { Button, Text } from '@nvidia/foundations-react-core'; +import { DeleteConfirmationModal } from '@studio/components/DeleteConfirmationModal'; +import { useRunWarGame } from '@studio/components/ironSwarm/useRunWarGame'; +import { QuickActionsMenuRoot } from '@studio/components/QuickActionsMenu/QuickActionsMenuRoot'; +import { useWorkspaceFromPath } from '@studio/hooks/useWorkspaceFromPath'; +import { + getIronSwarmManifestDetailRoute, + getNewIronSwarmManifestRoute, +} from '@studio/routes/utils'; +import { keepPreviousData, useQueryClient } from '@tanstack/react-query'; +import { ComponentProps, FC, useMemo, useState } from 'react'; +import { Link, useNavigate } from 'react-router-dom'; + +type IronSwarmManifestWithId = IronSwarmManifest & { id: string }; + +export const IronSwarmManifestsDataView: FC = () => { + const navigate = useNavigate(); + const workspace = useWorkspaceFromPath(); + const toast = useToast(); + const queryClient = useQueryClient(); + const dataViewState = useStudioDataViewState({ defaultSort: [{ id: 'created_at', desc: true }] }); + const [toDelete, setToDelete] = useState(null); + + const { data: response, isLoading } = useIronSwarmListManifests( + workspace, + { + sort: getSortParam(dataViewState.sorting.state), + page: dataViewState.pagination.state.pageIndex + 1, + page_size: dataViewState.pagination.state.pageSize, + }, + { query: { placeholderData: keepPreviousData, refetchOnMount: 'always', retry: false } } + ); + + const invalidate = () => + queryClient.invalidateQueries({ queryKey: getIronSwarmListManifestsQueryKey(workspace) }); + + const runWarGame = useRunWarGame(workspace); + const deleteManifest = useIronSwarmDeleteManifest(); + + // A war-game replays the manifest's benign suite, so require one first: with none, send the user to the + // manifest page to generate it rather than silently kicking off an inline interview mid-run. + const startRun = (manifest: IronSwarmManifestWithId) => { + if (!manifest.name) return; + if (!manifest.benign_suite?.length) { + toast.error( + 'No benign suite for this manifest yet — generate it first, then run the war-game.' + ); + navigate(getIronSwarmManifestDetailRoute(workspace, manifest.name)); + return; + } + runWarGame.mutate({ + workspace, + data: { spec: { manifest_id: manifest.name, driver: 'service' } }, + }); + }; + + const manifests = useMemo(() => { + const rows = (response?.data ?? []) as IronSwarmManifest[]; + return rows.map((m) => ({ ...m, id: m.id || `${m.workspace ?? ''}/${m.name ?? ''}` })); + }, [response]); + + const total = + (response?.pagination as { total_results?: number } | undefined)?.total_results ?? + manifests.length; + + const makeColumns: ComponentProps< + typeof StudioDataView + >['makeColumns'] = ({ accessor }, { rowActionsColumn }) => [ + accessor('name', { header: 'Manifest', cell: ({ row }) => row.original.name ?? '-' }), + accessor('agent', { + header: 'Agent', + cell: ({ row }) => ( + + {row.original.agent || '-'} + + ), + }), + accessor('source_type', { + header: 'Source', + size: 120, + cell: ({ row }) => row.original.source_type ?? 'agent', + }), + accessor('created_at', { + id: 'created_at', + header: 'Created', + enableSorting: true, + size: 160, + cell: ({ row }) => + row.original.created_at ? : null, + }), + rowActionsColumn({ + size: 70, + cell: ({ row }) => ( + startRun(row.original) }, + { + label: 'Edit', + onSelect: () => + row.original.name && + navigate(getIronSwarmManifestDetailRoute(workspace, row.original.name)), + }, + { label: 'Delete', onSelect: () => setToDelete(row.original) }, + ]} + /> + ), + }), + ]; + + return ( + <> + + dataViewState={dataViewState} + makeColumns={makeColumns} + onRowClick={(row) => + row.name && navigate(getIronSwarmManifestDetailRoute(workspace, row.name)) + } + attributes={{ + DataViewRoot: { + data: manifests, + totalCount: total, + requestStatus: isLoading && !response ? 'loading' : undefined, + }, + DataViewTableContent: { + renderEmptyState: () => ( + + New Manifest + + } + /> + ), + }, + }} + /> + setToDelete(null)} + title={`Delete ${toDelete?.name ?? 'manifest'}?`} + description="This permanently deletes the manifest and its cached benign suite." + successText="Manifest deleted." + errorText="Failed to delete the manifest." + onDelete={async () => { + if (!toDelete?.name) return false; + await deleteManifest.mutateAsync({ workspace, name: toDelete.name }); + invalidate(); + return true; + }} + /> + + ); +}; diff --git a/web/packages/studio/src/components/dataViews/IronSwarmRunsDataView/index.tsx b/web/packages/studio/src/components/dataViews/IronSwarmRunsDataView/index.tsx new file mode 100644 index 0000000000..3aface50f7 --- /dev/null +++ b/web/packages/studio/src/components/dataViews/IronSwarmRunsDataView/index.tsx @@ -0,0 +1,213 @@ +// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { withOperators } from '@nemo/common/src/api/filterOperators'; +import { StudioDataView } from '@nemo/common/src/components/DataView/StudioDataView'; +import { RelativeTime } from '@nemo/common/src/components/RelativeTime'; +import { StatusBadge, type StatusConfigEntry } from '@nemo/common/src/components/StatusBadge'; +import { TableEmptyState } from '@nemo/common/src/components/TableEmptyState'; +import { JOB_POLLING_INTERVAL_MS } from '@nemo/common/src/constants'; +import { useStudioDataViewState } from '@nemo/common/src/hooks/useStudioDataViewState'; +import { useToast } from '@nemo/common/src/providers/toast/useToast'; +import { getSortParam } from '@nemo/common/src/utils/query'; +import { + getIronSwarmListRunsQueryKey, + useIronSwarmCancelJob, + useIronSwarmDeleteJob, + useIronSwarmDeleteRun, + useIronSwarmListRuns, +} from '@nemo/sdk/generated/iron-swarm/api'; +import type { IronSwarmRun, RunFilter } from '@nemo/sdk/generated/iron-swarm/schema'; +import { Text } from '@nvidia/foundations-react-core'; +import { DeleteConfirmationModal } from '@studio/components/DeleteConfirmationModal'; +import { QuickActionsMenuRoot } from '@studio/components/QuickActionsMenu/QuickActionsMenuRoot'; +import { useWorkspaceFromPath } from '@studio/hooks/useWorkspaceFromPath'; +import { getIronSwarmRunDetailsRoute } from '@studio/routes/utils'; +import { keepPreviousData, useQueryClient } from '@tanstack/react-query'; +import { ComponentProps, FC, useMemo, useState } from 'react'; +import { useNavigate } from 'react-router-dom'; + +type IronSwarmRunWithId = IronSwarmRun & { id: string }; + +// Iron Swarm run statuses aren't platform-job statuses, so map them explicitly for the badge. +const RUN_STATUS_CONFIG: Record = { + running: { label: 'Running', color: 'blue' }, + completed: { label: 'Completed', color: 'green' }, + failed: { label: 'Failed', color: 'red' }, +}; + +const STATUS_FILTER_OPTIONS = [ + { label: 'Running', value: 'running' }, + { label: 'Completed', value: 'completed' }, + { label: 'Failed', value: 'failed' }, +]; + +export const IronSwarmRunsDataView: FC = () => { + const navigate = useNavigate(); + const workspace = useWorkspaceFromPath(); + const toast = useToast(); + const queryClient = useQueryClient(); + const [toDelete, setToDelete] = useState(null); + + const dataViewState = useStudioDataViewState({ + defaultSort: [{ id: 'created_at', desc: true }], + }); + + const invalidateRuns = () => + queryClient.invalidateQueries({ queryKey: getIronSwarmListRunsQueryKey(workspace) }); + + const cancelJob = useIronSwarmCancelJob({ + mutation: { + onSuccess: () => { + toast.success('War-game cancelled.'); + invalidateRuns(); + }, + onError: () => toast.error('Failed to cancel the war-game.'), + }, + }); + const deleteRun = useIronSwarmDeleteRun(); + const deleteJob = useIronSwarmDeleteJob(); + + const { data: runsResponse, isLoading } = useIronSwarmListRuns( + workspace, + { + sort: getSortParam(dataViewState.sorting.state), + page: dataViewState.pagination.state.pageIndex + 1, + page_size: dataViewState.pagination.state.pageSize, + filter: { + ...((dataViewState.apiFilter.filter ?? {}) as RunFilter), + ...(dataViewState.apiFilter.searchText + ? withOperators({ agent: { $like: dataViewState.apiFilter.searchText } }) + : {}), + }, + }, + { + query: { + placeholderData: keepPreviousData, + refetchInterval: JOB_POLLING_INTERVAL_MS, + refetchOnMount: 'always', + // Fail fast to the empty state instead of the app-default 3 retries (~7s of "loading") + // when the iron-swarm service isn't reachable. + retry: false, + }, + } + ); + + const runs = useMemo(() => { + const rows = (runsResponse?.data ?? []) as IronSwarmRun[]; + return rows.map((run) => ({ + ...run, + id: run.id || `${run.workspace ?? ''}/${run.name ?? ''}`, + })); + }, [runsResponse]); + + const totalResults = + (runsResponse?.pagination as { total_results?: number } | undefined)?.total_results ?? 0; + + const makeColumns: ComponentProps>['makeColumns'] = ( + { accessor }, + { rowActionsColumn } + ) => [ + accessor('name', { header: 'Run', cell: ({ row }) => row.original.name ?? '-' }), + accessor('agent', { + header: 'Agent', + cell: ({ row }) => ( + + {row.original.agent || '-'} + + ), + }), + accessor('status', { + header: 'Status', + size: 125, + meta: { + filter: { type: 'single-select' as const, label: 'Status', options: STATUS_FILTER_OPTIONS }, + }, + cell: ({ row }) => { + if (!row.original.status) return null; + const badge = ; + // Surface the classified failure cause on hover for a failed run. + return row.original.status === 'failed' && row.original.error_message ? ( + {badge} + ) : ( + badge + ); + }, + }), + accessor('created_at', { + id: 'created_at', + header: 'Started', + enableSorting: true, + size: 160, + cell: ({ row }) => + row.original.created_at ? : null, + }), + rowActionsColumn({ + size: 70, + cell: ({ row }) => { + const jobId = row.original.job_id; + return ( + cancelJob.mutate({ workspace, name: jobId }), + }, + ] + : []), + { label: 'Delete', onSelect: () => setToDelete(row.original) }, + ]} + /> + ); + }, + }), + ]; + + return ( + <> + + dataViewState={dataViewState} + searchField="agent" + makeColumns={makeColumns} + onRowClick={(row) => row.name && navigate(getIronSwarmRunDetailsRoute(workspace, row.name))} + attributes={{ + DataViewSearchBar: { placeholder: 'Search by agent...' }, + DataViewRoot: { + data: runs, + totalCount: totalResults, + requestStatus: isLoading && !runsResponse ? 'loading' : undefined, + }, + DataViewTableContent: { + renderEmptyState: () => ( + + ), + }, + }} + /> + setToDelete(null)} + title={`Delete ${toDelete?.name ?? 'run'}?`} + description="This permanently deletes the run record and its platform job." + successText="Run deleted." + errorText="Failed to delete the run." + onDelete={async () => { + if (!toDelete?.name) return false; + await deleteRun.mutateAsync({ workspace, name: toDelete.name }); + // The run's job is best-effort — the record is the user-facing artifact. + if (toDelete.job_id) + await deleteJob + .mutateAsync({ workspace, name: toDelete.job_id }) + .catch(() => undefined); + invalidateRuns(); + return true; + }} + /> + + ); +}; diff --git a/web/packages/studio/src/components/ironSwarm/BenignInterviewCard.tsx b/web/packages/studio/src/components/ironSwarm/BenignInterviewCard.tsx new file mode 100644 index 0000000000..ade8845d37 --- /dev/null +++ b/web/packages/studio/src/components/ironSwarm/BenignInterviewCard.tsx @@ -0,0 +1,31 @@ +// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { Stack, Text } from '@nvidia/foundations-react-core'; +import { ExpandableMessage } from '@studio/components/ExpandableMessage'; +import { FC } from 'react'; + +interface InterviewQA { + question?: string; + answer?: string; + gap?: string; +} + +interface BenignInterviewCardProps { + interview: InterviewQA[]; +} + +// The interview Q&A captured during the last benign-suite generation — the "why" behind the current suite. +export const BenignInterviewCard: FC = ({ interview }) => { + if (interview.length === 0) return null; + return ( + + {interview.map((qa, index) => ( + + {qa.question || qa.gap || `Question ${index + 1}`} + + + ))} + + ); +}; diff --git a/web/packages/studio/src/components/ironSwarm/BenignSuiteEditor.tsx b/web/packages/studio/src/components/ironSwarm/BenignSuiteEditor.tsx new file mode 100644 index 0000000000..6435db719e --- /dev/null +++ b/web/packages/studio/src/components/ironSwarm/BenignSuiteEditor.tsx @@ -0,0 +1,101 @@ +// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { + Button, + Flex, + FormField, + Stack, + Text, + TextArea, + TextInput, +} from '@nvidia/foundations-react-core'; +import type { SuiteRow } from '@studio/components/ironSwarm/hitlTypes'; +import { Plus, Trash } from 'lucide-react'; +import { FC } from 'react'; + +interface BenignSuiteEditorProps { + value: SuiteRow[]; + onChange: (rows: SuiteRow[]) => void; + disabled?: boolean; +} + +const EMPTY_ROW: SuiteRow = { tool: '', payload: '', label: 'benign', persona: '', rationale: '' }; + +// Structured editor for the manifest's cached benign suite. Each row is a replayed request +// (tool/payload/label/persona/rationale); add/remove/edit rows, then the parent persists via PATCH. +export const BenignSuiteEditor: FC = ({ value, onChange, disabled }) => { + const update = (index: number, patch: Partial) => + onChange(value.map((row, i) => (i === index ? { ...row, ...patch } : row))); + const remove = (index: number) => onChange(value.filter((_, i) => i !== index)); + const add = () => onChange([...value, { ...EMPTY_ROW }]); + + return ( + + {value.length === 0 ? ( + + No benign requests yet. Add rows manually, or generate the suite to populate it. + + ) : ( + value.map((row, index) => ( + + + + update(index, { tool: e.target.value })} + /> + + + update(index, { persona: e.target.value })} + /> + + + update(index, { label: e.target.value })} + /> + + + +