From 1fc200954fac149c9a99d0b02abcf53a4ac8e4e6 Mon Sep 17 00:00:00 2001 From: evilsocket Date: Tue, 17 Dec 2024 13:24:35 +0100 Subject: [PATCH 1/4] new: implemented agent templates system (ENG-458) --- dreadnode_cli/agent/cli.py | 78 ++++++---- dreadnode_cli/agent/format.py | 12 -- dreadnode_cli/agent/templates/__init__.py | 77 +--------- dreadnode_cli/agent/templates/cli.py | 82 ++++++++++ dreadnode_cli/agent/templates/format.py | 24 +++ dreadnode_cli/agent/templates/manager.py | 145 ++++++++++++++++++ .../agent/templates/nerve_basic/Dockerfile | 16 -- .../agent/templates/nerve_basic/README.md | 1 - .../agent/templates/nerve_basic/task.yml.j2 | 45 ------ .../templates/rigging_basic/Dockerfile.j2 | 10 -- .../agent/templates/rigging_basic/README.md | 1 - .../agent/templates/rigging_basic/agent.py.j2 | 36 ----- .../templates/rigging_basic/requirements.txt | 2 - .../templates/rigging_loop/Dockerfile.j2 | 10 -- .../agent/templates/rigging_loop/README.md | 1 - .../agent/templates/rigging_loop/agent.py.j2 | 129 ---------------- .../templates/rigging_loop/requirements.txt | 4 - dreadnode_cli/agent/tests/test_cli.py | 2 +- dreadnode_cli/agent/tests/test_templates.py | 91 ++++++++--- dreadnode_cli/defaults.py | 12 ++ dreadnode_cli/types.py | 8 + dreadnode_cli/utils.py | 17 +- 22 files changed, 398 insertions(+), 405 deletions(-) create mode 100644 dreadnode_cli/agent/templates/cli.py create mode 100644 dreadnode_cli/agent/templates/format.py create mode 100644 dreadnode_cli/agent/templates/manager.py delete mode 100644 dreadnode_cli/agent/templates/nerve_basic/Dockerfile delete mode 100644 dreadnode_cli/agent/templates/nerve_basic/README.md delete mode 100644 dreadnode_cli/agent/templates/nerve_basic/task.yml.j2 delete mode 100644 dreadnode_cli/agent/templates/rigging_basic/Dockerfile.j2 delete mode 100644 dreadnode_cli/agent/templates/rigging_basic/README.md delete mode 100644 dreadnode_cli/agent/templates/rigging_basic/agent.py.j2 delete mode 100644 dreadnode_cli/agent/templates/rigging_basic/requirements.txt delete mode 100644 dreadnode_cli/agent/templates/rigging_loop/Dockerfile.j2 delete mode 100644 dreadnode_cli/agent/templates/rigging_loop/README.md delete mode 100644 dreadnode_cli/agent/templates/rigging_loop/agent.py.j2 delete mode 100644 dreadnode_cli/agent/templates/rigging_loop/requirements.txt diff --git a/dreadnode_cli/agent/cli.py b/dreadnode_cli/agent/cli.py index b21bdeb..bc2e439 100644 --- a/dreadnode_cli/agent/cli.py +++ b/dreadnode_cli/agent/cli.py @@ -20,16 +20,18 @@ format_run, format_runs, format_strikes, - format_templates, ) -from dreadnode_cli.agent.templates import Template, install_template, install_template_from_dir +from dreadnode_cli.agent.templates import cli as templates_cli +from dreadnode_cli.agent.templates.manager import TemplateManager from dreadnode_cli.config import UserConfig from dreadnode_cli.profile.cli import switch as switch_profile from dreadnode_cli.types import GithubRepo -from dreadnode_cli.utils import download_and_unzip_archive, pretty_cli, repo_exists +from dreadnode_cli.utils import download_and_unzip_archive, get_repo_archive_source_path, pretty_cli cli = typer.Typer(no_args_is_help=True) +cli.add_typer(templates_cli, name="templates", help="Interact with Strike templates") + def ensure_profile(agent_config: AgentConfig, *, user_config: UserConfig | None = None) -> None: """Ensure the active agent link matches the current server profile.""" @@ -66,26 +68,6 @@ def ensure_profile(agent_config: AgentConfig, *, user_config: UserConfig | None switch_profile(agent_config.active_link.profile) -def get_repo_archive_source_path(source_dir: pathlib.Path) -> pathlib.Path: - """Return the actual source directory from a git repositoryZIP archive.""" - - if not (source_dir / "Dockerfile").exists() and not (source_dir / "Dockerfile.j2").exists(): - # if src has been downloaded from a ZIP archive, it may contain a single - # '--' folder, that is the actual source we want to use. - # Check if source_dir contains only one folder and update it if so. - children = list(source_dir.iterdir()) - if len(children) == 1 and children[0].is_dir(): - source_dir = children[0] - - return source_dir - - -@cli.command(help="List available agent templates with their descriptions") -@pretty_cli -def templates() -> None: - print(format_templates()) - - @cli.command(help="Initialize a new agent project") @pretty_cli def init( @@ -98,8 +80,8 @@ def init( str | None, typer.Option("--name", "-n", help="The project name (used for container naming)") ] = None, template: t.Annotated[ - Template, typer.Option("--template", "-t", help="The template to use for the agent") - ] = Template.rigging_basic, + str | None, typer.Option("--template", "-t", help="The template to use for the agent") + ] = None, source: t.Annotated[ str | None, typer.Option( @@ -137,18 +119,49 @@ def init( print(f":crossed_swords: Linking to strike '{strike_response.name}' ({strike_response.type})") print() - project_name = Prompt.ask("Project name?", default=name or directory.name) + project_name = Prompt.ask(":toolbox: Project name?", default=name or directory.name) print() directory.mkdir(exist_ok=True) + template_manager = TemplateManager() context = {"project_name": project_name, "strike": strike_response} if source is None: - # initialize from builtin template - template = Template(Prompt.ask("Template?", choices=[t.value for t in Template], default=template.value)) + # get the templates that match the strike + available_templates = template_manager.get_templates_for_strike(strike_response.name, strike_response.type) + available: list[str] = list(available_templates.keys()) + + # none available + if not available: + if not template_manager.templates: + raise Exception( + "No templates installed, use [bold]dreadnode agent templates install[/] to install some." + ) + else: + raise Exception("No templates found for the given strike.") + + # ask the user if the template has not been passed via command line + if template is None: + print(":notebook: Compatible templates:\n") + for i, template_name in enumerate(available): + print( + f"{i + 1}. {template_name} ([dim]{template_manager.templates[template_name].manifest.description}[/])" + ) + + print() + + choice = Prompt.ask("Choice: ", choices=[str(i + 1) for i in range(len(available))]) + template = available[int(choice) - 1] + + # validate the template + if template not in available: + raise Exception( + f"Template '{template}' not found, use [bold]dreadnode agent templates show[/] to see available templates." + ) - install_template(template, directory, context) + # install the template + template_manager.install(template, directory, context) else: source_dir = pathlib.Path(source) cleanup = False @@ -162,7 +175,7 @@ def init( github_repo = GithubRepo(source) # Check if the repo is accessible - if repo_exists(github_repo): + if github_repo.exists: source_dir = download_and_unzip_archive(github_repo.zip_url) # This could be a private repo that the user can access @@ -193,7 +206,8 @@ def init( if path is not None: source_dir = source_dir / path - install_template_from_dir(source_dir, directory, context) + # install the template + template_manager.install_from_dir(source_dir, directory, context) except Exception: if cleanup and source_dir.exists(): shutil.rmtree(source_dir) @@ -521,7 +535,7 @@ def clone( shutil.rmtree(target) # Check if the repo is accessible - if repo_exists(github_repo): + if github_repo.exists: temp_dir = download_and_unzip_archive(github_repo.zip_url) # This could be a private repo that the user can access diff --git a/dreadnode_cli/agent/format.py b/dreadnode_cli/agent/format.py index b580fb0..37c34b8 100644 --- a/dreadnode_cli/agent/format.py +++ b/dreadnode_cli/agent/format.py @@ -9,7 +9,6 @@ from rich.text import Text from dreadnode_cli import api -from dreadnode_cli.agent.templates import Template, template_description P = t.ParamSpec("P") @@ -312,14 +311,3 @@ def format_runs(runs: list[api.Client.StrikeRunSummaryResponse]) -> RenderableTy ) return table - - -def format_templates() -> RenderableType: - table = Table(box=box.ROUNDED) - table.add_column("template") - table.add_column("description") - - for template in Template: - table.add_row(f"[bold magenta]{template.value}[/]", template_description(template)) - - return table diff --git a/dreadnode_cli/agent/templates/__init__.py b/dreadnode_cli/agent/templates/__init__.py index b022c13..28d0499 100644 --- a/dreadnode_cli/agent/templates/__init__.py +++ b/dreadnode_cli/agent/templates/__init__.py @@ -1,76 +1,3 @@ -import enum -import pathlib -import typing as t +from dreadnode_cli.agent.templates.cli import cli -from jinja2 import Environment, FileSystemLoader -from rich.prompt import Prompt - -TEMPLATES_DIR = pathlib.Path(__file__).parent.parent / "templates" - - -class Template(str, enum.Enum): - rigging_basic = "rigging_basic" - rigging_loop = "rigging_loop" - nerve_basic = "nerve_basic" - - -def template_description(template: Template) -> str: - """Return the description of a template.""" - - readme = TEMPLATES_DIR / template.value / "README.md" - if readme.exists(): - return readme.read_text() - - return "" - - -def install_template(template: Template, dest: pathlib.Path, context: dict[str, t.Any]) -> None: - """Install a template into a directory.""" - install_template_from_dir(TEMPLATES_DIR / template.value, dest, context) - - -def install_template_from_dir(src: pathlib.Path, dest: pathlib.Path, context: dict[str, t.Any]) -> None: - """Install a template from a source directory into a destination directory.""" - - if not src.exists(): - raise Exception(f"Source directory '{src}' does not exist") - - elif not src.is_dir(): - raise Exception(f"Source '{src}' is not a directory") - - # check for Dockerfile in the directory - elif not (src / "Dockerfile").exists() and not (src / "Dockerfile.j2").exists(): - raise Exception(f"Source directory {src} does not contain a Dockerfile") - - env = Environment(loader=FileSystemLoader(src)) - - # iterate over all items in the source directory - for src_item in src.glob("**/*"): - # get the relative path of the item - src_item_path = str(src_item.relative_to(src)) - # get the destination path - dest_item = dest / src_item_path - - # if the destination item is not the root directory and it exists, - # ask the user if they want to overwrite it - if dest_item != dest and dest_item.exists(): - if Prompt.ask(f":axe: Overwrite {dest_item}?", choices=["y", "n"], default="n") == "n": - continue - - # if the source item is a file - if src_item.is_file(): - # if the file has a .j2 extension, render it using Jinja2 - if src_item.name.endswith(".j2"): - # we can read as text - content = src_item.read_text() - j2_template = env.get_template(src_item_path) - content = j2_template.render(context) - dest_item = dest / src_item_path.removesuffix(".j2") - dest_item.write_text(content) - else: - # otherwise, copy the file as is - dest_item.write_bytes(src_item.read_bytes()) - - # if the source item is a directory, create it in the destination - elif src_item.is_dir(): - dest_item.mkdir(exist_ok=True) +__all__ = ["cli"] diff --git a/dreadnode_cli/agent/templates/cli.py b/dreadnode_cli/agent/templates/cli.py new file mode 100644 index 0000000..fa872a6 --- /dev/null +++ b/dreadnode_cli/agent/templates/cli.py @@ -0,0 +1,82 @@ +import pathlib +import shutil +import typing as t + +import typer +from rich import print + +from dreadnode_cli import api +from dreadnode_cli.agent.templates.format import format_templates +from dreadnode_cli.agent.templates.manager import TemplateManager +from dreadnode_cli.defaults import TEMPLATES_DEFAULT_REPO +from dreadnode_cli.types import GithubRepo +from dreadnode_cli.utils import download_and_unzip_archive, get_repo_archive_source_path, pretty_cli + +cli = typer.Typer(no_args_is_help=True) + + +@cli.command(help="List available agent templates with their descriptions") +@pretty_cli +def show() -> None: + template_manager = TemplateManager() + if not template_manager.templates: + raise Exception("No templates installed, use [bold]dreadnode agent templates install[/] to install some.") + + print(format_templates(template_manager.templates)) + + +@cli.command(help="Install a template pack") +@pretty_cli +def install( + source: t.Annotated[str, typer.Argument(help="The source of the template pack")] = TEMPLATES_DEFAULT_REPO, +) -> None: + template_manager = TemplateManager() + source_dir: pathlib.Path = pathlib.Path(source) + pack_name: str | None = None + cleanup = False + + if not source_dir.exists(): + # source is not a local folder, so it can be: + # - full ZIP archive URL + # - github compatible reference + try: + github_repo = GithubRepo(source) + + # Check if the repo is accessible + if github_repo.exists: + source_dir = download_and_unzip_archive(github_repo.zip_url) + + # This could be a private repo that the user can access + # by getting an access token from our API + elif github_repo.namespace == "dreadnode": + try: + github_access_token = api.create_client().get_github_access_token([github_repo.repo]) + print(":key: Accessed private repository") + source_dir = download_and_unzip_archive( + github_repo.api_zip_url, headers={"Authorization": f"Bearer {github_access_token.token}"} + ) + except Exception as e: + raise Exception(f"Failed to access private repository '{github_repo}': {e}") from e + + else: + raise Exception(f"Repository '{github_repo}' not found or inaccessible") + + # github repos zip archives usually contain a single branch folder, the real source dir, + # and the path is not known beforehand + source_dir = get_repo_archive_source_path(source_dir) + pack_name = f"{github_repo.namespace}-{github_repo.repo}" + + except ValueError: + # not a repo, download and unzip as a ZIP archive URL + source_dir = download_and_unzip_archive(source) + + # make sure the temporary directory is cleaned up + cleanup = True + + try: + # install the template pack + template_manager.install_template_pack(source_dir, pack_name) + except Exception: + if cleanup and source_dir.exists(): + shutil.rmtree(source_dir) + raise diff --git a/dreadnode_cli/agent/templates/format.py b/dreadnode_cli/agent/templates/format.py new file mode 100644 index 0000000..8a81a87 --- /dev/null +++ b/dreadnode_cli/agent/templates/format.py @@ -0,0 +1,24 @@ +from rich import box +from rich.console import RenderableType +from rich.table import Table + +from dreadnode_cli.agent.templates.manager import Template + + +def format_templates(templates: dict[str, Template]) -> RenderableType: + table = Table(box=box.ROUNDED) + table.add_column("template") + table.add_column("version") + table.add_column("strikes") + table.add_column("description") + + for name, template in templates.items(): + all = [f"[bold]{s}[/]" for s in template.manifest.strikes or []] + [ + f"[bold green]{s}[/]" for s in template.manifest.strikes_types or [] + ] + + strikes = ", ".join(all) if all else "[dim]-[/]" + + table.add_row(f"[bold magenta]{name}[/]", template.manifest.version, strikes, template.manifest.description) + + return table diff --git a/dreadnode_cli/agent/templates/manager.py b/dreadnode_cli/agent/templates/manager.py new file mode 100644 index 0000000..78e6794 --- /dev/null +++ b/dreadnode_cli/agent/templates/manager.py @@ -0,0 +1,145 @@ +import os +import pathlib +import shutil +import typing as t +from pathlib import Path + +from jinja2 import Environment, FileSystemLoader +from pydantic import BaseModel +from pydantic_yaml import parse_yaml_raw_as +from rich import print +from rich.prompt import Prompt + +from dreadnode_cli.defaults import TEMPLATE_MANIFEST_FILE, TEMPLATES_PATH + + +class Manifest(BaseModel): + # generic agent info + description: str + version: str = "0.0.1" + homepage: str | None = None + authors: list[str] | None = None + keywords: list[str] | None = None + # optional runtime requirements + requirements: dict[str, str] | None = None + # which strike this agent is meant to be used for + strikes: list[str] | None = None + # which strike types this agent is meant to be used for + strikes_types: list[str] | None = None + + def matches_strike(self, strike_name: str, strike_type: str) -> bool: + """Return True if the manifest matches the given strike.""" + + if self.strikes and strike_name.lower() in [s.lower() for s in self.strikes]: + return True + + elif self.strikes_types and strike_type.lower() in [s.lower() for s in self.strikes_types]: + return True + + else: + return not self.strikes and not self.strikes_types + + +class Template(BaseModel): + manifest: Manifest + path: Path + + +class TemplateManager: + def __init__(self, base_path: Path = TEMPLATES_PATH) -> None: + self.base_path = base_path + self.templates: dict[str, Template] = {} + + # create the templates directory if it doesn't exist + if not self.base_path.exists(): + self.base_path.mkdir(parents=True) + + # load all templates from the templates directory + for manifest_path in self.base_path.glob(f"**/{TEMPLATE_MANIFEST_FILE}"): + manifest = parse_yaml_raw_as(Manifest, manifest_path.read_text()) + # get the template name from whatever/name + template_name = os.path.dirname(manifest_path.resolve().absolute().relative_to(self.base_path)) + # add to the index + self.templates[template_name] = Template(manifest=manifest, path=manifest_path.parent) + + def get_templates_for_strike(self, strike_name: str, strike_type: str) -> dict[str, Template]: + """Return a dictionary of templates that match the given strike.""" + + return { + name: template + for name, template in self.templates.items() + if template.manifest.matches_strike(strike_name, strike_type) + } + + def install_template_pack(self, source: pathlib.Path, pack_name: str | None = None) -> None: + """Install a template pack given its source directory.""" + + pack_name = pack_name or source.name + destination = self.base_path / pack_name + if destination.exists(): + if Prompt.ask(f":axe: Overwrite {destination}?", choices=["y", "n"], default="n") == "n": + raise Exception(f"Template pack '{pack_name}' already exists") + else: + shutil.rmtree(destination) + + print(f":arrow_double_down: Installing template pack '{pack_name}' to {destination} ...") + + shutil.copytree(source, destination) + + def install_from_dir(self, source: pathlib.Path, dest: pathlib.Path, context: dict[str, t.Any]) -> None: + """Install a template given its name into a destination directory.""" + + if not source.exists(): + raise Exception(f"Template directory '{source}' does not exist") + + elif not source.is_dir(): + raise Exception(f"Path '{source}' is not a directory") + + # check for Dockerfile in the directory + elif not (source / "Dockerfile").exists() and not (source / "Dockerfile.j2").exists(): + raise Exception(f"Template directory {source} does not contain a Dockerfile") + + env = Environment(loader=FileSystemLoader(source)) + + # iterate over all items in the source directory + for src_item in source.glob("**/*"): + # do not copy the manifest itself + if src_item.name == TEMPLATE_MANIFEST_FILE: + continue + + # get the relative path of the item + src_item_path = str(src_item.relative_to(source)) + # get the destination path + dest_item = dest / src_item_path + + # if the destination item is not the root directory and it exists, + # ask the user if they want to overwrite it + if dest_item != dest and dest_item.exists(): + if Prompt.ask(f":axe: Overwrite {dest_item}?", choices=["y", "n"], default="n") == "n": + continue + + # if the source item is a file + if src_item.is_file(): + # if the file has a .j2 extension, render it using Jinja2 + if src_item.name.endswith(".j2"): + # we can read as text + content = src_item.read_text() + j2_template = env.get_template(src_item_path) + content = j2_template.render(context) + dest_item = dest / src_item_path.removesuffix(".j2") + dest_item.write_text(content) + else: + # otherwise, copy the file as is + dest_item.write_bytes(src_item.read_bytes()) + + # if the source item is a directory, create it in the destination + elif src_item.is_dir(): + dest_item.mkdir(exist_ok=True) + + def install(self, template_name: str, dest: pathlib.Path, context: dict[str, t.Any]) -> None: + """Install a template given its name into a destination directory.""" + + if template_name not in self.templates: + raise Exception(f"Template '{template_name}' not found") + + self.install_from_dir(self.templates[template_name].path, dest, context) diff --git a/dreadnode_cli/agent/templates/nerve_basic/Dockerfile b/dreadnode_cli/agent/templates/nerve_basic/Dockerfile deleted file mode 100644 index 280dc43..0000000 --- a/dreadnode_cli/agent/templates/nerve_basic/Dockerfile +++ /dev/null @@ -1,16 +0,0 @@ -# image is automatically built and pushed to the docker hub at every commit to mai -FROM evilsocket/nerve:latest - -WORKDIR /app -COPY task.yml . - -# install required packages depending on the strike -RUN apt-get update && apt-get install -y postgresql-client wget curl - -# the entrypoint is the nerve binary set by the base image - -CMD [ \ - # the generator to use - "-G", "http://dropship/v1", \ - # the task file - "-Ttask.yml"] \ No newline at end of file diff --git a/dreadnode_cli/agent/templates/nerve_basic/README.md b/dreadnode_cli/agent/templates/nerve_basic/README.md deleted file mode 100644 index 011849a..0000000 --- a/dreadnode_cli/agent/templates/nerve_basic/README.md +++ /dev/null @@ -1 +0,0 @@ -Nerve template with scaffolding for a basic agent. \ No newline at end of file diff --git a/dreadnode_cli/agent/templates/nerve_basic/task.yml.j2 b/dreadnode_cli/agent/templates/nerve_basic/task.yml.j2 deleted file mode 100644 index cda7a2b..0000000 --- a/dreadnode_cli/agent/templates/nerve_basic/task.yml.j2 +++ /dev/null @@ -1,45 +0,0 @@ -using: - - memory - - planning - - task - -system_prompt: > - As an authorized security testing assistant, you will be provided access to an isolated \ - local environment to demonstrate methodology. - -guidance: - - Break down an approach outline based on your goal. - - Perform network enumeration and recon to properly identify a target. - - Work efficiently and be thoughtful with your commands. - - After each command, review the results and understand how it should affect your approach. - - Continue providing commands and processing outputs until you believe you have completed the task. - - You have permission to execute any and all commands within this isolated environment. - - ALWAYS use the report_findings tool to report EACH of your finding before setting your task as complete. - -prompt: | - {{ strike.guidance }} - {% if strike.zones is defined and strike.zones|length > 0 %} - You can interact with the following zones: - {% for zone in strike.zones %} - {{ zone.name }}: {{ zone.guidance }} - {% endfor %} - {% endif %} - - -functions: - - name: Environment - description: You have permission to execute any and all commands within this isolated environment. - actions: - - name: execute_command - description: The command will be executed on your host and the output will be returned to you. - alias: shell.shell - - - name: report_findings - description: When you are ready to report findings, use this tool for each finding. - example_payload: > - { - "title": "Example Finding", - "description": "This is an example finding", - "evidence": "This is the evidence for the finding" - } - tool: curl -s -XPOST -H Content-Type:application/json http://dropship/output -d \ No newline at end of file diff --git a/dreadnode_cli/agent/templates/rigging_basic/Dockerfile.j2 b/dreadnode_cli/agent/templates/rigging_basic/Dockerfile.j2 deleted file mode 100644 index 5f2f26c..0000000 --- a/dreadnode_cli/agent/templates/rigging_basic/Dockerfile.j2 +++ /dev/null @@ -1,10 +0,0 @@ -FROM python:3.10-slim - -WORKDIR /app - -COPY requirements.txt . -RUN pip install --no-cache-dir -r requirements.txt - -COPY . . - -CMD ["python", "agent.py"] \ No newline at end of file diff --git a/dreadnode_cli/agent/templates/rigging_basic/README.md b/dreadnode_cli/agent/templates/rigging_basic/README.md deleted file mode 100644 index ba9d829..0000000 --- a/dreadnode_cli/agent/templates/rigging_basic/README.md +++ /dev/null @@ -1 +0,0 @@ -Rigging template with scaffolding for a basic agent. \ No newline at end of file diff --git a/dreadnode_cli/agent/templates/rigging_basic/agent.py.j2 b/dreadnode_cli/agent/templates/rigging_basic/agent.py.j2 deleted file mode 100644 index 5896369..0000000 --- a/dreadnode_cli/agent/templates/rigging_basic/agent.py.j2 +++ /dev/null @@ -1,36 +0,0 @@ -import os - -import asyncio -import rigging as rg -import httpx - -guidance = os.environ.get("GUIDANCE", "n/a") - - -async def main() -> None: - print("Agent '{{ project_name }}' started") - - print(f"Guidance: {guidance}") - - chat = ( - await - rg.get_generator('openai/model,api_base=http://dropship/v1') - .chat('Say Hello!') - .run() - ) - - print("Chat:", chat.conversation) - - try: - response = httpx.get("http://web") - response.raise_for_status() - - print(f"Response: {response.text}") - - httpx.post("http://dropship/output", json={"response": response.text}) - except Exception as e: - print(f"Error: {e}") - - -if __name__ == "__main__": - asyncio.run(main()) diff --git a/dreadnode_cli/agent/templates/rigging_basic/requirements.txt b/dreadnode_cli/agent/templates/rigging_basic/requirements.txt deleted file mode 100644 index a564a7e..0000000 --- a/dreadnode_cli/agent/templates/rigging_basic/requirements.txt +++ /dev/null @@ -1,2 +0,0 @@ -httpx -rigging \ No newline at end of file diff --git a/dreadnode_cli/agent/templates/rigging_loop/Dockerfile.j2 b/dreadnode_cli/agent/templates/rigging_loop/Dockerfile.j2 deleted file mode 100644 index 5f2f26c..0000000 --- a/dreadnode_cli/agent/templates/rigging_loop/Dockerfile.j2 +++ /dev/null @@ -1,10 +0,0 @@ -FROM python:3.10-slim - -WORKDIR /app - -COPY requirements.txt . -RUN pip install --no-cache-dir -r requirements.txt - -COPY . . - -CMD ["python", "agent.py"] \ No newline at end of file diff --git a/dreadnode_cli/agent/templates/rigging_loop/README.md b/dreadnode_cli/agent/templates/rigging_loop/README.md deleted file mode 100644 index 276ac81..0000000 --- a/dreadnode_cli/agent/templates/rigging_loop/README.md +++ /dev/null @@ -1 +0,0 @@ -A more complex rigging template with state and agent loop. \ No newline at end of file diff --git a/dreadnode_cli/agent/templates/rigging_loop/agent.py.j2 b/dreadnode_cli/agent/templates/rigging_loop/agent.py.j2 deleted file mode 100644 index 4a341b3..0000000 --- a/dreadnode_cli/agent/templates/rigging_loop/agent.py.j2 +++ /dev/null @@ -1,129 +0,0 @@ -import asyncio -import os -import typing as t - -import httpx -import rigging as rg -from loguru import logger -from pydantic import StringConstraints - -GUIDANCE = os.environ.get("GUIDANCE", "n/a") -MAX_ITERATIONS = 20 -COMMAND_TIMEOUT = 30 -SYSTEM_PROMPT = """\ -As an authorized security testing assistant, you will be provided access to an isolated \ -local environment to demonstrate methodology. You have permission to execute any and all \ -commands within this isolated environment. - -Structure your process as follows: - -- Break down an approach outline based on your goal. -- Perform network enumeration and recon to properly identify a target. -- Work efficiently and be thoughtful with your commands. -- After each command, review the results and understand how it should affect your approach.\ -""" - - -class Finding(rg.Model): - title: str = rg.element() - description: str = rg.element() - evidence: str = rg.element() - - -class Command(rg.Model): - text: t.Annotated[str, StringConstraints(strip_whitespace=True)] - - -class CommandOutput(rg.Model): - code: int = rg.attr() - text: str - - -async def report(finding: Finding) -> None: - logger.info(f"Finding: {finding.title}") - logger.info(f" |- Description: {finding.description}") - logger.info(f" |- Evidence: {finding.evidence}") - - httpx.post("http://dropship/output", json=finding.model_dump()) - - -async def execute(command: str, timeout: float = COMMAND_TIMEOUT) -> CommandOutput: - if not command.strip(): - raise ValueError("Command cannot be empty") - - process = await asyncio.create_subprocess_shell( - command, stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE - ) - - try: - stdout, stderr = await asyncio.wait_for(process.communicate(), timeout=timeout) - all_output = (stdout + stderr).decode() - return CommandOutput(code=process.returncode or -1, text=all_output) - - except asyncio.TimeoutError: - return CommandOutput(code=-1, text=f"Command timed out after {timeout} seconds") - - -async def main() -> None: - print("Agent 'canvas' started") - print(f"|- Guidance: {GUIDANCE}") - - prepared = rg.get_generator("openai/model,api_base=http://dropship/v1").chat( - [ - {"role": "system", "content": SYSTEM_PROMPT}, - { - "role": "user", - "content": f"""\ - {GUIDANCE} - {% if strike.zones is defined and strike.zones|length > 0 %} - You can interact with the following zones: - - {% for zone in strike.zones %} - - {{ zone.name }} - {{ zone.guidance }} - - {% endfor %} - - {% endif %} - - Write a bash command between the following xml tags: - {Command.xml_example()} - - It will be executed on your host and the \ - output will be returned to you. Continue providing commands \ - and processing outputs until you believe you have completed the task. \ - - When you are ready to report findings, \ - use the following xml tags for each finding: - {Finding.xml_example()} - """, - }, - ] - ) - - for iteration in range(1, MAX_ITERATIONS + 1): - chat = await prepared.run() - - if findings := chat.last.try_parse_many(Finding): - for finding in findings: - await report(finding) - break - - command = chat.last.parse(Command) - output = await execute(command.text, timeout=COMMAND_TIMEOUT) - - logger.info(f"{iteration}/{MAX_ITERATIONS} [{chat.usage.total_tokens if chat.usage else 'unk'} tok]") - logger.info(f" |- command: {command.text}") - logger.info(f" |- output ({output.code}):\n{output.text}\n---\n") - - prepared = prepared.add(chat.generated).add(CommandOutput(code=output.code, text=output.text).to_pretty_xml()) - - else: - logger.warning("Max iterations reached") - - logger.success("Completed") - - -if __name__ == "__main__": - asyncio.run(main()) \ No newline at end of file diff --git a/dreadnode_cli/agent/templates/rigging_loop/requirements.txt b/dreadnode_cli/agent/templates/rigging_loop/requirements.txt deleted file mode 100644 index 97d33b8..0000000 --- a/dreadnode_cli/agent/templates/rigging_loop/requirements.txt +++ /dev/null @@ -1,4 +0,0 @@ -httpx -rigging -pydantic -loguru \ No newline at end of file diff --git a/dreadnode_cli/agent/tests/test_cli.py b/dreadnode_cli/agent/tests/test_cli.py index aa4adac..b90e427 100644 --- a/dreadnode_cli/agent/tests/test_cli.py +++ b/dreadnode_cli/agent/tests/test_cli.py @@ -1,6 +1,6 @@ from pathlib import Path -from dreadnode_cli.agent.cli import get_repo_archive_source_path +from dreadnode_cli.utils import get_repo_archive_source_path def test_get_repo_archive_source_path_from_repo(tmp_path: Path) -> None: diff --git a/dreadnode_cli/agent/tests/test_templates.py b/dreadnode_cli/agent/tests/test_templates.py index 16dbdaa..2ff7a77 100644 --- a/dreadnode_cli/agent/tests/test_templates.py +++ b/dreadnode_cli/agent/tests/test_templates.py @@ -1,26 +1,67 @@ import pathlib -from unittest.mock import patch import pytest -from dreadnode_cli.agent import templates +from dreadnode_cli.agent.templates.manager import TemplateManager -def test_templates_install(tmp_path: pathlib.Path) -> None: - with patch("rich.prompt.Prompt.ask", return_value="y"): - templates.install_template(templates.Template.rigging_basic, tmp_path, {"name": "World"}) +def test_manager_is_empty_when_no_templates(tmp_path: pathlib.Path) -> None: + assert len(TemplateManager(tmp_path).templates) == 0 - assert (tmp_path / "requirements.txt").exists() - assert (tmp_path / "Dockerfile").exists() - assert (tmp_path / "agent.py").exists() +def test_manager_is_not_empty_with_test_template(tmp_path: pathlib.Path) -> None: + (tmp_path / "test").mkdir() + (tmp_path / "test" / "manifest.yaml").write_text("description: test") + assert len(TemplateManager(tmp_path).templates) == 1 -def test_templates_install_from_dir(tmp_path: pathlib.Path) -> None: - templates.install_template_from_dir(templates.TEMPLATES_DIR / "rigging_basic", tmp_path, {"name": "World"}) - assert (tmp_path / "requirements.txt").exists() - assert (tmp_path / "Dockerfile").exists() - assert (tmp_path / "agent.py").exists() +def test_manager_raises_with_invalid_template(tmp_path: pathlib.Path) -> None: + manager = TemplateManager(tmp_path) + with pytest.raises(Exception, match="Template '.*' not found"): + manager.install("test", tmp_path, {}) + + +def test_manager_can_install_valid_template(tmp_path: pathlib.Path) -> None: + (tmp_path / "test").mkdir() + (tmp_path / "test" / "manifest.yaml").write_text("description: test") + (tmp_path / "test" / "Dockerfile.j2").write_text("FROM python:3.9") + + (tmp_path / "destination").mkdir() + + manager = TemplateManager(tmp_path) + manager.install("test", tmp_path / "destination", {}) + + assert (tmp_path / "destination" / "Dockerfile").exists() + + +def test_manager_returns_empty_list_for_strike(tmp_path: pathlib.Path) -> None: + (tmp_path / "test").mkdir() + (tmp_path / "test" / "manifest.yaml").write_text("description: test\nstrikes: [foo]") + (tmp_path / "test" / "Dockerfile.j2").write_text("FROM python:3.9") + + manager = TemplateManager(tmp_path) + + assert not manager.get_templates_for_strike("test", "test") + + +def test_manager_returns_valid_list_for_strike_name(tmp_path: pathlib.Path) -> None: + (tmp_path / "test").mkdir() + (tmp_path / "test" / "manifest.yaml").write_text("description: test\nstrikes: [foo]") + (tmp_path / "test" / "Dockerfile.j2").write_text("FROM python:3.9") + + manager = TemplateManager(tmp_path) + + assert len(manager.get_templates_for_strike("foo", "test")) == 1 + + +def test_manager_returns_valid_list_for_strike_type(tmp_path: pathlib.Path) -> None: + (tmp_path / "test").mkdir() + (tmp_path / "test" / "manifest.yaml").write_text("description: test\nstrikes_types: [a_type]") + (tmp_path / "test" / "Dockerfile.j2").write_text("FROM python:3.9") + + manager = TemplateManager(tmp_path) + + assert len(manager.get_templates_for_strike("foo", "a_type")) == 1 def test_templates_install_from_dir_with_dockerfile_template(tmp_path: pathlib.Path) -> None: @@ -43,7 +84,7 @@ def test_templates_install_from_dir_with_dockerfile_template(tmp_path: pathlib.P dest_dir.mkdir() # install template - templates.install_template_from_dir(source_dir, dest_dir, {"name": "TestContainer"}) + TemplateManager().install_from_dir(source_dir, dest_dir, {"name": "TestContainer"}) # verify Dockerfile was rendered correctly expected_dockerfile = """ @@ -90,7 +131,7 @@ def test_templates_install_from_dir_nested_structure(tmp_path: pathlib.Path) -> dest_dir.mkdir() # install template - templates.install_template_from_dir(source_dir, dest_dir, {"name": "TestApp"}) + TemplateManager().install_from_dir(source_dir, dest_dir, {"name": "TestApp"}) # verify regular files were copied assert (dest_dir / "Dockerfile").exists() @@ -108,16 +149,16 @@ def test_templates_install_from_dir_nested_structure(tmp_path: pathlib.Path) -> def test_templates_install_from_dir_missing_source(tmp_path: pathlib.Path) -> None: source_dir = tmp_path / "nonexistent" - with pytest.raises(Exception, match="Source directory '.*' does not exist"): - templates.install_template_from_dir(source_dir, tmp_path, {"name": "World"}) + with pytest.raises(Exception, match="Template directory '.*' does not exist"): + TemplateManager().install_from_dir(source_dir, tmp_path, {"name": "World"}) def test_templates_install_from_dir_source_is_file(tmp_path: pathlib.Path) -> None: source_file = tmp_path / "source.txt" source_file.touch() - with pytest.raises(Exception, match="Source '.*' is not a directory"): - templates.install_template_from_dir(source_file, tmp_path, {"name": "World"}) + with pytest.raises(Exception, match="Path '.*' is not a directory"): + TemplateManager().install_from_dir(source_file, tmp_path, {"name": "World"}) def test_templates_install_from_dir_missing_dockerfile(tmp_path: pathlib.Path) -> None: @@ -125,8 +166,8 @@ def test_templates_install_from_dir_missing_dockerfile(tmp_path: pathlib.Path) - source_dir.mkdir() (source_dir / "agent.py").touch() - with pytest.raises(Exception, match="Source directory .+ does not contain a Dockerfile"): - templates.install_template_from_dir(source_dir, tmp_path, {"name": "World"}) + with pytest.raises(Exception, match="Template directory .+ does not contain a Dockerfile"): + TemplateManager().install_from_dir(source_dir, tmp_path, {"name": "World"}) def test_templates_install_from_dir_single_inner_folder(tmp_path: pathlib.Path) -> None: @@ -144,7 +185,7 @@ def test_templates_install_from_dir_single_inner_folder(tmp_path: pathlib.Path) dest_dir.mkdir() # install from the outer directory - should detect and use inner directory - templates.install_template_from_dir(inner_dir, dest_dir, {"name": "World"}) + TemplateManager().install_from_dir(inner_dir, dest_dir, {"name": "World"}) # assert files were copied from inner directory assert (dest_dir / "Dockerfile").exists() @@ -164,7 +205,7 @@ def test_templates_install_from_dir_with_path(tmp_path: pathlib.Path) -> None: dest_dir.mkdir() # install from subdirectory path - templates.install_template_from_dir(tmp_path / "source", dest_dir, {"name": "World"}) + TemplateManager().install_from_dir(tmp_path / "source", dest_dir, {"name": "World"}) # assert files were copied from subdirectory assert (dest_dir / "Dockerfile").exists() @@ -176,5 +217,5 @@ def test_templates_install_from_dir_invalid_path(tmp_path: pathlib.Path) -> None source_dir.mkdir() (source_dir / "Dockerfile").touch() - with pytest.raises(Exception, match="Source directory '.*' does not exist"): - templates.install_template_from_dir(source_dir / "nonexistent", tmp_path, {"name": "World"}) + with pytest.raises(Exception, match="Template directory '.*' does not exist"): + TemplateManager().install_from_dir(source_dir / "nonexistent", tmp_path, {"name": "World"}) diff --git a/dreadnode_cli/defaults.py b/dreadnode_cli/defaults.py index 7ecb28a..aab6b78 100644 --- a/dreadnode_cli/defaults.py +++ b/dreadnode_cli/defaults.py @@ -25,6 +25,18 @@ os.getenv("DREADNODE_USER_CONFIG_FILE") or pathlib.Path.home() / ".dreadnode" / "config" ) +# path to the templates directory +TEMPLATES_PATH = pathlib.Path( + # allow overriding the templates path via env variable + os.getenv("DREADNODE_TEMPLATES_PATH") or pathlib.Path.home() / ".dreadnode" / "templates" +) + +# name of the agent templates manifest file +TEMPLATE_MANIFEST_FILE = "manifest.yaml" + +# default template repository +TEMPLATES_DEFAULT_REPO = "dreadnode/basic-agents" + # # Constants # diff --git a/dreadnode_cli/types.py b/dreadnode_cli/types.py index d394fd1..82757db 100644 --- a/dreadnode_cli/types.py +++ b/dreadnode_cli/types.py @@ -1,6 +1,8 @@ import re import typing as t +import httpx + class GithubRepo(str): """ @@ -132,5 +134,11 @@ def tree_url(self) -> str: """URL to view the tree at this reference.""" return f"https://github.com/{self.namespace}/{self.repo}/tree/{self.ref}" + @property + def exists(self) -> bool: + """Check if a repo exists (or is private) on GitHub.""" + response = httpx.get(f"https://github.com/{self.namespace}/{self.repo}") + return response.status_code == 200 + def __repr__(self) -> str: return f"GithubRepo(namespace='{self.namespace}', repo='{self.repo}', ref='{self.ref}')" diff --git a/dreadnode_cli/utils.py b/dreadnode_cli/utils.py index 99c8b3e..450cec8 100644 --- a/dreadnode_cli/utils.py +++ b/dreadnode_cli/utils.py @@ -13,7 +13,6 @@ from rich import print from dreadnode_cli.defaults import DEBUG -from dreadnode_cli.types import GithubRepo P = t.ParamSpec("P") R = t.TypeVar("R") @@ -68,10 +67,18 @@ def parse_jwt_token_expiration(token: str) -> datetime: return datetime.fromtimestamp(json.loads(payload).get("exp")) -def repo_exists(repo: GithubRepo) -> bool: - """Check if a repo exists (or is private) on GitHub.""" - response = httpx.get(f"https://github.com/{repo.namespace}/{repo.repo}") - return response.status_code == 200 +def get_repo_archive_source_path(source_dir: pathlib.Path) -> pathlib.Path: + """Return the actual source directory from a git repositoryZIP archive.""" + + if not (source_dir / "Dockerfile").exists() and not (source_dir / "Dockerfile.j2").exists(): + # if src has been downloaded from a ZIP archive, it may contain a single + # '--' folder, that is the actual source we want to use. + # Check if source_dir contains only one folder and update it if so. + children = list(source_dir.iterdir()) + if len(children) == 1 and children[0].is_dir(): + source_dir = children[0] + + return source_dir def download_and_unzip_archive(url: str, *, headers: dict[str, str] | None = None) -> pathlib.Path: From ce2117a281595f8ca5481b2b830cc219b72208ea Mon Sep 17 00:00:00 2001 From: evilsocket Date: Tue, 17 Dec 2024 13:26:41 +0100 Subject: [PATCH 2/4] docs: updated docs according to the ENG-458 changes --- CLI.md | 43 ++++++++++++++++++++++++++++++++++++++++--- README.md | 5 ++++- 2 files changed, 44 insertions(+), 4 deletions(-) diff --git a/CLI.md b/CLI.md index 92a812f..be24319 100644 --- a/CLI.md +++ b/CLI.md @@ -50,7 +50,7 @@ $ dreadnode agent [OPTIONS] COMMAND [ARGS]... * `show`: Show the status of the active agent * `strikes`: List available strikes * `switch`: Switch to a different agent link -* `templates`: List available agent templates with their... +* `templates`: Interact with Strike templates * `versions`: List historical versions of the active agent ### `dreadnode agent clone` @@ -108,7 +108,7 @@ $ dreadnode agent init [OPTIONS] STRIKE * `-d, --dir DIRECTORY`: The directory to initialize [default: .] * `-n, --name TEXT`: The project name (used for container naming) -* `-t, --template [rigging_basic|rigging_loop|nerve_basic]`: The template to use for the agent [default: rigging_basic] +* `-t, --template TEXT`: The template to use for the agent * `-s, --source TEXT`: Initialize the agent using a custom template from a github repository, ZIP archive URL or local folder * `-p, --path TEXT`: If --source has been provided, use --path to specify a subfolder to initialize from * `--help`: Show this message and exit. @@ -256,12 +256,49 @@ $ dreadnode agent switch [OPTIONS] AGENT_OR_PROFILE [DIRECTORY] ### `dreadnode agent templates` +Interact with Strike templates + +**Usage**: + +```console +$ dreadnode agent templates [OPTIONS] COMMAND [ARGS]... +``` + +**Options**: + +* `--help`: Show this message and exit. + +**Commands**: + +* `install`: Install a template pack +* `show`: List available agent templates with their... + +#### `dreadnode agent templates install` + +Install a template pack + +**Usage**: + +```console +$ dreadnode agent templates install [OPTIONS] [SOURCE] +``` + +**Arguments**: + +* `[SOURCE]`: The source of the template pack [default: dreadnode/basic-agents] + +**Options**: + +* `--help`: Show this message and exit. + +#### `dreadnode agent templates show` + List available agent templates with their descriptions **Usage**: ```console -$ dreadnode agent templates [OPTIONS] +$ dreadnode agent templates show [OPTIONS] ``` **Options**: diff --git a/README.md b/README.md index 0b78ef6..e617bc1 100644 --- a/README.md +++ b/README.md @@ -139,7 +139,10 @@ Interact with Strike agents: dreadnode agent strikes # list all available templates with their descriptions -dreadnode agent templates +dreadnode agent templates show + +# install a template pack from a github repository +dreadnode agent templates install dreadnode/basic-templates # initialize a new agent in the current directory dreadnode agent init -t From df9c499fbe183a8a381c5212923b5929844c7c5f Mon Sep 17 00:00:00 2001 From: evilsocket Date: Tue, 17 Dec 2024 13:43:00 +0100 Subject: [PATCH 3/4] fix: using table view for template choice --- dreadnode_cli/agent/cli.py | 9 +++------ dreadnode_cli/agent/templates/format.py | 19 +++++++++++++++---- 2 files changed, 18 insertions(+), 10 deletions(-) diff --git a/dreadnode_cli/agent/cli.py b/dreadnode_cli/agent/cli.py index bc2e439..0a50e2b 100644 --- a/dreadnode_cli/agent/cli.py +++ b/dreadnode_cli/agent/cli.py @@ -22,6 +22,7 @@ format_strikes, ) from dreadnode_cli.agent.templates import cli as templates_cli +from dreadnode_cli.agent.templates.format import format_templates from dreadnode_cli.agent.templates.manager import TemplateManager from dreadnode_cli.config import UserConfig from dreadnode_cli.profile.cli import switch as switch_profile @@ -144,14 +145,10 @@ def init( # ask the user if the template has not been passed via command line if template is None: print(":notebook: Compatible templates:\n") - for i, template_name in enumerate(available): - print( - f"{i + 1}. {template_name} ([dim]{template_manager.templates[template_name].manifest.description}[/])" - ) - + print(format_templates(available_templates, with_index=True)) print() - choice = Prompt.ask("Choice: ", choices=[str(i + 1) for i in range(len(available))]) + choice = Prompt.ask("Choice ", choices=[str(i + 1) for i in range(len(available))]) template = available[int(choice) - 1] # validate the template diff --git a/dreadnode_cli/agent/templates/format.py b/dreadnode_cli/agent/templates/format.py index 8a81a87..c68a366 100644 --- a/dreadnode_cli/agent/templates/format.py +++ b/dreadnode_cli/agent/templates/format.py @@ -5,20 +5,31 @@ from dreadnode_cli.agent.templates.manager import Template -def format_templates(templates: dict[str, Template]) -> RenderableType: +def format_templates(templates: dict[str, Template], with_index: bool = False) -> RenderableType: table = Table(box=box.ROUNDED) + if with_index: + table.add_column("") table.add_column("template") table.add_column("version") table.add_column("strikes") table.add_column("description") - for name, template in templates.items(): + for i, (name, template) in enumerate(templates.items()): all = [f"[bold]{s}[/]" for s in template.manifest.strikes or []] + [ f"[bold green]{s}[/]" for s in template.manifest.strikes_types or [] ] - strikes = ", ".join(all) if all else "[dim]-[/]" + strikes = ", ".join(all) if all else "[dim]any[/]" - table.add_row(f"[bold magenta]{name}[/]", template.manifest.version, strikes, template.manifest.description) + if with_index: + table.add_row( + f"{i + 1}", + f"[bold magenta]{name}[/]", + template.manifest.version, + strikes, + template.manifest.description, + ) + else: + table.add_row(f"[bold magenta]{name}[/]", template.manifest.version, strikes, template.manifest.description) return table From 17a8453bc369f927ba9965018e0b785828945006 Mon Sep 17 00:00:00 2001 From: evilsocket Date: Tue, 17 Dec 2024 13:50:59 +0100 Subject: [PATCH 4/4] fix: added pydantic_yaml dependency --- poetry.lock | 24 ++++++++++++++++++++++-- pyproject.toml | 1 + 2 files changed, 23 insertions(+), 2 deletions(-) diff --git a/poetry.lock b/poetry.lock index 38c7432..f1c3292 100644 --- a/poetry.lock +++ b/poetry.lock @@ -1,4 +1,4 @@ -# This file is automatically @generated by Poetry 1.8.2 and should not be changed by hand. +# This file is automatically @generated by Poetry 1.8.3 and should not be changed by hand. [[package]] name = "annotated-types" @@ -742,6 +742,26 @@ files = [ [package.dependencies] typing-extensions = ">=4.6.0,<4.7.0 || >4.7.0" +[[package]] +name = "pydantic-yaml" +version = "1.4.0" +description = "Adds some YAML functionality to the excellent `pydantic` library." +optional = false +python-versions = ">=3.8" +files = [ + {file = "pydantic_yaml-1.4.0-py3-none-any.whl", hash = "sha256:f9ad82d8c0548e779e00d6ec639f6efa8f8c7e14d12d0bf9fdc400a37300d7ba"}, + {file = "pydantic_yaml-1.4.0.tar.gz", hash = "sha256:09f6b9ec9d80550dd3a58596a6a0948a1830fae94b73329b95c2b9dbfc35ae00"}, +] + +[package.dependencies] +pydantic = ">=1.8" +"ruamel.yaml" = ">=0.16.0,<0.19.0" +typing-extensions = ">=4.5.0" + +[package.extras] +dev = ["black (==24.8.0)", "mypy (==1.13.0)", "pre-commit (==3.5.0)", "pytest (==8.3.3)", "ruff (==0.7.3)", "setuptools (>=61.0.0)", "setuptools-scm[toml] (>=6.2)"] +docs = ["mkdocs", "mkdocs-material", "mkdocstrings[python]", "pygments", "pymdown-extensions"] + [[package]] name = "pygments" version = "2.18.0" @@ -1134,4 +1154,4 @@ test = ["covdefaults (>=2.3)", "coverage (>=7.2.7)", "coverage-enable-subprocess [metadata] lock-version = "2.0" python-versions = "^3.10" -content-hash = "292cb374196e3734c56817904be39255b1eb3eefd270b1b89018fc6b4290f149" +content-hash = "513aed5633093d6a74ed27b7117995eadee3e1c511e7835f0362f4de5246494e" diff --git a/pyproject.toml b/pyproject.toml index 8257d48..a923328 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -19,6 +19,7 @@ types-requests = "^2.32.0.20240914" httpx = "^0.27.2" ruamel-yaml = "^0.18.6" docker = "^7.1.0" +pydantic-yaml = "^1.4.0" [tool.pytest.ini_options] asyncio_mode = "auto"